refactor(cv): read thresholds from runtime tuning + load from NVS on boot

cv_process and helpers (frame_diff, extract_blob, find_centroids) now read
diff_thresh, min_blob_px, max_move, max_missed, and line_offset from
state.tuning instead of file-scope static const constants. The four
thresholds are promoted to file-local constexpr defaults in cv.cpp
(CV_DEFAULT_*) and are no longer part of the public cv.h API — external
code can't depend on them.

cv_process signature drops the line_pct parameter; callers use
state.tuning.line_offset instead. This eliminates the drift hazard of
having two sources of truth (DeviceConfig.line_offset vs
CVTuning.line_offset); the former is deleted.

main.cpp now calls config_load_tuning(g_cv.tuning) after cv_init on boot
so previously persisted tuning survives reboot; logs whether tuning came
from NVS or defaults.

The legacy NVS key "line_offset" is intentionally left alone — harmless
and flash_device.py may still write it during provisioning. Migration is
out of scope.

Tests: 12/12 passing (11 existing + 1 new
test_cv_process_respects_runtime_min_blob proving the runtime-read path).
Flash: 1,414,069 bytes (89.9%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 15:55:35 -07:00
parent a992bfe391
commit 94d74e425c
6 changed files with 93 additions and 51 deletions

View File

@@ -5,6 +5,14 @@
#include <algorithm>
#include <vector>
// File-local defaults. Runtime values live in CVState::tuning and can be
// overridden via config_load_tuning() on boot or server push at runtime.
static constexpr uint8_t CV_DEFAULT_DIFF_THRESH = 30;
static constexpr int CV_DEFAULT_MIN_BLOB_PX = 64;
static constexpr float CV_DEFAULT_MAX_MOVE = 15.0f;
static constexpr int CV_DEFAULT_MAX_MISSED = 10;
static constexpr uint8_t CV_DEFAULT_LINE_OFFSET = 50;
void cv_init(CVState& state) {
// Initialize members directly — avoid CVState{} temporary which puts 9KB on stack
memset(state.background, 0, sizeof(state.background));
@@ -15,11 +23,11 @@ void cv_init(CVState& state) {
state.tracks.clear();
state.entries = 0;
state.exits = 0;
state.tuning.diff_thresh = CV_DIFF_THRESH;
state.tuning.min_blob_px = CV_MIN_BLOB_PX;
state.tuning.max_move = CV_MAX_MOVE;
state.tuning.max_missed = CV_MAX_MISSED;
state.tuning.line_offset = 50;
state.tuning.diff_thresh = CV_DEFAULT_DIFF_THRESH;
state.tuning.min_blob_px = CV_DEFAULT_MIN_BLOB_PX;
state.tuning.max_move = CV_DEFAULT_MAX_MOVE;
state.tuning.max_missed = CV_DEFAULT_MAX_MISSED;
state.tuning.line_offset = CV_DEFAULT_LINE_OFFSET;
state.tuning.cfg_version = 0;
}
@@ -32,8 +40,9 @@ struct Point { int x, y; };
// Note: queue may grow to CV_PIXELS entries (~72KB) on large blobs.
// Requires PSRAM (enabled via -DBOARD_HAS_PSRAM in platformio.ini).
// BFS flood fill. Marks visited pixels (sets fg to 0). Returns {-1,-1} if blob < CV_MIN_BLOB_PX.
static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y) {
// BFS flood fill. Marks visited pixels (sets fg to 0). Returns {-1,-1} if blob < min_blob_px.
static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y,
int min_blob_px) {
std::vector<Point> queue;
queue.reserve(512);
queue.push_back({start_x, start_y});
@@ -58,11 +67,12 @@ static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y
}
}
if (count < CV_MIN_BLOB_PX) return {-1.0f, -1.0f};
if (count < min_blob_px) return {-1.0f, -1.0f};
return {sum_x / count, sum_y / count};
}
static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg) {
static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg,
int min_blob_px) {
std::vector<std::pair<float,float>> result;
static uint8_t fg_copy[CV_PIXELS]; // static to avoid 9KB stack allocation
memcpy(fg_copy, fg, CV_PIXELS);
@@ -70,7 +80,7 @@ static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg) {
for (int y = 0; y < CV_H; y++) {
for (int x = 0; x < CV_W; x++) {
if (!fg_copy[y * CV_W + x]) continue;
auto c = extract_blob(fg_copy, x, y);
auto c = extract_blob(fg_copy, x, y, min_blob_px);
if (c.first >= 0) result.push_back(c);
}
}
@@ -78,15 +88,15 @@ static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg) {
}
static void frame_diff(const uint8_t* frame, const uint8_t* bg,
uint8_t* fg, int pixels) {
uint8_t* fg, int pixels, uint8_t diff_thresh) {
for (int i = 0; i < pixels; i++) {
int diff = (int)frame[i] - (int)bg[i];
if (diff < 0) diff = -diff;
fg[i] = (diff > CV_DIFF_THRESH) ? 1 : 0;
fg[i] = (diff > diff_thresh) ? 1 : 0;
}
}
CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
CVResult cv_process(CVState& state, const uint8_t* frame) {
CVResult result = {0, 0};
state.frame_index++;
@@ -96,13 +106,18 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
return result;
}
const uint8_t diff_thresh = state.tuning.diff_thresh;
const int min_blob_px = state.tuning.min_blob_px;
const float max_move = state.tuning.max_move;
const int max_missed = state.tuning.max_missed;
static uint8_t fg[CV_PIXELS]; // static: avoids 9KB on task stack
frame_diff(frame, state.background, fg, CV_PIXELS);
frame_diff(frame, state.background, fg, CV_PIXELS, diff_thresh);
int fg_count = 0;
for (int i = 0; i < CV_PIXELS; i++) fg_count += fg[i];
bool motion = fg_count > CV_MIN_BLOB_PX;
bool motion = fg_count > min_blob_px;
if (!motion) {
if (state.frame_index - state.last_motion_frame > 10) {
memcpy(state.background, frame, CV_PIXELS);
@@ -110,19 +125,19 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
for (auto& t : state.tracks) t.missed++;
state.tracks.erase(
std::remove_if(state.tracks.begin(), state.tracks.end(),
[](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }),
[max_missed](const CVTrack& t){ return t.missed > max_missed; }),
state.tracks.end());
return result;
}
state.last_motion_frame = state.frame_index;
auto centroids = find_centroids(fg);
auto centroids = find_centroids(fg, min_blob_px);
std::vector<bool> centroid_matched(centroids.size(), false);
for (auto& track : state.tracks) {
float best_dist = CV_MAX_MOVE * CV_MAX_MOVE;
float best_dist = max_move * max_move;
int best_idx = -1;
for (int i = 0; i < (int)centroids.size(); i++) {
@@ -145,10 +160,10 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
state.tracks.erase(
std::remove_if(state.tracks.begin(), state.tracks.end(),
[](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }),
[max_missed](const CVTrack& t){ return t.missed > max_missed; }),
state.tracks.end());
float line_y = (line_pct / 100.0f) * CV_H;
float line_y = (state.tuning.line_offset / 100.0f) * CV_H;
for (int i = 0; i < (int)centroids.size(); i++) {
if (centroid_matched[i]) continue;
CVTrack t;