Initial commit: digital reception monorepo (M1-M11 + demo extensions)

This commit is contained in:
2026-05-25 12:59:54 +05:00
commit b9f88194d9
182 changed files with 20578 additions and 0 deletions
@@ -0,0 +1,50 @@
"""CLI: python -m video_ingest --source clip.mp4 --camera-name cam-A --zone A."""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
from dotenv import load_dotenv
from .ingestor import IngestConfig, run
load_dotenv(Path(__file__).parent.parent.parent.parent / ".env")
load_dotenv()
def main() -> None:
parser = argparse.ArgumentParser(prog="video_ingest")
parser.add_argument("--source", required=True, type=Path, help="Путь к mp4 файлу")
parser.add_argument(
"--camera-name",
required=True,
help="Имя камеры (должно совпадать с seeded — cam-entrance/cam-corridor/cam-reception)",
)
parser.add_argument(
"--zone",
choices=["A", "B", "C"],
help="Код зоны (опционально — будет взят из связки камеры)",
)
parser.add_argument("--sample-every", type=int, default=None)
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
logging.basicConfig(
level=args.log_level,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
cfg = IngestConfig(
source=args.source,
camera_name=args.camera_name,
zone_code=args.zone,
**({"sample_every": args.sample_every} if args.sample_every else {}),
)
result = run(cfg)
print(result)
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
"""HTTP клиенты к face-service и apps/api для минимального video-ingest."""
from __future__ import annotations
import os
import base64
import logging
from datetime import datetime
from typing import Any
import requests
logger = logging.getLogger(__name__)
class FaceServiceClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or os.getenv("FACE_SERVICE_URL", "http://localhost:8001")).rstrip("/")
def embed(self, jpeg_bytes: bytes) -> tuple[list[float], float] | None:
"""Возвращает (embedding, quality) или None если лицо не найдено."""
b64 = base64.b64encode(jpeg_bytes).decode("ascii")
r = requests.post(f"{self.base_url}/embed", json={"frame": b64}, timeout=10)
r.raise_for_status()
data = r.json()
if data is None:
return None
return data["embedding"], data["quality"]
def save_track_embedding(
self,
jpeg_bytes: bytes,
track_id: str,
camera_id: str,
captured_at: datetime,
) -> dict[str, Any] | None:
b64 = base64.b64encode(jpeg_bytes).decode("ascii")
r = requests.post(
f"{self.base_url}/track-embeddings",
json={
"frame": b64,
"track_id": track_id,
"camera_id": camera_id,
"captured_at": captured_at.isoformat(),
},
timeout=10,
)
r.raise_for_status()
return r.json()
class ReceptionApiClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or os.getenv("API_BASE_URL", "http://localhost:4000")).rstrip("/")
def create_track(self, camera_name: str, first_seen_at: datetime) -> dict[str, Any]:
r = requests.post(
f"{self.base_url}/ingest/tracks",
json={"cameraName": camera_name, "firstSeenAt": first_seen_at.isoformat()},
timeout=10,
)
r.raise_for_status()
return r.json()
def add_event(
self,
track_id: str,
event_type: str,
camera_name: str,
occurred_at: datetime,
zone_code: str | None = None,
) -> dict[str, Any]:
body = {
"trackId": track_id,
"type": event_type,
"cameraName": camera_name,
"occurredAt": occurred_at.isoformat(),
}
if zone_code:
body["zoneCode"] = zone_code
r = requests.post(f"{self.base_url}/ingest/track-events", json=body, timeout=10)
r.raise_for_status()
return r.json()
+144
View File
@@ -0,0 +1,144 @@
"""Минимальный конвейер mp4 → face-service /embed → tracks → apps/api.
Замысел: smoke-тест для цепочки «видео → эмбеддинг → трек → БД».
Это НЕ боевой video-ingest из ТЗ §6 (RTSP, ByteTrack, GPU) — это
скаффолд Фазы 1, чтобы Фаза 0 не блокировала остальную работу.
Алгоритм:
1. Открыть mp4 через OpenCV.
2. Для каждого N-го кадра (SAMPLE_EVERY): JPEG → face-service /embed.
3. Если эмбеддинг есть — single-camera трекинг по cos-дистанции:
если дистанция к last_embedding < TRACK_DISTANCE_THRESH и
прошло < TRACK_WINDOW_SEC секунд → тот же трек.
Иначе — новый трек (создаём через apps/api /ingest/tracks).
4. Сохраняем эмбеддинг через face-service /track-embeddings.
5. На первом кадре трека постим событие `arrived` через /ingest/track-events.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
import cv2
import numpy as np
from .api import FaceServiceClient, ReceptionApiClient
logger = logging.getLogger(__name__)
@dataclass
class IngestConfig:
source: Path
camera_name: str
zone_code: str | None = None
sample_every: int = int(os.getenv("SAMPLE_EVERY", "10"))
track_distance_thresh: float = float(os.getenv("TRACK_DISTANCE_THRESH", "0.3"))
track_window_sec: float = float(os.getenv("TRACK_WINDOW_SEC", "2.0"))
jpeg_quality: int = 85
real_time_start: datetime | None = None
def cosine_distance(a: np.ndarray, b: np.ndarray) -> float:
return float(1.0 - np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
def run(cfg: IngestConfig) -> dict:
cap = cv2.VideoCapture(str(cfg.source))
if not cap.isOpened():
raise RuntimeError(f"Не могу открыть видео: {cfg.source}")
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
logger.info(f"Открыто видео {cfg.source} ({total} кадров, {fps:.1f} fps)")
face = FaceServiceClient()
api = ReceptionApiClient()
base_time = cfg.real_time_start or datetime.utcnow()
current_track_id: str | None = None
current_track_camera_id: str | None = None
last_embedding: np.ndarray | None = None
last_capture_time: datetime | None = None
tracks_created = 0
embeddings_saved = 0
frame_idx = 0
while True:
ok, frame = cap.read()
if not ok:
break
frame_idx += 1
if frame_idx % cfg.sample_every != 0:
continue
captured_at = base_time + timedelta(seconds=frame_idx / fps)
ok_jpg, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), cfg.jpeg_quality])
if not ok_jpg:
logger.warning(f"frame {frame_idx}: jpeg encode failed")
continue
jpeg_bytes = buf.tobytes()
try:
embed_result = face.embed(jpeg_bytes)
except Exception as e: # noqa: BLE001
logger.warning(f"frame {frame_idx}: face-service /embed failed: {e}")
continue
if embed_result is None:
continue
embedding_list, _quality = embed_result
embedding = np.array(embedding_list, dtype=np.float32)
new_track = True
if last_embedding is not None and last_capture_time is not None:
dist = cosine_distance(embedding, last_embedding)
age_sec = (captured_at - last_capture_time).total_seconds()
if dist < cfg.track_distance_thresh and age_sec < cfg.track_window_sec:
new_track = False
if new_track:
track_info = api.create_track(camera_name=cfg.camera_name, first_seen_at=captured_at)
current_track_id = track_info["trackId"]
current_track_camera_id = track_info["cameraId"]
api.add_event(
track_id=current_track_id,
event_type="arrived",
camera_name=cfg.camera_name,
occurred_at=captured_at,
zone_code=cfg.zone_code,
)
tracks_created += 1
logger.info(f"frame {frame_idx}: new track {current_track_id}")
assert current_track_id and current_track_camera_id
try:
face.save_track_embedding(
jpeg_bytes=jpeg_bytes,
track_id=current_track_id,
camera_id=current_track_camera_id,
captured_at=captured_at,
)
embeddings_saved += 1
except Exception as e: # noqa: BLE001
logger.warning(f"frame {frame_idx}: save_track_embedding failed: {e}")
last_embedding = embedding
last_capture_time = captured_at
cap.release()
logger.info(
f"Готово: создано {tracks_created} треков, сохранено {embeddings_saved} эмбеддингов "
f"(всего просмотрено {frame_idx} кадров)"
)
return {
"tracks_created": tracks_created,
"embeddings_saved": embeddings_saved,
"frames_processed": frame_idx,
}