Files
scene-actor-extraction/src/gallery/gallery_calibration.hpp
T
dtourolleandClaude Opus 5 b35d49c772 docs: tag the implemented core with its requirement IDs
Adds TRACES tags to code that already satisfies a Done requirement, so coverage
reflects what exists rather than starting from zero:

AR-001 face detection, AR-005 ArcFace alignment, AR-023 calibration fit,
DP-001/DP-002 the single analysis core behind the CLI, IR-001 truth-file
emission, IR-006 the Jellyfin round trip, GR-001/GR-002 gallery build and
incremental merge, VR-001 the embedding dump, VR-002 replay through the real
nodes, VR-003 per-second scoring.

Only Done requirements are tagged. A tag on Planned work would inflate coverage
with fiction that looks plausible — the same failure family as a gate that
cannot fail, and harder to spot.

GR-005 (gallery never leaves the instance) stays untagged deliberately: it is a
prohibition satisfied by the absence of an egress path, so there is no unit that
decides it. Same shape as PR-005 in the system spec, which has no software row
for the same reason. A goal held only by prohibitions cannot be verified by
pointing at code.

Coverage 5/63 to 14/63. The three VR tags are reported as tagged-but-unexecuted
and excluded from the numerator, since their tier cannot run on the CI host —
tagging deliberately cannot raise the number on its own.

Suite still 64 cases, 3199 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-001, AR-005, AR-023, DP-001, DP-002, IR-001, IR-006, GR-001, GR-002, VR-001, VR-002, VR-003
2026-07-30 21:20:27 +02:00

384 lines
16 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
/// TRACES: AR-023 | SR-002
#include "types.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#include <nlohmann/json.hpp>
#include <opencv2/core/ocl.hpp>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
// ── GalleryCalibration ────────────────────────────────────────────────────────
// Platt-style sigmoid calibration: P(match) = σ(a · similarity + b)
// where similarity = cosine similarity ∈ [-1, 1] (dot product of L2-normalised
// ArcFace embeddings).
//
// Fitted from intra-class (positive) and inter-class (negative) pairs built
// from the gallery reference embeddings. When calibration is invalid (too few
// positive pairs), the caller should fall back to the raw threshold.
struct GalleryCalibration {
float a{10.f}; // scale (positive → higher similarity → higher probability)
float b{-5.f}; // bias (decision boundary at similarity = -b/a)
bool valid{false};
// P(match | sim) using the balanced-prior calibration.
// Pass log_prior_odds = log(p0/(1-p0)) to adjust for a real base-rate prior p0:
// P(match | sim, p0) = σ(a·sim + b + log(p0/(1-p0)))
float probability(float similarity, float log_prior_odds = 0.f) const {
float z = a * similarity + b + log_prior_odds;
if (z >= 0.f) return 1.f / (1.f + std::exp(-z));
float e = std::exp(z);
return e / (1.f + e);
}
// Similarity at which P(match, prior) == p
float boundary_at(float p = 0.5f, float log_prior_odds = 0.f) const {
return (std::log(p / (1.f - p)) - b - log_prior_odds) / a;
}
};
// Fit a logistic sigmoid to gallery pair similarities.
// Positive pairs: same actor, different reference images.
// Negative pairs: different actors (all cross-actor embedding pairs).
// Class weights balance the (typically skewed) pos/neg ratio.
// Requires ≥2 positive pairs and ≥1 negative pair.
inline GalleryCalibration calibrate_gallery(
const std::vector<Embedding>& flat_emb,
const std::vector<int>& flat_actor)
{
constexpr int kMinEmbeddingsForPositive = 5;
constexpr float kDedupSimThreshold = 1.f - 1e-7f; // sim above this = duplicate
constexpr int kHistBins = 200;
// ── Per-actor dedup, then eligibility filter ────────────────────────────
// Drop near-identical duplicate embeddings within each actor (e.g. the
// same source image embedded twice). Actors left with fewer than
// kMinEmbeddingsForPositive distinct embeddings can't supply meaningful
// same-actor (positive) pairs, but still contribute negative
// (cross-actor) pairs for the p(unknown) side of the fit.
int n_actors = 0;
for (int a : flat_actor) n_actors = std::max(n_actors, a + 1);
std::vector<std::vector<Embedding>> by_actor(n_actors);
for (int i = 0; i < static_cast<int>(flat_emb.size()); ++i)
by_actor[flat_actor[i]].push_back(flat_emb[i]);
std::vector<Embedding> flat_emb_dedup;
std::vector<int> flat_actor_dedup;
std::vector<bool> actor_eligible(n_actors, false);
int n_eligible = 0;
for (int ai = 0; ai < n_actors; ++ai) {
std::vector<Embedding> kept;
for (const auto& e : by_actor[ai]) {
bool dup = false;
for (const auto& k : kept) {
if (cosine_similarity(e, k) > kDedupSimThreshold) { dup = true; break; }
}
if (!dup) kept.push_back(e);
}
if (static_cast<int>(kept.size()) >= kMinEmbeddingsForPositive) {
actor_eligible[ai] = true;
++n_eligible;
}
for (auto& e : kept) {
flat_emb_dedup.push_back(e);
flat_actor_dedup.push_back(ai);
}
}
const int n = static_cast<int>(flat_emb_dedup.size());
std::cerr << "[calibration] dedup: " << flat_emb.size() << " -> " << n
<< " embeddings (" << n_eligible << "/" << n_actors
<< " actors have >= " << kMinEmbeddingsForPositive
<< " distinct embeddings, eligible for positive pairs)\n";
// Pairwise cosine-similarity matrix S = E * E^T, computed via cv::gemm
// (BLAS/SIMD on CPU) instead of a naive O(n^2 * 512) scalar loop.
//
// SAE_CALIB_GEMM controls the backend:
// "cpu" - cv::Mat gemm only (default if OpenCL unavailable)
// "gpu" - cv::UMat gemm via OpenCL, falls back to CPU if unavailable
// "bench" - run both and report timings + max abs diff (default)
cv::Mat E(n, 512, CV_32F);
for (int i = 0; i < n; ++i)
std::memcpy(E.ptr<float>(i), flat_emb_dedup[i].data(), 512 * sizeof(float));
std::string mode = [] {
const char* env = std::getenv("SAE_CALIB_GEMM");
return env ? std::string(env) : std::string("bench");
}();
bool have_ocl = cv::ocl::haveOpenCL();
std::cerr << "[calibration] OpenCL available: " << (have_ocl ? "yes" : "no");
if (have_ocl) {
auto dev = cv::ocl::Device::getDefault();
std::cerr << " device=\"" << dev.name() << "\""
<< " type=" << (dev.type() == cv::ocl::Device::TYPE_GPU ? "GPU"
: dev.type() == cv::ocl::Device::TYPE_CPU ? "CPU"
: "OTHER");
}
std::cerr << " (SAE_CALIB_GEMM=" << mode << ")\n";
std::cerr << "[calibration] similarity matrix: " << n << "x512 ("
<< (n * 512LL * sizeof(float)) / (1024 * 1024) << " MB input, "
<< (n * (long long)n * sizeof(float)) / (1024 * 1024)
<< " MB output)\n" << std::flush;
cv::Mat S;
bool have_cpu = false, have_gpu = false;
cv::Mat S_cpu, S_gpu_host;
if (mode == "cpu" || mode == "bench") {
std::cerr << "[calibration] running cv::gemm on CPU...\n" << std::flush;
auto t0 = std::chrono::steady_clock::now();
cv::gemm(E, E, 1.0, cv::noArray(), 0.0, S_cpu, cv::GEMM_2_T);
auto t1 = std::chrono::steady_clock::now();
double secs = std::chrono::duration<double>(t1 - t0).count();
std::cerr << "[calibration] cv::gemm CPU: " << secs << "s for "
<< n << "x" << n << " similarity matrix\n";
have_cpu = true;
}
if ((mode == "gpu" || mode == "bench") && have_ocl) {
std::cerr << "[calibration] running cv::gemm on OpenCL device...\n" << std::flush;
cv::UMat E_gpu, S_gpu;
E.copyTo(E_gpu);
auto t0 = std::chrono::steady_clock::now();
cv::gemm(E_gpu, E_gpu, 1.0, cv::noArray(), 0.0, S_gpu, cv::GEMM_2_T);
S_gpu_host = S_gpu.getMat(cv::ACCESS_READ).clone();
auto t1 = std::chrono::steady_clock::now();
double secs = std::chrono::duration<double>(t1 - t0).count();
std::cerr << "[calibration] cv::gemm OpenCL: " << secs << "s for "
<< n << "x" << n << " similarity matrix";
if (have_cpu) std::cerr << " max|diff|=" << cv::norm(S_cpu, S_gpu_host, cv::NORM_INF);
std::cerr << "\n";
have_gpu = true;
} else if (mode == "gpu") {
std::cerr << "[calibration] SAE_CALIB_GEMM=gpu requested but OpenCL is unavailable\n";
}
if (mode == "gpu" && have_gpu) S = S_gpu_host;
else if (have_cpu) S = S_cpu;
else if (have_gpu) S = S_gpu_host;
else throw std::runtime_error("calibrate_gallery: no gemm backend produced a result");
// ── Histogram of pairwise similarities ──────────────────────────────────
// Instead of storing one (similarity, label) sample per pair (~n^2/2
// entries — too many for the gradient descent below), bucket pairs into
// kHistBins bins over sim ∈ [-1, 1] and fit the sigmoid against the
// per-bin (positive_count, negative_count).
std::vector<double> pos_hist(kHistBins, 0.0), neg_hist(kHistBins, 0.0);
constexpr float bin_width = 2.f / kHistBins;
for (int i = 0; i < n; ++i) {
const float* row = S.ptr<float>(i);
for (int j = i + 1; j < n; ++j) {
int bin = static_cast<int>((row[j] + 1.f) / bin_width);
bin = std::clamp(bin, 0, kHistBins - 1);
if (flat_actor_dedup[i] == flat_actor_dedup[j]) {
if (actor_eligible[flat_actor_dedup[i]]) pos_hist[bin] += 1.0;
// same actor but ineligible (< kMinEmbeddingsForPositive) — skip
} else {
neg_hist[bin] += 1.0;
}
}
}
double n_pos = 0.0, n_neg = 0.0;
for (int b = 0; b < kHistBins; ++b) { n_pos += pos_hist[b]; n_neg += neg_hist[b]; }
if (n_pos < 2 || n_neg < 1) {
std::cerr << "[calibration] insufficient pairs (+" << n_pos
<< "/-" << n_neg << ") — calibration skipped\n";
return {};
}
// Class weights to handle pos/neg imbalance
double total = n_pos + n_neg;
double w_pos = total / (2.0 * n_pos);
double w_neg = total / (2.0 * n_neg);
std::vector<float> bin_center(kHistBins);
for (int b = 0; b < kHistBins; ++b) bin_center[b] = -1.f + (b + 0.5f) * bin_width;
// Gradient descent logistic regression (2 parameters: a, bias)
float a = 10.f, bias = -5.f;
constexpr float lr = 0.05f;
constexpr int max_iter = 20000;
constexpr float tol = 1e-7f;
for (int iter = 0; iter < max_iter; ++iter) {
double da = 0.0, db = 0.0;
for (int b = 0; b < kHistBins; ++b) {
float x = bin_center[b];
float z = a * x + bias;
float sig = (z >= 0.f) ? 1.f / (1.f + std::exp(-z))
: std::exp(z) / (1.f + std::exp(z));
if (pos_hist[b] > 0.0) {
double err = (sig - 1.f) * w_pos * pos_hist[b];
da += err * x;
db += err;
}
if (neg_hist[b] > 0.0) {
double err = sig * w_neg * neg_hist[b];
da += err * x;
db += err;
}
}
da /= total;
db /= total;
a -= lr * static_cast<float>(da);
bias -= lr * static_cast<float>(db);
if (da * da + db * db < tol * tol) break;
}
// Training accuracy at P=0.5 decision boundary
double correct = 0.0;
for (int b = 0; b < kHistBins; ++b) {
float z = a * bin_center[b] + bias;
float sig = (z >= 0.f) ? 1.f / (1.f + std::exp(-z))
: std::exp(z) / (1.f + std::exp(z));
correct += (sig > 0.5f) ? pos_hist[b] : neg_hist[b];
}
double acc = 100.0 * correct / total;
GalleryCalibration cal{a, bias, true};
std::cerr << "[calibration] sigmoid fitted:"
<< " a=" << a << " b=" << bias
<< " boundary(P=0.5)=sim" << cal.boundary_at(0.5f)
<< " pairs=" << static_cast<long long>(total)
<< " (+" << static_cast<long long>(n_pos) << "/-" << static_cast<long long>(n_neg) << ")"
<< " bins=" << kHistBins
<< " train_acc=" << acc << "%\n";
return cal;
}
// Dumps the fitted P(match | similarity) sigmoid as both a CSV table
// (similarity, p_match) and a PNG plot, to <base_path>.csv / <base_path>.png.
inline void save_calibration_curve(const GalleryCalibration& cal, const std::string& base_path) {
if (!cal.valid) return;
constexpr int kSamples = 200;
std::ofstream csv(base_path + ".csv");
if (csv.is_open()) {
csv << "similarity,p_match\n";
for (int i = 0; i <= kSamples; ++i) {
float sim = -1.f + i * (2.f / kSamples);
csv << sim << "," << cal.probability(sim) << "\n";
}
}
constexpr int W = 800, H = 600, M = 50;
cv::Mat img(H, W, CV_8UC3, cv::Scalar(255, 255, 255));
cv::line(img, {M, H - M}, {W - M, H - M}, {0, 0, 0}, 1); // x-axis: similarity [-1,1]
cv::line(img, {M, M}, {M, H - M}, {0, 0, 0}, 1); // y-axis: P(match) [0,1]
auto to_point = [&](float sim, float p) {
int x = M + static_cast<int>((sim + 1.f) * 0.5f * (W - 2 * M));
int y = (H - M) - static_cast<int>(p * (H - 2 * M));
return cv::Point(x, y);
};
// P=0.5 reference line and decision boundary
float boundary = cal.boundary_at(0.5f);
cv::line(img, to_point(-1.f, 0.5f), to_point(1.f, 0.5f), {200, 200, 200}, 1);
if (boundary >= -1.f && boundary <= 1.f)
cv::line(img, to_point(boundary, 0.f), to_point(boundary, 1.f), {200, 200, 200}, 1);
cv::Point prev = to_point(-1.f, cal.probability(-1.f));
for (int i = 1; i <= kSamples; ++i) {
float sim = -1.f + i * (2.f / kSamples);
cv::Point pt = to_point(sim, cal.probability(sim));
cv::line(img, prev, pt, {255, 0, 0}, 2);
prev = pt;
}
cv::imwrite(base_path + ".png", img);
std::cerr << "[calibration] saved curve to " << base_path << ".csv / " << base_path << ".png\n";
}
// FNV-1a 64-bit hash over the gallery's reference embeddings and actor
// assignments, used to detect whether a cached calibration is still valid.
inline uint64_t hash_gallery_embeddings(
const std::vector<Embedding>& flat_emb,
const std::vector<int>& flat_actor)
{
uint64_t h = 1469598103934665603ULL;
constexpr uint64_t prime = 1099511628211ULL;
auto mix = [&](const void* data, size_t n) {
const auto* p = static_cast<const unsigned char*>(data);
for (size_t i = 0; i < n; ++i) {
h ^= p[i];
h *= prime;
}
};
uint64_t n = flat_emb.size();
mix(&n, sizeof(n));
for (const auto& emb : flat_emb)
mix(emb.data(), emb.size() * sizeof(float));
for (int a : flat_actor)
mix(&a, sizeof(a));
return h;
}
// Calibrates the gallery, reusing (cached_a, cached_b, cached_valid) if
// cached_hash matches a fresh hash of the current embeddings/actor
// assignments — the O(n^2) pairwise fit only re-runs when they actually
// change. Distinct from calibrate_gallery_cached's old sidecar-JSON-file
// design: the cache now lives in the gallery HDF5 itself (ActorGallery::
// calib_*, see gallery_store.hpp), so this takes the previous values
// in-memory rather than a file path. Sets `recomputed` so the caller (which
// holds the open gallery file/struct) knows whether it needs to persist the
// refreshed values back.
inline GalleryCalibration calibrate_gallery_cached(
const std::vector<Embedding>& flat_emb,
const std::vector<int>& flat_actor,
float cached_a,
float cached_b,
bool cached_valid,
uint64_t cached_hash,
const std::string& curve_base_path,
bool& recomputed)
{
uint64_t hash = hash_gallery_embeddings(flat_emb, flat_actor);
recomputed = false;
if (cached_hash != 0 && cached_hash == hash) {
GalleryCalibration cal{cached_a, cached_b, cached_valid};
std::cerr << "[calibration] using cached calibration from gallery"
<< " (a=" << cal.a << " b=" << cal.b
<< " valid=" << cal.valid << ")\n";
if (!curve_base_path.empty()) save_calibration_curve(cal, curve_base_path);
return cal;
}
if (cached_hash != 0)
std::cerr << "[calibration] cached calibration is stale (embeddings changed), "
"recomputing\n";
auto t0 = std::chrono::steady_clock::now();
GalleryCalibration cal = calibrate_gallery(flat_emb, flat_actor);
auto t1 = std::chrono::steady_clock::now();
std::cerr << "[calibration] fit took "
<< std::chrono::duration<double>(t1 - t0).count() << "s for "
<< flat_emb.size() << " embeddings\n";
if (!curve_base_path.empty()) save_calibration_curve(cal, curve_base_path);
recomputed = true;
return cal;
}