You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
288 lines
10 KiB
288 lines
10 KiB
"""face-service v2 для проекта «Цифровая рецепция». |
|
|
|
Расширение time-tracker face-service: добавлены эндпоинты cross-camera re-id, |
|
сохранения эмбеддингов с метаданными трека/камеры, узнавания пациента |
|
(только среди эмбеддингов с согласием), удаления при отзыве согласия. |
|
|
|
Эндпоинты: |
|
GET /health — статус + флаг loaded |
|
POST /embed — только эмбеддинг кадра (без БД) |
|
POST /track-embeddings — сохранить эмбеддинг с привязкой к треку |
|
POST /reid/search — cross-camera re-id (top-K в окне T мин) |
|
POST /recognize — узнать пациента (patient_id) по кадру |
|
POST /enroll — привязать эмбеддинги трека к пациенту |
|
DELETE /patient/{patient_id}/embeddings — удалить все эмбеддинги пациента |
|
GET /patient/{patient_id}/count — кол-во эмбеддингов у пациента |
|
""" |
|
import os |
|
import logging |
|
from contextlib import asynccontextmanager |
|
from datetime import datetime |
|
from typing import Optional |
|
|
|
from dotenv import load_dotenv |
|
from fastapi import FastAPI, HTTPException |
|
from fastapi.middleware.cors import CORSMiddleware |
|
from pydantic import BaseModel, Field |
|
|
|
from database import ( |
|
save_embedding_with_meta, |
|
attach_track_to_patient, |
|
find_topk_in_window, |
|
find_nearest_patient, |
|
delete_patient_embeddings, |
|
count_patient_embeddings, |
|
) |
|
from face_engine import detect_best_face, load_model, is_model_loaded |
|
|
|
load_dotenv() |
|
|
|
logging.basicConfig(level=logging.INFO) |
|
logger = logging.getLogger(__name__) |
|
|
|
# Порог узнавания пациента по лицу (ТЗ §4.3 — после первой партии данных тюним). |
|
RECOGNITION_THRESHOLD = float(os.getenv("RECOGNITION_THRESHOLD", "0.5")) |
|
# Порог склейки треков cross-camera (строже — иначе ложные склейки). |
|
REID_THRESHOLD = float(os.getenv("REID_THRESHOLD", "0.35")) |
|
# Окно для cross-camera re-id (минуты). |
|
DEFAULT_REID_WINDOW_MIN = int(os.getenv("REID_WINDOW_MINUTES", "5")) |
|
|
|
|
|
@asynccontextmanager |
|
async def lifespan(app: FastAPI): |
|
if os.getenv("SKIP_MODEL_LOAD", "false").lower() == "true": |
|
logger.warning("SKIP_MODEL_LOAD=true — модель не загружается, frame-эндпоинты вернут 503") |
|
yield |
|
return |
|
try: |
|
load_model() |
|
except Exception as e: # noqa: BLE001 |
|
logger.error(f"Не удалось загрузить InsightFace: {e}. Frame-эндпоинты не будут работать.") |
|
yield |
|
|
|
|
|
app = FastAPI(title="reception/face-service", version="0.2.0", lifespan=lifespan) |
|
|
|
app.add_middleware( |
|
CORSMiddleware, |
|
allow_origins=["*"], |
|
allow_methods=["*"], |
|
allow_headers=["*"], |
|
) |
|
|
|
|
|
# ---------- Схемы ---------- |
|
|
|
class EmbedRequest(BaseModel): |
|
frame: str = Field(..., description="base64-encoded JPEG/PNG") |
|
|
|
|
|
class FaceBbox(BaseModel): |
|
box: list[int] # [x1, y1, x2, y2] в пикселях |
|
imgW: int |
|
imgH: int |
|
|
|
|
|
class EmbedResponse(BaseModel): |
|
embedding: list[float] |
|
quality: float |
|
bbox: FaceBbox | None = None |
|
|
|
|
|
class TrackEmbeddingRequest(BaseModel): |
|
frame: str |
|
track_id: str |
|
camera_id: str |
|
captured_at: datetime | None = None |
|
patient_id: str | None = None |
|
|
|
|
|
class TrackEmbeddingResponse(BaseModel): |
|
id: str |
|
quality: float |
|
bbox: FaceBbox | None = None |
|
|
|
|
|
class ReidSearchRequest(BaseModel): |
|
frame: str | None = None |
|
embedding: list[float] | None = None |
|
camera_id: str |
|
window_minutes: int | None = None |
|
k: int = 5 |
|
exclude_same_camera: bool = True |
|
|
|
|
|
class ReidMatch(BaseModel): |
|
track_id: str |
|
camera_id: str |
|
captured_at: str |
|
distance: float |
|
|
|
|
|
class ReidSearchResponse(BaseModel): |
|
matches: list[ReidMatch] |
|
threshold: float |
|
|
|
|
|
class RecognizeRequest(BaseModel): |
|
frame: str |
|
|
|
|
|
class RecognizeResponse(BaseModel): |
|
patient_id: str |
|
confidence: float |
|
distance: float |
|
|
|
|
|
class EnrollRequest(BaseModel): |
|
track_id: str |
|
patient_id: str |
|
|
|
|
|
class EnrollResponse(BaseModel): |
|
ok: bool |
|
embeddings_attached: int |
|
|
|
|
|
class TrackEmbeddingRawRequest(BaseModel): |
|
"""Сохранить готовый эмбеддинг без детекции лица — для fixtures-runner.""" |
|
embedding: list[float] |
|
track_id: str |
|
camera_id: str |
|
captured_at: datetime | None = None |
|
patient_id: str | None = None |
|
quality: float = 0.9 |
|
|
|
|
|
class RecognizeEmbeddingRequest(BaseModel): |
|
"""Узнать пациента по готовому эмбеддингу — для fixtures-runner.""" |
|
embedding: list[float] |
|
|
|
|
|
# ---------- Эндпоинты ---------- |
|
|
|
@app.get("/health") |
|
def health(): |
|
return { |
|
"status": "ok", |
|
"model_loaded": is_model_loaded(), |
|
"recognition_threshold": RECOGNITION_THRESHOLD, |
|
"reid_threshold": REID_THRESHOLD, |
|
} |
|
|
|
|
|
@app.post("/embed", response_model=Optional[EmbedResponse]) |
|
def embed(req: EmbedRequest): |
|
"""Возвращает 512-d эмбеддинг лучшего лица на кадре, без записи в БД.""" |
|
embedding, quality, bbox = detect_best_face(req.frame) |
|
if embedding is None: |
|
return None |
|
return EmbedResponse(embedding=embedding.tolist(), quality=quality, bbox=bbox) |
|
|
|
|
|
@app.post("/track-embeddings", response_model=Optional[TrackEmbeddingResponse]) |
|
def store_track_embedding(req: TrackEmbeddingRequest): |
|
"""Сохраняет эмбеддинг с привязкой к треку (используется video-ingest/fixtures).""" |
|
embedding, quality, bbox = detect_best_face(req.frame) |
|
if embedding is None: |
|
return None |
|
|
|
record_id = save_embedding_with_meta( |
|
embedding=embedding, |
|
track_id=req.track_id, |
|
camera_id=req.camera_id, |
|
quality=quality, |
|
captured_at=req.captured_at, |
|
patient_id=req.patient_id, |
|
) |
|
return TrackEmbeddingResponse(id=record_id, quality=quality, bbox=bbox) |
|
|
|
|
|
@app.post("/reid/search", response_model=ReidSearchResponse) |
|
def reid_search(req: ReidSearchRequest): |
|
"""Cross-camera re-id: ищет top-K ближайших эмбеддингов с других камер в окне T мин.""" |
|
if req.embedding is None and req.frame is None: |
|
raise HTTPException(400, "Нужен либо frame, либо embedding") |
|
|
|
if req.embedding is not None: |
|
embedding = req.embedding |
|
else: |
|
embedding, _quality, _bbox = detect_best_face(req.frame) |
|
if embedding is None: |
|
return ReidSearchResponse(matches=[], threshold=REID_THRESHOLD) |
|
embedding = embedding.tolist() |
|
|
|
window = req.window_minutes or DEFAULT_REID_WINDOW_MIN |
|
matches = find_topk_in_window( |
|
embedding=embedding, |
|
camera_id=req.camera_id, |
|
window_minutes=window, |
|
k=req.k, |
|
exclude_same_camera=req.exclude_same_camera, |
|
) |
|
|
|
return ReidSearchResponse( |
|
matches=[ReidMatch(**m) for m in matches if m["distance"] <= REID_THRESHOLD], |
|
threshold=REID_THRESHOLD, |
|
) |
|
|
|
|
|
@app.post("/recognize", response_model=Optional[RecognizeResponse]) |
|
def recognize(req: RecognizeRequest): |
|
"""Узнавание пациента: ищет ближайший эмбеддинг среди записей с patient_id.""" |
|
embedding, _quality, _bbox = detect_best_face(req.frame) |
|
if embedding is None: |
|
return None |
|
|
|
result = find_nearest_patient(embedding, threshold=RECOGNITION_THRESHOLD) |
|
if result is None: |
|
return None |
|
|
|
return RecognizeResponse(**result) |
|
|
|
|
|
@app.post("/track-embeddings/raw", response_model=TrackEmbeddingResponse) |
|
def store_raw_track_embedding(req: TrackEmbeddingRawRequest): |
|
"""Сохранить эмбеддинг без детекции лица. Использует fixtures-runner с |
|
синтетическими векторами; в продовом потоке не используется.""" |
|
record_id = save_embedding_with_meta( |
|
embedding=req.embedding, |
|
track_id=req.track_id, |
|
camera_id=req.camera_id, |
|
quality=req.quality, |
|
captured_at=req.captured_at, |
|
patient_id=req.patient_id, |
|
) |
|
return TrackEmbeddingResponse(id=record_id, quality=req.quality) |
|
|
|
|
|
@app.post("/recognize/embedding", response_model=Optional[RecognizeResponse]) |
|
def recognize_by_embedding(req: RecognizeEmbeddingRequest): |
|
"""Узнать пациента по готовому эмбеддингу (для fixtures-runner).""" |
|
result = find_nearest_patient(req.embedding, threshold=RECOGNITION_THRESHOLD) |
|
if result is None: |
|
return None |
|
return RecognizeResponse(**result) |
|
|
|
|
|
@app.post("/enroll", response_model=EnrollResponse) |
|
def enroll(req: EnrollRequest): |
|
"""Привязывает все эмбеддинги трека к пациенту (после согласия).""" |
|
affected = attach_track_to_patient(req.track_id, req.patient_id) |
|
if affected == 0: |
|
raise HTTPException(404, f"Не найдено эмбеддингов для трека {req.track_id}") |
|
logger.info(f"Enroll: трек {req.track_id} → пациент {req.patient_id} ({affected} эмбеддингов)") |
|
return EnrollResponse(ok=True, embeddings_attached=affected) |
|
|
|
|
|
@app.delete("/patient/{patient_id}/embeddings") |
|
def delete_embeddings(patient_id: str): |
|
deleted = delete_patient_embeddings(patient_id) |
|
logger.info(f"Отозвано согласие пациента {patient_id}: удалено {deleted} эмбеддингов") |
|
return {"ok": True, "deleted": deleted} |
|
|
|
|
|
@app.get("/patient/{patient_id}/count") |
|
def patient_count(patient_id: str): |
|
count = count_patient_embeddings(patient_id) |
|
return {"patient_id": patient_id, "count": count}
|
|
|