Clarify face unknown alarm semantics

This commit is contained in:
tian 2026-04-18 10:54:39 +08:00
parent 8c910d55fc
commit 5beab86905
5 changed files with 17 additions and 15 deletions

2
.gitignore vendored
View File

@ -12,6 +12,8 @@
## Tooling outputs
/compile_commands.json
/.cache/
__pycache__/
*.py[cod]
## Binaries and objects
*.o

View File

@ -300,7 +300,8 @@ pre_rgb -> face_det -> person_det -> person_trk -> face_recog -> ...
- `known`:满足 `threshold.accept``threshold.margin`,可以作为“已知人”证据。
- `uncertain`:像某个已知人,但证据不足;不会直接当作陌生人。
- `unknown`:保留给明确陌生人语义。当前人脸识别节点主要输出 `known` / `uncertain`,陌生人告警由 alarm 结合 track 聚合、质量门槛和“没有已知人证据”来判断。
人脸识别模型输出的是 embedding不是带有 `unknown` 类别的分类结果。因此人脸识别节点只输出 `known` / `uncertain`。`unknown_face` 属于告警规则语义,由 alarm 结合 track 聚合、质量门槛和“没有已知人证据”来判断。
日志分析:
@ -779,9 +780,9 @@ python tools/analyze_face_recog_log.py .\logs\media-server_latest.log
- `face_track_aggregation.known.min_hits` 是否过高。测试触发链路可用 `1`,正式打卡建议 `2``3`
- `known_person.cooldown_ms``known.reentry_cooldown_ms` 是否正在抑制重复上报。
### Q5: unknown 和 uncertain 有什么区别?
### Q5: unknown_face 和 uncertain 有什么区别?
`uncertain` 是单帧识别状态,表示“没有稳定确认是已知人”。它本身不能直接当作陌生人。`unknown_face` 是告警规则,使用开放集识别思路:人脸质量足够、不是 known、`best_sim < unknown_face.max_known_sim`、同一人体 track 持续存在并累计足够质量帧后才认为是陌生人这样可以避免远处小脸合成人脸或短暂模糊帧被误报成陌生人
`uncertain`识别节点的单帧状态,表示“没有稳定确认是已知人”。它本身不能直接当作陌生人。`unknown_face` 是告警规则,使用开放集识别思路:人脸质量足够、不是 `known`、`best_sim < unknown_face.max_known_sim`、同一人体 track 持续存在并累计足够质量帧后才认为是陌生人这样可以避免远处小脸合成人脸或短暂模糊帧被误报成陌生人
---

View File

@ -9,14 +9,14 @@
现有链路已经具备以下能力:
- 从 gallery 中识别已知人员
- 基于单帧识别结果判定 `known``unknown`
- 基于单帧 embedding 匹配结果输出 `known``uncertain`
- 生成 `known_person``unknown_face` 告警
- 通过现有 alarm action 链路上传截图和视频片段
但当前告警行为仍然主要由单帧人脸识别结果驱动。在车间测试中,同一个已知人员可能出现:
- 近距离、高质量帧触发 `known_person`
- 远距离、小脸、低质量帧触发 `unknown_face`
- 远距离、小脸、低质量帧被误当作陌生人候选并触发 `unknown_face`
这不符合目标场景要求。对当前车间场景来说:
@ -85,7 +85,7 @@
2. 每张识别出的人脸在同帧内关联到一个人体框
3. 将关联后的人体 `track_id` 写入人脸识别结果
4. `alarm``person_track_id` 聚合多帧识别证据
5. 身份判定改为三态:
5. 告警层身份判定改为三态:
- `known`
- `unknown`
- `uncertain`
@ -95,7 +95,7 @@
这样可以保持当前 DAG 架构和插件边界不变:
- tracker 只负责跟踪人体
- 人脸插件只负责输出识别证据
- 人脸插件只负责输出识别证据。识别节点没有 `unknown` 分类,只输出 `known` / `uncertain`
- alarm 节点负责业务层面的身份确认、去重和抑制
## 5. 数据流设计
@ -154,7 +154,7 @@
### 7.1 状态定义
每个 `person_track_id` 只能处于以下三种状态之一:
每个 `person_track_id` 在 alarm 聚合层只能处于以下三种状态之一:
- `uncertain`
- 质量不足,或证据不足
@ -163,6 +163,8 @@
- `unknown`
- 已经稳定确认无法归属到已知人员
注意:`unknown` 不是人脸识别模型或 `ai_face_recog` 节点的单帧输出。人脸识别模型输出 embedding识别节点只判断当前帧是否可作为 `known` 证据;无法确认已知人时统一输出 `uncertain`。`unknown` 只在 alarm 层经过质量门控、持续跟踪和低已知相似度验证后形成。
### 7.2 为什么必须引入 `uncertain`
`uncertain` 是本方案的关键。

View File

@ -50,6 +50,7 @@ class FaceRecogLogAnalysisTest(unittest.TestCase):
self.assertEqual(summary.alarm_counts["known_person:reg_001"], 1)
self.assertEqual(summary.external_send_counts["ok"], 1)
self.assertEqual(summary.quantiles["known"]["best_sim"]["max"], 0.58)
self.assertNotIn("unknown", summary.quantiles)
self.assertEqual(summary.unknown_candidate_total, 2)
self.assertEqual(summary.unknown_candidate_reject_counts["min_face_area_ratio"], 1)
self.assertEqual(summary.unknown_candidate_gate_counts["waiting_track_age"], 1)

View File

@ -91,7 +91,6 @@ class LogSummary:
status_counts: Counter[str] = field(default_factory=Counter)
candidate_counts: Counter[str] = field(default_factory=Counter)
known_candidate_counts: Counter[str] = field(default_factory=Counter)
unknown_candidate_counts: Counter[str] = field(default_factory=Counter)
track_id_missing: int = 0
known_track_id_missing: int = 0
assoc_total: int = 0
@ -245,8 +244,6 @@ def analyze_lines(lines: Iterable[str]) -> LogSummary:
summary.candidate_counts[item.candidate] += 1
if item.status == "known":
summary.known_candidate_counts[item.candidate] += 1
if item.status == "unknown":
summary.unknown_candidate_counts[item.candidate] += 1
if item.track_id < 0:
summary.track_id_missing += 1
if item.status == "known":
@ -262,7 +259,7 @@ def analyze_lines(lines: Iterable[str]) -> LogSummary:
summary.first_frame = matches[0].frame
summary.last_frame = matches[-1].frame
for status in ("known", "uncertain", "unknown"):
for status in ("known", "uncertain"):
status_items = [item for item in matches if item.status == status]
summary.quantiles[status] = {
"best_sim": _quantiles([item.best_sim for item in status_items]),
@ -346,7 +343,7 @@ def format_summary(summary: LogSummary) -> str:
lines.append(f"- faces_in_sum: {summary.faces_in_sum}")
lines.append(f"- recog_items_sum: {summary.recog_items_sum}")
lines.append(f"- match_total: {summary.match_total}")
for status in ("known", "uncertain", "unknown"):
for status in ("known", "uncertain"):
count = summary.status_counts.get(status, 0)
lines.append(f"- {status}: {count} ({_pct(count, summary.match_total)})")
lines.append(f"- person_track_id=-1: {summary.track_id_missing} ({_pct(summary.track_id_missing, summary.match_total)})")
@ -355,10 +352,9 @@ def format_summary(summary: LogSummary) -> str:
lines.append("Candidates:")
lines.append(f"- top candidates: {summary.candidate_counts.most_common(10)}")
lines.append(f"- known candidates: {summary.known_candidate_counts.most_common()}")
lines.append(f"- unknown candidates: {summary.unknown_candidate_counts.most_common()}")
lines.append("")
lines.append("Quality Quantiles:")
for status in ("known", "uncertain", "unknown"):
for status in ("known", "uncertain"):
lines.append(f"- {status} best_sim: {summary.quantiles.get(status, {}).get('best_sim', {})}")
lines.append(f"- {status} sim_margin: {summary.quantiles.get(status, {}).get('sim_margin', {})}")
lines.append(f"- {status} bbox_area: {summary.quantiles.get(status, {}).get('bbox_area', {})}")