Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -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