From 456cbc37273094e7bad06b8bf2c2095a35b6e183 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Fri, 17 Apr 2026 15:07:34 +0800 Subject: [PATCH] Improve unknown face diagnostics analysis --- plugins/alarm/alarm_node.cpp | 10 +- tests/test_analyze_face_recog_log.py | 8 ++ tests/test_face_track_alarm.cpp | 16 ++++ tools/analyze_face_recog_log.py | 131 +++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/plugins/alarm/alarm_node.cpp b/plugins/alarm/alarm_node.cpp index 74a9d51..ce88fde 100644 --- a/plugins/alarm/alarm_node.cpp +++ b/plugins/alarm/alarm_node.cpp @@ -666,6 +666,12 @@ private: return oss.str(); } + static std::string Fixed6(double value) { + std::ostringstream oss; + oss << std::fixed << std::setprecision(6) << value; + return oss.str(); + } + static FaceDebugConfig ParseFaceDebugConfig(const SimpleJson& config) { FaceDebugConfig out; const SimpleJson* debug = config.Find("face_debug"); @@ -733,7 +739,7 @@ private: out.face_area_ratio = r; if (r < static_cast(rule.min_face_area_ratio)) { out.reject_reason = "min_face_area_ratio"; - out.detail = "area_ratio=" + Fixed3(r) + " min=" + Fixed3(rule.min_face_area_ratio); + out.detail = "area_ratio=" + Fixed6(r) + " min=" + Fixed6(rule.min_face_area_ratio); return false; } } @@ -1041,7 +1047,7 @@ private: << " margin=" << Fixed3(static_cast(it.best_sim) - static_cast(it.second_sim)) << " bbox=(" << Fixed3(it.bbox.x) << "," << Fixed3(it.bbox.y) << "," << Fixed3(it.bbox.w) << "," << Fixed3(it.bbox.h) << ")" - << " area_ratio=" << Fixed3(area_ratio) + << " area_ratio=" << Fixed6(area_ratio) << " aspect=" << Fixed3(aspect) << " rule_matched=" << (rule_matched ? "true" : "false") << " reject_reason=" << (eval.reject_reason.empty() ? "-" : eval.reject_reason) diff --git a/tests/test_analyze_face_recog_log.py b/tests/test_analyze_face_recog_log.py index f19f4e3..f9ee39b 100644 --- a/tests/test_analyze_face_recog_log.py +++ b/tests/test_analyze_face_recog_log.py @@ -29,6 +29,8 @@ class FaceRecogLogAnalysisTest(unittest.TestCase): [1003][I] [ai_face_recog] frame id=face_recog frame=10 faces_in=2 recog_items=2 [1004][I] [ai_face_recog] match id=face_recog frame=10 status=known person_track_id=7 candidate=reg_001 candidate_id=1 best_sim=0.58 second_sim=0.44 sim_margin=0.14 bbox=(10,20,30,40) [1005][I] [ai_face_recog] match id=face_recog frame=10 status=uncertain person_track_id=-1 candidate=reg_002 candidate_id=2 best_sim=0.42 second_sim=0.39 sim_margin=0.03 bbox=(50,60,12,18) + [1005][I] [alarm] unknown_candidate rule=unknown_face frame=10 item=0 status=uncertain track_id=7 candidate_id=2 candidate=reg_002 best_sim=0.420 second_sim=0.390 margin=0.030 bbox=(50.000,60.000,12.000,18.000) area_ratio=0.000104 aspect=0.667 rule_matched=false reject_reason=min_face_area_ratio reject_detail=area_ratio=0.000104 min=0.001000 track_age_ms=0 quality_hits=0/4 min_track_age_ms=2000 reported_known=false reported_unknown=false gate=rule_rejected + [1005][I] [alarm] unknown_candidate rule=unknown_face frame=10 item=1 status=uncertain track_id=8 candidate_id=3 candidate=reg_003 best_sim=0.360 second_sim=0.320 margin=0.040 bbox=(100.000,100.000,40.000,60.000) area_ratio=0.001157 aspect=0.667 rule_matched=true reject_reason=- reject_detail=- track_age_ms=500 quality_hits=1/4 min_track_age_ms=2000 reported_known=false reported_unknown=false gate=waiting_track_age [1006][I] [ALARM][info] 2026-04-17 10:00:00 node=alarm rule=known_person:reg_001 frame=10 detections=[] [1007][I] [ExternalApiAction] token fetched successfully [1008][I] [ExternalApiAction] send ok http=200 alarm_content=known_person:reg_001 pic_url=a video_url=b @@ -48,6 +50,12 @@ 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.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) + self.assertEqual(summary.unknown_candidate_rule_matched, 1) + self.assertEqual(summary.unknown_candidate_track_summaries[0]["track_id"], 8) + self.assertEqual(summary.unknown_candidate_track_summaries[0]["max_quality_hits"], 1) if __name__ == "__main__": diff --git a/tests/test_face_track_alarm.cpp b/tests/test_face_track_alarm.cpp index d776111..bb0fc5d 100644 --- a/tests/test_face_track_alarm.cpp +++ b/tests/test_face_track_alarm.cpp @@ -266,6 +266,22 @@ TEST(FaceTrackAlarmTest, UnknownRuleDiagnosticsExplainMinSimRejection) { EXPECT_NE(eval.detail.find("best_sim=0.200"), std::string::npos); } +TEST(FaceTrackAlarmTest, UnknownRuleDiagnosticsUseHighPrecisionAreaRatio) { + AlarmNode::FaceRule rule; + rule.name = "unknown_face"; + rule.kind = AlarmNode::FaceRule::Kind::Unknown; + rule.min_face_area_ratio = 0.001f; + + FaceRecogItem item = MakeUncertainFace(102, 7, "reg_007", 0.40f); + item.bbox = Rect{0.0f, 0.0f, 28.0f, 40.0f}; + + AlarmNode::FaceRuleEval eval; + EXPECT_FALSE(AlarmNode::FaceItemMatchesRule(rule, item, 1920.0 * 1080.0, &eval)); + EXPECT_EQ(eval.reject_reason, "min_face_area_ratio"); + EXPECT_NE(eval.detail.find("area_ratio=0.000540"), std::string::npos); + EXPECT_NE(eval.detail.find("min=0.001000"), std::string::npos); +} + TEST(FaceTrackAlarmTest, ParsesFaceDebugUnknownCandidateLogging) { AlarmNode node; NodeContext ctx; diff --git a/tools/analyze_face_recog_log.py b/tools/analyze_face_recog_log.py index 28d4ff7..5176e12 100644 --- a/tools/analyze_face_recog_log.py +++ b/tools/analyze_face_recog_log.py @@ -35,6 +35,8 @@ ASSOC_RE = re.compile( ALARM_RE = re.compile(r"\[(\d+)\].*\[ALARM\].* rule=([^\s]+) frame=(\d+)") TOKEN_RE = re.compile(r"\[ExternalApiAction\] token fetched successfully") SEND_RE = re.compile(r"\[(\d+)\].*\[ExternalApiAction\] send (ok|failed).*?(?:alarm_content=([^\s]+))?") +UNKNOWN_CANDIDATE_RE = re.compile(r"\[(\d+)\].*\[alarm\] unknown_candidate (.*)") +KEY_VALUE_RE = re.compile(r"(\w+)=([^\s]+)") @dataclass @@ -55,6 +57,24 @@ class MatchItem: return self.bbox[2] * self.bbox[3] +@dataclass +class UnknownCandidateItem: + ts_ms: int + frame: int + status: str + track_id: int + candidate: str + best_sim: float + area_ratio: float + aspect: float + rule_matched: bool + reject_reason: str + gate: str + track_age_ms: int + quality_hits: int + quality_hits_required: int + + @dataclass class LogSummary: version_git: str | None = None @@ -89,6 +109,15 @@ class LogSummary: last_match_ts_ms: int | None = None first_frame: int | None = None last_frame: int | None = None + unknown_candidate_total: int = 0 + unknown_candidate_rule_matched: int = 0 + unknown_candidate_status_counts: Counter[str] = field(default_factory=Counter) + unknown_candidate_reject_counts: Counter[str] = field(default_factory=Counter) + unknown_candidate_gate_counts: Counter[str] = field(default_factory=Counter) + unknown_candidate_candidate_counts: Counter[str] = field(default_factory=Counter) + unknown_candidate_track_id_missing: int = 0 + unknown_candidate_quantiles: dict[str, dict[str, float | int]] = field(default_factory=dict) + unknown_candidate_track_summaries: list[dict[str, object]] = field(default_factory=list) def _quantiles(values: list[float | int]) -> dict[str, float | int]: @@ -106,10 +135,44 @@ def _pct(count: int, total: int) -> str: return "n/a" if total <= 0 else f"{100.0 * count / total:.1f}%" +def _parse_unknown_candidate(line: str) -> UnknownCandidateItem | None: + m = UNKNOWN_CANDIDATE_RE.search(line) + if not m: + return None + ts_ms = int(m.group(1)) + fields = dict(KEY_VALUE_RE.findall(m.group(2))) + if not fields: + return None + quality = fields.get("quality_hits", "0/0").split("/", 1) + try: + quality_hits = int(quality[0]) + quality_hits_required = int(quality[1]) if len(quality) > 1 else 0 + return UnknownCandidateItem( + ts_ms=ts_ms, + frame=int(fields.get("frame", "0")), + status=fields.get("status", ""), + track_id=int(fields.get("track_id", "-1")), + candidate=fields.get("candidate", ""), + best_sim=float(fields.get("best_sim", "0")), + area_ratio=float(fields.get("area_ratio", "0")), + aspect=float(fields.get("aspect", "0")), + rule_matched=fields.get("rule_matched", "false") == "true", + reject_reason=fields.get("reject_reason", ""), + gate=fields.get("gate", ""), + track_age_ms=int(fields.get("track_age_ms", "0")), + quality_hits=quality_hits, + quality_hits_required=quality_hits_required, + ) + except ValueError: + return None + + def analyze_lines(lines: Iterable[str]) -> LogSummary: summary = LogSummary() matches: list[MatchItem] = [] by_track: dict[int, list[MatchItem]] = defaultdict(list) + unknown_candidates: list[UnknownCandidateItem] = [] + unknown_candidates_by_track: dict[int, list[UnknownCandidateItem]] = defaultdict(list) for line in lines: if summary.version_git is None and (m := VERSION_RE.search(line)): @@ -151,6 +214,19 @@ def analyze_lines(lines: Iterable[str]) -> LogSummary: summary.external_send_counts[status] += 1 summary.external_sends.append((status, content)) + if item := _parse_unknown_candidate(line): + unknown_candidates.append(item) + summary.unknown_candidate_status_counts[item.status] += 1 + summary.unknown_candidate_reject_counts[item.reject_reason] += 1 + summary.unknown_candidate_gate_counts[item.gate] += 1 + summary.unknown_candidate_candidate_counts[item.candidate] += 1 + if item.rule_matched: + summary.unknown_candidate_rule_matched += 1 + if item.track_id < 0: + summary.unknown_candidate_track_id_missing += 1 + else: + unknown_candidates_by_track[item.track_id].append(item) + if m := MATCH_RE.search(line): item = MatchItem( ts_ms=int(m.group(1)), @@ -179,6 +255,7 @@ def analyze_lines(lines: Iterable[str]) -> LogSummary: by_track[item.track_id].append(item) summary.match_total = len(matches) + summary.unknown_candidate_total = len(unknown_candidates) if matches: summary.first_match_ts_ms = matches[0].ts_ms summary.last_match_ts_ms = matches[-1].ts_ms @@ -214,6 +291,38 @@ def analyze_lines(lines: Iterable[str]) -> LogSummary: key=lambda row: (-row["status_counts"].get("known", 0), -row["count"], row["track_id"]), )[:12] + matched_unknown_candidates = [item for item in unknown_candidates if item.rule_matched] + summary.unknown_candidate_quantiles = { + "all_best_sim": _quantiles([item.best_sim for item in unknown_candidates]), + "all_area_ratio": _quantiles([item.area_ratio for item in unknown_candidates]), + "all_aspect": _quantiles([item.aspect for item in unknown_candidates]), + "matched_best_sim": _quantiles([item.best_sim for item in matched_unknown_candidates]), + "matched_area_ratio": _quantiles([item.area_ratio for item in matched_unknown_candidates]), + "matched_track_age_ms": _quantiles([item.track_age_ms for item in matched_unknown_candidates]), + "matched_quality_hits": _quantiles([item.quality_hits for item in matched_unknown_candidates]), + } + + unknown_track_rows = [] + for track_id, items in unknown_candidates_by_track.items(): + matched_items = [item for item in items if item.rule_matched] + if not matched_items: + continue + unknown_track_rows.append( + { + "track_id": track_id, + "matched_count": len(matched_items), + "max_quality_hits": max(item.quality_hits for item in matched_items), + "max_track_age_ms": max(item.track_age_ms for item in matched_items), + "best_sim_max": max(item.best_sim for item in matched_items), + "gates": Counter(item.gate for item in matched_items).most_common(3), + "candidates": Counter(item.candidate for item in matched_items).most_common(3), + } + ) + summary.unknown_candidate_track_summaries = sorted( + unknown_track_rows, + key=lambda row: (-row["max_quality_hits"], -row["matched_count"], -row["max_track_age_ms"], row["track_id"]), + )[:12] + return summary @@ -265,6 +374,28 @@ def format_summary(summary: LogSummary) -> str: "best_max={best_max:.2f} frames={first_frame}->{last_frame}".format(**row) ) lines.append("") + lines.append("Unknown Face Candidate Diagnostics:") + lines.append(f"- unknown_candidate_total: {summary.unknown_candidate_total}") + lines.append( + f"- rule_matched: {summary.unknown_candidate_rule_matched} " + f"({_pct(summary.unknown_candidate_rule_matched, summary.unknown_candidate_total)})" + ) + lines.append( + f"- track_id=-1: {summary.unknown_candidate_track_id_missing} " + f"({_pct(summary.unknown_candidate_track_id_missing, summary.unknown_candidate_total)})" + ) + lines.append(f"- status_counts: {summary.unknown_candidate_status_counts.most_common()}") + lines.append(f"- reject_reason_counts: {summary.unknown_candidate_reject_counts.most_common()}") + lines.append(f"- gate_counts: {summary.unknown_candidate_gate_counts.most_common()}") + lines.append(f"- candidate_counts: {summary.unknown_candidate_candidate_counts.most_common(10)}") + for name, values in summary.unknown_candidate_quantiles.items(): + lines.append(f"- {name}: {values}") + for row in summary.unknown_candidate_track_summaries: + lines.append( + "- unknown track {track_id}: matched={matched_count} max_quality_hits={max_quality_hits} " + "max_age_ms={max_track_age_ms} best_sim_max={best_sim_max:.3f} gates={gates} candidates={candidates}".format(**row) + ) + lines.append("") lines.append("Alarms And Uploads:") lines.append(f"- alarms: {sum(summary.alarm_counts.values())} {summary.alarm_counts.most_common()}") lines.append(f"- alarm_frames: {summary.alarm_frames}")