diff --git a/configs/full_pipeline_1080p_test_alarm.json b/configs/full_pipeline_1080p_test_alarm.json index 9072d14..4071d9e 100644 --- a/configs/full_pipeline_1080p_test_alarm.json +++ b/configs/full_pipeline_1080p_test_alarm.json @@ -358,6 +358,18 @@ "per_track_cooldown_ms": 0 } ], + "face_track_aggregation": { + "known": { + "min_hits": 3, + "hit_window_ms": 3000, + "reentry_cooldown_ms": 300000 + }, + "unknown": { + "min_track_age_ms": 2000, + "min_quality_hits": 4, + "reentry_cooldown_ms": 300000 + } + }, "face_rules": [ { "name": "unknown_face", diff --git a/plugins/alarm/alarm_node.cpp b/plugins/alarm/alarm_node.cpp index 7c43812..3ea9190 100644 --- a/plugins/alarm/alarm_node.cpp +++ b/plugins/alarm/alarm_node.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "utils/logger.h" @@ -32,8 +33,10 @@ namespace { struct FaceTrackAggregationConfig { int known_min_hits = 3; int known_hit_window_ms = 3000; + int known_reentry_cooldown_ms = 0; int unknown_min_track_age_ms = 2000; int unknown_min_quality_hits = 4; + int unknown_reentry_cooldown_ms = 0; int state_expire_ms = 5000; }; @@ -90,17 +93,21 @@ FaceTrackAggregationConfig ParseFaceTrackAggregationConfig(const SimpleJson& con cfg.known_min_hits = std::max(1, agg->ValueOr("known_min_hits", cfg.known_min_hits)); cfg.known_hit_window_ms = std::max(0, agg->ValueOr("known_hit_window_ms", cfg.known_hit_window_ms)); + cfg.known_reentry_cooldown_ms = std::max(0, agg->ValueOr("known_reentry_cooldown_ms", cfg.known_reentry_cooldown_ms)); cfg.unknown_min_track_age_ms = std::max(0, agg->ValueOr("unknown_min_track_age_ms", cfg.unknown_min_track_age_ms)); cfg.unknown_min_quality_hits = std::max(1, agg->ValueOr("unknown_min_quality_hits", cfg.unknown_min_quality_hits)); + cfg.unknown_reentry_cooldown_ms = std::max(0, agg->ValueOr("unknown_reentry_cooldown_ms", cfg.unknown_reentry_cooldown_ms)); cfg.state_expire_ms = std::max(0, agg->ValueOr("state_expire_ms", cfg.state_expire_ms)); if (const SimpleJson* known = agg->Find("known"); known && known->IsObject()) { cfg.known_min_hits = std::max(1, known->ValueOr("min_hits", cfg.known_min_hits)); cfg.known_hit_window_ms = std::max(0, known->ValueOr("hit_window_ms", cfg.known_hit_window_ms)); + cfg.known_reentry_cooldown_ms = std::max(0, known->ValueOr("reentry_cooldown_ms", cfg.known_reentry_cooldown_ms)); } if (const SimpleJson* unknown = agg->Find("unknown"); unknown && unknown->IsObject()) { cfg.unknown_min_track_age_ms = std::max(0, unknown->ValueOr("min_track_age_ms", cfg.unknown_min_track_age_ms)); cfg.unknown_min_quality_hits = std::max(1, unknown->ValueOr("min_quality_hits", cfg.unknown_min_quality_hits)); + cfg.unknown_reentry_cooldown_ms = std::max(0, unknown->ValueOr("reentry_cooldown_ms", cfg.unknown_reentry_cooldown_ms)); } return cfg; @@ -109,6 +116,7 @@ FaceTrackAggregationConfig ParseFaceTrackAggregationConfig(const SimpleJson& con FaceTrackDecision UpdateFaceTrackState( const FaceTrackAggregationConfig& cfg, FaceTrackState& state, + std::unordered_map& known_identity_last_alarm_ms, const FaceRecogItem& item, uint64_t now_ms) { FaceTrackDecision decision; @@ -145,8 +153,21 @@ FaceTrackDecision UpdateFaceTrackState( if (!state.reported_known && static_cast(state.known_hit_times.size()) >= std::max(1, cfg.known_min_hits)) { + if (cfg.known_reentry_cooldown_ms > 0 && state.best_known_person_id >= 0) { + const auto it_last = known_identity_last_alarm_ms.find(state.best_known_person_id); + if (it_last != known_identity_last_alarm_ms.end() && + now_ms >= it_last->second && + (now_ms - it_last->second) < static_cast(cfg.known_reentry_cooldown_ms)) { + state.reported_known = true; + state.reported_unknown = false; + return decision; + } + } state.reported_known = true; state.reported_unknown = false; + if (state.best_known_person_id >= 0) { + known_identity_last_alarm_ms[state.best_known_person_id] = now_ms; + } decision.trigger_known = true; } return decision; @@ -256,6 +277,7 @@ public: ParseFaceRules(config); track_agg_cfg_ = ParseFaceTrackAggregationConfig(config); face_track_states_.clear(); + known_identity_last_alarm_ms_.clear(); // Optional evaluation throttle (for alarm side). 0 = disabled. eval_interval_ms_ = std::max(0, static_cast(config.ValueOr("eval_interval_ms", 0))); @@ -528,6 +550,7 @@ public: face_last_trigger_.clear(); face_person_last_trigger_.clear(); face_track_states_.clear(); + known_identity_last_alarm_ms_.clear(); packet_buffer_ = std::move(new_packet_buffer); old_actions = std::move(actions_); actions_ = std::move(new_actions); @@ -644,6 +667,7 @@ private: face_last_trigger_.clear(); face_person_last_trigger_.clear(); face_track_states_.clear(); + known_identity_last_alarm_ms_.clear(); } static bool FaceItemMatchesRule( @@ -745,7 +769,8 @@ private: if (!qualifying_observations[i]) continue; const auto& it = frame->face_recog->items[i]; auto& state = face_track_states_[it.person_track_id]; - track_decisions[i] = UpdateFaceTrackState(track_agg_cfg_, state, it, now_epoch_ms); + track_decisions[i] = UpdateFaceTrackState( + track_agg_cfg_, state, known_identity_last_alarm_ms_, it, now_epoch_ms); have_track_decision[i] = true; } @@ -888,6 +913,7 @@ private: std::map face_person_last_trigger_; FaceTrackAggregationConfig track_agg_cfg_; std::map face_track_states_; + std::unordered_map known_identity_last_alarm_ms_; std::shared_ptr packet_buffer_; std::vector> actions_; diff --git a/tests/test_face_track_alarm.cpp b/tests/test_face_track_alarm.cpp index 7589b67..000c292 100644 --- a/tests/test_face_track_alarm.cpp +++ b/tests/test_face_track_alarm.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "node.h" @@ -72,10 +73,11 @@ AlarmNode::FaceRule MakeKnownRule() { TEST(FaceTrackAlarmTest, IgnoresLowQualityUntrackedFace) { FaceTrackAggregationConfig cfg; FaceTrackState state; + std::unordered_map known_identity_last_alarm_ms; FaceRecogItem item = MakeUnknownFace(-1, -1, "", 0.40f); - const FaceTrackDecision decision = UpdateFaceTrackState(cfg, state, item, 1000); + const FaceTrackDecision decision = UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, item, 1000); EXPECT_FALSE(decision.trigger_known); EXPECT_FALSE(decision.trigger_unknown); } @@ -86,16 +88,42 @@ TEST(FaceTrackAlarmTest, EmitsKnownAfterStableHitsOnSameTrack) { cfg.known_hit_window_ms = 3000; FaceTrackState state; + std::unordered_map known_identity_last_alarm_ms; const FaceRecogItem item = MakeKnownFace(5, 1, "reg_001", 0.62f); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, item, 1000).trigger_known); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, item, 1500).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, item, 1000).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, item, 1500).trigger_known); - const FaceTrackDecision decision = UpdateFaceTrackState(cfg, state, item, 2000); + const FaceTrackDecision decision = UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, item, 2000); EXPECT_TRUE(decision.trigger_known); EXPECT_FALSE(decision.trigger_unknown); } +TEST(FaceTrackAlarmTest, SuppressesKnownReentryInsideConfiguredCooldown) { + FaceTrackAggregationConfig cfg; + cfg.known_min_hits = 2; + cfg.known_hit_window_ms = 3000; + cfg.known_reentry_cooldown_ms = 5000; + + std::unordered_map known_identity_last_alarm_ms; + const FaceRecogItem initial_track = MakeKnownFace(8, 1, "alice", 0.66f); + + FaceTrackState first_track_state; + EXPECT_FALSE(UpdateFaceTrackState(cfg, first_track_state, known_identity_last_alarm_ms, initial_track, 1000).trigger_known); + EXPECT_TRUE(UpdateFaceTrackState(cfg, first_track_state, known_identity_last_alarm_ms, initial_track, 1500).trigger_known); + + FaceTrackState reentry_state; + FaceRecogItem reentry_track = initial_track; + reentry_track.person_track_id = 18; + + EXPECT_FALSE(UpdateFaceTrackState(cfg, reentry_state, known_identity_last_alarm_ms, reentry_track, 4000).trigger_known); + const FaceTrackDecision suppressed = UpdateFaceTrackState( + cfg, reentry_state, known_identity_last_alarm_ms, reentry_track, 4500); + EXPECT_FALSE(suppressed.trigger_known); + EXPECT_TRUE(reentry_state.reported_known); + EXPECT_EQ(known_identity_last_alarm_ms.at(1), 1500u); +} + TEST(FaceTrackAlarmTest, DoesNotEmitUnknownForKnownPersonScoreWobble) { FaceTrackAggregationConfig cfg; cfg.known_min_hits = 3; @@ -104,14 +132,16 @@ TEST(FaceTrackAlarmTest, DoesNotEmitUnknownForKnownPersonScoreWobble) { cfg.unknown_min_quality_hits = 1; FaceTrackState state; + std::unordered_map known_identity_last_alarm_ms; const FaceRecogItem known = MakeKnownFace(9, 1, "reg_001", 0.63f); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known, 1000).trigger_known); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known, 1500).trigger_known); - EXPECT_TRUE(UpdateFaceTrackState(cfg, state, known, 2000).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, known, 1000).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, known, 1500).trigger_known); + EXPECT_TRUE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, known, 2000).trigger_known); const FaceRecogItem wobble = MakeUnknownFace(9, 1, "reg_001", 0.42f); - const FaceTrackDecision decision = UpdateFaceTrackState(cfg, state, wobble, 2600); + const FaceTrackDecision decision = UpdateFaceTrackState( + cfg, state, known_identity_last_alarm_ms, wobble, 2600); EXPECT_FALSE(decision.trigger_known); EXPECT_FALSE(decision.trigger_unknown); } @@ -124,16 +154,18 @@ TEST(FaceTrackAlarmTest, DoesNotEmitUnknownWhileTrackIsKnownLeaning) { cfg.unknown_min_quality_hits = 1; FaceTrackState state; + std::unordered_map known_identity_last_alarm_ms; const FaceRecogItem known = MakeKnownFace(10, 1, "alice", 0.63f); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known, 1000).trigger_known); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known, 1500).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, known, 1000).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, known, 1500).trigger_known); EXPECT_EQ(state.best_known_person_id, 1); EXPECT_EQ(state.best_known_name, "alice"); EXPECT_EQ(state.known_hit_times.size(), 2u); const FaceRecogItem wobble = MakeUnknownFace(10, 1, "alice", 0.42f); - const FaceTrackDecision decision = UpdateFaceTrackState(cfg, state, wobble, 2600); + const FaceTrackDecision decision = UpdateFaceTrackState( + cfg, state, known_identity_last_alarm_ms, wobble, 2600); EXPECT_FALSE(decision.trigger_known); EXPECT_FALSE(decision.trigger_unknown); } @@ -144,14 +176,15 @@ TEST(FaceTrackAlarmTest, ResetsKnownStateWhenIdentityChangesOnSameTrack) { cfg.known_hit_window_ms = 3000; FaceTrackState state; + std::unordered_map known_identity_last_alarm_ms; const FaceRecogItem alice = MakeKnownFace(7, 1, "alice", 0.80f); const FaceRecogItem bob = MakeKnownFace(7, 2, "bob", 0.82f); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, alice, 1000).trigger_known); - EXPECT_TRUE(UpdateFaceTrackState(cfg, state, alice, 1500).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, alice, 1000).trigger_known); + EXPECT_TRUE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, alice, 1500).trigger_known); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, bob, 2000).trigger_known); - EXPECT_TRUE(UpdateFaceTrackState(cfg, state, bob, 2500).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, bob, 2000).trigger_known); + EXPECT_TRUE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, bob, 2500).trigger_known); } TEST(FaceTrackAlarmTest, ExpiresStateAfterTrackInactivity) { @@ -161,13 +194,14 @@ TEST(FaceTrackAlarmTest, ExpiresStateAfterTrackInactivity) { cfg.state_expire_ms = 1000; FaceTrackState state; + std::unordered_map known_identity_last_alarm_ms; const FaceRecogItem item = MakeKnownFace(8, 1, "alice", 0.80f); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, item, 1000).trigger_known); - EXPECT_TRUE(UpdateFaceTrackState(cfg, state, item, 1500).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, item, 1000).trigger_known); + EXPECT_TRUE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, item, 1500).trigger_known); - EXPECT_FALSE(UpdateFaceTrackState(cfg, state, item, 4000).trigger_known); - EXPECT_TRUE(UpdateFaceTrackState(cfg, state, item, 4500).trigger_known); + EXPECT_FALSE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, item, 4000).trigger_known); + EXPECT_TRUE(UpdateFaceTrackState(cfg, state, known_identity_last_alarm_ms, item, 4500).trigger_known); } TEST(FaceTrackAlarmTest, DisqualifiedFramesDoNotAdvanceTrackAggregation) { @@ -277,6 +311,63 @@ TEST(FaceTrackAlarmTest, OverlappingRulesDoNotDoubleCountSingleFrameEvidence) { EXPECT_TRUE(it_second->second.reported_known); } +TEST(FaceTrackAlarmTest, KnownPersonDoesNotReAlarmWhileTrackStaysActive) { + AlarmNode node; + node.track_agg_cfg_.known_min_hits = 2; + node.track_agg_cfg_.known_hit_window_ms = 5000; + + auto rule = MakeKnownRule(); + rule.cooldown_ms = 0; + node.face_rules_.push_back(rule); + + FaceRecogItem item = MakeKnownFace(61, 5, "alice", 0.88f); + item.bbox = Rect{0.0f, 0.0f, 20.0f, 20.0f}; + + EXPECT_EQ(node.Process(MakeFaceFrame(1, item)), NodeStatus::OK); + EXPECT_EQ(node.alarm_count_, 0u); + + EXPECT_EQ(node.Process(MakeFaceFrame(2, item)), NodeStatus::OK); + EXPECT_EQ(node.alarm_count_, 1u); + + EXPECT_EQ(node.Process(MakeFaceFrame(3, item)), NodeStatus::OK); + EXPECT_EQ(node.alarm_count_, 1u); + + EXPECT_EQ(node.Process(MakeFaceFrame(4, item)), NodeStatus::OK); + EXPECT_EQ(node.alarm_count_, 1u); +} + +TEST(FaceTrackAlarmTest, KnownPersonReEntryInsideCooldownOnNewTrackDoesNotReAlarm) { + AlarmNode node; + node.track_agg_cfg_.known_min_hits = 2; + node.track_agg_cfg_.known_hit_window_ms = 5000; + node.track_agg_cfg_.known_reentry_cooldown_ms = 1000; + + auto rule = MakeKnownRule(); + rule.cooldown_ms = 0; + node.face_rules_.push_back(rule); + + FaceRecogItem first_track = MakeKnownFace(71, 5, "alice", 0.88f); + first_track.bbox = Rect{0.0f, 0.0f, 20.0f, 20.0f}; + FaceRecogItem second_track = MakeKnownFace(72, 5, "alice", 0.88f); + second_track.bbox = first_track.bbox; + + EXPECT_EQ(node.Process(MakeFaceFrame(1, first_track)), NodeStatus::OK); + EXPECT_EQ(node.alarm_count_, 0u); + + EXPECT_EQ(node.Process(MakeFaceFrame(2, first_track)), NodeStatus::OK); + EXPECT_EQ(node.alarm_count_, 1u); + ASSERT_EQ(node.known_identity_last_alarm_ms_.size(), 1u); + const auto first_alarm_it = node.known_identity_last_alarm_ms_.find(5); + ASSERT_NE(first_alarm_it, node.known_identity_last_alarm_ms_.end()); + + EXPECT_EQ(node.Process(MakeFaceFrame(3, second_track)), NodeStatus::OK); + EXPECT_EQ(node.alarm_count_, 1u); + + EXPECT_EQ(node.Process(MakeFaceFrame(4, second_track)), NodeStatus::OK); + EXPECT_EQ(node.alarm_count_, 1u); + EXPECT_EQ(node.known_identity_last_alarm_ms_.at(5), first_alarm_it->second); +} + TEST(FaceTrackAlarmTest, IgnoresLegacyDisableFlagAndStillUsesTrackAggregation) { AlarmNode node; NodeContext ctx; @@ -296,7 +387,8 @@ TEST(FaceTrackAlarmTest, IgnoresLegacyDisableFlagAndStillUsesTrackAggregation) { "enable": false, "known": { "min_hits": 2, - "hit_window_ms": 5000 + "hit_window_ms": 5000, + "reentry_cooldown_ms": 60000 } } })"); @@ -304,6 +396,7 @@ TEST(FaceTrackAlarmTest, IgnoresLegacyDisableFlagAndStillUsesTrackAggregation) { ASSERT_TRUE(node.Init(config, ctx)); EXPECT_EQ(node.track_agg_cfg_.known_min_hits, 2); EXPECT_EQ(node.track_agg_cfg_.known_hit_window_ms, 5000); + EXPECT_EQ(node.track_agg_cfg_.known_reentry_cooldown_ms, 60000); FaceRecogItem item = MakeKnownFace(31, 5, "alice", 0.88f); item.bbox = Rect{0.0f, 0.0f, 20.0f, 20.0f};