Files
Frametop--frametop/hands/tests/flagbuf_test.cpp
T
DeeJanuzandClaude Opus 5.5 2ae1a36b33 Hands: flag tracking failures (ft-handsctl flag, a rolling buffer in ft-hands)
ft-hands keeps the last 12 s of hand crops (each view's landmark crop, 1.6x, at most
224 px), its hands, gestures and landmarks in RAM (track/flagbuf.h, HANDS_FLAG_BUFFER=1).
`ft-handsctl flag [note]` asks for them through run_dir/flag; ft-hands writes them and the
next 2 s to HANDS_FLAG_DIR/flag-<time> from a low-priority thread, for frame-hands'
offline tools. ft-handsctl also saves a /dev/video99 frame and ft-handview's head poses,
and the drawn hands pulse cyan when a flag is taken (ft-handview's new flash and poses
commands). ft-handreplay --flag-at/--flag-every makes the same flags from recordings.
Measured on a replayed ring: +58 MB peak RSS, 0.1 ms a crop on an A720 (about 1% of a
core). Unit tests: tests/flagbuf_test.cpp (make check), tests/test_handsflag.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:11:53 -06:00

295 lines
14 KiB
C++

// Unit tests for the flag buffer (track/flagbuf.h): the crops' geometry, the rolling window, the
// slots running out, a flag's window and files, ghosts, a second flag while one waits, and the
// clock completing a flag when no sets come. Made-up images and views; no models.
// Build and run: make check (in the dev container; hands/build.sh's environment).
#include "../track/flagbuf.h"
#include <json/json.h>
#include <sys/stat.h>
#include <unistd.h>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
static int failures = 0;
#define CHECK(cond, ...) \
do { \
if (!(cond)) { \
std::printf("FAIL %s:%d: ", __FILE__, __LINE__); \
std::printf(__VA_ARGS__); \
std::printf("\n"); \
++failures; \
} \
} while (0)
namespace {
constexpr int64_t kSec = 1'000'000'000;
struct Img {
int w, h;
std::vector<uint8_t> px;
Img(int w_, int h_) : w(w_), h(h_), px(size_t(w_) * h_) {
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x) px[size_t(y) * w + x] = uint8_t((x + 2 * y) & 255);
}
Image image() const { return {px.data(), w, h, w}; }
int at(int x, int y) const { return px[size_t(y) * w + x]; }
};
Seen view(const std::string &cam, int hand, double cx, double cy, double size) {
Seen s;
s.cam = cam, s.hand = hand;
s.roi.center = {cx, cy}, s.roi.size = size, s.roi.rotation = 0.3;
s.lm.presence = 0.9, s.lm.right = 0.8;
for (int k = 0; k < 21; ++k) s.lm.pts[k] = {cx + k, cy - k};
return s;
}
bool parse(const std::string &text, Json::Value &v) {
Json::CharReaderBuilder b;
std::string e;
std::istringstream in(text);
return Json::parseFromStream(b, in, &v, &e);
}
std::vector<Json::Value> lines(const std::string &path) {
std::vector<Json::Value> out;
std::ifstream f(path);
std::string line;
while (std::getline(f, line)) {
Json::Value v;
CHECK(parse(line, v), "%s: a line isn't JSON: %.80s", path.c_str(), line.c_str());
out.push_back(v);
}
return out;
}
Json::Value file_json(const std::string &path) {
std::ifstream f(path);
std::stringstream ss;
ss << f.rdbuf();
Json::Value v;
CHECK(parse(ss.str(), v), "%s isn't JSON", path.c_str());
return v;
}
std::string temp_dir() {
char tmpl[] = "/tmp/flagbuf-test-XXXXXX";
const char *d = mkdtemp(tmpl);
return d ? d : "/tmp";
}
void test_crop_geometry() {
const Img img(320, 240);
std::vector<uint8_t> out(224 * 224);
// native size: crop pixel (u, v) is image pixel (x0 + u, y0 + v) exactly
CropGeom g = crop_square(img.image(), {100.5, 80.5}, 60, 224, out.data());
CHECK(g.w == 60 && g.h == 60 && std::fabs(g.scale - 1) < 1e-9, "native crop %dx%d scale %f", g.w, g.h, g.scale);
bool same = true;
for (int v = 0; v < g.h; ++v)
for (int u = 0; u < g.w; ++u) {
const int x = int(std::lround(g.x0 + (u + 0.5) * g.scale - 0.5)), y = int(std::lround(g.y0 + (v + 0.5) * g.scale - 0.5));
same = same && out[size_t(v) * g.w + u] == img.at(x, y);
}
CHECK(same, "native crop's pixels aren't the image's at the mapped place");
// the crop's middle maps to the centre
CHECK(std::fabs(g.x0 + (g.w / 2.0) * g.scale - 0.5 - 100.5) < 1e-9, "centre x %f", g.x0 + (g.w / 2.0) * g.scale - 0.5);
// downscaled: at most max_px, the mapping still holds (the pattern is smooth: within a level or two)
g = crop_square(img.image(), {160, 120}, 200, 64, out.data());
CHECK(g.w == 64 && std::fabs(g.scale - 200.0 / 64) < 1e-9, "downscaled %d scale %f", g.w, g.scale);
int worst = 0;
for (int v = 0; v < g.h; ++v)
for (int u = 0; u < g.w; ++u) {
const double x = g.x0 + (u + 0.5) * g.scale - 0.5, y = g.y0 + (v + 0.5) * g.scale - 0.5;
const int want = int(std::lround(x + 2 * y)) & 255, got = out[size_t(v) * g.w + u];
if (want > 8 && want < 248) worst = std::max(worst, std::abs(want - got));
}
CHECK(worst <= 3, "downscaled crop off the mapping by %d levels", worst);
// past the image's corner: 0 there, the image inside
g = crop_square(img.image(), {2, 2}, 40, 224, out.data());
CHECK(out[0] == 0 && out[size_t(g.h - 1) * g.w + g.w - 1] == img.at(22, 22), "corner crop: %d, %d (want 0, %d)", out[0],
out[size_t(g.h - 1) * g.w + g.w - 1], img.at(22, 22));
CHECK(g.x0 < 0 && g.y0 < 0, "corner crop's x0 %f y0 %f", g.x0, g.y0);
}
FlagConfig small_config() {
FlagConfig c;
c.before_s = 1.0, c.after_s = 0.5, c.ghost_s = 0.25, c.crop_px = 32, c.scale = 1.6;
c.max_bytes = 32 * 32 * 20; // 20 slots
c.writer_cpus.clear();
return c;
}
void test_window_and_slots() {
const Img img(128, 128);
const std::map<std::string, Image> images = {{"a", img.image()}, {"b", img.image()}};
FlagBuffer fb(small_config());
// 10 sets a second, two views each (20 slots: 10 sets fit)
for (int i = 0; i < 30; ++i)
fb.add(i * kSec / 10, i * kSec / 10, i, images, {view("a", 1, 60, 60, 20), view("b", 1, 64, 50, 20)}, "\"hands\": []");
FlagBuffer::Stats s = fb.stats();
CHECK(s.slots == 20 && s.slots_used <= 20, "slots %zu used %zu", s.slots, s.slots_used);
CHECK(s.dropped == 0, "nothing held, so nothing dropped: %zu", s.dropped);
CHECK(s.crops == 60, "crops %zu", s.crops);
// one view a set: the window (1 s), not the slots, keeps it to about 11 sets
for (int i = 30; i < 60; ++i)
fb.add(i * kSec / 10, i * kSec / 10, i, images, {view("a", 1, 60, 60, 20)}, "\"hands\": []");
s = fb.stats();
CHECK(s.slots_used >= 10 && s.slots_used <= 12, "one view a set: %zu slots in use", s.slots_used);
}
void test_flag_files() {
const Img img(128, 128);
const std::map<std::string, Image> images = {{"a", img.image()}, {"b", img.image()}};
FlagBuffer fb(small_config());
fb.set_static("{\"a\": {\"width\": 128}}", "{\"models\": \"m\"}");
const std::string root = temp_dir(), dir = root + "/flag-1";
int i = 0;
for (; i < 30; ++i) fb.add(i * kSec / 10, i * kSec / 10, i, images, {view("a", 1, 60, 60, 20)}, "\"hands\": []");
CHECK(fb.flag({dir, "\"source\": \"test\", \"note\": \"fingers \\\"crossed\\\"\""}, 29 * kSec / 10), "flag refused");
CHECK(!fb.flag({dir + "-2", ""}, 30 * kSec / 10, "again"), "a second flag while one waits was taken");
CHECK(fb.waiting(), "not waiting");
// two views a set now: the held sets fill the slots, so some crops drop
for (; i < 34; ++i)
fb.add(i * kSec / 10, i * kSec / 10, i, images, {view("a", 1, 60, 60, 20), view("b", 2, 30, 30, 20)}, "\"hands\": []");
CHECK(fb.waiting(), "completed early (0.5 s after 2.9 s is 3.4 s; the last set is 3.3 s)");
fb.add(i * kSec / 10, i * kSec / 10, i, images, {}, "\"hands\": []");
++i;
CHECK(!fb.waiting(), "not completed");
fb.wait();
const FlagBuffer::Stats s = fb.stats();
CHECK(s.flags == 1 && s.last_dir == dir && s.last_error.empty(), "flags %zu dir %s error %s", s.flags, s.last_dir.c_str(),
s.last_error.c_str());
CHECK(s.slots_used == 0, "the written sets' slots weren't given back: %zu in use", s.slots_used);
const Json::Value m = file_json(dir + "/manifest.json");
CHECK(m["version"].asInt() == 1 && m["source"].asString() == "test", "manifest version/source");
CHECK(m["note"].asString() == "fingers \"crossed\"", "note %s", m["note"].asString().c_str());
CHECK(m["more_notes"].size() == 1 && m["more_notes"][0].asString() == "again", "more_notes");
CHECK(m["flag_t_raw_ns"].asInt64() == 29 * kSec / 10, "flag time");
CHECK(m["settings"]["models"].asString() == "m" && m["cameras"]["a"]["width"].asInt() == 128, "static parts");
const std::vector<Json::Value> sets = lines(dir + "/sets.jsonl"), crops = lines(dir + "/crops.jsonl");
// held: from 1.9 s (1 s before) to the set at 3.4 s
CHECK(!sets.empty() && sets.front()["t_raw_ns"].asInt64() >= 19 * kSec / 10 - 1 &&
sets.back()["t_raw_ns"].asInt64() == 34 * kSec / 10,
"window %lld..%lld", sets.empty() ? 0LL : (long long)sets.front()["t_raw_ns"].asInt64(),
sets.empty() ? 0LL : (long long)sets.back()["t_raw_ns"].asInt64());
CHECK(m["counts"]["sets"].asUInt() == sets.size() && m["counts"]["crops"].asUInt() == crops.size(), "counts");
CHECK(m["counts"]["crops_dropped"].asUInt() > 0, "held sets filling the slots should drop crops");
// every view's crop index points at a crop line of that set and camera; the bytes add up
size_t bytes = 0;
for (const Json::Value &c : crops) {
CHECK(c["offset"].asUInt64() == bytes, "crop %u offset", c["k"].asUInt());
bytes += c["bytes"].asUInt64();
CHECK(c["bytes"].asUInt64() == c["w"].asUInt64() * c["h"].asUInt64(), "crop bytes");
}
struct stat st;
CHECK(stat((dir + "/crops.bin").c_str(), &st) == 0 && size_t(st.st_size) == bytes, "crops.bin size");
for (const Json::Value &set : sets)
for (const Json::Value &v : set["views"]) {
const int k = v["crop"].asInt();
if (k < 0) continue;
CHECK(k < int(crops.size()) && crops[size_t(k)]["set"].asInt() == set["i"].asInt() &&
crops[size_t(k)]["cam"].asString() == v["cam"].asString(),
"view crop %d", k);
CHECK(v["lm"].size() == 21 && v["roi"].size() == 4, "view landmarks");
}
// the first crop's pixels are the image's where the mapping says
if (!crops.empty()) {
std::ifstream f(dir + "/crops.bin", std::ios::binary);
std::vector<uint8_t> px(crops[0]["bytes"].asUInt64());
f.read(reinterpret_cast<char *>(px.data()), std::streamsize(px.size()));
const double x0 = crops[0]["x0"].asDouble(), y0 = crops[0]["y0"].asDouble(), sc = crops[0]["scale"].asDouble();
const int w = crops[0]["w"].asInt(), u = w / 2, v = w / 3;
const int x = int(std::lround(x0 + (u + 0.5) * sc - 0.5)), y = int(std::lround(y0 + (v + 0.5) * sc - 0.5));
CHECK(std::abs(int(px[size_t(v) * w + u]) - img.at(x, y)) <= 3, "crop pixel %d vs image %d", px[size_t(v) * w + u], img.at(x, y));
}
std::system(("rm -rf " + root).c_str());
}
void test_ghosts() {
const Img img(128, 128);
const std::map<std::string, Image> images = {{"a", img.image()}};
FlagConfig c = small_config();
c.after_s = 0.05;
FlagBuffer fb(c);
const std::string root = temp_dir(), dir = root + "/flag-g";
fb.add(0, 0, 0, images, {view("a", 7, 60, 60, 20)}, "");
for (int i = 1; i <= 5; ++i) fb.add(i * kSec / 10, i * kSec / 10, i, images, {}, "");
CHECK(fb.stats().ghosts == 2, "ghosts within 0.25 s: %zu (want 2: at 0.1 and 0.2 s)", fb.stats().ghosts);
fb.flag({dir, ""}, 5 * kSec / 10);
fb.add(6 * kSec / 10, 6 * kSec / 10, 6, images, {}, "");
fb.wait();
const std::vector<Json::Value> sets = lines(dir + "/sets.jsonl"), crops = lines(dir + "/crops.jsonl");
int ghost_lines = 0;
for (const Json::Value &s : sets) ghost_lines += int(s["ghosts"].size());
CHECK(ghost_lines == 2 && crops.size() == 3, "ghost lines %d crops %zu", ghost_lines, crops.size());
if (crops.size() == 3) CHECK(!crops[0]["ghost"].asBool() && crops[1]["ghost"].asBool() && crops[1]["hand"].asInt() == 7, "ghost crop");
std::system(("rm -rf " + root).c_str());
}
void test_tick() {
const Img img(64, 64);
FlagBuffer fb(small_config());
const std::string root = temp_dir(), dir = root + "/flag-t";
fb.add(0, 0, 0, {{"a", img.image()}}, {view("a", 1, 30, 30, 10)}, "");
fb.flag({dir, ""}, 0);
fb.tick(kSec / 2); // 0.5 s: not yet (after_s + 0.5)
CHECK(fb.waiting(), "ticked too early");
fb.tick(kSec + 1);
CHECK(!fb.waiting(), "tick didn't complete it");
fb.wait();
CHECK(file_json(dir + "/manifest.json")["counts"]["sets"].asInt() == 1, "tick flag sets");
// a folder it can't make: reported, the slots still come back
FlagBuffer fb2(small_config());
fb2.add(0, 0, 0, {{"a", img.image()}}, {view("a", 1, 30, 30, 10)}, "");
fb2.flag({root + "/missing/deeper", ""}, 0);
fb2.finish();
fb2.wait();
CHECK(!fb2.stats().last_error.empty() && fb2.stats().slots_used == 0, "bad folder: error '%s', %zu slots",
fb2.stats().last_error.c_str(), fb2.stats().slots_used);
std::system(("rm -rf " + root).c_str());
}
// Not a check: what a crop costs here, a hand at arm's length in a 1056 x 1024 camera (a 375 px crop,
// so a 600 px square) down to 224 and 288, and a small one copied as it is.
void bench() {
const Img img(1056, 1024);
std::vector<uint8_t> out(512 * 512);
for (int px : {224, 288}) {
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 300; ++i) crop_square(img.image(), {500.0 + i % 7, 480}, 600, px, out.data());
const double us = std::chrono::duration<double, std::micro>(std::chrono::steady_clock::now() - t0).count() / 300;
std::printf("flagbuf_test: a 600 px square to %d px: %.0f us a crop\n", px, us);
}
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 300; ++i) crop_square(img.image(), {500.0 + i % 7, 480}, 200, 224, out.data());
std::printf("flagbuf_test: a 200 px square as it is: %.0f us a crop\n",
std::chrono::duration<double, std::micro>(std::chrono::steady_clock::now() - t0).count() / 300);
}
void test_json_string() {
CHECK(json_string("a\"b\\c\nd") == "\"a\\\"b\\\\c\\nd\"", "%s", json_string("a\"b\\c\nd").c_str());
CHECK(json_string("", true) == "null" && json_string("") == "\"\"", "empty");
}
} // namespace
int main() {
test_crop_geometry();
test_window_and_slots();
test_flag_files();
test_ghosts();
test_tick();
test_json_string();
bench();
std::printf("flagbuf_test: %s (%d failures)\n", failures ? "FAILED" : "ok", failures);
return failures ? 1 : 0;
}