Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/reception
|
||||
MODEL_NAME=buffalo_l
|
||||
DET_SCORE_THRESHOLD=0.7
|
||||
RECOGNITION_THRESHOLD=0.5
|
||||
REID_THRESHOLD=0.35
|
||||
REID_WINDOW_MINUTES=5
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8001/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -0,0 +1,51 @@
|
||||
# face-service
|
||||
|
||||
Python + FastAPI + InsightFace `buffalo_l`. Считает 512-d L2-нормализованные эмбеддинги лиц, хранит в pgvector, делает cross-camera re-id и узнавание пациентов.
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|---|---|---|
|
||||
| `GET` | `/health` | Статус + флаг загрузки модели |
|
||||
| `POST` | `/embed` | Только эмбеддинг (без БД). Body: `{frame: base64}` |
|
||||
| `POST` | `/track-embeddings` | Сохранить эмбеддинг с привязкой к треку/камере |
|
||||
| `POST` | `/reid/search` | Cross-camera re-id (top-K в окне T мин) |
|
||||
| `POST` | `/recognize` | Узнать пациента (`patient_id`) по кадру |
|
||||
| `POST` | `/enroll` | Привязать эмбеддинги трека к пациенту |
|
||||
| `DELETE` | `/patient/{id}/embeddings` | Удалить эмбеддинги пациента (отзыв согласия) |
|
||||
| `GET` | `/patient/{id}/count` | Кол-во эмбеддингов у пациента |
|
||||
|
||||
## Запуск (dev)
|
||||
|
||||
```bash
|
||||
cd apps/face-service
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # либо использовать корневой .env
|
||||
uvicorn main:app --reload --port 8001
|
||||
```
|
||||
|
||||
## Запуск (docker)
|
||||
|
||||
```bash
|
||||
docker build -t reception/face-service .
|
||||
docker run --rm -p 8001:8001 \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5434/reception \
|
||||
reception/face-service
|
||||
```
|
||||
|
||||
## Пороги (ТЗ §4.3)
|
||||
|
||||
- `RECOGNITION_THRESHOLD=0.5` — узнавание уже зарегистрированного пациента.
|
||||
- `REID_THRESHOLD=0.35` — склейка треков между камерами (строже, иначе ложные склейки).
|
||||
|
||||
Тюним после baseline-замеров на проде.
|
||||
|
||||
## Источник
|
||||
|
||||
Скопировано и расширено из `work-pcs-adm-time-tracker/apps/face-service/`. Изменения:
|
||||
|
||||
- Новая схема `face_embeddings` (track_id, camera_id, patient_id, quality, captured_at) — управляется через Prisma в `packages/db`.
|
||||
- Добавлены функции `save_embedding_with_meta`, `attach_track_to_patient`, `find_topk_in_window`, `find_nearest_patient`, `delete_patient_embeddings`.
|
||||
- Эндпоинты `/embed`, `/track-embeddings`, `/reid/search`, `/enroll`, `/patient/.../embeddings` — новые.
|
||||
- Эндпоинт `/recognize` теперь работает только с эмбеддингами с проставленным `patient_id` (т.е. с согласием).
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Прямой доступ к pgvector через psycopg2.
|
||||
|
||||
Расширение time-tracker/apps/face-service/database.py для домена «Цифровой
|
||||
рецепции»: новая схема (patient_id, track_id, camera_id, quality, captured_at),
|
||||
cross-camera re-id (top-K в окне T минут с фильтром по камерам),
|
||||
поиск пациента (только эмбеддинги с patient_id IS NOT NULL).
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from pgvector.psycopg2 import register_vector
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://postgres:postgres@localhost:5434/reception",
|
||||
)
|
||||
|
||||
|
||||
def get_connection():
|
||||
conn = psycopg2.connect(DATABASE_URL)
|
||||
register_vector(conn)
|
||||
return conn
|
||||
|
||||
|
||||
# ---------- WRITE ----------
|
||||
|
||||
def save_embedding_with_meta(
|
||||
embedding,
|
||||
track_id: str,
|
||||
camera_id: str,
|
||||
quality: float,
|
||||
captured_at: datetime | None = None,
|
||||
patient_id: str | None = None,
|
||||
) -> str:
|
||||
"""Сохраняет эмбеддинг с привязкой к треку/камере/пациенту.
|
||||
|
||||
Возвращает id записи. captured_at по умолчанию — now().
|
||||
"""
|
||||
record_id = str(uuid.uuid4())
|
||||
captured_at = captured_at or datetime.utcnow()
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO face_embeddings
|
||||
(id, embedding, track_id, camera_id, quality, patient_id, captured_at, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
""",
|
||||
(record_id, embedding, track_id, camera_id, quality, patient_id, captured_at),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return record_id
|
||||
|
||||
|
||||
def attach_track_to_patient(track_id: str, patient_id: str) -> int:
|
||||
"""Привязывает все эмбеддинги трека к пациенту. Возвращает кол-во затронутых строк."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE face_embeddings SET patient_id = %s WHERE track_id = %s",
|
||||
(patient_id, track_id),
|
||||
)
|
||||
affected = cur.rowcount
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return affected
|
||||
|
||||
|
||||
# ---------- READ ----------
|
||||
|
||||
def find_topk_in_window(
|
||||
embedding,
|
||||
camera_id: str,
|
||||
window_minutes: int = 5,
|
||||
k: int = 5,
|
||||
exclude_same_camera: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Top-K ближайших эмбеддингов с других камер в окне последних window_minutes минут.
|
||||
|
||||
Используется для cross-camera re-id: на новой камере появился человек,
|
||||
ищем тот же эмбеддинг с другой камеры в недавнем прошлом → склейка треков.
|
||||
"""
|
||||
since = datetime.utcnow() - timedelta(minutes=window_minutes)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
sql = """
|
||||
SELECT fe.track_id, fe.camera_id, fe.captured_at,
|
||||
(fe.embedding <=> %s::vector) AS distance
|
||||
FROM face_embeddings fe
|
||||
WHERE fe.captured_at >= %s
|
||||
AND fe.track_id IS NOT NULL
|
||||
"""
|
||||
params: list[Any] = [embedding, since]
|
||||
|
||||
if exclude_same_camera:
|
||||
sql += " AND fe.camera_id <> %s"
|
||||
params.append(camera_id)
|
||||
|
||||
sql += " ORDER BY distance ASC LIMIT %s"
|
||||
params.append(k)
|
||||
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
{
|
||||
"track_id": str(r["track_id"]),
|
||||
"camera_id": str(r["camera_id"]),
|
||||
"captured_at": r["captured_at"].isoformat(),
|
||||
"distance": float(r["distance"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def find_nearest_patient(embedding, threshold: float) -> dict[str, Any] | None:
|
||||
"""Узнать пациента по лицу: ищет ближайший эмбеддинг среди записей,
|
||||
у которых patient_id IS NOT NULL (т.е. дано согласие).
|
||||
|
||||
Возвращает {patient_id, confidence, distance} или None.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT fe.patient_id, (fe.embedding <=> %s::vector) AS distance
|
||||
FROM face_embeddings fe
|
||||
WHERE fe.patient_id IS NOT NULL
|
||||
ORDER BY distance ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(embedding,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
distance = float(row["distance"])
|
||||
if distance > threshold:
|
||||
return None
|
||||
|
||||
confidence = round(max(0.0, 1.0 - (distance / threshold)), 3)
|
||||
return {
|
||||
"patient_id": str(row["patient_id"]),
|
||||
"confidence": confidence,
|
||||
"distance": distance,
|
||||
}
|
||||
|
||||
|
||||
# ---------- DELETE ----------
|
||||
|
||||
def delete_patient_embeddings(patient_id: str) -> int:
|
||||
"""Удаляет все эмбеддинги пациента. Возвращает кол-во удалённых.
|
||||
|
||||
Вызывается при отзыве согласия (через 24 ч).
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM face_embeddings WHERE patient_id = %s",
|
||||
(patient_id,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return deleted
|
||||
|
||||
|
||||
def count_patient_embeddings(patient_id: str) -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM face_embeddings WHERE patient_id = %s",
|
||||
(patient_id,),
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""InsightFace wrapper: load model, decode images, extract 512-d embeddings.
|
||||
|
||||
Скопировано и расширено из work-pcs-adm-time-tracker. Импорты InsightFace и PIL
|
||||
сделаны ленивыми — face-service может запускаться без них (SKIP_MODEL_LOAD=true)
|
||||
для интеграционных тестов raw-embedding эндпоинтов.
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_NAME = os.getenv("MODEL_NAME", "buffalo_l")
|
||||
DET_SCORE_THRESHOLD = float(os.getenv("DET_SCORE_THRESHOLD", "0.7"))
|
||||
|
||||
_app = None # FaceAnalysis | None — ленивый импорт.
|
||||
|
||||
|
||||
def load_model():
|
||||
global _app
|
||||
if _app is not None:
|
||||
return _app
|
||||
from insightface.app import FaceAnalysis # ленивый импорт
|
||||
logger.info(f"Загружаю модель InsightFace '{MODEL_NAME}'...")
|
||||
app = FaceAnalysis(name=MODEL_NAME, providers=["CPUExecutionProvider"])
|
||||
app.prepare(ctx_id=0, det_thresh=DET_SCORE_THRESHOLD, det_size=(640, 640))
|
||||
_app = app
|
||||
logger.info("Модель загружена.")
|
||||
return _app
|
||||
|
||||
|
||||
def decode_image(base64_str: str) -> np.ndarray:
|
||||
"""Декодирует base64-строку в numpy-массив (BGR, формат OpenCV)."""
|
||||
from PIL import Image # ленивый импорт
|
||||
if "," in base64_str:
|
||||
base64_str = base64_str.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(base64_str)
|
||||
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||
img_array = np.array(image)
|
||||
return img_array[:, :, ::-1].copy()
|
||||
|
||||
|
||||
def detect_best_face(base64_str: str):
|
||||
"""Возвращает (embedding, quality, bbox_norm) лучшего лица или (None, None, None).
|
||||
|
||||
bbox_norm — [x1, y1, x2, y2] в нормализованных 0..1 координатах относительно
|
||||
размера изображения. UI рисует overlay поверх displayed image.
|
||||
"""
|
||||
app = load_model()
|
||||
|
||||
try:
|
||||
img = decode_image(base64_str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка декодирования изображения: {e}")
|
||||
return None, None, None
|
||||
|
||||
faces = app.get(img)
|
||||
if not faces:
|
||||
return None, None, None
|
||||
|
||||
best_face = max(faces, key=lambda f: f.det_score)
|
||||
if best_face.det_score < DET_SCORE_THRESHOLD:
|
||||
return None, None, None
|
||||
|
||||
embedding = best_face.normed_embedding.astype(np.float32)
|
||||
quality = float(best_face.det_score)
|
||||
|
||||
h, w = img.shape[:2]
|
||||
box = best_face.bbox.tolist() # [x1, y1, x2, y2] в пикселях
|
||||
bbox = {
|
||||
"box": [
|
||||
max(0, int(box[0])),
|
||||
max(0, int(box[1])),
|
||||
min(w, int(box[2])),
|
||||
min(h, int(box[3])),
|
||||
],
|
||||
"imgW": w,
|
||||
"imgH": h,
|
||||
}
|
||||
return embedding, quality, bbox
|
||||
|
||||
|
||||
def get_embedding(base64_str: str) -> np.ndarray | None:
|
||||
"""Обратная совместимость: только эмбеддинг лучшего лица."""
|
||||
embedding, _, _ = detect_best_face(base64_str)
|
||||
return embedding
|
||||
|
||||
|
||||
def is_model_loaded() -> bool:
|
||||
return _app is not None
|
||||
@@ -0,0 +1,288 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,22 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
insightface==0.7.3
|
||||
onnxruntime==1.24.1
|
||||
opencv-python-headless==4.11.0.86
|
||||
numpy==2.2.3
|
||||
psycopg2-binary==2.9.11
|
||||
pgvector==0.3.6
|
||||
python-dotenv==1.0.1
|
||||
Pillow==12.1.0
|
||||
onnx
|
||||
tqdm
|
||||
prettytable
|
||||
requests
|
||||
scipy
|
||||
scikit-learn
|
||||
scikit-image
|
||||
matplotlib
|
||||
albumentations
|
||||
easydict
|
||||
cython
|
||||
pytest==8.3.4
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Pytest-фикстуры для face-service.
|
||||
|
||||
Подключение к локальному pgvector через DATABASE_URL из корневого .env.
|
||||
Тесты re-id логики работают на чистых эмбеддингах — модель InsightFace не нужна.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Подгружаем корневой .env и .env сервиса.
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
load_dotenv(Path(__file__).parent.parent.parent.parent / ".env")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Импортируем после load_dotenv, чтобы DATABASE_URL подцепился.
|
||||
from database import get_connection # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_conn():
|
||||
conn = get_connection()
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_camera_and_track(db_conn):
|
||||
"""Создаёт уникальные camera_id + track_id для теста и убирает после."""
|
||||
camera_ids = []
|
||||
track_ids = []
|
||||
|
||||
def _make(zone_code: str = "A"):
|
||||
cam_id = str(uuid.uuid4())
|
||||
zone_id = str(uuid.uuid4())
|
||||
track_id = str(uuid.uuid4())
|
||||
with db_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO zones (id, code, name) VALUES (%s, %s::\"ZoneCode\", %s)"
|
||||
" ON CONFLICT (code) DO NOTHING RETURNING id",
|
||||
(zone_id, zone_code, f"test-zone-{zone_code}"),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
cur.execute('SELECT id FROM zones WHERE code = %s::"ZoneCode"', (zone_code,))
|
||||
zone_id = str(cur.fetchone()[0])
|
||||
cur.execute(
|
||||
"INSERT INTO cameras (id, name, zone_id) VALUES (%s, %s, %s)",
|
||||
(cam_id, f"test-cam-{cam_id[:6]}", zone_id),
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO tracks (id, status, first_seen_at, last_seen_at, updated_at)"
|
||||
" VALUES (%s, 'UNMATCHED', NOW(), NOW(), NOW())",
|
||||
(track_id,),
|
||||
)
|
||||
db_conn.commit()
|
||||
camera_ids.append(cam_id)
|
||||
track_ids.append(track_id)
|
||||
return cam_id, track_id
|
||||
|
||||
yield _make
|
||||
|
||||
# Cleanup
|
||||
with db_conn.cursor() as cur:
|
||||
if track_ids:
|
||||
cur.execute(
|
||||
"DELETE FROM face_embeddings WHERE track_id = ANY(%s::uuid[])",
|
||||
(track_ids,),
|
||||
)
|
||||
cur.execute("DELETE FROM tracks WHERE id = ANY(%s::uuid[])", (track_ids,))
|
||||
if camera_ids:
|
||||
cur.execute("DELETE FROM cameras WHERE id = ANY(%s::uuid[])", (camera_ids,))
|
||||
db_conn.commit()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Тесты cross-camera re-id логики.
|
||||
|
||||
Используем синтетические 512-мерные эмбеддинги (без InsightFace).
|
||||
Проверяем, что find_topk_in_window:
|
||||
1. Возвращает соседей в правильном порядке по cos-дистанции.
|
||||
2. Фильтрует по camera_id (исключает ту же камеру).
|
||||
3. Фильтрует по временному окну.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from database import (
|
||||
save_embedding_with_meta,
|
||||
find_topk_in_window,
|
||||
find_nearest_patient,
|
||||
attach_track_to_patient,
|
||||
delete_patient_embeddings,
|
||||
)
|
||||
|
||||
|
||||
def normed(vec: np.ndarray) -> np.ndarray:
|
||||
return (vec / np.linalg.norm(vec)).astype(np.float32)
|
||||
|
||||
|
||||
def make_embedding(seed: int) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
return normed(rng.standard_normal(512))
|
||||
|
||||
|
||||
def test_topk_in_window_basic(seed_camera_and_track):
|
||||
"""Из 3 эмбеддингов на 3 разных камерах находим 2 ближайших к query (исключая саму камеру query)."""
|
||||
cam_a, track_a = seed_camera_and_track("A")
|
||||
cam_b, track_b = seed_camera_and_track("B")
|
||||
cam_c, track_c = seed_camera_and_track("C")
|
||||
|
||||
base = make_embedding(seed=42)
|
||||
|
||||
# Соседи: tweak base слегка для cam_b, сильнее для cam_c.
|
||||
near = normed(base + 0.05 * make_embedding(seed=43))
|
||||
far = normed(base + 0.5 * make_embedding(seed=44))
|
||||
|
||||
save_embedding_with_meta(base, track_a, cam_a, quality=0.9, captured_at=datetime.utcnow())
|
||||
save_embedding_with_meta(near, track_b, cam_b, quality=0.9, captured_at=datetime.utcnow())
|
||||
save_embedding_with_meta(far, track_c, cam_c, quality=0.9, captured_at=datetime.utcnow())
|
||||
|
||||
# Запрос с cam_a — должен вернуть cam_b раньше cam_c, cam_a исключаем.
|
||||
results = find_topk_in_window(
|
||||
embedding=base.tolist(),
|
||||
camera_id=cam_a,
|
||||
window_minutes=5,
|
||||
k=5,
|
||||
exclude_same_camera=True,
|
||||
)
|
||||
|
||||
cam_ids_in_results = [r["camera_id"] for r in results]
|
||||
assert cam_a not in cam_ids_in_results
|
||||
assert cam_b in cam_ids_in_results
|
||||
assert cam_c in cam_ids_in_results
|
||||
# Порядок: ближе → дальше
|
||||
assert results[0]["camera_id"] == cam_b
|
||||
assert results[0]["distance"] < results[-1]["distance"]
|
||||
|
||||
|
||||
def test_topk_filters_by_window(seed_camera_and_track):
|
||||
"""Старый эмбеддинг (вне окна) не должен попадать в результат."""
|
||||
cam_a, track_a = seed_camera_and_track("A")
|
||||
cam_b, track_b = seed_camera_and_track("B")
|
||||
|
||||
base = make_embedding(seed=7)
|
||||
|
||||
save_embedding_with_meta(
|
||||
base, track_b, cam_b, quality=0.9,
|
||||
captured_at=datetime.utcnow() - timedelta(hours=1), # вне окна 5 мин
|
||||
)
|
||||
|
||||
results = find_topk_in_window(
|
||||
embedding=base.tolist(),
|
||||
camera_id=cam_a,
|
||||
window_minutes=5,
|
||||
k=5,
|
||||
)
|
||||
|
||||
cam_ids = [r["camera_id"] for r in results]
|
||||
assert cam_b not in cam_ids
|
||||
|
||||
|
||||
def test_find_nearest_patient_only_consented(db_conn, seed_camera_and_track):
|
||||
"""find_nearest_patient ищет только среди эмбеддингов с patient_id IS NOT NULL."""
|
||||
cam_a, track_a = seed_camera_and_track("A")
|
||||
base = make_embedding(seed=100)
|
||||
|
||||
# Сохраняем эмбеддинг без patient_id.
|
||||
save_embedding_with_meta(base, track_a, cam_a, quality=0.9)
|
||||
|
||||
# Ищем — никого не должно найти.
|
||||
assert find_nearest_patient(base.tolist(), threshold=0.5) is None
|
||||
|
||||
# Создаём пациента и привязываем трек.
|
||||
import uuid
|
||||
patient_id = str(uuid.uuid4())
|
||||
with db_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO patients (id, full_name, updated_at) VALUES (%s, %s, NOW())",
|
||||
(patient_id, "Тестовый Пациент"),
|
||||
)
|
||||
db_conn.commit()
|
||||
|
||||
affected = attach_track_to_patient(track_a, patient_id)
|
||||
assert affected == 1
|
||||
|
||||
# Теперь должны найти.
|
||||
result = find_nearest_patient(base.tolist(), threshold=0.5)
|
||||
assert result is not None
|
||||
assert result["patient_id"] == patient_id
|
||||
assert result["distance"] < 0.01 # тот же эмбеддинг
|
||||
|
||||
# Очистка.
|
||||
deleted = delete_patient_embeddings(patient_id)
|
||||
assert deleted == 1
|
||||
with db_conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM patients WHERE id = %s", (patient_id,))
|
||||
db_conn.commit()
|
||||
Reference in New Issue
Block a user