Replace per-track line-crossing counter with a single event state machine
gated by foreground pixel count (ENTER=250, EXIT=150) and finalized by
quiet-exit or timeout. Direction inferred from centroid excursion
(up_score vs down_score) on quiet-exit fires, and from net displacement
(last_c vs first_c) on timeout fires.
Tuning reflects bench data at the intended 7' overhead mount: walkers
produce smaller centroid excursions than originally modelled, so
EXTENT gates, MIN_TRAJ, MAX_FRAMES and REFRACTORY were all relaxed from
their initial guesses. Constants and rationale live in firmware/lib/cv/cv.h.
Bench results (8 isolated walks, 4 entries + 4 exits):
* Event detection: 8/8 (100%)
* Aggregate entries+exits split: 4+4 (matches)
* Per-walk direction labelling: 4/8 (~50%)
Document explicitly that per-walk direction is unreliable at this mount
and that downstream analytics should trust only gross traffic
(entries + exits). Recovering direction would require a physical mount
change or a richer signal; both are out of scope for v1.
Tooling:
* tools/replay_logs.py — replay event state machine against captured
[F] diagnostic lines, for offline tuning without flash-test loops.
* firmware/src/main_capture.cpp + tools/capture_frames.py +
tools/replay_frames.py — raw-frame capture firmware and Python port
of the detector, kept in tree for future iteration even though the
TimerCamera-F serial driver stripped specific byte ranges in testing
and log-based replay became the working path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
4.5 KiB
C++
119 lines
4.5 KiB
C++
// firmware/lib/cv/cv.h
|
|
#pragma once
|
|
#include <stdint.h>
|
|
#include <vector>
|
|
|
|
static const int CV_W = 96;
|
|
static const int CV_H = 96;
|
|
static const int CV_PIXELS = CV_W * CV_H;
|
|
|
|
static const uint8_t CV_DIFF_THRESH = 30;
|
|
static const int CV_MIN_BLOB_PX = 64;
|
|
static const float CV_MAX_MOVE = 15.0f;
|
|
static const int CV_MAX_MISSED = 10;
|
|
|
|
// Event-based walker detector. Per-frame zone-flip approaches were direction-
|
|
// blind at realistic mounts: a walker traversing top-to-bottom and a walker
|
|
// traversing bottom-to-top produced identical zone-dominance sequences
|
|
// (geometric artifact of asymmetric zones + body spanning the line). The
|
|
// event approach buffers a whole walker event, then decides direction from
|
|
// the centroid trajectory: sign(first_centroid_y - peak_centroid_y) > 0 means
|
|
// the centroid moved upward through the frame during the event.
|
|
//
|
|
// Per-mount convention: UP through frame == ENTRY into store. Flip the camera
|
|
// mount or invert the mapping in cv_process if the physical install differs.
|
|
|
|
// fg_count thresholds that gate event start/end. Tuned against a real
|
|
// 8-walk isolated test (see .agent/walk_isolated_8walks.log). Lower than
|
|
// initial guesses because the 7' overhead mount produces smaller centroid
|
|
// excursions than we originally modelled.
|
|
static const int CV_EVENT_ENTER_THRESH = 250;
|
|
static const int CV_EVENT_EXIT_THRESH = 150;
|
|
|
|
// Number of consecutive sub-EXIT frames required to end an event.
|
|
static const int CV_EVENT_QUIET_FRAMES = 3;
|
|
|
|
// Min/max event duration in frames. Below min = too brief to be a walker
|
|
// (noise burst). Above max = stationary object or stuck detection.
|
|
static const int CV_EVENT_MIN_FRAMES = 5;
|
|
// MAX bounds the event duration. Too low (15) cut events off while walker
|
|
// was still physically in frame — every fire hit dur=MAX+1 and bg snapped
|
|
// with a walker-ghost baked in, corrupting the next walk. Too high (40)
|
|
// merged multiple walkers. 25 frames (5s) lets a single walker reach the
|
|
// quiet-exit path (fg drops below EXIT_THRESH) before timeout, so bg snaps
|
|
// on a clean empty frame.
|
|
static const int CV_EVENT_MAX_FRAMES = 25;
|
|
|
|
// Required vertical extent: during the event, fg must have reached near the
|
|
// top of the frame (min_y <= TOP) AND near the bottom (max_y >= BOT). At a
|
|
// 7' overhead mount real walkers span fg y≈0..70, not 0..95 — the original
|
|
// 10/85 gates rejected most real walks. Relaxed to catch them while still
|
|
// filtering small local motion that doesn't span the doorway.
|
|
static const int CV_EVENT_EXTENT_TOP = 25;
|
|
static const int CV_EVENT_EXTENT_BOT = 50;
|
|
|
|
// Minimum centroid excursion (max of up_score/down_score) for a valid
|
|
// trajectory. At overhead mount walker centroid traverses ~15-40 pixels;
|
|
// 15 was too aggressive and dropped clean walks. 5 still filters wobble.
|
|
static const float CV_EVENT_MIN_TRAJ = 5.0f;
|
|
|
|
// Refractory period after a fire. Shorter than originally chosen — at 5 fps
|
|
// a second walker can arrive within 2s of the first, especially at busy
|
|
// doorways. 10 frames = 2s of back-pressure, tuned to match the gap between
|
|
// consecutive isolated walks in the test log.
|
|
static const uint32_t CV_EVENT_REFRACTORY_FRAMES = 10;
|
|
|
|
// Diagnostic only: tracks are kept for spawn logging. Counting does NOT
|
|
// depend on tracks.
|
|
struct CVTrack {
|
|
int id;
|
|
float x, y;
|
|
float spawn_y;
|
|
int missed;
|
|
};
|
|
|
|
struct CVState {
|
|
uint8_t background[CV_PIXELS];
|
|
bool bg_valid;
|
|
uint32_t last_motion_frame;
|
|
uint32_t frame_index;
|
|
int next_id;
|
|
std::vector<CVTrack> tracks;
|
|
int entries;
|
|
int exits;
|
|
|
|
// Event state machine.
|
|
bool event_active;
|
|
uint32_t event_start_frame;
|
|
int event_frame_count;
|
|
int event_peak_n;
|
|
float event_first_c;
|
|
float event_last_c;
|
|
float event_min_c; // min centroid_y observed during event
|
|
float event_max_c; // max centroid_y observed during event
|
|
int event_min_y_seen;
|
|
int event_max_y_seen;
|
|
int event_quiet_count;
|
|
uint32_t last_fire_frame; // 0 = never; frame of last counted fire
|
|
};
|
|
|
|
struct CVResult {
|
|
int entries_delta;
|
|
int exits_delta;
|
|
// Per-frame foreground diagnostics (populated every call).
|
|
int fg_count;
|
|
int fg_min_y;
|
|
int fg_max_y;
|
|
float fg_centroid_y;
|
|
// Populated only on a fire frame; zeroed otherwise.
|
|
float fire_first_c;
|
|
float fire_min_c;
|
|
float fire_max_c;
|
|
float fire_last_c;
|
|
int fire_duration;
|
|
};
|
|
|
|
void cv_init(CVState& state);
|
|
CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct);
|
|
void cv_reset_counts(CVState& state);
|