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:
13
README.md
13
README.md
@@ -21,7 +21,7 @@ pio run -t upload --upload-port /dev/ttyUSB0
|
|||||||
|
|
||||||
| Module | Behavior |
|
| 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 |
|
| BLE scanner | Continuous passive scan; deinits during hourly upload to free heap |
|
||||||
| Reporter | Hourly HMAC-signed POST; 60s boot report for fast connectivity check |
|
| Reporter | Hourly HMAC-signed POST; 60s boot report for fast connectivity check |
|
||||||
| Provisioning | Captive portal AP on first boot for WiFi setup |
|
| 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)
|
- **First report**: 60 seconds after NTP sync (connectivity check)
|
||||||
- **Subsequent reports**: every 3600 seconds
|
- **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
|
## Operator Setup
|
||||||
|
|
||||||
### 1. Flash firmware
|
### 1. Flash firmware
|
||||||
|
|||||||
78
docs/server-prompt-crossing-cooldown.md
Normal file
78
docs/server-prompt-crossing-cooldown.md
Normal file
@@ -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`.
|
||||||
@@ -92,10 +92,12 @@ Capture → Grayscale → Downscale 96×96 → Frame diff → Threshold → Blob
|
|||||||
| Blob detect | Connected components; blobs < 8×8 px discarded as noise |
|
| 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 |
|
| 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) |
|
| 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:**
|
**Counting logic:**
|
||||||
- Centroid crosses line top→bottom = **entry**
|
- Centroid crosses line top→bottom = **entry**
|
||||||
- Centroid crosses line bottom→top = **exit**
|
- 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.
|
Counts accumulate as `{entries, exits}` in RAM and reset each hour on report.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ void cv_init(CVState& state) {
|
|||||||
state.tracks.clear();
|
state.tracks.clear();
|
||||||
state.entries = 0;
|
state.entries = 0;
|
||||||
state.exits = 0;
|
state.exits = 0;
|
||||||
|
state.last_entry_frame = 0;
|
||||||
|
state.last_exit_frame = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void cv_reset_counts(CVState& state) {
|
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 != track.above_line) {
|
||||||
if (!now_above) {
|
if (!now_above) {
|
||||||
// was above, now below → entry
|
// was above, now below → entry
|
||||||
state.entries++;
|
bool in_cooldown = state.last_entry_frame != 0 &&
|
||||||
result.entries_delta++;
|
(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 {
|
} else {
|
||||||
// was below, now above → exit
|
// was below, now above → exit
|
||||||
state.exits++;
|
bool in_cooldown = state.last_exit_frame != 0 &&
|
||||||
result.exits_delta++;
|
(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;
|
track.above_line = now_above;
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ static const int CV_MIN_BLOB_PX = 64;
|
|||||||
static const float CV_MAX_MOVE = 15.0f;
|
static const float CV_MAX_MOVE = 15.0f;
|
||||||
static const int CV_MAX_MISSED = 10;
|
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 {
|
struct CVTrack {
|
||||||
int id;
|
int id;
|
||||||
float x, y;
|
float x, y;
|
||||||
@@ -28,6 +34,8 @@ struct CVState {
|
|||||||
std::vector<CVTrack> tracks;
|
std::vector<CVTrack> tracks;
|
||||||
int entries;
|
int entries;
|
||||||
int exits;
|
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 {
|
struct CVResult {
|
||||||
|
|||||||
@@ -135,6 +135,39 @@ void test_blob_crossing_line_bottom_to_top_is_exit() {
|
|||||||
TEST_ASSERT_EQUAL_INT(1, r.exits_delta);
|
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() {
|
void test_no_crossing_same_side_no_count() {
|
||||||
CVState state;
|
CVState state;
|
||||||
cv_init(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_top_to_bottom_is_entry);
|
||||||
RUN_TEST(test_blob_crossing_line_bottom_to_top_is_exit);
|
RUN_TEST(test_blob_crossing_line_bottom_to_top_is_exit);
|
||||||
RUN_TEST(test_no_crossing_same_side_no_count);
|
RUN_TEST(test_no_crossing_same_side_no_count);
|
||||||
|
RUN_TEST(test_cooldown_suppresses_rapid_re_entry);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user