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

@@ -15,6 +15,8 @@ void cv_init(CVState& state) {
state.tracks.clear();
state.entries = 0;
state.exits = 0;
state.last_entry_frame = 0;
state.last_exit_frame = 0;
}
void cv_reset_counts(CVState& state) {
@@ -160,12 +162,22 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
if (now_above != track.above_line) {
if (!now_above) {
// was above, now below → entry
state.entries++;
result.entries_delta++;
bool in_cooldown = state.last_entry_frame != 0 &&
(state.frame_index - state.last_entry_frame) < CV_CROSSING_COOLDOWN_FRAMES;
if (!in_cooldown) {
state.entries++;
result.entries_delta++;
state.last_entry_frame = state.frame_index;
}
} else {
// was below, now above → exit
state.exits++;
result.exits_delta++;
bool in_cooldown = state.last_exit_frame != 0 &&
(state.frame_index - state.last_exit_frame) < CV_CROSSING_COOLDOWN_FRAMES;
if (!in_cooldown) {
state.exits++;
result.exits_delta++;
state.last_exit_frame = state.frame_index;
}
}
}
track.above_line = now_above;

View File

@@ -12,6 +12,12 @@ static const int CV_MIN_BLOB_PX = 64;
static const float CV_MAX_MOVE = 15.0f;
static const int CV_MAX_MISSED = 10;
// Per-direction crossing cooldown. Any same-direction crossing whose frame gap
// is strictly less than this value is dropped. At 5 fps, a value of 5 → ≈0.8s
// suppression window. Purpose: mask track churn (blob briefly drops below
// min_blob_px, track dies & respawns, re-crosses).
static const uint32_t CV_CROSSING_COOLDOWN_FRAMES = 5;
struct CVTrack {
int id;
float x, y;
@@ -28,6 +34,8 @@ struct CVState {
std::vector<CVTrack> tracks;
int entries;
int exits;
uint32_t last_entry_frame; // 0 = never; frame_index of last counted entry
uint32_t last_exit_frame; // 0 = never; frame_index of last counted exit
};
struct CVResult {

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();
}