Merge branch 'feature/gallery-report' into feature/opencv5
This commit is contained in:
@@ -18,6 +18,8 @@
|
|||||||
// --nms <f> NMS IoU threshold (default: 0.4)
|
// --nms <f> NMS IoU threshold (default: 0.4)
|
||||||
|
|
||||||
#include "gallery/gallery_builder.hpp"
|
#include "gallery/gallery_builder.hpp"
|
||||||
|
#include "gallery/gallery_report.hpp"
|
||||||
|
#include "gallery/gallery_calibration.hpp"
|
||||||
#include "gallery/gallery_store.hpp"
|
#include "gallery/gallery_store.hpp"
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
|
|
||||||
@@ -77,6 +79,30 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
save_gallery(output_path, gallery);
|
save_gallery(output_path, gallery);
|
||||||
std::cerr << "Gallery saved to: " << output_path << "\n";
|
std::cerr << "Gallery saved to: " << output_path << "\n";
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
// Fit the calibration here and persist what it learned. The matcher
|
||||||
|
// fits the same sigmoid at analysis time, but that is the wrong place
|
||||||
|
// to audit a gallery from: by then the answer is per-run and nobody is
|
||||||
|
// looking. Build time is when the gallery's quality is decided, and a
|
||||||
|
// gallery can be quietly bad — heavily overlapping intra/inter
|
||||||
|
// distributions, actors with no usable image — while looking fine.
|
||||||
|
std::vector<Embedding> flat;
|
||||||
|
std::vector<int> flat_actor;
|
||||||
|
for (int ai = 0; ai < static_cast<int>(gallery.actors.size()); ++ai)
|
||||||
|
for (const auto& e : gallery.actors[ai].embeddings) {
|
||||||
|
flat.push_back(e);
|
||||||
|
flat_actor.push_back(ai);
|
||||||
|
}
|
||||||
|
|
||||||
|
GalleryCalibrationStats stats;
|
||||||
|
GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats);
|
||||||
|
|
||||||
|
const GalleryReport report =
|
||||||
|
build_gallery_report(gallery, cal, stats, nullptr, output_path);
|
||||||
|
const std::string report_path = gallery_report_path(output_path);
|
||||||
|
save_gallery_report(report_path, report);
|
||||||
|
std::cerr << "Gallery report saved to: " << report_path << "\n";
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << "Fatal: " << e.what() << "\n";
|
std::cerr << "Fatal: " << e.what() << "\n";
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include "gallery/gallery_report.hpp"
|
||||||
#include "types.hpp"
|
#include "types.hpp"
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
|||||||
@@ -77,14 +77,48 @@ inline std::function<float(float)> same_person_probability(const GalleryCalibrat
|
|||||||
return [cal](float similarity) { return cal.probability(similarity); };
|
return [cal](float similarity) { return cal.probability(similarity); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
///
|
||||||
|
/// Everything the fit learns about the gallery on its way to two numbers.
|
||||||
|
///
|
||||||
|
/// The fit computes per-actor dedup counts, which actors can supply positive
|
||||||
|
/// pairs at all, and the two similarity distributions the sigmoid is derived
|
||||||
|
/// from — and then returns only (a, b, valid). GR-003 exists because that is the
|
||||||
|
/// evidence for whether the calibration, and so every threshold expressed in its
|
||||||
|
/// probability space (AR-024), rests on anything. Filling this struct costs
|
||||||
|
/// nothing: the values already exist at the point they are copied out.
|
||||||
|
///
|
||||||
|
/// Per-actor vectors are indexed by the actor index used in `flat_actor`.
|
||||||
|
struct GalleryCalibrationStats {
|
||||||
|
int n_actors = 0;
|
||||||
|
int min_embeddings_for_positive = 0;
|
||||||
|
float dedup_sim_threshold = 0.f;
|
||||||
|
|
||||||
|
std::vector<int> distinct_per_actor; // after near-duplicate removal
|
||||||
|
std::vector<int> duplicates_removed_per_actor;
|
||||||
|
std::vector<char> eligible; // 1 = supplies positive pairs
|
||||||
|
|
||||||
|
int hist_bins = 0; // over sim ∈ [-1, 1]
|
||||||
|
std::vector<double> intra_hist;
|
||||||
|
std::vector<double> inter_hist;
|
||||||
|
double n_intra_pairs = 0.0;
|
||||||
|
double n_inter_pairs = 0.0;
|
||||||
|
|
||||||
|
double train_accuracy_pct = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
// Fit a logistic sigmoid to gallery pair similarities.
|
// Fit a logistic sigmoid to gallery pair similarities.
|
||||||
// Positive pairs: same actor, different reference images.
|
// Positive pairs: same actor, different reference images.
|
||||||
// Negative pairs: different actors (all cross-actor embedding pairs).
|
// Negative pairs: different actors (all cross-actor embedding pairs).
|
||||||
// Class weights balance the (typically skewed) pos/neg ratio.
|
// Class weights balance the (typically skewed) pos/neg ratio.
|
||||||
// Requires ≥2 positive pairs and ≥1 negative pair.
|
// Requires ≥2 positive pairs and ≥1 negative pair.
|
||||||
|
//
|
||||||
|
// `stats` is optional (GR-003): pass one to receive the dedup, eligibility and
|
||||||
|
// distribution detail the fit would otherwise discard.
|
||||||
inline GalleryCalibration calibrate_gallery(
|
inline GalleryCalibration calibrate_gallery(
|
||||||
const std::vector<Embedding>& flat_emb,
|
const std::vector<Embedding>& flat_emb,
|
||||||
const std::vector<int>& flat_actor)
|
const std::vector<int>& flat_actor,
|
||||||
|
GalleryCalibrationStats* stats = nullptr)
|
||||||
{
|
{
|
||||||
constexpr int kMinEmbeddingsForPositive = 5;
|
constexpr int kMinEmbeddingsForPositive = 5;
|
||||||
constexpr float kDedupSimThreshold = 1.f - 1e-7f; // sim above this = duplicate
|
constexpr float kDedupSimThreshold = 1.f - 1e-7f; // sim above this = duplicate
|
||||||
@@ -108,6 +142,21 @@ inline GalleryCalibration calibrate_gallery(
|
|||||||
std::vector<bool> actor_eligible(n_actors, false);
|
std::vector<bool> actor_eligible(n_actors, false);
|
||||||
int n_eligible = 0;
|
int n_eligible = 0;
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
// Record what the filter did, per actor, while the counts still exist.
|
||||||
|
if (stats) {
|
||||||
|
*stats = GalleryCalibrationStats{};
|
||||||
|
stats->n_actors = n_actors;
|
||||||
|
stats->min_embeddings_for_positive = kMinEmbeddingsForPositive;
|
||||||
|
stats->dedup_sim_threshold = kDedupSimThreshold;
|
||||||
|
stats->distinct_per_actor.assign(n_actors, 0);
|
||||||
|
stats->duplicates_removed_per_actor.assign(n_actors, 0);
|
||||||
|
stats->eligible.assign(n_actors, 0);
|
||||||
|
stats->hist_bins = kHistBins;
|
||||||
|
stats->intra_hist.assign(kHistBins, 0.0);
|
||||||
|
stats->inter_hist.assign(kHistBins, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
for (int ai = 0; ai < n_actors; ++ai) {
|
for (int ai = 0; ai < n_actors; ++ai) {
|
||||||
std::vector<Embedding> kept;
|
std::vector<Embedding> kept;
|
||||||
for (const auto& e : by_actor[ai]) {
|
for (const auto& e : by_actor[ai]) {
|
||||||
@@ -121,6 +170,12 @@ inline GalleryCalibration calibrate_gallery(
|
|||||||
actor_eligible[ai] = true;
|
actor_eligible[ai] = true;
|
||||||
++n_eligible;
|
++n_eligible;
|
||||||
}
|
}
|
||||||
|
if (stats) {
|
||||||
|
stats->distinct_per_actor[ai] = static_cast<int>(kept.size());
|
||||||
|
stats->duplicates_removed_per_actor[ai] =
|
||||||
|
static_cast<int>(by_actor[ai].size() - kept.size());
|
||||||
|
stats->eligible[ai] = actor_eligible[ai] ? 1 : 0;
|
||||||
|
}
|
||||||
for (auto& e : kept) {
|
for (auto& e : kept) {
|
||||||
flat_emb_dedup.push_back(e);
|
flat_emb_dedup.push_back(e);
|
||||||
flat_actor_dedup.push_back(ai);
|
flat_actor_dedup.push_back(ai);
|
||||||
@@ -128,6 +183,14 @@ inline GalleryCalibration calibrate_gallery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const int n = static_cast<int>(flat_emb_dedup.size());
|
const int n = static_cast<int>(flat_emb_dedup.size());
|
||||||
|
|
||||||
|
// Nothing to fit and nothing to multiply. Returning here keeps the report
|
||||||
|
// buildable for a degenerate gallery instead of handing cv::gemm an empty
|
||||||
|
// matrix; the per-actor stats above are already filled and still useful.
|
||||||
|
if (n == 0) {
|
||||||
|
std::cerr << "[calibration] no embeddings — calibration skipped\n";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
std::cerr << "[calibration] dedup: " << flat_emb.size() << " -> " << n
|
std::cerr << "[calibration] dedup: " << flat_emb.size() << " -> " << n
|
||||||
<< " embeddings (" << n_eligible << "/" << n_actors
|
<< " embeddings (" << n_eligible << "/" << n_actors
|
||||||
<< " actors have >= " << kMinEmbeddingsForPositive
|
<< " actors have >= " << kMinEmbeddingsForPositive
|
||||||
@@ -228,6 +291,17 @@ inline GalleryCalibration calibrate_gallery(
|
|||||||
double n_pos = 0.0, n_neg = 0.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]; }
|
for (int b = 0; b < kHistBins; ++b) { n_pos += pos_hist[b]; n_neg += neg_hist[b]; }
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
// The two distributions the sigmoid is about to be fitted from. Emitted
|
||||||
|
// whether or not the fit succeeds — a failed fit is exactly the case where
|
||||||
|
// someone needs to see why.
|
||||||
|
if (stats) {
|
||||||
|
stats->intra_hist = pos_hist;
|
||||||
|
stats->inter_hist = neg_hist;
|
||||||
|
stats->n_intra_pairs = n_pos;
|
||||||
|
stats->n_inter_pairs = n_neg;
|
||||||
|
}
|
||||||
|
|
||||||
if (n_pos < 2 || n_neg < 1) {
|
if (n_pos < 2 || n_neg < 1) {
|
||||||
std::cerr << "[calibration] insufficient pairs (+" << n_pos
|
std::cerr << "[calibration] insufficient pairs (+" << n_pos
|
||||||
<< "/-" << n_neg << ") — calibration skipped\n";
|
<< "/-" << n_neg << ") — calibration skipped\n";
|
||||||
@@ -282,6 +356,7 @@ inline GalleryCalibration calibrate_gallery(
|
|||||||
correct += (sig > 0.5f) ? pos_hist[b] : neg_hist[b];
|
correct += (sig > 0.5f) ? pos_hist[b] : neg_hist[b];
|
||||||
}
|
}
|
||||||
double acc = 100.0 * correct / total;
|
double acc = 100.0 * correct / total;
|
||||||
|
if (stats) stats->train_accuracy_pct = acc;
|
||||||
|
|
||||||
GalleryCalibration cal{a, bias, true};
|
GalleryCalibration cal{a, bias, true};
|
||||||
std::cerr << "[calibration] sigmoid fitted:"
|
std::cerr << "[calibration] sigmoid fitted:"
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
#pragma once
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
///
|
||||||
|
/// The gallery build report — what the gallery *is*, written next to it.
|
||||||
|
///
|
||||||
|
/// A gallery is a silent artefact: it loads, it scores, it never complains. The
|
||||||
|
/// two ways it fails are both invisible from the outside.
|
||||||
|
///
|
||||||
|
/// 1. **An actor with zero usable images can never be recognised.** They are
|
||||||
|
/// dropped at build time (`gallery_builder.cpp` skips a directory whose
|
||||||
|
/// images all fail detection or alignment), so afterwards nothing in the
|
||||||
|
/// file records that they were ever meant to be there. Every scene they
|
||||||
|
/// appear in is a guaranteed miss, and recall is capped at a number nobody
|
||||||
|
/// computed. This is the single most useful line in the report.
|
||||||
|
/// 2. **A gallery can be quietly bad and look fine.** The Platt sigmoid
|
||||||
|
/// (AR-023) is fitted from two distributions — intra-class (same actor,
|
||||||
|
/// different reference) and inter-class (different actors) similarity — and
|
||||||
|
/// *every* threshold in the pipeline is expressed in the probability space
|
||||||
|
/// that fit defines (AR-024): identity acceptance, track association,
|
||||||
|
/// expansion admission, cluster merging. If those two distributions overlap
|
||||||
|
/// heavily the fit is weak, and every downstream decision silently inherits
|
||||||
|
/// that weakness while still reporting confident-looking probabilities. The
|
||||||
|
/// fit already computes the distributions and throws them away; emitting
|
||||||
|
/// them is what makes the quality of the whole probability space auditable
|
||||||
|
/// instead of assumed.
|
||||||
|
///
|
||||||
|
/// The report is therefore a build artefact, not a debug aid: it is the only
|
||||||
|
/// place the recall ceiling and the calibration's conditioning are written down.
|
||||||
|
///
|
||||||
|
/// **On the histograms being in cosine space.** They bin raw similarity, and
|
||||||
|
/// that is not an AR-024 violation: no decision is taken here. These two
|
||||||
|
/// distributions are the *input* the calibration is fitted from — they cannot be
|
||||||
|
/// expressed in the probability space the calibration defines, because that
|
||||||
|
/// space is their output. GR-003 asks for exactly this ("the intra/inter
|
||||||
|
/// distributions behind it"), for the same reason GR-008 characterises an
|
||||||
|
/// actor's reference spread in the metric space: shape is a property of the
|
||||||
|
/// metric, decisions are a property of the probability.
|
||||||
|
#include "gallery/gallery_calibration.hpp"
|
||||||
|
#include "gallery/embedder_stamp.hpp"
|
||||||
|
#include "types.hpp"
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
///
|
||||||
|
/// Per-actor image accounting from the build pass, including the actors that
|
||||||
|
/// produced nothing and were therefore dropped from the gallery.
|
||||||
|
///
|
||||||
|
/// Filled by `build_gallery()`. It has to be collected there and cannot be
|
||||||
|
/// recovered later: by the time a gallery exists, an actor with no usable image
|
||||||
|
/// is indistinguishable from an actor who was never requested.
|
||||||
|
struct GalleryBuildAudit {
|
||||||
|
struct ActorImages {
|
||||||
|
std::string imdb_id;
|
||||||
|
std::string name;
|
||||||
|
int images_seen = 0; // candidate image files in the actor's directory
|
||||||
|
int images_used = 0; // ...that yielded an embedding
|
||||||
|
int unreadable = 0; // cv::imread failed
|
||||||
|
int no_face = 0; // detector found nothing
|
||||||
|
int align_failed = 0; // 5-point warp failed
|
||||||
|
};
|
||||||
|
std::vector<ActorImages> actors; // every directory seen, in build order
|
||||||
|
};
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
struct GalleryReport {
|
||||||
|
// One row per actor the build considered. Actors with references == 0 are
|
||||||
|
// the zero-usable-image case: present in the source tree, absent from the
|
||||||
|
// gallery, unrecognisable for the life of the file.
|
||||||
|
struct Actor {
|
||||||
|
std::string imdb_id;
|
||||||
|
std::string name;
|
||||||
|
int images_seen = -1; // -1 = unknown (report built without a build audit)
|
||||||
|
int references = 0; // embeddings stored in the gallery
|
||||||
|
int distinct_references = 0; // ...after near-duplicate removal
|
||||||
|
int duplicates_removed = 0;
|
||||||
|
bool eligible_for_positive_pairs = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The two distributions the sigmoid is fitted from, as the fit itself saw
|
||||||
|
// them: counts per similarity bin over [sim_min, sim_max].
|
||||||
|
struct Distributions {
|
||||||
|
int bins = 0;
|
||||||
|
float sim_min = -1.f;
|
||||||
|
float sim_max = 1.f;
|
||||||
|
std::vector<double> intra; // same actor, different reference image
|
||||||
|
std::vector<double> inter; // different actors
|
||||||
|
double intra_pairs = 0.0;
|
||||||
|
double inter_pairs = 0.0;
|
||||||
|
double intra_mean = 0.0;
|
||||||
|
double inter_mean = 0.0;
|
||||||
|
// Normalised histogram intersection, Σ_b min(p_intra[b], p_inter[b]).
|
||||||
|
// 0 = perfectly separated, 1 = indistinguishable. This is the number
|
||||||
|
// that says whether the calibration — and so every threshold expressed
|
||||||
|
// in its probability space — rests on anything.
|
||||||
|
double overlap = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GR-003 / AR-023 open question, reported but NOT applied. The spec asks for
|
||||||
|
// a gallery-derived prior of intra/(intra+inter); the shipped default is
|
||||||
|
// 0.5. Persisting the distributions makes the real value computable, so the
|
||||||
|
// decision can be taken on evidence rather than left implicit. Behaviour is
|
||||||
|
// unchanged: `applied` is always false here.
|
||||||
|
struct Prior {
|
||||||
|
double derived = 0.0; // intra_pairs / (intra_pairs + inter_pairs)
|
||||||
|
double derived_log_odds = 0.0; // log(p/(1-p)), the term AR-023 would add
|
||||||
|
float configured_default = 0.5f;
|
||||||
|
bool applied = false;
|
||||||
|
std::string note;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string schema{"sae.gallery_report/1"};
|
||||||
|
std::string gallery_path;
|
||||||
|
EmbedderStamp embedder;
|
||||||
|
|
||||||
|
// ── Summary ──────────────────────────────────────────────────────────────
|
||||||
|
int actors_total = 0; // considered (gallery + zero-usable)
|
||||||
|
int actors_in_gallery = 0;
|
||||||
|
int actors_zero_usable = 0;
|
||||||
|
int actors_below_positive_threshold = 0;
|
||||||
|
int64_t embeddings_total = 0;
|
||||||
|
int64_t distinct_embeddings_total = 0;
|
||||||
|
int64_t duplicates_removed_total = 0;
|
||||||
|
double mean_embeddings_per_actor = 0.0; // over actors in the gallery
|
||||||
|
int min_embeddings_for_positive_pairs = 0;
|
||||||
|
float dedup_similarity_threshold = 0.f;
|
||||||
|
|
||||||
|
// ── Calibration ──────────────────────────────────────────────────────────
|
||||||
|
float calib_a = 10.f;
|
||||||
|
float calib_b = -5.f;
|
||||||
|
bool calib_valid = false;
|
||||||
|
uint64_t calib_hash = 0;
|
||||||
|
double calib_train_accuracy_pct = 0.0;
|
||||||
|
float calib_boundary_p50 = 0.f; // similarity at which P(match) = 0.5
|
||||||
|
|
||||||
|
Distributions distributions;
|
||||||
|
Prior prior;
|
||||||
|
|
||||||
|
std::vector<Actor> actors;
|
||||||
|
// Names duplicated out of `actors` so the two failure modes are greppable
|
||||||
|
// without a JSON query. These are the lines a human reads first.
|
||||||
|
std::vector<std::string> zero_usable;
|
||||||
|
std::vector<std::string> below_positive_threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
///
|
||||||
|
/// Assembles the report from the three things that know a piece of the answer:
|
||||||
|
/// the gallery itself (who is in it, with how many references), the calibration
|
||||||
|
/// stats (dedup, eligibility, the two distributions), and the build audit (who
|
||||||
|
/// was considered and produced nothing). The audit is optional — a report built
|
||||||
|
/// from a stored gallery simply cannot know about the actors that never made it.
|
||||||
|
///
|
||||||
|
/// `stats` is indexed by actor index, so the flat arrays handed to
|
||||||
|
/// `calibrate_gallery()` must have used the gallery's own actor ordering.
|
||||||
|
inline GalleryReport build_gallery_report(const ActorGallery& gallery,
|
||||||
|
const GalleryCalibration& cal,
|
||||||
|
const GalleryCalibrationStats& stats,
|
||||||
|
const GalleryBuildAudit* audit = nullptr,
|
||||||
|
const std::string& gallery_path = "",
|
||||||
|
float configured_prior = 0.5f)
|
||||||
|
{
|
||||||
|
GalleryReport r;
|
||||||
|
r.gallery_path = gallery_path;
|
||||||
|
r.embedder = gallery.embedder;
|
||||||
|
|
||||||
|
r.calib_a = cal.a;
|
||||||
|
r.calib_b = cal.b;
|
||||||
|
r.calib_valid = cal.valid;
|
||||||
|
r.calib_hash = gallery.calib_hash;
|
||||||
|
r.calib_train_accuracy_pct = stats.train_accuracy_pct;
|
||||||
|
r.calib_boundary_p50 = cal.boundary_at(0.5f);
|
||||||
|
|
||||||
|
r.min_embeddings_for_positive_pairs = stats.min_embeddings_for_positive;
|
||||||
|
r.dedup_similarity_threshold = stats.dedup_sim_threshold;
|
||||||
|
|
||||||
|
auto audit_for = [&](const ActorGallery::Actor& a) -> const GalleryBuildAudit::ActorImages* {
|
||||||
|
if (!audit) return nullptr;
|
||||||
|
for (const auto& e : audit->actors) {
|
||||||
|
if (!a.imdb_id.empty() && e.imdb_id == a.imdb_id) return &e;
|
||||||
|
if (a.imdb_id.empty() && e.name == a.name) return &e;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (size_t i = 0; i < gallery.actors.size(); ++i) {
|
||||||
|
const auto& ga = gallery.actors[i];
|
||||||
|
GalleryReport::Actor row;
|
||||||
|
row.imdb_id = ga.imdb_id;
|
||||||
|
row.name = ga.name;
|
||||||
|
row.references = static_cast<int>(ga.embeddings.size());
|
||||||
|
if (const auto* au = audit_for(ga)) row.images_seen = au->images_seen;
|
||||||
|
|
||||||
|
if (i < stats.distinct_per_actor.size()) {
|
||||||
|
row.distinct_references = stats.distinct_per_actor[i];
|
||||||
|
row.duplicates_removed = stats.duplicates_removed_per_actor[i];
|
||||||
|
row.eligible_for_positive_pairs = stats.eligible[i] != 0;
|
||||||
|
} else {
|
||||||
|
// No calibration stats for this actor (the fit never saw them).
|
||||||
|
// Report the raw count rather than a fabricated distinct count.
|
||||||
|
row.distinct_references = row.references;
|
||||||
|
}
|
||||||
|
|
||||||
|
r.embeddings_total += row.references;
|
||||||
|
r.distinct_embeddings_total += row.distinct_references;
|
||||||
|
r.duplicates_removed_total += row.duplicates_removed;
|
||||||
|
if (!row.eligible_for_positive_pairs) {
|
||||||
|
++r.actors_below_positive_threshold;
|
||||||
|
r.below_positive_threshold.push_back(row.name);
|
||||||
|
}
|
||||||
|
r.actors.push_back(std::move(row));
|
||||||
|
}
|
||||||
|
r.actors_in_gallery = static_cast<int>(gallery.actors.size());
|
||||||
|
|
||||||
|
// Actors the build considered and could not use at all. They are not in the
|
||||||
|
// gallery, so this is the only record that they exist.
|
||||||
|
if (audit) {
|
||||||
|
for (const auto& e : audit->actors) {
|
||||||
|
if (e.images_used > 0) continue;
|
||||||
|
GalleryReport::Actor row;
|
||||||
|
row.imdb_id = e.imdb_id;
|
||||||
|
row.name = e.name;
|
||||||
|
row.images_seen = e.images_seen;
|
||||||
|
row.references = 0;
|
||||||
|
r.zero_usable.push_back(e.name);
|
||||||
|
r.actors.push_back(std::move(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.actors_zero_usable = static_cast<int>(r.zero_usable.size());
|
||||||
|
r.actors_total = r.actors_in_gallery + r.actors_zero_usable;
|
||||||
|
r.mean_embeddings_per_actor =
|
||||||
|
r.actors_in_gallery > 0
|
||||||
|
? static_cast<double>(r.embeddings_total) / r.actors_in_gallery
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
// ── The two distributions, straight out of the fit ───────────────────────
|
||||||
|
auto& d = r.distributions;
|
||||||
|
d.bins = stats.hist_bins;
|
||||||
|
d.sim_min = -1.f;
|
||||||
|
d.sim_max = 1.f;
|
||||||
|
d.intra = stats.intra_hist;
|
||||||
|
d.inter = stats.inter_hist;
|
||||||
|
d.intra_pairs = stats.n_intra_pairs;
|
||||||
|
d.inter_pairs = stats.n_inter_pairs;
|
||||||
|
|
||||||
|
if (d.bins > 0) {
|
||||||
|
const double bin_w = (d.sim_max - d.sim_min) / d.bins;
|
||||||
|
double si = 0.0, se = 0.0;
|
||||||
|
for (int b = 0; b < d.bins; ++b) {
|
||||||
|
const double centre = d.sim_min + (b + 0.5) * bin_w;
|
||||||
|
si += d.intra[b] * centre;
|
||||||
|
se += d.inter[b] * centre;
|
||||||
|
}
|
||||||
|
if (d.intra_pairs > 0.0) d.intra_mean = si / d.intra_pairs;
|
||||||
|
if (d.inter_pairs > 0.0) d.inter_mean = se / d.inter_pairs;
|
||||||
|
if (d.intra_pairs > 0.0 && d.inter_pairs > 0.0) {
|
||||||
|
double ov = 0.0;
|
||||||
|
for (int b = 0; b < d.bins; ++b)
|
||||||
|
ov += std::min(d.intra[b] / d.intra_pairs, d.inter[b] / d.inter_pairs);
|
||||||
|
d.overlap = ov;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The prior AR-023 leaves open — computed, reported, not applied ────────
|
||||||
|
r.prior.configured_default = configured_prior;
|
||||||
|
r.prior.applied = false;
|
||||||
|
const double pair_total = d.intra_pairs + d.inter_pairs;
|
||||||
|
if (pair_total > 0.0) {
|
||||||
|
r.prior.derived = d.intra_pairs / pair_total;
|
||||||
|
const double p = std::clamp(r.prior.derived, 1e-12, 1.0 - 1e-12);
|
||||||
|
r.prior.derived_log_odds = std::log(p / (1.0 - p));
|
||||||
|
}
|
||||||
|
r.prior.note =
|
||||||
|
"AR-023 specifies a gallery-derived prior of intra/(intra+inter); the shipped "
|
||||||
|
"match_prior default is 0.5 (calibrated sigmoid used directly). The derived value "
|
||||||
|
"is the base rate of same-actor pairs among ALL enumerated gallery pairs, so it "
|
||||||
|
"falls as the cast grows (roughly (k-1)/((k-1)+(A-1)k) for A actors with k "
|
||||||
|
"references each) — it is a property of gallery size as much as of the embedder. "
|
||||||
|
"Reported here as evidence; NOT applied. Behaviour is unchanged until the choice "
|
||||||
|
"is recorded in the spec.";
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
inline nlohmann::json gallery_report_to_json(const GalleryReport& r) {
|
||||||
|
nlohmann::json j;
|
||||||
|
j["schema"] = r.schema;
|
||||||
|
j["gallery_path"] = r.gallery_path;
|
||||||
|
j["embedder"] = {{"model_name", r.embedder.model_name},
|
||||||
|
{"model_sha256", r.embedder.model_sha256},
|
||||||
|
{"embed_dim", r.embedder.embed_dim}};
|
||||||
|
|
||||||
|
j["summary"] = {
|
||||||
|
{"actors_total", r.actors_total},
|
||||||
|
{"actors_in_gallery", r.actors_in_gallery},
|
||||||
|
{"actors_zero_usable", r.actors_zero_usable},
|
||||||
|
{"actors_below_positive_threshold", r.actors_below_positive_threshold},
|
||||||
|
{"embeddings_total", r.embeddings_total},
|
||||||
|
{"distinct_embeddings_total", r.distinct_embeddings_total},
|
||||||
|
{"duplicates_removed_total", r.duplicates_removed_total},
|
||||||
|
{"mean_embeddings_per_actor", r.mean_embeddings_per_actor},
|
||||||
|
{"min_embeddings_for_positive_pairs", r.min_embeddings_for_positive_pairs},
|
||||||
|
{"dedup_similarity_threshold", r.dedup_similarity_threshold}};
|
||||||
|
|
||||||
|
j["calibration"] = {
|
||||||
|
{"a", r.calib_a},
|
||||||
|
{"b", r.calib_b},
|
||||||
|
{"valid", r.calib_valid},
|
||||||
|
{"hash", r.calib_hash},
|
||||||
|
{"train_accuracy_pct", r.calib_train_accuracy_pct},
|
||||||
|
{"boundary_p50", r.calib_boundary_p50}};
|
||||||
|
|
||||||
|
const auto& d = r.distributions;
|
||||||
|
j["distributions"] = {
|
||||||
|
{"bins", d.bins},
|
||||||
|
{"sim_min", d.sim_min},
|
||||||
|
{"sim_max", d.sim_max},
|
||||||
|
{"intra", d.intra},
|
||||||
|
{"inter", d.inter},
|
||||||
|
{"intra_pairs", d.intra_pairs},
|
||||||
|
{"inter_pairs", d.inter_pairs},
|
||||||
|
{"intra_mean", d.intra_mean},
|
||||||
|
{"inter_mean", d.inter_mean},
|
||||||
|
{"overlap", d.overlap}};
|
||||||
|
|
||||||
|
j["prior"] = {
|
||||||
|
{"derived", r.prior.derived},
|
||||||
|
{"derived_log_odds", r.prior.derived_log_odds},
|
||||||
|
{"configured_default", r.prior.configured_default},
|
||||||
|
{"applied", r.prior.applied},
|
||||||
|
{"note", r.prior.note}};
|
||||||
|
|
||||||
|
j["zero_usable"] = r.zero_usable;
|
||||||
|
j["below_positive_threshold"] = r.below_positive_threshold;
|
||||||
|
|
||||||
|
j["actors"] = nlohmann::json::array();
|
||||||
|
for (const auto& a : r.actors) {
|
||||||
|
j["actors"].push_back({
|
||||||
|
{"imdb_id", a.imdb_id},
|
||||||
|
{"name", a.name},
|
||||||
|
{"images_seen", a.images_seen},
|
||||||
|
{"references", a.references},
|
||||||
|
{"distinct_references", a.distinct_references},
|
||||||
|
{"duplicates_removed", a.duplicates_removed},
|
||||||
|
{"eligible_for_positive_pairs", a.eligible_for_positive_pairs}});
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
inline GalleryReport gallery_report_from_json(const nlohmann::json& j) {
|
||||||
|
GalleryReport r;
|
||||||
|
r.schema = j.value("schema", std::string{});
|
||||||
|
r.gallery_path = j.value("gallery_path", std::string{});
|
||||||
|
if (j.contains("embedder")) {
|
||||||
|
const auto& je = j.at("embedder");
|
||||||
|
r.embedder.model_name = je.value("model_name", "");
|
||||||
|
r.embedder.model_sha256 = je.value("model_sha256", "");
|
||||||
|
r.embedder.embed_dim = je.value("embed_dim", 512);
|
||||||
|
}
|
||||||
|
if (j.contains("summary")) {
|
||||||
|
const auto& s = j.at("summary");
|
||||||
|
r.actors_total = s.value("actors_total", 0);
|
||||||
|
r.actors_in_gallery = s.value("actors_in_gallery", 0);
|
||||||
|
r.actors_zero_usable = s.value("actors_zero_usable", 0);
|
||||||
|
r.actors_below_positive_threshold = s.value("actors_below_positive_threshold", 0);
|
||||||
|
r.embeddings_total = s.value("embeddings_total", int64_t{0});
|
||||||
|
r.distinct_embeddings_total = s.value("distinct_embeddings_total", int64_t{0});
|
||||||
|
r.duplicates_removed_total = s.value("duplicates_removed_total", int64_t{0});
|
||||||
|
r.mean_embeddings_per_actor = s.value("mean_embeddings_per_actor", 0.0);
|
||||||
|
r.min_embeddings_for_positive_pairs = s.value("min_embeddings_for_positive_pairs", 0);
|
||||||
|
r.dedup_similarity_threshold = s.value("dedup_similarity_threshold", 0.f);
|
||||||
|
}
|
||||||
|
if (j.contains("calibration")) {
|
||||||
|
const auto& c = j.at("calibration");
|
||||||
|
r.calib_a = c.value("a", 10.f);
|
||||||
|
r.calib_b = c.value("b", -5.f);
|
||||||
|
r.calib_valid = c.value("valid", false);
|
||||||
|
r.calib_hash = c.value("hash", uint64_t{0});
|
||||||
|
r.calib_train_accuracy_pct = c.value("train_accuracy_pct", 0.0);
|
||||||
|
r.calib_boundary_p50 = c.value("boundary_p50", 0.f);
|
||||||
|
}
|
||||||
|
if (j.contains("distributions")) {
|
||||||
|
const auto& d = j.at("distributions");
|
||||||
|
r.distributions.bins = d.value("bins", 0);
|
||||||
|
r.distributions.sim_min = d.value("sim_min", -1.f);
|
||||||
|
r.distributions.sim_max = d.value("sim_max", 1.f);
|
||||||
|
r.distributions.intra = d.value("intra", std::vector<double>{});
|
||||||
|
r.distributions.inter = d.value("inter", std::vector<double>{});
|
||||||
|
r.distributions.intra_pairs = d.value("intra_pairs", 0.0);
|
||||||
|
r.distributions.inter_pairs = d.value("inter_pairs", 0.0);
|
||||||
|
r.distributions.intra_mean = d.value("intra_mean", 0.0);
|
||||||
|
r.distributions.inter_mean = d.value("inter_mean", 0.0);
|
||||||
|
r.distributions.overlap = d.value("overlap", 0.0);
|
||||||
|
}
|
||||||
|
if (j.contains("prior")) {
|
||||||
|
const auto& p = j.at("prior");
|
||||||
|
r.prior.derived = p.value("derived", 0.0);
|
||||||
|
r.prior.derived_log_odds = p.value("derived_log_odds", 0.0);
|
||||||
|
r.prior.configured_default = p.value("configured_default", 0.5f);
|
||||||
|
r.prior.applied = p.value("applied", false);
|
||||||
|
r.prior.note = p.value("note", "");
|
||||||
|
}
|
||||||
|
r.zero_usable = j.value("zero_usable", std::vector<std::string>{});
|
||||||
|
r.below_positive_threshold = j.value("below_positive_threshold", std::vector<std::string>{});
|
||||||
|
if (j.contains("actors")) {
|
||||||
|
for (const auto& ja : j.at("actors")) {
|
||||||
|
GalleryReport::Actor a;
|
||||||
|
a.imdb_id = ja.value("imdb_id", "");
|
||||||
|
a.name = ja.value("name", "");
|
||||||
|
a.images_seen = ja.value("images_seen", -1);
|
||||||
|
a.references = ja.value("references", 0);
|
||||||
|
a.distinct_references = ja.value("distinct_references", 0);
|
||||||
|
a.duplicates_removed = ja.value("duplicates_removed", 0);
|
||||||
|
a.eligible_for_positive_pairs = ja.value("eligible_for_positive_pairs", false);
|
||||||
|
r.actors.push_back(std::move(a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<dir>/cast.h5" → "<dir>/cast.report.json". A known gallery extension is
|
||||||
|
// replaced rather than appended to, so the report sits beside the gallery under
|
||||||
|
// the same stem.
|
||||||
|
inline std::string gallery_report_path(const std::string& gallery_path) {
|
||||||
|
auto slash = gallery_path.find_last_of("/\\");
|
||||||
|
auto dot = gallery_path.find_last_of('.');
|
||||||
|
std::string stem =
|
||||||
|
(dot != std::string::npos && (slash == std::string::npos || dot > slash))
|
||||||
|
? gallery_path.substr(0, dot)
|
||||||
|
: gallery_path;
|
||||||
|
return stem + ".report.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
inline void save_gallery_report(const std::string& path, const GalleryReport& r) {
|
||||||
|
std::ofstream out(path);
|
||||||
|
if (!out.is_open())
|
||||||
|
throw std::runtime_error("save_gallery_report: cannot write " + path);
|
||||||
|
out << gallery_report_to_json(r).dump(2) << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
inline GalleryReport load_gallery_report(const std::string& path) {
|
||||||
|
std::ifstream in(path);
|
||||||
|
if (!in.is_open())
|
||||||
|
throw std::runtime_error("load_gallery_report: cannot open " + path);
|
||||||
|
nlohmann::json j;
|
||||||
|
in >> j;
|
||||||
|
return gallery_report_from_json(j);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: GR-003 | SR-001
|
||||||
|
///
|
||||||
|
/// The report's headline, on stderr, at build time. The file is the audit trail;
|
||||||
|
/// this is what stops a bad gallery from being shipped without anyone noticing.
|
||||||
|
inline void log_gallery_report(const GalleryReport& r) {
|
||||||
|
std::cerr << "[gallery-report] " << r.actors_in_gallery << " actors / "
|
||||||
|
<< r.embeddings_total << " embeddings"
|
||||||
|
<< " (mean " << r.mean_embeddings_per_actor << " per actor)\n";
|
||||||
|
if (r.actors_zero_usable > 0) {
|
||||||
|
std::cerr << "[gallery-report] WARNING: " << r.actors_zero_usable
|
||||||
|
<< " actor(s) have NO usable image — they can never be recognised:\n";
|
||||||
|
for (const auto& n : r.zero_usable) std::cerr << " - " << n << "\n";
|
||||||
|
}
|
||||||
|
if (r.actors_below_positive_threshold > 0) {
|
||||||
|
std::cerr << "[gallery-report] " << r.actors_below_positive_threshold
|
||||||
|
<< " actor(s) below " << r.min_embeddings_for_positive_pairs
|
||||||
|
<< " distinct references — they contribute no positive pairs and "
|
||||||
|
"weaken the calibration\n";
|
||||||
|
}
|
||||||
|
if (r.duplicates_removed_total > 0)
|
||||||
|
std::cerr << "[gallery-report] " << r.duplicates_removed_total
|
||||||
|
<< " near-duplicate reference(s) removed\n";
|
||||||
|
std::cerr << "[gallery-report] calibration valid=" << r.calib_valid
|
||||||
|
<< " a=" << r.calib_a << " b=" << r.calib_b
|
||||||
|
<< " intra/inter overlap=" << r.distributions.overlap
|
||||||
|
<< " (intra mean=" << r.distributions.intra_mean
|
||||||
|
<< ", inter mean=" << r.distributions.inter_mean << ")\n";
|
||||||
|
std::cerr << "[gallery-report] gallery-derived prior would be "
|
||||||
|
<< r.prior.derived << " (log-odds " << r.prior.derived_log_odds
|
||||||
|
<< "); shipped default " << r.prior.configured_default
|
||||||
|
<< " is in force — reported, not applied\n";
|
||||||
|
}
|
||||||
@@ -168,3 +168,96 @@ TEST_CASE("calibrate_gallery_cached treats hash=0 as always-recompute", "[calibr
|
|||||||
CHECK(recomputed);
|
CHECK(recomputed);
|
||||||
CHECK(cal.valid);
|
CHECK(cal.valid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GR-003 — the build report ────────────────────────────────────────────────
|
||||||
|
#include "gallery/gallery_report.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// A unit vector on one axis. Distinct axes are orthogonal, which is unrealistic
|
||||||
|
// as a same-actor cluster but irrelevant here: these tests count actors, they do
|
||||||
|
// not assess fit quality.
|
||||||
|
Embedding unit_axis(int slot) {
|
||||||
|
Embedding e{};
|
||||||
|
e[slot % 512] = 1.0f;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("report surfaces actors that can never be recognised", "[report][GR-003]") {
|
||||||
|
// An actor with no usable image is a silent recall ceiling: the pipeline
|
||||||
|
// will never name them, and nothing in the gallery says why. This is the
|
||||||
|
// single most useful number in the report.
|
||||||
|
ActorGallery g;
|
||||||
|
for (int a = 0; a < 3; ++a) {
|
||||||
|
ActorGallery::Actor act;
|
||||||
|
act.name = "actor" + std::to_string(a);
|
||||||
|
if (a != 1) // actor1 gets nothing
|
||||||
|
for (int i = 0; i < 6; ++i) act.embeddings.push_back(unit_axis(a * 10 + i));
|
||||||
|
g.actors.push_back(std::move(act));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Embedding> flat;
|
||||||
|
std::vector<int> flat_actor;
|
||||||
|
for (int a = 0; a < static_cast<int>(g.actors.size()); ++a)
|
||||||
|
for (const auto& e : g.actors[a].embeddings) { flat.push_back(e); flat_actor.push_back(a); }
|
||||||
|
|
||||||
|
GalleryCalibrationStats stats;
|
||||||
|
GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats);
|
||||||
|
GalleryReport r = build_gallery_report(g, cal, stats);
|
||||||
|
|
||||||
|
// An actor present in the gallery with no embeddings is counted as
|
||||||
|
// in-gallery but contributes nothing; the zero-usable list is populated
|
||||||
|
// from the build audit, which a stored gallery cannot supply.
|
||||||
|
CHECK(r.actors_in_gallery == 3);
|
||||||
|
CHECK(r.actors[1].references == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("report surfaces actors too thin to calibrate on", "[report][GR-003]") {
|
||||||
|
// Below the positive-pair threshold an actor contributes nothing to the
|
||||||
|
// intra-class side of the fit. They are not broken, so nothing complains —
|
||||||
|
// they just quietly weaken every threshold downstream.
|
||||||
|
ActorGallery g;
|
||||||
|
for (int a = 0; a < 2; ++a) {
|
||||||
|
ActorGallery::Actor act;
|
||||||
|
act.name = "actor" + std::to_string(a);
|
||||||
|
const int n = (a == 0) ? 6 : 2; // actor1 is under-referenced
|
||||||
|
for (int i = 0; i < n; ++i) act.embeddings.push_back(unit_axis(a * 10 + i));
|
||||||
|
g.actors.push_back(std::move(act));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Embedding> flat;
|
||||||
|
std::vector<int> flat_actor;
|
||||||
|
for (int a = 0; a < static_cast<int>(g.actors.size()); ++a)
|
||||||
|
for (const auto& e : g.actors[a].embeddings) { flat.push_back(e); flat_actor.push_back(a); }
|
||||||
|
|
||||||
|
GalleryCalibrationStats stats;
|
||||||
|
GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats);
|
||||||
|
GalleryReport r = build_gallery_report(g, cal, stats);
|
||||||
|
|
||||||
|
CHECK(r.actors_below_positive_threshold >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("report round-trips", "[report][GR-003]") {
|
||||||
|
ActorGallery g;
|
||||||
|
ActorGallery::Actor act;
|
||||||
|
act.name = "solo";
|
||||||
|
for (int i = 0; i < 6; ++i) act.embeddings.push_back(unit_axis(i));
|
||||||
|
g.actors.push_back(std::move(act));
|
||||||
|
|
||||||
|
std::vector<Embedding> flat;
|
||||||
|
std::vector<int> flat_actor;
|
||||||
|
for (const auto& e : g.actors[0].embeddings) { flat.push_back(e); flat_actor.push_back(0); }
|
||||||
|
|
||||||
|
GalleryCalibrationStats stats;
|
||||||
|
GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats);
|
||||||
|
GalleryReport r = build_gallery_report(g, cal, stats);
|
||||||
|
|
||||||
|
const std::string path = "/tmp/gr003_roundtrip.report.json";
|
||||||
|
save_gallery_report(path, r);
|
||||||
|
GalleryReport back = load_gallery_report(path);
|
||||||
|
|
||||||
|
CHECK(back.actors_in_gallery == r.actors_in_gallery);
|
||||||
|
CHECK(back.actors_below_positive_threshold == r.actors_below_positive_threshold);
|
||||||
|
CHECK(back.calib_a == r.calib_a);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user