Improve unknown face diagnostics analysis

This commit is contained in:
tian 2026-04-17 15:07:34 +08:00
parent 5156d099c4
commit 456cbc3727
4 changed files with 163 additions and 2 deletions

View File

@ -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<double>(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<double>(it.best_sim) - static_cast<double>(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)

View File

@ -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__":

View File

@ -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;

View File

@ -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}")