Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# video-ingest (минимальный скаффолд)
|
||||
|
||||
**Это не боевой video-ingest из ТЗ §6.** Это smoke-скаффолд Фазы 1, чтобы проверить цепочку «видео → face-service → apps/api → БД» без RTSP/GPU/ByteTrack. Полноценный сервис делается в Фазе 0.
|
||||
|
||||
Что делает:
|
||||
- Открывает локальный mp4 через OpenCV.
|
||||
- Каждый N-й кадр (`SAMPLE_EVERY`, default 10) отправляет в `face-service /embed`.
|
||||
- Простейший single-camera трекинг по cos-дистанции (`< TRACK_DISTANCE_THRESH=0.3` в окне `TRACK_WINDOW_SEC=2`).
|
||||
- На новый трек: `POST /ingest/tracks` + событие `arrived`.
|
||||
- Каждый эмбеддинг — `POST /face-service/track-embeddings`.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd apps/video-ingest
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Убедись, что face-service, apps/api, postgres подняты.
|
||||
python -m video_ingest --source samples/walk-by.mp4 --camera-name cam-entrance --zone A
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- `FACE_SERVICE_URL` (default `http://localhost:8001`)
|
||||
- `API_BASE_URL` (default `http://localhost:4000`)
|
||||
- `SAMPLE_EVERY` (default `10`)
|
||||
- `TRACK_DISTANCE_THRESH` (default `0.3`)
|
||||
- `TRACK_WINDOW_SEC` (default `2.0`)
|
||||
|
||||
## Критерий приёмки
|
||||
|
||||
После прогона:
|
||||
- В `tracks` появилось хотя бы 1 запись.
|
||||
- В `face_embeddings` сохранены эмбеддинги.
|
||||
- В `track_events` есть `arrived`.
|
||||
- В логах `apps/api` видны POST /ingest/tracks и /ingest/track-events.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@reception/video-ingest",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Минимальный mp4-консьюмер (скаффолд Ф1). Python entrypoint: python -m video_ingest",
|
||||
"scripts": {
|
||||
"build": "echo 'video-ingest is python — no build'",
|
||||
"lint": "echo 'lint: TODO ruff'",
|
||||
"typecheck": "echo 'typecheck: TODO mypy'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
opencv-python-headless==4.11.0.86
|
||||
numpy==2.2.3
|
||||
requests==2.32.3
|
||||
python-dotenv==1.0.1
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user