Архитектура¶
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/<cam><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)), затем
синхронно прогоняет на этом единственном кадре весь конвейер:
_safe_detect→ YOLOv8n на GPU, отфильтрованный доtrigger_classesкамеры._track_and_identify→ продвижение жадного IoU-трекера (app/reid/tracker.py), финализация закрытых треков и (повторное) эмбеддинг/назначение ограниченного числа активных треков (§4)._handle_detections/_finalize_presence→ создание событий и постановка задач на клипы в очередь._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. Сквозной жизненный цикл кадра¶
Путь одного кадра через один воркер:
- Декодирование. Поток декодирования читает кадр из RTSP и публикует его как единственный последний кадр (любой не потреблённый предшественник отбрасывается).
- Подхват. Главный цикл просыпается, берёт последний кадр + временную
метку, тикает fps и публикует heartbeat
online(троттлинг ~1 Гц). - Гейт каденса. Адаптивный каденс (§4) решает, детектировать ли этот
кадр. Если нет, цикл переиспользует
_last_boxesдля оверлея превью и сразу переходит к записи слота кадра. - Детекция. YOLOv8n работает на GPU (
_safe_detect); боксы фильтруются доtrigger_classes. Если присутствует любой триггерный объект, обновляется_last_activity_ts(удерживая камеру в режиме active). - Трекинг.
ObjectTracker.updateжадно сопоставляет боксы этого кадра с существующими треками по IoU одного класса, открывает треки для несопоставленных боксов и закрывает треки, простаивавшие дольшеtrack_gap_seconds. - Финализация закрытых треков. Каждый закрытый трек создаёт (в режиме
track) одноEventна всё присутствие и ставит в очередь задачу на клип, затем добавляет своё время пребывания вPresenceSegmentиIdentity.total_seconds(_finalize_presence). - (Повторная) идентификация активных треков. Ограниченный набор треков «на
очереди» эмбеддится (
IdentityPipeline.extract→ вектор лица ArcFace + вектор тела OSNet), иIdentityManager.assignсвязывает каждый с существующей identity либо создаёт новую (§4, §6). Строки Sighting коммитятся; миниатюры/сэмплы лиц ставятся в очередь сбросчику персистенса. - Превью.
_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:
- Отбирает только треки, подошедшие к (повторной) идентификации. Свежие
треки без identity используют быстрый каденс (
reid_sample_seconds, по умолчанию 3 с); уже идентифицированные треки обновляются на более медленном «уверенном» каденсе (reid_confident_sample_seconds, по умолчанию 9 с), потому что identity «липкая». - Приоритезирует неназначенные треки, затем — дольше всех ожидающие.
- Ограничивает число фактически эмбеддируемых в этом кадре значением
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_mode — track: ровно одно Event на
присутствие объекта. При закрытии трека _finalize_presence ставит в очередь
задачу на клип; build_clip_from_track (app/recording/clipper.py) собирает
клип, охватывающий [enter - pre_seconds, last + post_seconds]:
- Дождаться финализации сегмента(ов) post-roll, покрывающих конец окна
(
_wait_for_post_roll) — сегментер записывает сегмент под его финальным именем только по завершении, поэтому наличие более нового сегмента доказывает, что хвост полон. - Выбрать каждый сегмент, перекрывающийся с окном, исключая ещё растущий
live-хвост (у которого пока нет атома
moov→ concat провалился бы) и любой незавершённый файл (_is_finalizedпроверяет через ffprobe). - Сконкатенировать через concat-демультиплексор ffmpeg с
-c copy -movflags +faststart(без перекодирования) вdata/recordings/<camera_id>/<event_id>.mp4. - Извлечь одну миниатюру вблизи момента триггера.
Унаследованный режим фиксированного окна 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=5000 — app/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 identitydelete_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_id ↔
sightings.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):
Роутеры 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 |