fix(cv): add per-direction crossing cooldown to suppress track-churn double-counts

When a blob briefly drops below CV_MIN_BLOB_PX, its track is killed and respawns,
causing the same person to generate multiple counts per visit (~50/min observed
in field). Add a per-direction cooldown (default 5 frames ≈ 0.8s @ 5 fps) that
drops subsequent entries (or exits) within the window of the last counted one.
Entry and exit cooldowns are tracked independently.

Fixed at compile time for now; exposing as a server-push tunable is deferred
until the server-push-config branch lands. See docs/server-prompt-crossing-
cooldown.md for the server-side coordination notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 06:33:11 -07:00
parent 9d5b588231
commit 62931e26ff
6 changed files with 150 additions and 5 deletions

View File

@@ -135,6 +135,39 @@ void test_blob_crossing_line_bottom_to_top_is_exit() {
TEST_ASSERT_EQUAL_INT(1, r.exits_delta);
}
void test_cooldown_suppresses_rapid_re_entry() {
// Set up post-crossing state with a pre-existing track just above the line.
// Force a second above→below crossing within the cooldown window; second
// must not increment entries. Then advance past cooldown; third crossing must.
CVState state;
cv_init(state);
state.bg_valid = true;
memset(state.background, 100, CV_PIXELS);
state.frame_index = 100;
state.entries = 1;
state.last_entry_frame = 100;
CVTrack t;
t.id = 1; t.x = 48; t.y = 40; t.above_line = true; t.missed = 0;
state.tracks.push_back(t);
// Frame 101: blob at y=52 (below line=48). Track matches (delta=12 < CV_MAX_MOVE).
// Crossing above→below occurs but cooldown (101-100=1 < 5) suppresses it.
uint8_t f1[CV_PIXELS]; make_blob_frame(f1, 48, 52);
CVResult r1 = cv_process(state, f1, 50);
TEST_ASSERT_EQUAL_INT(0, r1.entries_delta);
TEST_ASSERT_EQUAL_INT(1, state.entries);
// Advance past cooldown, reset track above the line, then cross again.
state.frame_index = 200;
state.tracks[0].y = 40;
state.tracks[0].above_line = true;
uint8_t f2[CV_PIXELS]; make_blob_frame(f2, 48, 52);
CVResult r2 = cv_process(state, f2, 50);
TEST_ASSERT_EQUAL_INT(1, r2.entries_delta);
TEST_ASSERT_EQUAL_INT(2, state.entries);
}
void test_no_crossing_same_side_no_count() {
CVState state;
cv_init(state);
@@ -162,5 +195,6 @@ int main() {
RUN_TEST(test_blob_crossing_line_top_to_bottom_is_entry);
RUN_TEST(test_blob_crossing_line_bottom_to_top_is_exit);
RUN_TEST(test_no_crossing_same_side_no_count);
RUN_TEST(test_cooldown_suppresses_rapid_re_entry);
return UNITY_END();
}