From 62931e26ff88e29512a282043ef8275eff6d061d Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Fri, 17 Apr 2026 06:33:11 -0700 Subject: [PATCH] fix(cv): add per-direction crossing cooldown to suppress track-churn double-counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 13 +++- docs/server-prompt-crossing-cooldown.md | 78 +++++++++++++++++++ .../specs/2026-04-13-door-counter-design.md | 2 + firmware/lib/cv/cv.cpp | 20 ++++- firmware/lib/cv/cv.h | 8 ++ firmware/test/test_cv/test_cv.cpp | 34 ++++++++ 6 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 docs/server-prompt-crossing-cooldown.md diff --git a/README.md b/README.md index 1755822..16a6c89 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ pio run -t upload --upload-port /dev/ttyUSB0 | Module | Behavior | |--------|----------| -| CV pipeline | 5 fps, 96×96 grayscale, blob tracking, line-crossing count | +| CV pipeline | 5 fps, 96×96 grayscale, blob tracking, line-crossing count with per-direction cooldown | | BLE scanner | Continuous passive scan; deinits during hourly upload to free heap | | Reporter | Hourly HMAC-signed POST; 60s boot report for fast connectivity check | | Provisioning | Captive portal AP on first boot for WiFi setup | @@ -32,6 +32,17 @@ pio run -t upload --upload-port /dev/ttyUSB0 - **First report**: 60 seconds after NTP sync (connectivity check) - **Subsequent reports**: every 3600 seconds +### Crossing cooldown + +To suppress double-counts from track churn (a blob briefly dropping below the +minimum-blob-pixel threshold, causing the tracker to kill and respawn a track +that then re-crosses the line), each direction enforces a cooldown window +between counted crossings. Default: `CV_CROSSING_COOLDOWN_FRAMES = 5`, which +suppresses any second crossing in the same direction whose frame gap is `< 5` +— ≈0.8s at 5 fps. Entries and exits maintain separate cooldowns, so a real entry +immediately followed by a real exit still counts both. See +`firmware/lib/cv/cv.h`. + ## Operator Setup ### 1. Flash firmware diff --git a/docs/server-prompt-crossing-cooldown.md b/docs/server-prompt-crossing-cooldown.md new file mode 100644 index 0000000..fd7c14c --- /dev/null +++ b/docs/server-prompt-crossing-cooldown.md @@ -0,0 +1,78 @@ +# Server-Side Prompt — Crossing Cooldown Awareness + +> Give this to your server-side agent after the firmware commit that introduces +> `CV_CROSSING_COOLDOWN_FRAMES` in `firmware/lib/cv/cv.h` has been flashed to devices. + +## Context + +The DoorCounter firmware now enforces a **per-direction crossing cooldown** in +its CV pipeline. After a counted entry, subsequent entries within 5 frames +(~1 second at 5 fps) are silently dropped on-device. Exits follow the same +rule independently. This is a device-side fix for the known track-churn bug +(single person producing 5+ counts per visit when their blob briefly drops +below the minimum-blob-pixel threshold). + +Constants live in `firmware/lib/cv/cv.h`: + +```c++ +static const uint32_t CV_CROSSING_COOLDOWN_FRAMES = 5; // per-direction +``` + +Cooldown is **fixed at compile time**. It is **not** currently a server- +pushable tuning parameter. + +## What the server should do + +1. **Expect lower per-hour entry/exit counts** from devices running this + firmware compared to their historical baseline. This reflects suppression + of false positives, not a device regression. Do not alert on the drop. + +2. **If you have a per-device tuning-config push mechanism** (the one planned + in `.agent/plan.md` — server-push CV config via heartbeat response), add + `cooldown_frames` to the sendable tuning set with: + - Default: `5` + - Valid range: `0..60` (0 disables cooldown; 60 ≈ 12s at 5 fps) + - Semantics: per-direction, applied to both entries and exits + - Persist alongside other CV tuning keys in NVS under a new key `cv_cool`. + - Bump the `cfg_version` scheme accordingly. + + **Do not ship this server-side change yet** — the firmware change in this + commit keeps the cooldown as a compile-time constant. A future firmware + commit on `feature/server-push-config` will expose it as a runtime + tunable and bump `cfg_version`. Coordinate the rollout: firmware first, + then server. + +3. **Dashboard**: if you render device CV parameters in a per-device settings + view, add a read-only row "Crossing cooldown (frames): 5" sourced from + the firmware's compiled default. Mark it editable only once the firmware + exposes it as a tunable. + +4. **Telemetry (optional, low priority)**: consider adding a field + `suppressed_crossings_delta` to the heartbeat or camera-events payload + so operators can see how often cooldown is kicking in. This would require + a firmware change; flag it as future work only if churn continues. + +## What NOT to do + +- Do not attempt to push `cooldown_frames` via the existing config channel + today — the firmware will ignore unknown fields, which is fine, but + shipping server changes that assume the firmware-side plumbing exists + will break the integration contract. +- Do not "correct" the lower counts via server-side multiplication. The + cooldown is the correct behavior; old counts were inflated by the churn + bug. + +## Verification checklist + +- [ ] Historical counts chart annotated with "firmware v{N} deployed" + marker on the rollout date. +- [ ] Per-device tuning view renders cooldown row (read-only for now). +- [ ] No alert fires on the per-device count drop post-rollout. + +## Reference + +- Firmware change: `firmware/lib/cv/cv.h` (`CV_CROSSING_COOLDOWN_FRAMES`), + `firmware/lib/cv/cv.cpp` (suppression logic in `cv_process`). +- Design spec: `docs/superpowers/specs/2026-04-13-door-counter-design.md` + § 3.1 "Counting logic". +- Unit test: `firmware/test/test_cv/test_cv.cpp::test_cooldown_suppresses_rapid_re_entry`. diff --git a/docs/superpowers/specs/2026-04-13-door-counter-design.md b/docs/superpowers/specs/2026-04-13-door-counter-design.md index aca28f4..f622c24 100644 --- a/docs/superpowers/specs/2026-04-13-door-counter-design.md +++ b/docs/superpowers/specs/2026-04-13-door-counter-design.md @@ -92,10 +92,12 @@ Capture → Grayscale → Downscale 96×96 → Frame diff → Threshold → Blob | Blob detect | Connected components; blobs < 8×8 px discarded as noise | | Centroid track | Nearest-centroid matching frame-to-frame (max 15px), tracks persist up to 10 missed frames | | Line crossing | Virtual horizontal line at configurable vertical position (default: 50% of frame height) | +| Cooldown | Per-direction cooldown between counted crossings (default 5 frames ≈ 0.8s @ 5 fps) — suppresses duplicate counts from track churn | **Counting logic:** - Centroid crosses line top→bottom = **entry** - Centroid crosses line bottom→top = **exit** +- After a counted entry (resp. exit), subsequent entries (resp. exits) within `CV_CROSSING_COOLDOWN_FRAMES` are ignored. Entry and exit cooldowns are tracked independently, so an entry immediately followed by an exit (or vice versa) still counts both. Counts accumulate as `{entries, exits}` in RAM and reset each hour on report. diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp index d76629a..4e9f002 100644 --- a/firmware/lib/cv/cv.cpp +++ b/firmware/lib/cv/cv.cpp @@ -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; diff --git a/firmware/lib/cv/cv.h b/firmware/lib/cv/cv.h index 30de5b1..dc3bc6c 100644 --- a/firmware/lib/cv/cv.h +++ b/firmware/lib/cv/cv.h @@ -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 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 { diff --git a/firmware/test/test_cv/test_cv.cpp b/firmware/test/test_cv/test_cv.cpp index 72b9cba..20aa7f3 100644 --- a/firmware/test/test_cv/test_cv.cpp +++ b/firmware/test/test_cv/test_cv.cpp @@ -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(); }