improved performance

This commit is contained in:
2026-06-13 22:44:44 +02:00
parent fc16d4a0e1
commit a3ba53ddf7
8 changed files with 142 additions and 42 deletions
+7 -4
View File
@@ -44,18 +44,21 @@ find_library(CUDART_LIB cudart
find_path(CUDART_INCLUDE cuda_runtime_api.h find_path(CUDART_INCLUDE cuda_runtime_api.h
HINTS /opt/cuda/targets/x86_64-linux/include /opt/cuda/include HINTS /opt/cuda/targets/x86_64-linux/include /opt/cuda/include
/usr/local/cuda/include /usr/include) /usr/local/cuda/include /usr/include)
if(NOT (NVINFER_LIB AND NVINFER_INCLUDE AND CUDART_LIB AND CUDART_INCLUDE)) find_library(CUBLAS_LIB cublas
HINTS /opt/cuda/targets/x86_64-linux/lib /opt/cuda/lib64
/usr/local/cuda/lib64 /usr/lib)
if(NOT (NVINFER_LIB AND NVINFER_INCLUDE AND CUDART_LIB AND CUDART_INCLUDE AND CUBLAS_LIB))
message(FATAL_ERROR message(FATAL_ERROR
"TensorRT or CUDA runtime not found " "TensorRT or CUDA runtime not found "
"(nvinfer=${NVINFER_LIB} headers=${NVINFER_INCLUDE} " "(nvinfer=${NVINFER_LIB} headers=${NVINFER_INCLUDE} "
"cudart=${CUDART_LIB} headers=${CUDART_INCLUDE})") "cudart=${CUDART_LIB} cublas=${CUBLAS_LIB} headers=${CUDART_INCLUDE})")
endif() endif()
add_library(trt_runtime INTERFACE) add_library(trt_runtime INTERFACE)
target_include_directories(trt_runtime INTERFACE target_include_directories(trt_runtime INTERFACE
"${NVINFER_INCLUDE}" "${CUDART_INCLUDE}") "${NVINFER_INCLUDE}" "${CUDART_INCLUDE}")
target_link_libraries(trt_runtime INTERFACE target_link_libraries(trt_runtime INTERFACE
"${NVINFER_LIB}" "${CUDART_LIB}") "${NVINFER_LIB}" "${CUDART_LIB}" "${CUBLAS_LIB}")
message(STATUS "TensorRT: ${NVINFER_LIB} CUDA runtime: ${CUDART_LIB}") message(STATUS "TensorRT: ${NVINFER_LIB} CUDA runtime: ${CUDART_LIB} cuBLAS: ${CUBLAS_LIB}")
# FFmpeg (NVDEC hardware video decode + swscale colour conversion) # FFmpeg (NVDEC hardware video decode + swscale colour conversion)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
+6 -3
View File
@@ -5,7 +5,7 @@ Identifies actors in movie files and produces X-ray-style scene annotations comp
## How it works ## How it works
1. **Build a gallery** — download actor headshots from TMDB/IMDB, embed them with ArcFace (`build_gallery` / `scripts/make_gallery.py`). 1. **Build a gallery** — download actor headshots from TMDB/IMDB, embed them with ArcFace (`build_gallery` / `scripts/make_gallery.py`).
2. **Analyze a movie**`scene_analyze` decodes frames at configurable FPS, detects faces (YuNet/SCRFD), tracks them across cuts, matches identities against the gallery using calibrated similarity, and writes time-window JSON. 2. **Analyze a movie**`scene_analyze` decodes frames at configurable FPS, detects faces (SCRFD), tracks them across cuts, matches identities against the gallery using calibrated similarity, and writes time-window JSON.
3. **Output** — minimal mode produces Jellyfin-ready actor name + time-window JSON; standard mode adds per-frame bbox, similarity, and track data. 3. **Output** — minimal mode produces Jellyfin-ready actor name + time-window JSON; standard mode adds per-frame bbox, similarity, and track data.
## Dependencies ## Dependencies
@@ -13,9 +13,12 @@ Identifies actors in movie files and produces X-ray-style scene annotations comp
| Dependency | Role | | Dependency | Role |
|---|---| |---|---|
| KPN++ | Pipeline backbone (nodes, networks) | | KPN++ | Pipeline backbone (nodes, networks) |
| OpenCV 4 | Video decode, image ops, DNN inference, YuNet face detection | | OpenCV 4 | Video decode, image ops, DNN inference |
| ONNX Runtime | SCRFD face detector (dynamic shape nodes unsupported by cv::dnn) | | ONNX Runtime | SCRFD face detector (dynamic shape nodes unsupported by cv::dnn) |
| TensorRT + CUDA runtime + cuBLAS | Optional TRT engines for SCRFD/ArcFace (`--detector-engine`/`--arcface-engine`); identity_matcher's GPU gallery scan |
| FFmpeg (libav*) | NVDEC hardware video decode + colour conversion |
| nlohmann/json | JSON I/O | | nlohmann/json | JSON I/O |
| nanobind | Python bindings for `sae_embed` |
## Build ## Build
@@ -62,6 +65,7 @@ Models are placed in `external/`:
| `scene_analyze_debug` | Same as above + per-frame annotated JPEGs (`SAE_DEBUG=1`) | | `scene_analyze_debug` | Same as above + per-frame annotated JPEGs (`SAE_DEBUG=1`) |
| `scene_preview` | Live OpenCV display window while analysing | | `scene_preview` | Live OpenCV display window while analysing |
| `build_gallery` | Offline gallery builder from a directory of images | | `build_gallery` | Offline gallery builder from a directory of images |
| `embed_faces` | CLI: image(s) → embedding JSON, used by gallery-builder scripts |
| `sae_embed` | Python module (nanobind) used by gallery-builder scripts — loads SCRFD+ArcFace once | | `sae_embed` | Python module (nanobind) used by gallery-builder scripts — loads SCRFD+ArcFace once |
### `scene_analyze` ### `scene_analyze`
@@ -82,7 +86,6 @@ Key options:
| `--track-min-iou` | — | Minimum IoU gate for spatial assignment | | `--track-min-iou` | — | Minimum IoU gate for spatial assignment |
| `--track-max-embed` | — | Maximum embedding distance gate | | `--track-max-embed` | — | Maximum embedding distance gate |
| `--track-max-missing` | — | Frames a track survives without a detection | | `--track-max-missing` | — | Frames a track survives without a detection |
| `--track-min-frames` | 3 | Observations before a track's mean embedding is used for matching |
### Gallery builders ### Gallery builders
-1
View File
@@ -53,7 +53,6 @@ struct Config {
float track_min_iou{0.1f}; // IoU below which spatial link alone is rejected float track_min_iou{0.1f}; // IoU below which spatial link alone is rejected
float track_max_embed_dist{0.7f}; // cosine dist above which embedding link alone is rejected float track_max_embed_dist{0.7f}; // cosine dist above which embedding link alone is rejected
int track_max_frames_missing{5}; // expire track after N consecutive missed frames int track_max_frames_missing{5}; // expire track after N consecutive missed frames
int track_min_frames{3}; // frames before track mean replaces per-frame embedding
// ── Scene tracking ──────────────────────────────────────────────────────── // ── Scene tracking ────────────────────────────────────────────────────────
double extinction_sec{5.0}; // keep actor active this many seconds after last detection double extinction_sec{5.0}; // keep actor active this many seconds after last detection
-1
View File
@@ -94,7 +94,6 @@ static Config parse_args(int argc, char** argv) {
else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next()); else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next());
else if (arg("--track-max-embed")) cfg.track_max_embed_dist = std::stof(next()); else if (arg("--track-max-embed")) cfg.track_max_embed_dist = std::stof(next());
else if (arg("--track-max-missing")) cfg.track_max_frames_missing = std::stoi(next()); else if (arg("--track-max-missing")) cfg.track_max_frames_missing = std::stoi(next());
else if (arg("--track-min-frames")) cfg.track_min_frames = std::stoi(next());
else if (arg("--anneal")) cfg.anneal_sec = std::stod(next()); else if (arg("--anneal")) cfg.anneal_sec = std::stod(next());
else if (arg("--trt-cache")) cfg.trt.cache_dir = next(); else if (arg("--trt-cache")) cfg.trt.cache_dir = next();
else if (arg("--trt-fp16")) cfg.trt.fp16 = true; else if (arg("--trt-fp16")) cfg.trt.fp16 = true;
+4 -14
View File
@@ -14,10 +14,8 @@
// algorithm on a combined spatial (IoU) + embedding (cosine distance) cost. // algorithm on a combined spatial (IoU) + embedding (cosine distance) cost.
// //
// Each track accumulates a running directional mean of its ArcFace embeddings // Each track accumulates a running directional mean of its ArcFace embeddings
// (averaged then re-normalised to the unit sphere). Once a track reaches // (averaged then re-normalised to the unit sphere), used as the embedding side
// min_frames observations its mean embedding is forwarded as track_embeddings[i] // of the assignment cost below for more stable track continuity.
// and track_mature[i] is set, allowing the identity matcher to use a cleaner,
// multi-frame signal instead of the noisy single-frame embedding.
// //
// Assignment cost (track i, detection j): // Assignment cost (track i, detection j):
// cost = alpha * (1 - IoU) + (1-alpha) * min(cosine_dist/2, 1) // cost = alpha * (1 - IoU) + (1-alpha) * min(cosine_dist/2, 1)
@@ -41,13 +39,11 @@ struct FaceTrackerFunc {
, min_iou_(cfg.track_min_iou) , min_iou_(cfg.track_min_iou)
, max_embed_dist_(cfg.track_max_embed_dist) , max_embed_dist_(cfg.track_max_embed_dist)
, max_missing_(cfg.track_max_frames_missing) , max_missing_(cfg.track_max_frames_missing)
, min_frames_(cfg.track_min_frames)
{ {
std::cerr << "[face_tracker] alpha=" << alpha_ std::cerr << "[face_tracker] alpha=" << alpha_
<< " min_iou=" << min_iou_ << " min_iou=" << min_iou_
<< " max_embed_dist=" << max_embed_dist_ << " max_embed_dist=" << max_embed_dist_
<< " max_missing=" << max_missing_ << " max_missing=" << max_missing_ << "\n";
<< " min_frames=" << min_frames_ << "\n";
} }
TrackedSceneFrame operator()(EmbeddedSceneFrame ef) { TrackedSceneFrame operator()(EmbeddedSceneFrame ef) {
@@ -102,8 +98,6 @@ struct FaceTrackerFunc {
out.crops = ef.crops; out.crops = ef.crops;
out.embeddings = ef.embeddings; out.embeddings = ef.embeddings;
out.track_ids.assign(n_det, -1); out.track_ids.assign(n_det, -1);
out.track_embeddings = ef.embeddings; // default: per-frame embedding
out.track_mature.assign(n_det, false);
std::vector<bool> det_matched(n_det, false); std::vector<bool> det_matched(n_det, false);
@@ -122,9 +116,7 @@ struct FaceTrackerFunc {
ts.frames_missing = 0; ts.frames_missing = 0;
det_matched[di] = true; det_matched[di] = true;
out.track_ids[di] = tids[ti]; out.track_ids[di] = tids[ti];
out.track_embeddings[di] = ts.mean_emb;
out.track_mature[di] = (ts.n_frames >= min_frames_);
} }
// Create new tracks for unmatched detections // Create new tracks for unmatched detections
@@ -137,7 +129,6 @@ struct FaceTrackerFunc {
ts.n_frames = 1; ts.n_frames = 1;
tracks_[tid] = ts; tracks_[tid] = ts;
out.track_ids[di] = tid; out.track_ids[di] = tid;
// track_embeddings[di] already initialised to per-frame embedding
} }
// Expire stale tracks // Expire stale tracks
@@ -239,5 +230,4 @@ private:
float min_iou_; float min_iou_;
float max_embed_dist_; float max_embed_dist_;
int max_missing_; int max_missing_;
int min_frames_;
}; };
+124 -14
View File
@@ -4,9 +4,15 @@
#include "gallery/gallery_store.hpp" #include "gallery/gallery_store.hpp"
#include "gallery/gallery_calibration.hpp" #include "gallery/gallery_calibration.hpp"
#include <cmath> #include <cublas_v2.h>
#include <limits> #include <cuda_runtime_api.h>
#include <cstdint>
#include <cstring>
#include <iostream> #include <iostream>
#include <limits>
#include <stdexcept>
#include <vector>
// ── IdentityMatcherFunc ─────────────────────────────────────────────────────── // ── IdentityMatcherFunc ───────────────────────────────────────────────────────
// KPN node: compares each embedding against every reference embedding in the // KPN node: compares each embedding against every reference embedding in the
@@ -23,12 +29,38 @@
// (b) ratio test: best_dist/second_best_dist < match_ratio // (b) ratio test: best_dist/second_best_dist < match_ratio
// AND best_dist < match_ratio_ceil. // AND best_dist < match_ratio_ceil.
// //
// In both modes, per-actor best similarity is determined by scanning all // In both modes, per-actor best similarity is determined by scanning
// reference embeddings and taking the closest (best-of-N). // reference embeddings and taking the closest (best-of-N).
//
// Gallery scan: the full reference set (tens of thousands of 512-dim
// embeddings) is uploaded to the GPU once at construction time and stays
// resident there. Per frame, only the small query matrix (n_faces x 512) is
// uploaded and a single cublasSgemm computes the full similarity matrix
// (n_faces x N_gallery) in well under a millisecond — far faster than any
// CPU GEMM or scalar scan, making gallery-side pruning unnecessary.
namespace identity_matcher_detail {
struct CudaError : std::runtime_error {
using std::runtime_error::runtime_error;
};
inline void check_cuda(cudaError_t e, const char* what) {
if (e != cudaSuccess)
throw CudaError(std::string(what) + ": " + cudaGetErrorString(e));
}
inline void check_cublas(cublasStatus_t s, const char* what) {
if (s != CUBLAS_STATUS_SUCCESS)
throw CudaError(std::string(what) + ": cublas error " + std::to_string(s));
}
} // namespace identity_matcher_detail
struct IdentityMatcherFunc { struct IdentityMatcherFunc {
static constexpr std::string_view label() { return "identity_matcher"; } static constexpr std::string_view label() { return "identity_matcher"; }
// Max faces handled per frame without reallocating GPU buffers.
static constexpr int kMaxFaces = 32;
IdentityMatcherFunc(const ActorGallery& gallery, const Config& cfg) IdentityMatcherFunc(const ActorGallery& gallery, const Config& cfg)
: gallery_(gallery) : gallery_(gallery)
, prob_threshold_(cfg.prob_threshold) , prob_threshold_(cfg.prob_threshold)
@@ -37,6 +69,8 @@ struct IdentityMatcherFunc {
, ratio_(cfg.match_ratio) , ratio_(cfg.match_ratio)
, ratio_ceil_(cfg.match_ratio_ceil) , ratio_ceil_(cfg.match_ratio_ceil)
{ {
using namespace identity_matcher_detail;
std::cerr << "[identity_matcher] flattening gallery embeddings...\n"; std::cerr << "[identity_matcher] flattening gallery embeddings...\n";
for (int ai = 0; ai < static_cast<int>(gallery_.actors.size()); ++ai) { for (int ai = 0; ai < static_cast<int>(gallery_.actors.size()); ++ai) {
for (const auto& emb : gallery_.actors[ai].embeddings) { for (const auto& emb : gallery_.actors[ai].embeddings) {
@@ -44,6 +78,7 @@ struct IdentityMatcherFunc {
flat_actor_.push_back(ai); flat_actor_.push_back(ai);
} }
} }
n_gallery_ = static_cast<int>(flat_emb_.size());
std::cerr << "[identity_matcher] starting calibration (" std::cerr << "[identity_matcher] starting calibration ("
<< flat_emb_.size() << " embeddings)...\n"; << flat_emb_.size() << " embeddings)...\n";
@@ -64,27 +99,93 @@ struct IdentityMatcherFunc {
std::cerr << "[identity_matcher] gallery: " std::cerr << "[identity_matcher] gallery: "
<< gallery_.actors.size() << " actors, " << gallery_.actors.size() << " actors, "
<< flat_emb_.size() << " reference embeddings\n"; << flat_emb_.size() << " reference embeddings\n";
// Flatten gallery into a contiguous (N x 512) row-major host buffer,
// then upload once. Row-major NxD == column-major DxN, which is the
// layout cublasSgemm wants for the transposed operand below.
std::vector<float> host_gallery(static_cast<size_t>(n_gallery_) * 512);
for (int i = 0; i < n_gallery_; ++i)
std::memcpy(host_gallery.data() + static_cast<size_t>(i) * 512,
flat_emb_[i].data(), 512 * sizeof(float));
check_cuda(cudaMalloc(reinterpret_cast<void**>(&d_gallery_), host_gallery.size() * sizeof(float)),
"cudaMalloc gallery");
check_cuda(cudaMemcpy(d_gallery_, host_gallery.data(),
host_gallery.size() * sizeof(float),
cudaMemcpyHostToDevice),
"cudaMemcpy gallery H2D");
check_cuda(cudaMalloc(reinterpret_cast<void**>(&d_query_), static_cast<size_t>(kMaxFaces) * 512 * sizeof(float)),
"cudaMalloc query");
check_cuda(cudaMalloc(reinterpret_cast<void**>(&d_sims_), static_cast<size_t>(kMaxFaces) * n_gallery_ * sizeof(float)),
"cudaMalloc sims");
check_cuda(cudaStreamCreate(&stream_), "cudaStreamCreate");
check_cublas(cublasCreate(&handle_), "cublasCreate");
check_cublas(cublasSetStream(handle_, stream_), "cublasSetStream");
host_sims_.resize(static_cast<size_t>(kMaxFaces) * n_gallery_);
std::cerr << "[identity_matcher] gallery resident on GPU ("
<< (host_gallery.size() * sizeof(float)) / (1024 * 1024) << " MiB)\n";
}
~IdentityMatcherFunc() {
if (d_gallery_) cudaFree(d_gallery_);
if (d_query_) cudaFree(d_query_);
if (d_sims_) cudaFree(d_sims_);
if (handle_) cublasDestroy(handle_);
if (stream_) cudaStreamDestroy(stream_);
} }
MatchedSceneFrame operator()(TrackedSceneFrame tf) { MatchedSceneFrame operator()(TrackedSceneFrame tf) {
using namespace identity_matcher_detail;
if (tf.source.eof) return {std::move(tf.source), {}}; if (tf.source.eof) return {std::move(tf.source), {}};
const int n_faces = static_cast<int>(tf.embeddings.size());
std::vector<IdentifiedActor> actors; std::vector<IdentifiedActor> actors;
actors.reserve(tf.embeddings.size()); actors.reserve(n_faces);
for (int fi = 0; fi < static_cast<int>(tf.embeddings.size()); ++fi) { if (n_faces == 0) return {std::move(tf.source), {}};
// Prefer the track's accumulated mean embedding when the track is if (n_faces > kMaxFaces)
// mature (≥ min_frames observations) — more stable than single-frame. throw std::runtime_error("identity_matcher: n_faces exceeds kMaxFaces");
const Embedding& query = tf.track_mature[fi]
? tf.track_embeddings[fi]
: tf.embeddings[fi];
// Per-actor best cosine similarity (max dot product) // Build the (n_faces x 512) query matrix (row-major == col-major 512 x n_faces).
std::vector<float> host_query(static_cast<size_t>(n_faces) * 512);
for (int fi = 0; fi < n_faces; ++fi) {
std::memcpy(host_query.data() + static_cast<size_t>(fi) * 512,
tf.embeddings[fi].data(), 512 * sizeof(float));
}
check_cuda(cudaMemcpyAsync(d_query_, host_query.data(),
host_query.size() * sizeof(float),
cudaMemcpyHostToDevice, stream_),
"cudaMemcpy query H2D");
// S (N_gallery x n_faces) col-major = G(512 x N_gallery)^T * Q(512 x n_faces)
// i.e. S[g + f*N_gallery] = cosine_similarity(gallery[g], query[f]).
const float alpha = 1.f, beta = 0.f;
check_cublas(cublasSgemm(handle_, CUBLAS_OP_T, CUBLAS_OP_N,
n_gallery_, n_faces, 512,
&alpha, d_gallery_, 512, d_query_, 512,
&beta, d_sims_, n_gallery_),
"cublasSgemm");
check_cuda(cudaMemcpyAsync(host_sims_.data(), d_sims_,
static_cast<size_t>(n_gallery_) * n_faces * sizeof(float),
cudaMemcpyDeviceToHost, stream_),
"cudaMemcpy sims D2H");
check_cuda(cudaStreamSynchronize(stream_), "cudaStreamSynchronize");
for (int fi = 0; fi < n_faces; ++fi) {
const float* sims = host_sims_.data() + static_cast<size_t>(fi) * n_gallery_;
// Per-actor best cosine similarity (max over that actor's reference embeddings)
std::vector<float> best_sim(gallery_.actors.size(), std::vector<float> best_sim(gallery_.actors.size(),
-std::numeric_limits<float>::max()); -std::numeric_limits<float>::max());
for (int ei = 0; ei < n_gallery_; ++ei) {
for (int ei = 0; ei < static_cast<int>(flat_emb_.size()); ++ei) { float sim = sims[ei];
float sim = cosine_similarity(query, flat_emb_[ei]);
int ai = flat_actor_[ei]; int ai = flat_actor_[ei];
if (sim > best_sim[ai]) best_sim[ai] = sim; if (sim > best_sim[ai]) best_sim[ai] = sim;
} }
@@ -157,4 +258,13 @@ private:
float ratio_ceil_; float ratio_ceil_;
std::vector<Embedding> flat_emb_; std::vector<Embedding> flat_emb_;
std::vector<int> flat_actor_; std::vector<int> flat_actor_;
int n_gallery_{0};
float* d_gallery_{nullptr}; // (n_gallery_ x 512), resident for the lifetime of this node
float* d_query_{nullptr}; // (kMaxFaces x 512)
float* d_sims_{nullptr}; // (n_gallery_ x kMaxFaces), column-major
std::vector<float> host_sims_;
cudaStream_t stream_{nullptr};
cublasHandle_t handle_{nullptr};
}; };
-1
View File
@@ -79,7 +79,6 @@ static Config parse_args(int argc, char** argv) {
else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next()); else if (arg("--track-min-iou")) cfg.track_min_iou = std::stof(next());
else if (arg("--track-max-embed")) cfg.track_max_embed_dist = std::stof(next()); else if (arg("--track-max-embed")) cfg.track_max_embed_dist = std::stof(next());
else if (arg("--track-max-missing")) cfg.track_max_frames_missing = std::stoi(next()); else if (arg("--track-max-missing")) cfg.track_max_frames_missing = std::stoi(next());
else if (arg("--track-min-frames")) cfg.track_min_frames = std::stoi(next());
else if (arg("--anneal")) cfg.anneal_sec = std::stod(next()); else if (arg("--anneal")) cfg.anneal_sec = std::stod(next());
else if (arg("--trt-cache")) cfg.trt.cache_dir = next(); else if (arg("--trt-cache")) cfg.trt.cache_dir = next();
else if (arg("--trt-fp16")) cfg.trt.fp16 = true; else if (arg("--trt-fp16")) cfg.trt.fp16 = true;
+1 -4
View File
@@ -72,8 +72,7 @@ struct EmbeddedSceneFrame {
// ── Face tracking ───────────────────────────────────────────────────────────── // ── Face tracking ─────────────────────────────────────────────────────────────
// Output of FaceTrackerFunc — EmbeddedSceneFrame augmented with per-detection // Output of FaceTrackerFunc — EmbeddedSceneFrame augmented with per-detection
// track context. track_embeddings[i] is the L2-normalised running mean across // track context.
// the track's history; use it for identity matching when track_mature[i] is true.
struct TrackedSceneFrame { struct TrackedSceneFrame {
Frame source; Frame source;
@@ -81,8 +80,6 @@ struct TrackedSceneFrame {
std::vector<cv::Mat> crops; std::vector<cv::Mat> crops;
std::vector<int> track_ids; // -1 = brand-new track this frame std::vector<int> track_ids; // -1 = brand-new track this frame
std::vector<Embedding> embeddings; // per-frame raw (from embedder) std::vector<Embedding> embeddings; // per-frame raw (from embedder)
std::vector<Embedding> track_embeddings; // accumulated mean per track
std::vector<bool> track_mature; // true once track has ≥ min_frames obs.
}; };
// ── Identity matching ───────────────────────────────────────────────────────── // ── Identity matching ─────────────────────────────────────────────────────────