Improve unknown face diagnostics analysis
This commit is contained in:
parent
5156d099c4
commit
456cbc3727
@ -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)
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user