Add unknown face candidate diagnostics

This commit is contained in:
tian 2026-04-17 11:06:12 +08:00
parent 498a8216fe
commit 5156d099c4
3 changed files with 241 additions and 14 deletions

View File

@ -368,6 +368,10 @@
"min_quality_hits": 4
}
},
"face_debug": {
"log_unknown_candidates": true,
"unknown_candidate_interval_ms": 0
},
"face_rules": [
{
"name": "unknown_face",

View File

@ -5,9 +5,11 @@
#include <cstring>
#include <algorithm>
#include <deque>
#include <iomanip>
#include <map>
#include <memory>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <unordered_map>
@ -281,6 +283,10 @@ public:
ParseFaceRules(config);
track_agg_cfg_ = ParseFaceTrackAggregationConfig(config);
const FaceDebugConfig face_debug = ParseFaceDebugConfig(config);
face_debug_log_unknown_candidates_ = face_debug.log_unknown_candidates;
face_debug_unknown_candidate_interval_ms_ = face_debug.unknown_candidate_interval_ms;
last_unknown_candidate_log_ms_ = 0;
face_track_states_.clear();
known_identity_last_alarm_ms_.clear();
@ -463,6 +469,7 @@ public:
// Parse face rules (independent from detection rules)
std::vector<FaceRule> new_face_rules = ParseFaceRulesFromConfig(new_config);
FaceTrackAggregationConfig new_track_agg_cfg = ParseFaceTrackAggregationConfig(new_config);
const FaceDebugConfig new_face_debug = ParseFaceDebugConfig(new_config);
// Packet ring window settings
int pre_sec = 5;
@ -552,6 +559,9 @@ public:
rule_engine_ = std::move(new_engine);
face_rules_ = std::move(new_face_rules);
track_agg_cfg_ = new_track_agg_cfg;
face_debug_log_unknown_candidates_ = new_face_debug.log_unknown_candidates;
face_debug_unknown_candidate_interval_ms_ = new_face_debug.unknown_candidate_interval_ms;
last_unknown_candidate_log_ms_ = 0;
face_last_trigger_.clear();
face_person_last_trigger_.clear();
face_track_states_.clear();
@ -637,6 +647,36 @@ private:
float min_sim = 0.0f;
};
struct FaceRuleEval {
bool matched = false;
std::string reject_reason;
std::string detail;
double face_area_ratio = 0.0;
double face_aspect = 0.0;
};
struct FaceDebugConfig {
bool log_unknown_candidates = false;
uint64_t unknown_candidate_interval_ms = 0;
};
static std::string Fixed3(double value) {
std::ostringstream oss;
oss << std::fixed << std::setprecision(3) << value;
return oss.str();
}
static FaceDebugConfig ParseFaceDebugConfig(const SimpleJson& config) {
FaceDebugConfig out;
const SimpleJson* debug = config.Find("face_debug");
if (!debug || !debug->IsObject()) return out;
out.log_unknown_candidates = debug->ValueOr<bool>("log_unknown_candidates", out.log_unknown_candidates);
out.unknown_candidate_interval_ms = static_cast<uint64_t>(std::max<int64_t>(
0, static_cast<int64_t>(debug->ValueOr<int>("unknown_candidate_interval_ms", 0))));
return out;
}
static std::vector<FaceRule> ParseFaceRulesFromConfig(const SimpleJson& config) {
std::vector<FaceRule> rules;
const SimpleJson* fr = config.Find("face_rules");
@ -651,10 +691,13 @@ private:
r.kind = (type == "person") ? FaceRule::Kind::Person : FaceRule::Kind::Unknown;
r.cooldown_ms = std::max(0, item.ValueOr<int>("cooldown_ms", r.cooldown_ms));
r.per_person_cooldown_ms = std::max(0, item.ValueOr<int>("per_person_cooldown_ms", r.per_person_cooldown_ms));
r.min_face_area_ratio = item.ValueOr<double>("min_face_area_ratio", static_cast<double>(r.min_face_area_ratio));
r.min_face_aspect = item.ValueOr<double>("min_face_aspect", static_cast<double>(r.min_face_aspect));
r.max_face_aspect = item.ValueOr<double>("max_face_aspect", static_cast<double>(r.max_face_aspect));
r.min_sim = item.ValueOr<double>("min_sim", static_cast<double>(r.min_sim));
r.min_face_area_ratio = static_cast<float>(
item.ValueOr<double>("min_face_area_ratio", static_cast<double>(r.min_face_area_ratio)));
r.min_face_aspect = static_cast<float>(
item.ValueOr<double>("min_face_aspect", static_cast<double>(r.min_face_aspect)));
r.max_face_aspect = static_cast<float>(
item.ValueOr<double>("max_face_aspect", static_cast<double>(r.max_face_aspect)));
r.min_sim = static_cast<float>(item.ValueOr<double>("min_sim", static_cast<double>(r.min_sim)));
if (const SimpleJson* persons = item.Find("persons"); persons && persons->IsArray()) {
for (const auto& p : persons->AsArray()) {
@ -678,25 +721,60 @@ private:
static bool FaceItemMatchesRule(
const FaceRule& rule,
const FaceRecogItem& it,
double img_area) {
double img_area,
FaceRuleEval* eval = nullptr) {
FaceRuleEval local;
FaceRuleEval& out = eval ? *eval : local;
out = FaceRuleEval{};
if (rule.min_face_area_ratio > 0.0f && img_area > 0.0) {
const double a = static_cast<double>(it.bbox.w) * static_cast<double>(it.bbox.h);
const double r = a / img_area;
if (r < static_cast<double>(rule.min_face_area_ratio)) return false;
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);
return false;
}
}
if (rule.min_face_aspect > 0.0f || rule.max_face_aspect > 0.0f) {
const double h = std::max(1e-6, static_cast<double>(it.bbox.h));
const double aspect = static_cast<double>(it.bbox.w) / h;
if (rule.min_face_aspect > 0.0f && aspect < static_cast<double>(rule.min_face_aspect)) return false;
if (rule.max_face_aspect > 0.0f && aspect > static_cast<double>(rule.max_face_aspect)) return false;
out.face_aspect = aspect;
if (rule.min_face_aspect > 0.0f && aspect < static_cast<double>(rule.min_face_aspect)) {
out.reject_reason = "min_face_aspect";
out.detail = "aspect=" + Fixed3(aspect) + " min=" + Fixed3(rule.min_face_aspect);
return false;
}
if (rule.max_face_aspect > 0.0f && aspect > static_cast<double>(rule.max_face_aspect)) {
out.reject_reason = "max_face_aspect";
out.detail = "aspect=" + Fixed3(aspect) + " max=" + Fixed3(rule.max_face_aspect);
return false;
}
}
if (rule.kind == FaceRule::Kind::Unknown) {
if (FaceRecogStateIsKnown(it.state)) return false;
if (it.best_sim < rule.min_sim) return false;
if (FaceRecogStateIsKnown(it.state)) {
out.reject_reason = "known_state";
out.detail = "status=known";
return false;
}
if (it.best_sim < rule.min_sim) {
out.reject_reason = "min_sim";
out.detail = "best_sim=" + Fixed3(it.best_sim) + " min=" + Fixed3(rule.min_sim);
return false;
}
} else {
if (!FaceRecogStateIsKnown(it.state)) return false;
if (it.best_sim < rule.min_sim) return false;
if (!FaceRecogStateIsKnown(it.state)) {
out.reject_reason = "not_known";
out.detail = std::string("status=") + FaceRecogStateName(it.state);
return false;
}
if (it.best_sim < rule.min_sim) {
out.reject_reason = "min_sim";
out.detail = "best_sim=" + Fixed3(it.best_sim) + " min=" + Fixed3(rule.min_sim);
return false;
}
if (!rule.persons.empty()) {
bool ok = false;
for (const auto& p : rule.persons) {
@ -705,11 +783,22 @@ private:
break;
}
}
if (!ok) return false;
if (!ok) {
out.reject_reason = "person_filter";
out.detail = "best_name=" + it.best_name;
return false;
}
}
}
return it.person_track_id >= 0;
if (it.person_track_id < 0) {
out.reject_reason = "missing_track_id";
out.detail = "person_track_id=-1";
return false;
}
out.matched = true;
return true;
}
void EvaluateFaceRulesLocked(const FramePtr& frame) {
@ -779,6 +868,26 @@ private:
have_track_decision[i] = true;
}
if (face_debug_log_unknown_candidates_) {
for (const auto& rule : face_rules_) {
if (rule.kind != FaceRule::Kind::Unknown) continue;
for (size_t i = 0; i < frame->face_recog->items.size(); ++i) {
FaceRuleEval eval;
const bool rule_matched = FaceItemMatchesRule(
rule, frame->face_recog->items[i], img_area, &eval);
LogUnknownCandidateDebug(
rule,
frame,
i,
img_area,
eval,
rule_matched,
have_track_decision[i] ? &track_decisions[i] : nullptr,
now_epoch_ms);
}
}
}
for (const auto& rule : face_rules_) {
if (rule.per_person_cooldown_ms <= 0) {
const auto it_last = face_last_trigger_.find(rule.name);
@ -869,6 +978,83 @@ private:
}
}
void LogUnknownCandidateDebug(
const FaceRule& rule,
const FramePtr& frame,
size_t item_index,
double img_area,
const FaceRuleEval& eval,
bool rule_matched,
const FaceTrackDecision* decision,
uint64_t now_epoch_ms) {
if (face_debug_unknown_candidate_interval_ms_ > 0 &&
last_unknown_candidate_log_ms_ > 0 &&
now_epoch_ms > last_unknown_candidate_log_ms_ &&
(now_epoch_ms - last_unknown_candidate_log_ms_) < face_debug_unknown_candidate_interval_ms_) {
return;
}
last_unknown_candidate_log_ms_ = now_epoch_ms;
const auto& it = frame->face_recog->items[item_index];
const auto state_it = face_track_states_.find(it.person_track_id);
const FaceTrackState* state = (state_it != face_track_states_.end()) ? &state_it->second : nullptr;
const uint64_t track_age_ms = (state && state->first_seen_ms > 0 && now_epoch_ms >= state->first_seen_ms)
? (now_epoch_ms - state->first_seen_ms)
: 0;
const int quality_hits = state ? state->quality_hits : 0;
const bool known_leaning = state &&
(state->reported_known || state->known_hit_times.size() >= static_cast<size_t>(track_agg_cfg_.known_min_hits));
std::string gate = "rule_rejected";
if (rule_matched && !decision) {
gate = "no_track_decision";
} else if (decision && decision->trigger_unknown) {
gate = "trigger_unknown";
} else if (state && state->reported_unknown) {
gate = "already_reported";
} else if (known_leaning) {
gate = "known_leaning";
} else if (rule_matched && track_age_ms < static_cast<uint64_t>(track_agg_cfg_.unknown_min_track_age_ms)) {
gate = "waiting_track_age";
} else if (rule_matched && quality_hits < track_agg_cfg_.unknown_min_quality_hits) {
gate = "waiting_quality_hits";
}
const double area_ratio = eval.face_area_ratio > 0.0
? eval.face_area_ratio
: ((img_area > 0.0) ? (static_cast<double>(it.bbox.w) * static_cast<double>(it.bbox.h) / img_area) : 0.0);
const double aspect = eval.face_aspect > 0.0
? eval.face_aspect
: (static_cast<double>(it.bbox.w) / std::max(1e-6, static_cast<double>(it.bbox.h)));
std::ostringstream oss;
oss << "[alarm] unknown_candidate"
<< " rule=" << rule.name
<< " frame=" << frame->frame_id
<< " item=" << item_index
<< " status=" << FaceRecogStateName(it.state)
<< " track_id=" << it.person_track_id
<< " candidate_id=" << it.candidate_person_id
<< " candidate=" << (it.candidate_name.empty() ? "-" : it.candidate_name)
<< " best_sim=" << Fixed3(it.best_sim)
<< " second_sim=" << Fixed3(it.second_sim)
<< " 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)
<< " aspect=" << Fixed3(aspect)
<< " rule_matched=" << (rule_matched ? "true" : "false")
<< " reject_reason=" << (eval.reject_reason.empty() ? "-" : eval.reject_reason)
<< " reject_detail=" << (eval.detail.empty() ? "-" : eval.detail)
<< " track_age_ms=" << track_age_ms
<< " quality_hits=" << quality_hits << "/" << track_agg_cfg_.unknown_min_quality_hits
<< " min_track_age_ms=" << track_agg_cfg_.unknown_min_track_age_ms
<< " reported_known=" << (state && state->reported_known ? "true" : "false")
<< " reported_unknown=" << (state && state->reported_unknown ? "true" : "false")
<< " gate=" << gate;
LogInfo(oss.str());
}
static std::string BuildFaceTrackKey(const FaceRule& rule, int track_id) {
return rule.name + "#track:" + std::to_string(track_id);
}
@ -921,6 +1107,9 @@ private:
FaceTrackAggregationConfig track_agg_cfg_;
std::map<int, FaceTrackState> face_track_states_;
std::unordered_map<int, uint64_t> known_identity_last_alarm_ms_;
bool face_debug_log_unknown_candidates_ = false;
uint64_t face_debug_unknown_candidate_interval_ms_ = 0;
uint64_t last_unknown_candidate_log_ms_ = 0;
std::shared_ptr<PacketRingBuffer> packet_buffer_;
std::vector<std::unique_ptr<IAlarmAction>> actions_;

View File

@ -250,6 +250,40 @@ TEST(FaceTrackAlarmTest, UntrackedQualifiedFaceDoesNotTriggerAlarm) {
EXPECT_EQ(node.alarm_count_, 0u);
}
TEST(FaceTrackAlarmTest, UnknownRuleDiagnosticsExplainMinSimRejection) {
AlarmNode::FaceRule rule;
rule.name = "unknown_face";
rule.kind = AlarmNode::FaceRule::Kind::Unknown;
rule.min_sim = 0.35f;
FaceRecogItem item = MakeUncertainFace(101, 7, "reg_007", 0.20f);
item.bbox = Rect{0.0f, 0.0f, 20.0f, 20.0f};
AlarmNode::FaceRuleEval eval;
EXPECT_FALSE(AlarmNode::FaceItemMatchesRule(rule, item, 10000.0, &eval));
EXPECT_FALSE(eval.matched);
EXPECT_EQ(eval.reject_reason, "min_sim");
EXPECT_NE(eval.detail.find("best_sim=0.200"), std::string::npos);
}
TEST(FaceTrackAlarmTest, ParsesFaceDebugUnknownCandidateLogging) {
AlarmNode node;
NodeContext ctx;
ctx.input_queue = std::make_shared<SpscQueue<FramePtr>>(4, QueueDropStrategy::DropOldest);
const SimpleJson config = ParseConfig(R"({
"id": "alarm",
"face_debug": {
"log_unknown_candidates": true,
"unknown_candidate_interval_ms": 123
}
})");
ASSERT_TRUE(node.Init(config, ctx));
EXPECT_TRUE(node.face_debug_log_unknown_candidates_);
EXPECT_EQ(node.face_debug_unknown_candidate_interval_ms_, 123);
}
TEST(FaceTrackAlarmTest, KnownLeaningTrackDoesNotFireUnknownRule) {
AlarmNode node;
node.track_agg_cfg_.known_min_hits = 3;