Bali was chosen because the TRECVID DVU set ships character mugshots, but its reference crops are unusable at scale: median detected face 27 px against a 69 px maximum, so every reference was upscaled 4x or more past what the embedder was trained for (AR-011). A 66 px floor left 2 of 69 references; no threshold exists that both keeps the faces in distribution and leaves enough of them to calibrate. SuperHero is 69 px median and 241 px max. Its gallery builds at a 66 px floor with 14 references over 5 characters, and calibrates on its own (a=15.2867 b=-4.98633, 100% train accuracy) instead of borrowing constants. Measured on the fused 17-minute film, one stream rather than per-scene clips so presence windows cross real scene boundaries as SR-002 intends: precision 1.00, recall 0.65, F1 0.79 — 13 true positives, 0 false positives, 7 misses. Every out-of-gallery character was declined rather than forced onto a nearest match. The misses are the short scenes (14 s, 38 s, 27 s), consistent with per-track accumulation needing sightings. - build_gallery gains --min-face-px, filtering the *detected face* rather than the crop. The DVU images are scene crops, not mugshots, so crop dimensions say nothing about face scale. A poisoned reference is permanent in a way a bad frame is not: it corrupts every future match against that identity. - scripts/fetch_dvu.sh fetches mugshots, scene graphs and segmentation for any DVU film. NIST names the same film three different ways, so KG_DIR and KG_FILE are overridable rather than derived. This exists as a script because the first copy of this data was assembled ad hoc in /tmp and was lost with it, taking the working gallery along. - Replay fixtures move to the artifact registry: push/pull_artifacts.sh gain a replay-fixtures target, and tests/fixtures/dumps/.gitignore keeps them out of git. superhero.h5 is ~9 MB and regenerating it needs the film, the models and a GPU — none of which CI has. The gallery ships with the dumps, since a dump only replays against the gallery it was produced with. - AR-012 and AR-013 coverage is ported onto the new fixture rather than dropped with the Bali cases: 12369 assertions, up from 7991, since the film is an order of magnitude larger than the clips. Suite: 15679 assertions, 101 test cases. TRACES: AR-011, AR-012, AR-013 | VR-001, VR-005 | SR-002
146 lines
5.8 KiB
C++
146 lines
5.8 KiB
C++
#include "gallery_builder.hpp"
|
|
#include "config.hpp"
|
|
#include "embedder_stamp.hpp"
|
|
#include "face_utils.hpp"
|
|
#include "inference/face_detector.hpp"
|
|
#include "inference/face_embedder.hpp"
|
|
|
|
#include <opencv2/imgcodecs.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <iostream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
// ── Parse "nm0000093_Brad_Pitt" → ("nm0000093", "Brad Pitt") ─────────────────
|
|
static std::pair<std::string, std::string> parse_dir_name(const std::string& dirname) {
|
|
auto pos = dirname.find('_');
|
|
if (pos == std::string::npos) return {dirname, dirname};
|
|
|
|
std::string imdb_id = dirname.substr(0, pos);
|
|
std::string raw = dirname.substr(pos + 1);
|
|
std::string name;
|
|
name.reserve(raw.size());
|
|
for (char c : raw)
|
|
name += (c == '_' ? ' ' : c);
|
|
return {imdb_id, name};
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
ActorGallery build_gallery(const BuildConfig& cfg) {
|
|
Config icfg;
|
|
icfg.detector_model = cfg.detector_model;
|
|
icfg.arcface_model = cfg.arcface_model;
|
|
icfg.detector_conf = cfg.detector_conf;
|
|
icfg.detector_nms = cfg.detector_nms;
|
|
auto decoder = make_face_detector(icfg);
|
|
auto arcface = make_face_embedder(icfg);
|
|
|
|
ActorGallery gallery;
|
|
|
|
/// TRACES: GR-004 | SR-001
|
|
// Stamp before the first embedding exists, so there is no window in which a
|
|
// gallery holds vectors without recording what produced them.
|
|
gallery.embedder = make_embedder_stamp(cfg.arcface_model);
|
|
std::cerr << "[build_gallery] embedder: " << gallery.embedder.describe() << "\n";
|
|
|
|
for (const auto& actor_dir : fs::directory_iterator(cfg.gallery_root)) {
|
|
if (!actor_dir.is_directory()) continue;
|
|
|
|
auto [imdb_id, name] = parse_dir_name(actor_dir.path().filename().string());
|
|
std::cerr << "[build_gallery] " << name << " (" << imdb_id << ")\n";
|
|
|
|
ActorGallery::Actor actor;
|
|
actor.imdb_id = imdb_id;
|
|
actor.name = name;
|
|
|
|
static const std::vector<std::string> kExts{".jpg", ".jpeg", ".png", ".webp"};
|
|
for (const auto& img_file : fs::directory_iterator(actor_dir.path())) {
|
|
if (!img_file.is_regular_file()) continue;
|
|
std::string ext = img_file.path().extension().string();
|
|
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
|
if (std::find(kExts.begin(), kExts.end(), ext) == kExts.end()) continue;
|
|
|
|
cv::Mat img = cv::imread(img_file.path().string());
|
|
if (img.empty()) {
|
|
std::cerr << " [skip] cannot read " << img_file.path().filename() << "\n";
|
|
continue;
|
|
}
|
|
|
|
if (cfg.max_side > 0) {
|
|
const int big = std::max(img.cols, img.rows);
|
|
if (big > cfg.max_side) {
|
|
const double s = static_cast<double>(cfg.max_side) / big;
|
|
cv::resize(img, img, {}, s, s, cv::INTER_AREA);
|
|
}
|
|
}
|
|
|
|
auto faces = decoder->detect(img);
|
|
|
|
if (faces.empty()) {
|
|
std::cerr << " [skip] no face: " << img_file.path().filename() << "\n";
|
|
continue;
|
|
}
|
|
if (faces.size() > 1) {
|
|
std::cerr << " [warn] " << faces.size() << " faces, using highest confidence: "
|
|
<< img_file.path().filename() << "\n";
|
|
}
|
|
|
|
const auto& best = *std::max_element(
|
|
faces.begin(), faces.end(),
|
|
[](const DetectedFace& a, const DetectedFace& b) {
|
|
return a.confidence < b.confidence;
|
|
});
|
|
|
|
// Reject faces too small to embed honestly.
|
|
//
|
|
// The reference images are crops cut from the film, not mugshots, so
|
|
// the detected face can be a small fraction of the image. Upscaling a
|
|
// 30 px face to ArcFace's 112x112 feeds the model an input it was
|
|
// never trained for, and it answers with a confident, plausible,
|
|
// wrong embedding.
|
|
//
|
|
// At inference that costs one frame. Here it is permanent: a poisoned
|
|
// reference sits in the gallery and corrupts every future match
|
|
// against that character, which is exactly the kind of error that is
|
|
// invisible without a study that should not have been needed.
|
|
if (cfg.min_face_px > 0.f) {
|
|
const float side = std::min(best.bbox.width, best.bbox.height);
|
|
if (side < cfg.min_face_px) {
|
|
std::cerr << " [skip] face " << side << "px < " << cfg.min_face_px
|
|
<< "px: " << img_file.path().filename() << "\n";
|
|
continue;
|
|
}
|
|
}
|
|
|
|
cv::Mat crop = align_face(img, best.landmarks);
|
|
if (crop.empty()) {
|
|
std::cerr << " [skip] alignment failed: " << img_file.path().filename() << "\n";
|
|
continue;
|
|
}
|
|
|
|
Embedding emb = arcface->embed_one(crop);
|
|
actor.embeddings.push_back(emb);
|
|
actor.source_images.push_back(img_file.path().filename().string());
|
|
|
|
std::cerr << " [ok] " << img_file.path().filename()
|
|
<< " conf=" << best.confidence << "\n";
|
|
}
|
|
|
|
if (actor.embeddings.empty()) {
|
|
std::cerr << " [warn] no valid embeddings for " << name << " — skipped\n";
|
|
continue;
|
|
}
|
|
|
|
std::cerr << " → " << actor.embeddings.size() << " embeddings\n";
|
|
gallery.actors.push_back(std::move(actor));
|
|
}
|
|
|
|
std::cerr << "[build_gallery] total: " << gallery.actors.size() << " actors\n";
|
|
return gallery;
|
|
}
|