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:
@@ -151,36 +151,46 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
|
||||
t.id = state.next_id++;
|
||||
t.x = centroids[i].first;
|
||||
t.y = centroids[i].second;
|
||||
t.spawn_y = t.y;
|
||||
t.above_line = (t.y < line_y);
|
||||
t.counted = false;
|
||||
t.missed = 0;
|
||||
state.tracks.push_back(t);
|
||||
}
|
||||
// Line crossing check
|
||||
// Directional crossing check. A track counts at most once, and only if it
|
||||
// spawned clearly on one side of the line AND is now clearly on the other.
|
||||
// This rejects blobs that wobble around the line (shadows, body straddling
|
||||
// the line, track churn at spawn) — only a true traversal fires an event.
|
||||
for (auto& track : state.tracks) {
|
||||
if (track.missed > 0) continue; // only check tracks matched this frame
|
||||
bool now_above = (track.y < line_y);
|
||||
if (now_above != track.above_line) {
|
||||
if (!now_above) {
|
||||
// was above, now below → entry
|
||||
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
|
||||
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;
|
||||
}
|
||||
if (track.missed > 0) continue; // only check tracks matched this frame
|
||||
if (track.counted) continue; // one track = one trip
|
||||
|
||||
bool spawned_above = track.spawn_y < (line_y - CV_TRAVERSAL_MARGIN_PX);
|
||||
bool spawned_below = track.spawn_y > (line_y + CV_TRAVERSAL_MARGIN_PX);
|
||||
bool now_above_firm = track.y < (line_y - CV_TRAVERSAL_MARGIN_PX);
|
||||
bool now_below_firm = track.y > (line_y + CV_TRAVERSAL_MARGIN_PX);
|
||||
|
||||
if (spawned_above && now_below_firm) {
|
||||
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;
|
||||
track.counted = true;
|
||||
}
|
||||
} else if (spawned_below && now_above_firm) {
|
||||
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.counted = true;
|
||||
}
|
||||
}
|
||||
track.above_line = now_above;
|
||||
|
||||
track.above_line = (track.y < line_y);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -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;
|
||||
|
||||
// Directional counting margin: a track only counts if it spawned and is now
|
||||
// both at least this far from the line (in pixels). Prevents counting blobs
|
||||
// that wobble around the line or spawn on top of it. Value chosen at ~15% of
|
||||
// the 96px frame: 14px ≈ the typical torso half-width overhead.
|
||||
static const float CV_TRAVERSAL_MARGIN_PX = 14.0f;
|
||||
|
||||
// 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
|
||||
@@ -21,7 +27,9 @@ static const uint32_t CV_CROSSING_COOLDOWN_FRAMES = 5;
|
||||
struct CVTrack {
|
||||
int id;
|
||||
float x, y;
|
||||
float spawn_y; // y at track creation — used for directional counting
|
||||
bool above_line;
|
||||
bool counted; // fires at most once per track (one track = one trip)
|
||||
int missed;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user