feat(cv): directional once-per-track counting + detection LED blinks

A single person walking under the overhead camera was generating both an
entry and an exit within a few seconds — the line-crossing logic treated
a blob's traversal into one side of the frame and out the other as two
separate events whenever the track spawned near the line, oscillated
against shadows, or churned at creation.

Replaced line-crossing semantics with directional traversal:
- Each track records spawn_y at creation and a counted flag.
- An event fires only if the track is not yet counted, spawned firm on
  one side of the line (|spawn_y - line_y| > CV_TRAVERSAL_MARGIN_PX),
  and is now firm on the opposite side. Direction of travel determines
  entry vs exit. The track is then flagged counted — one trip, one count.
- Cooldown remains as a secondary safety net.

main.cpp: single/double LED pulse on entry/exit detections. Saves and
restores the current LED state so upload (yellow-on) and no-WiFi
indicators aren't clobbered.

Tests updated to walk blobs beyond the margin and register two new
cases: wobble-at-line doesn't count, and a reversed full traversal
doesn't double-count on the same track.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 09:46:59 -07:00
parent 24aaae6ff2
commit 3b471992f2
6 changed files with 180 additions and 62 deletions

View File

@@ -94,19 +94,22 @@ void test_blob_crossing_line_top_to_bottom_is_entry() {
CVState state;
cv_init(state);
// Line at 50% = y=48; step ≤14px per frame to stay within CV_MAX_MOVE
// Line at 50% = y=48, traversal margin = 14px. Spawn must be y<34, final y>62.
// Step ≤14px per frame to stay within CV_MAX_MOVE.
uint8_t bg[CV_PIXELS];
fill_frame(bg, 100);
cv_process(state, bg, 50); // init background
// Walk blob from y=20 toward line; crossing occurs at y=48 (above→below)
// Stop at crossing frame and assert its result
int setup[] = {20, 34};
for (int i = 0; i < 2; i++) {
int setup[] = {20, 34, 48, 62}; // spawn firm above, walk across, not yet firm below
for (int i = 0; i < 4; i++) {
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]);
cv_process(state, f, 50);
}
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 48);
// Still no count — y=62 is not firm below (needs >62)
TEST_ASSERT_EQUAL_INT(0, state.entries);
// One more step: y=70 is firm below → entry fires now
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 70);
CVResult r = cv_process(state, fcross, 50);
TEST_ASSERT_EQUAL_INT(1, r.entries_delta);
@@ -121,24 +124,77 @@ void test_blob_crossing_line_bottom_to_top_is_exit() {
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
cv_process(state, bg, 50);
// Walk blob from y=76 toward line; crossing occurs at y=34 (below→above)
// Stop at crossing frame and assert its result
int setup[] = {76, 62, 48};
for (int i = 0; i < 3; i++) {
// Spawn firm below (y=76 > 62), walk toward and across line (y=48), continue
// until firm above (y<34). Each step ≤14px.
int setup[] = {76, 62, 48, 34};
for (int i = 0; i < 4; i++) {
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]);
cv_process(state, f, 50);
}
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 34);
// y=34 not firm above (needs <34) — no count yet
TEST_ASSERT_EQUAL_INT(0, state.exits);
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 22); // firm above
CVResult r = cv_process(state, fcross, 50);
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
TEST_ASSERT_EQUAL_INT(1, r.exits_delta);
}
void test_track_spawned_near_line_does_not_count_on_wobble() {
// Simulates a blob that appears right on the line (e.g. shadow or noise)
// and wobbles across it. With directional margin, no count should fire —
// this is the false-positive pattern the feature guards against.
CVState state;
cv_init(state);
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
cv_process(state, bg, 50);
// Spawn within margin of line (y=44, margin=14 so 44 ∈ [34,62])
// then wobble above to y=38, below to y=58. Both within margin.
int setup[] = {44, 38, 58, 42, 56};
for (int i = 0; i < 5; i++) {
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]);
cv_process(state, f, 50);
}
TEST_ASSERT_EQUAL_INT(0, state.entries);
TEST_ASSERT_EQUAL_INT(0, state.exits);
}
void test_track_counts_at_most_once_even_if_it_wobbles_back() {
// A track that traverses fully should count once. If it then reverses and
// crosses back, the track should NOT fire again — it's already counted.
// (A separate new track on the return trip would count as exit, but while
// the same track persists, it's one trip.)
CVState state;
cv_init(state);
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
cv_process(state, bg, 50);
// Full traversal top→bottom
int walk_down[] = {20, 34, 48, 62, 70};
for (int i = 0; i < 5; i++) {
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, walk_down[i]);
cv_process(state, f, 50);
}
TEST_ASSERT_EQUAL_INT(1, state.entries);
// Same track reverses back to top. counted=true prevents a second event.
int walk_up[] = {62, 48, 34, 22};
for (int i = 0; i < 4; i++) {
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, walk_up[i]);
cv_process(state, f, 50);
}
TEST_ASSERT_EQUAL_INT(1, state.entries);
TEST_ASSERT_EQUAL_INT(0, state.exits);
}
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.
// Cooldown is a safety net on top of directional counting. Construct two
// DIFFERENT tracks (each counts once on its own) whose crossings happen
// within the cooldown window — the second should still be suppressed.
CVState state;
cv_init(state);
state.bg_valid = true;
@@ -147,22 +203,28 @@ void test_cooldown_suppresses_rapid_re_entry() {
state.entries = 1;
state.last_entry_frame = 100;
// Track at y=50 (just below line), spawn_y=20 (firm above) — a valid trajectory.
CVTrack t;
t.id = 1; t.x = 48; t.y = 40; t.above_line = true; t.missed = 0;
t.id = 1; t.x = 48; t.y = 50; t.spawn_y = 20;
t.above_line = false; t.counted = false; 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);
// Frame 101: blob at y=64 (delta=14, matches; firm below line+margin=62).
// Would count but cooldown (101-100=1 < 5) suppresses.
uint8_t f1[CV_PIXELS]; make_blob_frame(f1, 48, 64);
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);
// Advance past cooldown; reset a fresh track (previous one had counted=true
// set only if it actually counted — cooldown path leaves counted=false so
// we reuse the same track).
state.frame_index = 200;
state.tracks[0].y = 50;
state.tracks[0].spawn_y = 20;
state.tracks[0].counted = false;
state.tracks[0].above_line = false;
uint8_t f2[CV_PIXELS]; make_blob_frame(f2, 48, 64);
CVResult r2 = cv_process(state, f2, 50);
TEST_ASSERT_EQUAL_INT(1, r2.entries_delta);
TEST_ASSERT_EQUAL_INT(2, state.entries);
@@ -194,6 +256,8 @@ int main() {
RUN_TEST(test_tracking_spawns_track_for_new_blob);
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_track_spawned_near_line_does_not_count_on_wobble);
RUN_TEST(test_track_counts_at_most_once_even_if_it_wobbles_back);
RUN_TEST(test_no_crossing_same_side_no_count);
RUN_TEST(test_cooldown_suppresses_rapid_re_entry);
return UNITY_END();