Files
scene-actor-extraction/src/face_embedder_engine.hpp
T
dtourolleandClaude Opus 5 f99f1c5ccc feat: no fixed cap on faces per frame
AR-003 — max_faces defaults to 0, meaning no cap. A fixed cap discards the
SMALLEST faces first, which are exactly the background cast X-Ray still credits
with scene membership, so the pipeline was systematically losing the people it
is supposed to find in crowded scenes.

This is only safe now that AR-004 landed. Previously an uncapped frame would
have pushed more work into channels that dropped on overflow, trading a visible
cap for silent loss. With backpressure the producer slows instead, so per-frame
cost is contained rather than discarded.

The matcher's kMaxFaces used to throw above 32, which made it an accidental
second cap. It sizes the similarity engine's preallocated buffer, so it bounds
memory rather than face count — the frame is now scored in batches of that size.
Memory stays bounded; faces do not.

Largest-first ordering is kept even without the cap, and the comment now says
why: the Hungarian solver tie-breaks on index order, so that ordering is
load-bearing for the replay determinism test rather than a leftover of the cap.

Verified end to end on a real clip: identical output to the capped run (385
frames, 693 faces), which is expected since that footage peaks at 4 faces per
frame — the point is the absence of a regression. The committed fixtures remain
byte-identical and valid.

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

TRACES: AR-003 | SR-002
2026-07-31 14:22:20 +02:00

131 lines
4.7 KiB
C++

#pragma once
// FaceEmbedderEngine — load SCRFD + ArcFace once, embed many images.
//
// Extracted from embed_faces.cpp so the same detect→align→embed pipeline can
// be driven from a long-lived process (the sae_embed Python module) instead
// of a fresh CLI invocation per image, which would reload both ONNX sessions
// every time.
#include "config.hpp"
#include "face_utils.hpp"
#include "inference/face_detector.hpp"
#include "inference/face_embedder.hpp"
#include "types.hpp"
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <array>
#include <iostream>
#include <memory>
#include <string>
struct FaceEmbedResult {
bool ok{false};
std::string error;
Embedding embedding{};
float confidence{0.f};
float bbox[4]{}; // x, y, w, h
std::array<cv::Point2f, 5> landmarks{};
};
class FaceEmbedderEngine {
public:
// detector_engine/arcface_engine are optional paths to pre-built TensorRT
// engines. They are required when built with SAE_INFERENCE_BACKEND=TRT
// (which cannot load .onnx directly) and ignored by the ORT backend.
FaceEmbedderEngine(const std::string& detector_model,
const std::string& arcface_model,
float conf = 0.5f, float nms = 0.4f, int max_side = 500,
const std::string& detector_engine = "",
const std::string& arcface_engine = "")
: max_side_(max_side)
{
Config cfg;
cfg.detector_model = detector_model;
cfg.arcface_model = arcface_model;
cfg.detector_engine = detector_engine;
cfg.arcface_engine = arcface_engine;
cfg.detector_conf = conf;
cfg.detector_nms = nms;
detector_ = make_face_detector(cfg);
embedder_ = make_face_embedder(cfg);
}
FaceEmbedResult embed_path(const std::string& path) const {
cv::Mat img = cv::imread(path);
if (img.empty()) {
FaceEmbedResult res;
res.error = "cannot read image";
return res;
}
return embed_mat(img);
}
FaceEmbedResult embed_mat(cv::Mat img) const {
FaceEmbedResult res;
if (max_side_ > 0) {
const int big = std::max(img.cols, img.rows);
if (big > max_side_) {
const double s = static_cast<double>(max_side_) / big;
cv::resize(img, img, {}, s, s, cv::INTER_AREA);
}
}
std::vector<DetectedFace> faces = detector_->detect(img);
if (faces.empty()) {
cv::Mat enhanced = enhance_for_retry(img);
faces = detector_->detect(enhanced);
if (!faces.empty())
img = enhanced;
}
if (faces.empty()) {
res.error = "no face detected";
return res;
}
if (faces.size() > 1)
std::cerr << "[warn] " << faces.size()
<< " faces detected, using highest-confidence one\n";
const auto& best = *std::max_element(
faces.begin(), faces.end(),
[](const DetectedFace& a, const DetectedFace& b) {
return a.confidence < b.confidence;
});
cv::Mat crop = align_face(img, best.landmarks);
if (crop.empty()) {
res.error = "alignment failed";
return res;
}
res.ok = true;
res.embedding = embedder_->embed_one(crop);
res.confidence = best.confidence;
res.bbox[0] = best.bbox.x;
res.bbox[1] = best.bbox.y;
res.bbox[2] = best.bbox.width;
res.bbox[3] = best.bbox.height;
res.landmarks = best.landmarks;
return res;
}
// ── Stage accessors ──────────────────────────────────────────────────────
// embed_mat() above is the whole detect→align→embed chain, which is the
// right entry point for embedding a gallery image. Studies that need to
// intervene between the stages — swapping the landmark source, degrading a
// crop before it reaches the embedder — drive these instead, so they still
// exercise the shipped detector, alignment and embedder rather than a
// re-implementation of them.
std::vector<DetectedFace> detect(const cv::Mat& img) { return detector_->detect(img); }
Embedding embed_crop(const cv::Mat& crop) { return embedder_->embed_one(crop); }
private:
std::unique_ptr<IFaceDetector> detector_;
std::unique_ptr<IFaceEmbedder> embedder_;
int max_side_;
};