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

Кросс-камерная реидентификация

Языки: English · Русский**

Подробный разбор устройства движка идентификации, лежащего в app/reid/. Источником истины является код; каждый порог, фильтр и поведение, описанные здесь, соответствуют именованному символу в app/reid/manager.py, gallery.py, pipeline.py, decay.py, maintenance.py и app/faces/recognizer.py. Там, где приводится значение по умолчанию, это значение из app/config.py (Settings) или MatchConfig.

Реидентификация — это та часть Iris, которая превращает поток разрозненных обнаружений в небольшую устойчивую библиотеку людей и объектов, сохраняющуюся между камерами и между днями. Человек, прошедший мимо передней камеры в 09:00, дворовой камеры в 12:00 и дверной камеры на следующее утро, должен свестись к одной личности — «Человек 14» — а не к трём и не к четырнадцати.

Этот документ объясняет, как это сделано, почему очевидные подходы не работают и какую именно процедуру принятия решений выполняет онлайн-сопоставитель для каждого наблюдения.


1. Постановка задачи

Кросс-камерная идентификация сложна по причинам, не имеющим ничего общего с точностью модели:

  • Ракурс. Один и тот же человек виден анфас на одной камере, в профиль на следующей и прямо со спины на третьей. Кроп тела со спины и кроп тела спереди у одного и того же человека могут быть менее похожи, чем у двух разных людей, снятых под одним и тем же углом.
  • Одежда в разные дни. Модели реидентификации по телу/внешности (OSNet и др.) сильно опираются на одежду и силуэт. Это сильный признак в пределах одной сессии и бесполезный между днями — тот же человек в другом пальто выглядит как незнакомец, а двое незнакомцев в одинаковой форме выглядят одинаково.
  • Освещение и отклик камеры. Статистики цвета и текстуры смещаются между дворовой камерой при дневном свете и коридором с ИК-подсветкой.

Наивный подход — закодировать каждый кроп человека моделью реидентификации по телу, сопоставлять по косинусу и создавать новую личность, когда ничего достаточно близкого не нашлось, — терпит крах вполне определённым и наглядным образом: это взрыв дубликатов. Человек, снятый со спины, даёт эмбеддинг тела, не совпадающий с его же эмбеддингом анфас, поэтому сопоставитель создаёт новую личность. Проведите одного человека через поле зрения камеры — и вы получите дюжину «людей». У одной лишь внешности нет якоря, который переживал бы смену ракурса, поэтому она не может решить, что два непохоже выглядящих кропа — это один и тот же человек.

Проектное решение, благодаря которому работает всё остальное, состоит в том, чтобы выбрать признак, который действительно устойчив к смене одежды, ракурса, освещения и течению времени, и закрепить идентичность на нём.


2. Якорь по лицу

Идентичность закреплена на лице/голове — единственном сигнале, устойчивом к смене одежды, смене ракурса (в пределах собственного диапазона поз лица), смене освещения и течению дней.

Извлечение признаков

Признаки лица берутся из набора buffalo_l библиотеки insightface через app/faces/recognizer.py:

  1. Детекция с помощью SCRFD-10G (вход 640×640, det_thresh по умолчанию 0.5), дающая ограничивающий прямоугольник, оценку детекции и 5 ключевых точек лица (DetectedFace.kps).
  2. Выравнивание + кодирование с помощью ArcFace (r50), дающее 512-мерный, L2-нормированный эмбеддинг. Поскольку векторы единичной нормы, индекс по скалярному произведению напрямую даёт косинусную близость.

ArcFace запускается один раз на всём триггерном кадре, а не заново внутри каждого кропа человека. IdentityPipeline.extract() (app/reid/pipeline.py) детектирует все лица, после чего относит каждое лицо к наименьшему по площади прямоугольнику человека, содержащему центроид лица (включение по наименьшей площади корректно обрабатывает перекрывающихся людей). Это дешевле, чем повторная детекция в каждом кропе, и удерживает единственную модель лица в памяти GPU.

Фильтр качества «приличного лица»

Не каждое обнаруженное лицо заслуживает доверия. Крошечное, размытое или сильно профильное лицо даёт мусорный вектор ArcFace — близкий ни к кому конкретно или ложно близкий не к тому человеку. Его сохранение навсегда загрязняет галерею.

Поэтому каждое назначенное лицо несёт учитывающую позу оценку качества:

face_quality = det_score × frontalness(landmarks)

frontalness() (pipeline.py) — это дешёвая геометрия по ключевым точкам: у лица анфас нос горизонтально отцентрован между глазами; профиль смещает его в одну сторону, толкая оценку к 0. Лицо считается приличным, только когда face_quality ≥ face_exemplar_min_quality (по умолчанию 0.35).

Этот единственный фильтр несёт нагрузку в двух местах:

  • Новая личность создаётся по лицу только если лицо приличное (_quality_ok_for_new).
  • Лицо сохраняется как эталон галереи только если оно приличное (_maybe_add_face_exemplar).

Почему вид без лица никогда не порождает личность

Это правило, побеждающее взрыв дубликатов, в MatchConfig:

require_face_for_new_person = True

В _quality_ok_for_new():

  • Приличное лицо даёт право на новую личность (лица — это сильное, редкое свидетельство).
  • Для человека без приличного лица — спина, дальний вид сбоку, кроп головы без ключевых точек — require_face_for_new_person форсирует False. Ему никогда не позволено создать новую личность.

У кропа человека без лица ровно две судьбы: он либо присоединяется к существующей личности по внешности в пределах сессии (раздел 4), либо отбрасывается (match_kind="dropped", строка в БД не записывается). Он никогда не может породить человека. Именно это не даёт одному человеку, снятому со спины, раздробиться на десятки дубликатов — затылок не несёт идентичности, поэтому система отказывается изобретать её из него.

Объекты, не являющиеся людьми (машины, животные), не имеют лица, поэтому к ним применяется иной, основанный на внешности порог качества, а не требование лица (require_quality_for_new, min_app_box_area_frac, min_crop_quality_for_new). Человек без лица, всё же переживший первое наблюдение, помечается как is_provisional=True и удаляется обслуживанием, если он так и не накопит второго наблюдения или лица (раздел 6).


3. Многоракурсная галерея лиц с разбиением по позам

Галерея только из анфасов всё равно проваливается по ракурсу: профильный запрос против одних только анфасных эталонов даёт низкую оценку даже для правильного человека. ArcFace устойчив к некоторой позе, но не к произвольной. Решение — сделать саму галерею инвариантной к углу, храня эталоны во всём диапазоне поз.

Знаковый рыскание (yaw) → корзины поз

face_pose() (pipeline.py) вычисляет знаковое рыскание примерно в диапазоне [-0.5, +0.5] по 5 ключевым точкам (смещение носа относительно межглазного расстояния): 0 ≈ анфас, отрицательные и положительные значения = голова повёрнута в ту или иную сторону. Оно сохраняется в каждом FaceExemplar.pose.

_face_pose_bucket() (manager.py) квантует его в три корзины:

Корзина Условие Значение
-1 yaw ≤ -0.12 повёрнута в одну сторону
0 -0.12 < yaw < 0.12 анфас
+1 yaw ≥ 0.12 повёрнута в другую сторону

Приём ради разнообразия поз

Когда для существующей личности приходит новое приличное лицо, старый инстинкт — «пропустить, у нас уже есть лицо для этого человека» — неверен: он удерживает галерею только анфасной. Вместо этого _maybe_add_face_exemplar() отвергает лишь лицо, являющееся настоящим почти-дубликатом уже сохранённого (косинус > face_exemplar_hi, по умолчанию 0.90). Лицо, набравшее низкую оценку против существующих эталонов, — это не шум, а новая поза того же отслеживаемого человека, и это ровно тот вид слева/справа, который нужен галерее, чтобы позже узнать этого человека под этим углом. Поэтому оно сохраняется.

Учитывающее позу вытеснение

Лимит на личность — max_face_exemplars (по умолчанию 12, рассчитан на анфас + лево + право). При переполнении вытеснение не должно обеднять какую-либо корзину поз — иначе галерея под напором сноса снова сместится к одним анфасам. Поэтому вытеснение:

  1. считает эталоны по каждой корзине поз,
  2. выбирает наиболее представленную корзину (over),
  3. удаляет эталон с наименьшим det_score внутри этой корзины.

Недопредставленные профили защищены; избыточные анфасы подрезаются. Чистый эффект — инвариантность к углу на уровне галереи: профильный запрос попадает рядом с сохранённым профильным эталоном того же человека, а не только рядом с его анфасами.

Галерея (IdentityGallery, gallery.py) хранит каждый эталон лица в FAISS IndexFlatIP (скалярное произведение = косинус на единичных векторах). best_face_per_identity() сворачивает сырые попадания эталонов в лучшее попадание на личность, так что проверка зазора (раздел 5) сравнивает двух лучших разных людей, а не два эталона одного.


4. Внешность — только как внутрисессионный помощник

Внешность тела (OSNet) намеренно понижена до внутрисессионной, затухающей во времени подсказки — но никогда не является признаком идентичности между днями.

  • Модель. OSNet (reid_model по умолчанию osnet_ain_x1_0_msmt17.onnx — доменно-обобщающий вариант AIN x1.0 для лучшего кросс-камерного/ракурсного поведения; osnet_x0_25_msmt17.onnx — документированный облегчённый CPU-запасной вариант), экспортированная в ONNX, запускается под onnxruntime-gpu. Вход 128×256, ImageNet-нормировка, 512-мерный L2-нормированный выход. app/reid/embedder.py.
  • Вычисляется всегда. Человек, отвернувшийся или в маске, всё равно даёт вектор тела, даже когда face_vec равен None — в этом и смысл: внешность — это то, что позволяет кропу без лица присоединиться к человеку, установленному по его лицу.

Затухание во времени

app/reid/decay.py взвешивает каждое совпадение по внешности через

w(Δt) = exp(-Δt / TAU),   TAU = app_decay_tau_seconds (по умолчанию 43 200 с = 12 ч)

Эффективная оценка внешности кандидата-личности равна max_i(cosine_i × w(Δt_i)) — одного недавнего похожего наряда достаточно для связывания, тогда как наряд 12-часовой давности стоит ~e^{-1} своего косинуса, а 2-дневной — по сути ничего. Это буквальное кодирование принципа «к завтрашнему дню наряд уже устарел».

Фильтр временно-пространственного окна

appearance_candidates() рассматривает только ту личность, чей самый свежий эталон внешности всё ещё находится в пределах app_window_seconds (по умолчанию 600 с) от запроса — жёсткое окно переводится в нижний порог минимального веса затухания exp(-window / TAU). Это ограничивает «телепортацию»: личность, в последний раз виденная как тело несколько часов назад, сейчас не является кандидатом по внешности. Лица такого окна не несут — они стабильны во времени и никогда не затухают.


5. Алгоритм сопоставления — assign()

IdentityManager.assign() (manager.py) решает для каждого наблюдения: СОВПАДЕНИЕ (какая личность) против НОВАЯ против ОТБРОС. Это единственный источник истины; подпроцессы-воркеры камер держат каждый производную галерею и пишут в общую БД SQLite, так что все воркеры сходятся. Порядок — от сильнейшего свидетельства.

flowchart TD
    A["SightingFeature<br/>(box, face_vec?, appearance_vec?,<br/>face_quality, pose, color_hist, object_class)"] --> B{Sticky / hysteresis?<br/>IoU >= sticky_iou & same class<br/>within sticky_seconds}
    B -- yes --> STK["MATCH: keep last identity<br/>(match_kind = sticky)<br/>skip exemplar churn"]
    B -- no --> C{Face present?}

    C -- yes --> D["best_face_per_identity()<br/>best, second = top-2 distinct"]
    D --> E{best >= FACE_STRONG<br/>(0.55)?}
    E -- yes --> FM["MATCH by face<br/>(authoritative; ignore margin<br/>& appearance)"]
    E -- no --> F{best >= FACE_MATCH (0.42)<br/>AND margin >= 0.06?}
    F -- yes --> FM
    F -- no --> G["set veto_identity if<br/>best >= FACE_MATCH<br/>(forbid appearance->other id)"]

    C -- no --> H[appearance step]
    G --> H

    H --> I{appearance_vec present<br/>AND candidates in window?}
    I -- no --> N[new-identity step]
    I -- yes --> J["appearance_candidates()<br/>(decay-weighted, class-scoped,<br/>in time window)"]
    J --> K{Face contradiction?<br/>veto_identity set &<br/>best != veto}
    K -- yes --> K2["drop to vetoed candidate<br/>or fail appearance"]
    K -- no --> L
    K2 --> L{Borderline-face corroborates<br/>best & best >= APP_GATE (0.50)<br/>& margin >= 0.05?}
    L -- yes --> AM["MATCH by appearance"]
    L -- no --> M{Colour gate ok (non-person)<br/>AND best >= bar<br/>(same-cam 0.62 / cross-cam 0.66)<br/>AND margin >= 0.05?}
    M -- yes --> AM
    M -- no --> N

    N{Quality OK for NEW?<br/>person: decent face REQUIRED<br/>else: app + size + sharpness}
    N -- no --> DROP["DROP (match_kind = dropped)<br/>no identity, no row"]
    N -- yes --> O{New-identity rate<br/>< new_identity_rate_per_min?}
    O -- no --> DROP
    O -- yes --> NEW["CREATE identity 'Class N'<br/>(provisional if person w/o face)"]

    FM --> FIN[finalize: write Sighting,<br/>update exemplars, counters,<br/>refresh sticky]
    AM --> FIN
    NEW --> FIN
    STK --> FIN

Шаг 0 — Sticky / гистерезис (_sticky_lookup)

Непрерывный трек на одной камере не должен переоцениваться кадр за кадром (это грозит мерцанием идентичности и жжёт GPU). Если текущий прямоугольник перекрывается с недавним прямоугольником на той же камере и того же класса объекта с IoU ≥ sticky_iou (0.30) в пределах sticky_seconds (2.0 с), наблюдение наследует эту личность напрямую (match_kind="sticky", сохраняется как appearance). Обновления эталонов на этом горячем пути пропускаются — серия уже зарегистрирована. Проверка совпадения класса не даёт машине унаследовать личность человека лишь потому, что их прямоугольники на миг перекрылись.

Шаг 1 — Лицо (_decide_by_face)

Опросить FAISS-индекс лиц; взять лучшее попадание на каждую отдельную личность и второе лучшее для зазора.

  • best ≥ face_strong (0.55): авторитетно. Сильное лицо убедительно даже среди похожих — внешность игнорируется, и узкий зазор игнорируется.
  • best ≥ face_match (0.42) И margin ≥ match_margin_face (0.06): совпадение. Зазор «лучшее минус второе лучшее» отвергает неоднозначный случай, когда два разных человека оба набрали умеренно — если верхние двое близки, лицо недостаточно различающее, и мы проваливаемся дальше, а не гадаем.
  • Иначе провалиться к внешности. Если лучшее лицо всё же ≥ face_match, оно устанавливает личность-вето (_face_veto_identity): теперь внешности запрещено назначать это наблюдение другой личности.

Шаг 2 — Внешность (_decide_by_appearance)

Достигается только когда лицо отсутствовало или было неубедительным.

  • Ограничение по классу. appearance_candidates() возвращает только личности того же object_class — машина совпадает только с машинами, человек только с людьми. Класс проверяется в галерее, поэтому типы объектов никогда не могут слиться в одну личность.
  • Вето от противоречия лица. Если установлена личность-вето, а верхний кандидат по внешности — другая личность, совпадение отклоняется; сопоставитель примет только саму вето-личность (ту, на которую указало пограничное лицо).
  • Слияние пограничного лица (_face_corroborator). Если лицо в диапазоне [face_reject_new (0.32), face_match (0.42)) слабо подтверждает того же кандидата, что выбрала внешность, порог снижается с полного app_match до app_gate (0.50) — слабое лицо + приличная внешность совместно берут порог, который поодиночке не берёт ни одно.
  • Цветовой фильтр (не для людей). Для объектов пересечение цветовых гистограмм (по оттенку) ниже color_gate (0.35) запрещает совпадение — красная машина никогда не становится синей (app/reid/attributes.py). Люди освобождены (цвет одежды — не идентичность).
  • Порог той же камеры против кросс-камеры. Чистая связь по внешности берёт app_match (0.62) на той же камере или более строгий app_match_cross (0.66), когда кандидат когда-либо наблюдался только на других камерах — к кросс-камерным связям по внешности предъявляется более высокий стандарт. Оба также требуют margin ≥ match_margin_app (0.05).

Шаг 3 — Новая личность (_create_new)

Достигается только когда ни лицо, ни внешность не дали совпадения. Перед созданием стоят два фильтра:

  • Фильтр качества (_quality_ok_for_new): человеку нужно приличное лицо; иначе кроп отбрасывается, а не повышается (раздел 2). Объектам нужен достаточно крупный, резкий кроп с вектором внешности.
  • Ограничение скорости новых личностей (_rate_limit_ok): не более new_identity_rate_per_min (по умолчанию 30) новых личностей на камеру за скользящую минуту. Всплеск несопоставленных кропов (толпа, сбой) не может затопить библиотеку — излишек отбрасывается.

При успехе создаётся Identity, автоматически именуется «Person N» / «Car N» / «Dog N» по своему классу и id, регистрируется в галерее и засевается своей цветовой гистограммой. Безликий объект-не-человек или человек с лицом — реальны; человек без лица создаётся временным (provisional) и находится на испытании до обслуживания.

Финализация (_finalize)

Для каждого принятого наблюдения (совпадение или новое): записать строку Sighting (bbox, оценки, match_kind, путь к миниатюре, опциональный event_id); по условию добавить эталон лица (приличного качества, с корзиной позы) и эталон внешности (с фильтром по размеру, с временной меткой); инкрементировать num_sightings, first/last_seen, rep_sighting_id; вывести личность из временного статуса на её 2-м наблюдении или первом лице; влить визуальные атрибуты (цвет — однократно; марку автомобиля / тип кузова, сохраняя значение с наибольшей уверенностью); и обновить sticky-кэш. Вызывающая сторона делает commit, поэтому весь пакет одного триггерного кадра сохраняется атомарно.

Ограничение работы на кадр

Сопоставитель не может застопорить цикл камеры на толпе. Воркер (camera_worker._reidentify) выбирает, какие треки подошли на этом кадре — неидентифицированные треки первыми (быстрый ритм), уже идентифицированные треки на более медленном уверенном ритме — затем ограничивает число фактически кодируемых значением max_reid_per_frame (по умолчанию 4). Идентичность липкая, поэтому трек, переждавший кадр, тем временем сохраняет свою личность.


6. Проход обслуживания

Поток-демон (app/reid/maintenance.py, каждые reid_maintenance_interval_seconds, по умолчанию 120 с) выполняет run_once(), чтобы держать библиотеку маленькой, точной и дешёвой. Один проход, по порядку:

  1. Очистка временных. Безымянная личность без свидетельства лица и с ≤ 1 наблюдением, старше provisional_grace_seconds (600 с), удаляется — это шум детектора, а не человек.
  2. Консервативное автослияние только по лицам. Две безымянные личности, чьи центроиды лиц имеют косинус ≥ face_merge_threshold (0.60) и которые никогда не наблюдались на разных камерах в один и тот же момент (_temporally_conflicting, ±5 с — нельзя быть в двух местах сразу), сливаются, исправляя пересегментацию. Выживает меньший id («Person N» остаётся стабильным). Внешность никогда не сливает (одежда общая/неоднозначная), а личности с is_named заморожены.
  3. Подрезка + пересчёт на личность.
  4. Внешность: отбросить эталоны, чей вес затухания упал ниже порога подрезки (exp(-2)) или которые превысили жёсткий лимит возраста (по умолчанию 7 дней); затем ограничить до max_app_exemplars (16) по совокупному качество × затухание.
  5. Лица: никогда не затухают; ограничить до max_face_exemplars (вытеснить с наименьшим det_score).
  6. Пересчитать face_centroid (среднее эталонов лиц) и appearance_centroid (взвешенное по затуханию среднее); обновить rep_sighting_id на лучшую лиценосную, высокооценённую, недавнюю миниатюру; держать num_sightings / first/last_seen честными.

После прохода галерею API-процесса просят перезагрузиться, чтобы операторские интерфейсы и внутрипроцессное сопоставление видели уплотнённое состояние. Операторы также могут сливать и разделять личности вручную через API личностей; split() может автоматически перекластеризовать эталоны личности (2-means по лицам, с откатом на внешность), когда явный список наблюдений не задан.


7. Честное ограничение

Камера, видящая всегда лишь затылок, не несёт никакой биометрии, которую мог бы использовать хоть какой-то метод. ArcFace нужно лицо; OSNet видит лишь пальто, которое завтра будет другим. Нет алгоритма, способного превратить затылок в надёжную междневную идентичность — и любая система, утверждающая обратное, её выдумывает.

Iris спроектирована так, чтобы быть корректной при этом ограничении, а не притворяться, что его нет:

  • Вид человека без лица никогда не порождает новую личность (require_face_for_new_person). Он может только присоединиться к человеку, уже установленному по его лицу, и только в пределах окна живой сессии, иначе он отбрасывается. Режим отказа — «мы не идентифицировали этот затылок», а не «мы изобрели двенадцать человек».
  • Связи по внешности затухают во времени и ограничены окном, поэтому совпадение безликого тела не может незаметно перекинуться через дни на одной только одежде.
  • Фильтры зазора и скорости новых личностей делают систему консервативной при неоднозначности: когда два кандидата близки или свидетельство слабо, она отказывается гадать.

Практическое следствие — намеренное смещение: Iris скорее недозаявляет, чем перезаявляет. Она предпочтёт оставить плохо рассмотренного человека неидентифицированным, чем разбить реального человека на дубликаты или слить двух незнакомцев. Для системы видеонаблюдения, по выводам которой действуют операторы, отказ выдумывать идентичность из неидентифицируемого вида — это не недостающая функция, а правильное поведение, и оно закреплено в коде, а не отдано на откуп порогу, который кто-нибудь может опустить.


Приложение — ключевые пороги

Все переопределяемы через Settings / env (app/config.py); MatchConfig хранит значения по умолчанию на стороне сопоставителя.

Символ По умолчанию Роль
face_strong 0.55 авторитетное совпадение по лицу (игнорирует зазор/внешность)
face_match 0.42 совпадение по лицу (с зазором); также задаёт вето внешности
face_reject_new 0.32 нижний порог для подтверждения пограничным лицом
match_margin_face 0.06 требуемое «лучшее − 2-е лучшее» для несильного совпадения по лицу
face_exemplar_min_quality 0.35 фильтр приличного лица (det_score × frontalness) для новой личности + эталонов
face_exemplar_hi 0.90 выше этого лицо — почти-дубликат, не сохраняется
face_merge_threshold 0.60 консервативное автослияние только по лицам (только безымянные)
app_match / app_match_cross 0.62 / 0.66 порог внешности, та же камера против кросс-камеры
app_gate 0.50 сниженный порог внешности при подтверждении пограничным лицом
match_margin_app 0.05 требуемое «лучшее − 2-е лучшее» для совпадения по внешности
color_gate 0.35 нижний порог гистограммы оттенков для совпадений внешности не-людей
app_window_seconds 600 временное окно кандидатства по внешности
app_decay_tau_seconds 43 200 (12 ч) константа затухания внешности во времени
max_face_exemplars / max_app_exemplars 12 / 16 лимиты галереи на личность
new_identity_rate_per_min 30 антивзрывной лимит на камеру
sticky_iou / sticky_seconds 0.30 / 2.0 гистерезис непрерывного трека
max_reid_per_frame 4 ограничение работы реидентификации на кадр (воркер)