Перейти к содержанию

Архитектура

Iris — это self-hosted система видеонаблюдения (VMS), которая выполняет межкамерную ре-идентификацию с привязкой к лицу на одной NVIDIA T4 (16 ГБ). Этот документ описывает, как устроена система: модель процессов и потоков, сквозной жизненный цикл кадра, модель данных, способ совместного использования GPU, адаптивное распределение нагрузки, удерживающее горячий путь в реальном времени, подсистему записи и то, как отдаётся фронтенд.

Документ рассчитан на инженера, которому нужно разобраться в системе, эксплуатировать её или расширять. Всё, что описано ниже, прослеживается до исходного кода; по тексту приведены конкретные ссылки на файлы и функции.


1. Обзор системы

Есть один процесс приложения FastAPI (процесс API) и один порождаемый подпроцесс на каждую включённую камеру (воркеры камер). Процесс API владеет HTTP-поверхностью, базой данных SQLite, авторитетными галереями в памяти и жизненным циклом воркеров. Каждый воркер камеры владеет ровно одним RTSP-потоком и прогоняет на нём полный конвейер detect → track → re-ID.

flowchart TB
    subgraph client["Browser (vanilla-JS SPA)"]
      UI["Live grid · History · Identities · People · Faces"]
    end

    nginx["nginx<br/>TLS · cookie-SSO (auth_request)<br/>injects X-Email, strips client copy"]
    client -->|HTTPS| nginx
    nginx -->|"127.0.0.1:8120"| api

    subgraph api["API process (FastAPI + uvicorn)"]
      routers["Routers: cameras · live · events ·<br/>identities · people · face-groups · system"]
      wm["WorkerManager<br/>(spawn / reconcile / status)"]
      gal["Authoritative IdentityGallery<br/>FaceIndex (FAISS)"]
      maint["ReID maintenance thread<br/>(decay · prune · merge)"]
      hls["HLS manager (on-demand)"]
      static["StaticFiles SPA mount"]
    end

    db[("SQLite (WAL)<br/>vms.db — single source of truth")]
    shm[["/dev/shm/vms_frames<br/>one JPEG slot per camera"]]
    seg[["data/segments/&lt;cam&gt;<br/>rolling ffmpeg buffer"]]

    api --- db
    wm -.spawn.-> w1
    wm -.spawn.-> w2

    subgraph w1["Camera worker N (subprocess, spawn)"]
      dec["decode thread<br/>(drop-to-latest)"]
      loop["main loop<br/>detect → track → re-ID"]
      clip["clip drain thread"]
      pers["persist drain thread"]
      ffmpeg["ffmpeg segmenter<br/>(-c:v copy)"]
      dec --> loop
      loop --> clip
      loop --> pers
    end
    w2["Camera worker M ..."]

    loop --> shm
    ffmpeg --> seg
    w1 --- db
    routers -->|read JPEG slot| shm
    routers -->|assemble manual clip| seg
    routers --- db

Межпроцессное состояние сознательно сведено к минимуму, и для каждого типа данных используется канал с наименьшим трением:

  • Метаданные и пути к медиа проходят через базу данных SQLite. БД — единственный источник истины (app/db/models.py); каждая структура в памяти (индекс лиц FAISS, галерея identity) — это производное состояние, перестраиваемое из неё.
  • Кадры live-превью передаются как целые JPEG-файлы через слот кадра на камеру в tmpfs (/dev/shm/vms_frames/cam_<id>.jpg), записываемый атомарно. Это избавляет от хрупкости блоков shared_memory фиксированного размера для JPEG переменного размера, оставаясь при этом фактически таким же быстрым, как разделяемая память (WorkerManager._resolve_frames_dir, read_frame).
  • Состояние воркеров (state, fps, last_seen, pid) публикуется в словарь словарей multiprocessing.Manager (WorkerManager.status).

2. Модель процессов и потоков

Это ядро всей архитектуры, поэтому здесь оно разбирается наиболее подробно. Руководящий принцип: горячий путь детекции никогда не должен блокироваться на I/O, который ему строго не нужен — на задержке RTSP-декодирования, сборке клипа в ffmpeg, кодировании JPEG или fsync.

2.1 Один подпроцесс на камеру (spawn)

WorkerManager (app/workers/manager.py) порождает один процесс ОС на каждую включённую камеру через multiprocessing.get_context("spawn"). Именно spawn, а не fork, обязателен, потому что контексты CUDA и onnxruntime не fork-безопасны; форкнутый потомок наследует сломанный CUDA-хендл. Точка входа _worker_entrypoint импортирует cv2/onnxruntime внутри потомка, чтобы родительский процесс никогда не загружал эти тяжёлые, работающие с GPU библиотеки.

Процесс-на-камеру даёт:

  • Изоляцию сбоев. Камера с заклинившим декодером, повреждённым потоком или segfault в нативной зависимости кладёт только собственный воркер. Цикл сверки менеджера (_reconcile_loop, по умолчанию каждые 20 с) и политика OOM на уровне cgroup (см. §5) перезапускают его; остальные камеры и API не затрагиваются.
  • Чёткую границу жизненного цикла. Удаление или отключение камеры останавливает ровно один процесс. WorkerManager.sync — архитектурная гарантия того, что запущенные воркеры == включённые камеры: он запускает воркеры для включённых камер и завершает любой осиротевший воркер, чья камера исчезла, поэтому удалённая камера никогда не сможет продолжать генерировать данные.
  • Истинный параллелизм между камерами без GIL: каждый воркер — отдельный интерпретатор.

Конфигурация пересекает границу spawn в виде обычного picklable-словаря (WorkerManager._camera_config) — настраиваемые параметры на камеру разрешаются относительно глобальных значений по умолчанию из Settings до сборки словаря, поэтому воркеру объект настроек нужен только для путей к моделям, а не для каждого порога.

2.2 Внутри воркера: конвейер из трёх потоков

Каждый CameraWorker (app/workers/camera_worker.py) запускает три взаимодействующие роли потоков. Причина их разделения — контроль задержки и обратного давления (back-pressure).

sequenceDiagram
    participant RTSP as RTSP camera
    participant Dec as Decode thread<br/>(_decode_loop)
    participant Slot as latest-frame slot<br/>(Condition + seq)
    participant Loop as Main loop<br/>(_loop)
    participant GPU as GPU (YOLO/ArcFace/OSNet)
    participant DB as SQLite
    participant Clip as Clip drain<br/>(_clip_drain_loop)
    participant Pers as Persist drain<br/>(_persist_drain_loop)

    loop as fast as stream delivers
        Dec->>RTSP: cap.read()
        Dec->>Slot: overwrite latest_frame, ++seq, notify()
        Note over Slot: an unconsumed frame is DROPPED here
    end

    loop每 newest frame
        Loop->>Slot: wait_for(seq != last_seq)
        Slot-->>Loop: newest frame only (stale ones skipped)
        Loop->>GPU: detect (adaptive cadence)
        Loop->>GPU: embed faces/bodies (≤ max_reid_per_frame)
        Loop->>DB: assign sightings, commit (cheap rows)
        Loop->>Clip: enqueue clip job on track close (put_nowait)
        Loop->>Pers: enqueue thumbnail/face-sample (put_nowait)
    end

    Clip->>DB: build_clip_from_track + update Event (off hot path)
    Pers->>DB: imwrite crops + batched commit (off hot path)

(1) Поток декодирования — drop-to-latest (_decode_loop)

Выделенный поток-производитель владеет VideoCapture (OpenCV/FFMPEG) и не делает ничего, кроме cap.read() в плотном цикле, плюс обработка переподключения с экспоненциальной задержкой. Каждый прочитанный кадр перезаписывает единственный слот _latest_frame под threading.Condition, инкрементирует _frame_seq и уведомляет потребителя.

Зачем отдельный поток декодирования, хранящий только самый свежий кадр: RTSP отдаёт кадры в темпе реальных часов камеры. Если анализ на мгновение оказывается медленнее потока (толпа, пауза GC), очередь начала бы расти и воркер всё сильнее отставал бы от реального времени — это фатально для live-системы мониторинга. Храня только последний кадр, медленный проход анализа просто отбрасывает устаревшие кадры, которые он пропустил; воркер всегда обрабатывает самое свежее доступное изображение и остаётся привязан к реальному времени. CAP_PROP_BUFFERSIZE=1 по той же причине удерживает собственный буфер драйвера неглубоким.

(2) Главный цикл — синхронный detect → track → re-ID (_loop)

Потребитель блокируется на condition, пока не придёт кадр новее того, который он обработал последним (wait_for(self._frame_seq != last_seq)), затем синхронно прогоняет на этом единственном кадре весь конвейер:

  1. _safe_detect → YOLOv8n на GPU, отфильтрованный до trigger_classes камеры.
  2. _track_and_identify → продвижение жадного IoU-трекера (app/reid/tracker.py), финализация закрытых треков и (повторное) эмбеддинг/назначение ограниченного числа активных треков (§4).
  3. _handle_detections / _finalize_presence → создание событий и постановка задач на клипы в очередь.
  4. _maybe_write_frame_slot → троттлированное JPEG-кодирование аннотированного превью.

Эти шаги намеренно синхронны и однопоточны: детекция, трекинг и назначение identity разделяют состояние кадра и предположения о порядке, и выполнение их в одном потоке сохраняет логику простой, а работу на кадр — ограниченной. Единственное, чего цикл никогда не должен делать, — блокироваться на медленном, несущественном I/O, для которого и существуют потоки-сбросчики.

(3) Внеконвейерные потоки-сбросчики — персистенс + сборка клипов

Два демонических потока-сбросчика снимают медленную работу с горячего пути:

  • Сбросчик клипов (_clip_drain_loop): при закрытии трека цикл лишь записывает дешёвую строку Event и делает put_nowait маленького словаря задачи. Поток-сбросчик вызывает build_clip_from_track (который ждёт финализации сегментов post-roll и вызывает ffmpeg concat), а затем обновляет строку Event путём к клипу. Все записи в БД из потока клипов идут через один этот поток, сериализуясь, чтобы избежать конкуренции на запись в SQLite. Ограниченная очередь (maxsize=64); при полной очереди клип отбрасывается с предупреждением, а не подвешивает детекцию.
  • Сбросчик персистенса (_persist_drain_loop): все записи миниатюр кропов тел и сэмплов лиц — cv2.imwrite (JPEG-кодирование) плюс коммиты в БД Sighting/FaceSample — выполняются здесь, пакетно (до 64 задач) в одной транзакции на сброс. Цикл лишь ставит в очередь задачу, несущую .copy() кропа (буфер кадра перезаписывается следующим cap.read()). Если очередь (maxsize=256) переполнена, _enqueue_persist откатывается к встроенной записи, так что данные не теряются.

Зачем такое разделение: ожидание post-roll в ffmpeg длится секунды; JPEG-кодирование и fsync — миллисекунды, но не ограничены сверху при нагрузке на диск. Любое из них, выполняемое встроенно, заставило бы воркер отстать от live-потока и пропускать детекции. Перенос их в потоки-сбросчики означает, что единственная синхронная работа горячего пути с БД — небольшие индексированные вставки/обновления строк.

Порядок завершения (_teardown)

Останов упорядочен так, чтобы не терять работу в полёте и не зависать на мёртвых потоках: сначала останавливается производитель декодирования, затем flush() трекера, чтобы открытые присутствия всё же записались, далее очередь клипов сбрасывается, пока сегментер ещё жив, затем очередь персистенса, затем close() компонентов. Join'ы на потоках сброса/персистенса происходят только когда эти потоки действительно живы (иначе queue.join() блокировался бы вечно).


3. Сквозной жизненный цикл кадра

Путь одного кадра через один воркер:

  1. Декодирование. Поток декодирования читает кадр из RTSP и публикует его как единственный последний кадр (любой не потреблённый предшественник отбрасывается).
  2. Подхват. Главный цикл просыпается, берёт последний кадр + временную метку, тикает fps и публикует heartbeat online (троттлинг ~1 Гц).
  3. Гейт каденса. Адаптивный каденс (§4) решает, детектировать ли этот кадр. Если нет, цикл переиспользует _last_boxes для оверлея превью и сразу переходит к записи слота кадра.
  4. Детекция. YOLOv8n работает на GPU (_safe_detect); боксы фильтруются до trigger_classes. Если присутствует любой триггерный объект, обновляется _last_activity_ts (удерживая камеру в режиме active).
  5. Трекинг. ObjectTracker.update жадно сопоставляет боксы этого кадра с существующими треками по IoU одного класса, открывает треки для несопоставленных боксов и закрывает треки, простаивавшие дольше track_gap_seconds.
  6. Финализация закрытых треков. Каждый закрытый трек создаёт (в режиме track) одно Event на всё присутствие и ставит в очередь задачу на клип, затем добавляет своё время пребывания в PresenceSegment и Identity.total_seconds (_finalize_presence).
  7. (Повторная) идентификация активных треков. Ограниченный набор треков «на очереди» эмбеддится (IdentityPipeline.extract → вектор лица ArcFace + вектор тела OSNet), и IdentityManager.assign связывает каждый с существующей identity либо создаёт новую (§4, §6). Строки Sighting коммитятся; миниатюры/сэмплы лиц ставятся в очередь сбросчику персистенса.
  8. Превью. _maybe_write_frame_slot перекодирует аннотированный кадр в JPEG с active/idle preview fps и атомарно (tmp + replace) записывает его в слот кадра камеры, откуда его читает MJPEG-эндпоинт /api/live/{id}/stream.

Тем временем, полностью независимо от этого цикла, ffmpeg-сегментер на камеру (§5) непрерывно пишет на диск двухсекундные сегменты -c:v copy, так что pre-roll для любого события уже существует в момент открытия трека.


4. Совместное использование GPU, адаптивный каденс и лимит re-ID на кадр

4.1 Модель совместного использования GPU

Одна T4 (16 ГБ) совместно используется всеми моделями всех воркеров камер. Явного планировщика GPU нет; совместное использование достигается тем, что каждая модель остаётся маленькой, а спрос на GPU каждого воркера — ограниченным:

  • Детекция: YOLOv8n, экспортированный в ONNX, запускается через onnxruntime-gpu (app/detect/yolo_onnx.py). Запуск детекции на GPU стоит ~1 ядро CPU на камеру против ~7 на CPU — главная причина выноса инференса.
  • Лица: insightface buffalo_l — детектор SCRFD-10G + эмбеддер ArcFace — общий через один FaceRecognizer на воркер. Детекция лиц выполняется один раз на кадр по всему кадру, затем каждое лицо назначается наименьшему содержащему его боксу человека (IdentityPipeline.extract), поэтому второй модели лиц нет и нет повторной детекции по кропам.
  • Внешний вид: OSNet-AIN x1.0 (MSMT17), экспортированный в ONNX через ReIDEmbedder.
  • Атрибуты транспорта: опциональные классификаторы марки/типа кузова NVIDIA TAO, вызываемые только для кропов класса «транспорт».

Dockerfile намеренно следит за тем, чтобы загружался именно CUDAExecutionProvider: insightface жёстко зависит от CPU-колеса onnxruntime, которое затеняет onnxruntime-gpu в той же директории пакета; сборка деинсталлирует CPU-сборку и принудительно переустанавливает GPU-сборку, чтобы инференс попадал на T4.

Поскольку каждая модель загружается лениво внутри каждого дочернего процесса (_build_components), VRAM растёт с числом камер. Два механизма ниже как раз и удерживают совокупный спрос на GPU (а также CPU и диск) ограниченным при масштабировании числа камер.

4.2 Адаптивный каденс детекции

Каждый воркер отслеживает, активна ли его сцена. Сцена активна в течение active_grace_seconds после последнего кадра, содержавшего триггерный объект (active = (now - self._last_activity_ts) < self.active_grace_seconds). Интервал детекции следует за этим состоянием:

  • Active: детектировать каждые detect_interval секунд (по умолчанию 0.0 = каждый кадр).
  • Idle: детектировать каждые detect_interval_idle секунд (по умолчанию 0.5).

Частота JPEG-кодирования live-превью следует тому же состоянию active/idle (active_preview_fps против idle_preview_fps), потому что кодировать пустую сцену на полной частоте — чистое расточительство. Итоговый эффект: тихая камера потребляет долю GPU/CPU/диска занятой, но в момент, когда объект входит, камера мгновенно переходит на полную частоту и никогда не пропускает вход (метка активности выставляется на той самой детекции, которая впервые увидела объект).

4.3 Лимит re-ID на кадр

Эмбеддинг re-ID (ArcFace + OSNet + опциональный TAO) гораздо дороже детекции. Без ограничения толпа из N человек заставила бы делать N эмбеддингов на кадр и застопорила бы цикл. Поэтому _track_and_identify:

  1. Отбирает только треки, подошедшие к (повторной) идентификации. Свежие треки без identity используют быстрый каденс (reid_sample_seconds, по умолчанию 3 с); уже идентифицированные треки обновляются на более медленном «уверенном» каденсе (reid_confident_sample_seconds, по умолчанию 9 с), потому что identity «липкая».
  2. Приоритезирует неназначенные треки, затем — дольше всех ожидающие.
  3. Ограничивает число фактически эмбеддируемых в этом кадре значением max_reid_per_frame (по умолчанию 4).

Так работа re-ID на кадр постоянна независимо от размера толпы; накопившийся бэклог просто сливается за последующие кадры. Липкость identity (гистерезис в IdentityManager, IoU + временное окно) означает, что непрерывный трек сохраняет свою identity между эмбеддингами без повторной оценки.


5. Запись

5.1 «Тёплый» скользящий буфер сегментов

Каждый воркер запускает один долгоживущий процесс ffmpeg через Segmenter (app/recording/segmenter.py), который непрерывно пишет сегменты .mp4 фиксированной длины (segment_seconds, ~2 с), именованные по UTC-шаблону strftime (seg_YYYYMMDDThhmmss.mp4), в data/segments/<camera_id>/. Ключевые свойства:

  • Stream copy, без декодирования. -map 0:v:0 -c:v copy — ffmpeg никогда не перекодирует, поэтому CPU пренебрежимо мал, а GPU равен нулю. Воркер декодирует отдельно для детекции; сегментер — чисто I/O-рекордер.
  • Аудио отброшено (-an). Звук IP-камер (часто G.711 со сломанными таймстемпами) периодически вешал мультиплексор сегментов и молча останавливал клипы; его отбрасывание делает буфер незыблемым. (Live-со-звуком обрабатывается отдельно через on-demand HLS.)
  • Анти-SSRF. -protocol_whitelist rtsp,rtsps,rtp,rtcp,udp,tcp,tls,crypto не даёт вредоносному RTSP-URL заставить ffmpeg читать локальные файлы или достучаться до внутреннего HTTP.
  • Имена файлов, привязанные к UTC. Подпроцесс работает с TZ=UTC, так что таймстемпы сегментов совпадают с UTC-таймстемпами, которые воркер пишет в БД — от этого зависят выбор клипа и обрезка.
  • Ограниченное хранение + самовосстановление. Поток-watchdog обрезает сегменты старше retention_seconds (по умолчанию 120), перезапускает ffmpeg с задержкой, если тот умирает, и обнаруживает зависший ffmpeg (живой, но не производящий новых сегментов) по mtime самого свежего сегмента — перезапуская его при застое. PR_SET_PDEATHSIG гарантирует, что ffmpeg получит SIGKILL, если воркер умрёт даже нечисто.

Поскольку настраиваемый объём pre-roll всегда уже лежит на диске, клипы могут включать секунды до появления объекта без буферизации кадров в памяти.

5.2 События в режиме track

Режим записи по умолчанию recording_modetrack: ровно одно Event на присутствие объекта. При закрытии трека _finalize_presence ставит в очередь задачу на клип; build_clip_from_track (app/recording/clipper.py) собирает клип, охватывающий [enter - pre_seconds, last + post_seconds]:

  1. Дождаться финализации сегмента(ов) post-roll, покрывающих конец окна (_wait_for_post_roll) — сегментер записывает сегмент под его финальным именем только по завершении, поэтому наличие более нового сегмента доказывает, что хвост полон.
  2. Выбрать каждый сегмент, перекрывающийся с окном, исключая ещё растущий live-хвост (у которого пока нет атома moov → concat провалился бы) и любой незавершённый файл (_is_finalized проверяет через ffprobe).
  3. Сконкатенировать через concat-демультиплексор ffmpeg с -c copy -movflags +faststart (без перекодирования) в data/recordings/<camera_id>/<event_id>.mp4.
  4. Извлечь одну миниатюру вблизи момента триггера.

Унаследованный режим фиксированного окна trigger (_trigger_event / _record_and_persist) всё ещё существует, но отключён при recording_mode == "track", чтобы избежать двойной записи.

5.3 Ручная запись

Оператор может нажать ● REC в Live Monitoring. Это stateless: POST /api/live/{id}/record/start возвращает доверенную сервером метку времени старта, которую клиент возвращает в record/stop, собирающий [started_at, now] из того же дискового буфера сегментов (build_clip_from_track через заглушку сегментера на SimpleNamespace) и персистящий Event с меткой manual. Обращения к воркеру не требуется; буфер — общий субстрат.


6. Re-ID и модель identity (резюме)

Re-ID подробно описан в другом месте; здесь — то, что нужно держать в голове на уровне архитектуры. Identity привязана к лицу — единственному признаку, стабильному при смене одежды, ракурса, освещения и через дни. IdentityManager.assign (app/reid/manager.py) оценивает каждое наблюдение по порядку: липкость/гистерезис → уверенное лицо (с запасом best-minus-second) → внешний вид в пределах временного окна сессии (с вето по противоречию лица и, для не-человеческих объектов, с цветовым гейтом) → иначе новая identity, ограниченная порогом качества лица и лимитом частоты создания новых identity на камеру. Безликий вид сзади/сбоку никогда не порождает новую identity — он может лишь прицепиться к существующей по внешнему виду, иначе отбрасывается. Именно этот гейт не даёт человеку, увиденному со спины, расплодиться в десятки дубликатов.

IdentityGallery в памяти (app/reid/gallery.py) — производное состояние: FAISS IndexFlatIP поверх экземпляров ArcFace на identity (лица стабильны во времени, не затухают) плюс экземпляры внешнего вида OSNet на identity (затухающие во времени). Каждый воркер держит собственную галерею, перестраиваемую из БД при старте и ре-синхронизируемую по таймеру (_maybe_reload_gallery, по умолчанию 30 с), так что identity, созданные другими воркерами, сходятся. Процесс API держит авторитетную галерею и фоновый поток обслуживания (app/reid/maintenance.py), который затухает/обрезает экземпляры, пересчитывает центроиды, удаляет временные шумовые identity и выполняет консервативные автослияния только по лицам.


7. Модель данных

Десять таблиц в app/db/models.py, все на одной базе данных SQLite в режиме WAL с PRAGMA на соединение (journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout=5000app/db/database.py). Векторы — 512-мерные little-endian float32, L2-нормализованные, хранятся как BLOB и (де)сериализуются через numpy.frombuffer/tobytes (никогда не pickle — нет RCE при десериализации).

erDiagram
    CAMERA ||--o{ EVENT : "has (cascade delete)"
    CAMERA ||--o{ SIGHTING : "captured on (cascade)"
    PERSON ||--o{ FACE_EMBEDDING : "enrolled (cascade)"
    PERSON |o--o{ EVENT : "best face match (SET NULL)"
    IDENTITY ||--o{ SIGHTING : "has (cascade)"
    IDENTITY ||--o{ FACE_EXEMPLAR : "has (cascade)"
    IDENTITY ||--o{ APPEARANCE_EXEMPLAR : "has (cascade)"
    IDENTITY ||--o{ PRESENCE_SEGMENT : "dwell (cascade)"
    IDENTITY |o--o{ EVENT : "auto identity (SET NULL / app-code)"
    EVENT |o--o{ SIGHTING : "links (SET NULL)"
    IDENTITY |o--|| SIGHTING : "rep_sighting (SET NULL, use_alter)"

    CAMERA {
      int id PK
      string rtsp_url
      bool enabled
      string status
      string trigger_classes "nullable per-cam tunables"
    }
    EVENT {
      int id PK
      int camera_id FK
      datetime ts
      string clip_path
      string thumb_path
      int person_id FK "manual match (SET NULL)"
      int identity_id "auto identity (plain INT on SQLite)"
      string label
    }
    PERSON {
      int id PK
      string name
    }
    FACE_EMBEDDING {
      int id PK
      int person_id FK
      blob vector "512 f32"
    }
    IDENTITY {
      int id PK
      string name
      bool is_named
      string object_class "class-scoped matching"
      float total_seconds
      bool is_provisional
      blob face_centroid
      blob appearance_centroid
    }
    SIGHTING {
      int id PK
      int identity_id FK
      int camera_id FK
      int event_id FK
      string match_kind "face|appearance|new"
      string thumb_path
    }
    FACE_EXEMPLAR {
      int id PK
      int identity_id FK
      blob vector
      float pose "signed yaw"
    }
    APPEARANCE_EXEMPLAR {
      int id PK
      int identity_id FK
      blob vector
      datetime ts "decay clock"
    }
    PRESENCE_SEGMENT {
      int id PK
      int identity_id FK
      float seconds
    }
    FACE_SAMPLE {
      int id PK
      blob vector "ArcFace"
      blob app_vector "OSNet"
      string label "named group"
    }

Десять таблиц:

Таблица Роль
Camera RTSP-источник + его настройки на камеру (nullable → откат к глобальным Settings). Поля heartbeat (status, last_seen) обновляются воркером.
Event Одно записанное присутствие: путь к клипу, миниатюра, денормализованные снимки ручного (person_*) и автоматического (identity_*) совпадений, метаданные трека.
Person Вручную зарегистрированный известный человек (слой «People»).
FaceEmbedding 512-мерный вектор ArcFace, принадлежащий Person; FAISS FaceIndex производится из них.
Identity Автоматически обнаруженный человек/объект, построенный онлайн из наблюдений — без регистрации. Несёт object_class (совпадение в пределах класса), время пребывания total_seconds, производные центроиды и флаги is_provisional/is_named.
Sighting Одна идентифицированная детекция: bbox, оценки, match_kind, миниатюра кропа тела.
FaceExemplar Репрезентативный вектор ArcFace для identity (лимит ~8/12), сгруппированный по знаковому yaw pose для многоракурсной галереи.
AppearanceExemplar Вектор OSNet на identity с меткой захвата ts для затухания во времени (лимит ~16).
PresenceSegment Одно непрерывное появление identity на одной камере; суммируется в Identity.total_seconds (аудит-след пребывания).
FaceSample Захваченный кроп лица + вектор ArcFace (+ опциональный OSNet app_vector) для неконтролируемой группировки лиц — независимой от identity body-Re-ID.

7.1 Поведение каскадов и удаления

Каскады FK объявлены на уровне ORM с passive_deletes=True (БД обеспечивает их при foreign_keys=ON):

  • Удаление Camera → её Event'ы и Sighting'и удаляются каскадно.
  • Удаление Person → её FaceEmbedding'и удаляются каскадно; любой Event.person_id, ссылающийся на неё, получает SET NULL (история событий сохраняется).
  • Удаление Identity → её Sighting'и, FaceExemplar'ы, AppearanceExemplar'ы и PresenceSegment'ы удаляются каскадно. Event.identity_id — это денормализованная связь, которая обнуляется в коде приложения (на SQLite это обычный столбец INTEGER, материализованный схемой-шимом, а не настоящий FK — см. ниже). API identity delete_identity выполняет рекурсивное удаление, которое также вычищает дисковые артефакты: директорию кропов на identity и связанные строки FaceSample + их файлы кропов (у FaceSample нет каскада FK на уровне БД). |

7.2 Управление схемой

Фреймворка миграций нет. init_db (app/db/database.py) вызывает Base.metadata.create_all, а затем набор идемпотентных шимов (ensure_reid_schema, ensure_camera_schema, ensure_identity_object_schema, ensure_event_track_schema, ensure_face_pose_schema). Они существуют потому, что SQLite не может ALTER столбец со встроенным FK на существующую таблицу, поэтому events.identity_id и подобные добавляются как обычные столбцы под защитой проверок PRAGMA table_info (no-op при повторном запуске). Шимы выполняются в процессе API при старте, до того как любой воркер запишет событие в режиме track. Цикл FK identities.rep_sighting_idsightings.identity_id разрывается при создании через use_alter=True.


8. Фронтенд и live-поверхность

Фронтенд — это vanilla-JS одностраничное приложение без шага сборки (app/static/: index.html, app.js, identities.js, people.js, faces.js, CSS, плюс вендоренный hls.min.js). Он монтируется как последний маршрут в create_app (app/main.py):

app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")

Роутеры API (/api/*) и /health регистрируются до монтирования, поэтому они имеют приоритет; всё остальное проваливается к статическим ассетам, а index.html отдаётся по /.

У live-просмотра три режима, все опираются на слоты кадров воркеров и буфер сегментов:

  • MJPEG-сетка с низкой задержкой. Каждая плитка — это <img src="/api/live/{id}/stream?fps=…">, держащая одно долгоживущее соединение multipart/x-mixed-replace (app/api/live.py). Генератор отдаёт новую часть только при изменении слота кадра и уважает лимит ?fps=, так что сетка может запросить меньшую частоту (много плиток), пока сфокусированный зритель запрашивает полный live_mjpeg_fps. nginx работает с proxy_buffering off / X-Accel-Buffering: no для покадрового сброса.
  • Одиночный снимок. /api/live/{id}/snapshot возвращает последний аннотированный JPEG (или заглушку «no signal», чтобы UI никогда не ломался, пока воркер прогревается).
  • Live со звуком. MJPEG не несёт аудио, поэтому по запросу SPA запрашивает /api/live/{id}/hls/index.m3u8, что запускает on-demand сессию RTSP→HLS (app/recording/hls.py), воспроизводимую hls.js (или нативно в Safari), со строгой валидацией имён сегментов ^seg\d{5}\.ts$.

Воспроизведение истории/клипов стримит записанный mp4 с полной поддержкой HTTP Range (app/api/events.py, _iter_file_range), так что перемотка <video> работает, а os.path.commonpath сторожит от выхода за пределы корня данных (path traversal).

Сфокусированный монитор добавляет на клиенте эргономику для оператора: зум колесом мыши и двумя пальцами (pinch/pan), двойной тап, полноэкранный режим с учётом ориентации и кнопку ручной записи. SPA адаптивно по принципу mobile-first.


9. Безопасность и развёртывание (архитектурно)

Релевантные инварианты, на которые опирается архитектура:

  • Граница доверия на nginx. Приложение слушает только 127.0.0.1:8120 (compose публикует на loopback). nginx терминирует TLS, выполняет cookie-SSO (auth_request) и инъецирует доверенный заголовок identity (X-Email) — и переопределяет любую копию, предоставленную клиентом (анти-спуфинг). require_user (app/auth.py) fail-closed (auth_required по умолчанию true) и принимает либо SSO-заголовок, либо опциональный bearer API-ключ, сравниваемый за константное время (hmac.compare_digest).
  • Закалённый контейнер. Работает от non-root uid 1000 с cap_drop: [ALL], no-new-privileges и монтированием кода приложения в режиме read-only; GPU при этом работает, потому что /dev/nvidia* доступны всем.
  • Безопасность совместного хостинга. Жёсткий mem_limit (6 ГБ) плюс oom_score_adj: 800 делают разогнавшийся VMS предпочтительной жертвой OOM — kill в рамках cgroup перезапускает один воркер камеры, а не позволяет VMS столкнуть общий хост в глобальный OOM, который нарушил бы работу совместно хостящихся VM.
  • Никаких секретов в репозитории. .env.example документирует AUTH_REQUIRED и генерацию API-ключей; RTSP-учётки маскируются в каждом ответе API (сырой URL остаётся на стороне сервера).

10. С чего начать чтение кода

Тема Точка входа
Связывание приложения, lifespan, монтирование роутеров, SPA app/main.py
Жизненный цикл воркеров, spawn, reconcile, слоты кадров app/workers/manager.py
Конвейер из трёх потоков (decode / loop / drains) app/workers/camera_worker.py
Назначение identity (face → appearance → new) app/reid/manager.py
Извлечение признаков (ArcFace + OSNet, поза) app/reid/pipeline.py
Производная галерея (FAISS + хранилище внешнего вида) app/reid/gallery.py
Трекер (жадный IoU, тайминг пребывания) app/reid/tracker.py
Скользящий буфер сегментов / сборка клипов app/recording/segmenter.py, clipper.py
Модель данных + каскады app/db/models.py, app/db/database.py
Auth / граница доверия app/auth.py, app/config.py
Live MJPEG / HLS / ручная запись app/api/live.py