Answer M1: tract loads both face graphs once their dims are pinned

Neither InsightFace export parses as shipped -- SCRFD fails at its input
node, ArcFace at the first Conv -- which is the same wall dr-segment hit
on YOLO's dynamic export. Both load cleanly with the input dims frozen,
so the pure-Rust runtime holds for the face pipeline too.

tools/fix-face-model-shapes.sh does the freezing, and exists so the
artefact is reproducible rather than a binary someone once produced. It
takes two forms because the two graphs need different ones: ArcFace's
batch is a named dim_param, SCRFD's H and W are dynamic but unnamed.

Also notes YuNet loading with no intervention, which matters for the
licence question in faces.md 2.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:51:02 +02:00
co-authored by Claude Opus 5
parent ec740115b6
commit 72410f39c6
9 changed files with 435 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
//! SCRFD face detection (docs/faces.md §4).
//!
//! For now: enough of the loader to answer M1 — whether tract will parse these
//! graphs at all — plus the load-time shape validation that keeps a YuNet file
//! from being decoded as an SCRFD one.
use crate::{install_backend, FaceError};
/// A loaded SCRFD graph.
pub struct Detector {
session: ort::session::Session,
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
///
/// Discovered from the output count rather than assumed, because both
/// exports exist and hardcoding 3 silently ignores the largest faces in a
/// four-stride model.
strides: usize,
}
/// Strides, in the order SCRFD emits them.
pub const ALL_STRIDES: [usize; 4] = [8, 16, 32, 64];
/// Anchors per feature-map location.
pub const ANCHORS: usize = 2;
impl Detector {
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
Self::from_bytes(&bytes)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
install_backend();
let session = ort::session::Session::builder()
.map_err(FaceError::Inference)?
.commit_from_memory(bytes)
.map_err(FaceError::Inference)?;
let n_out = session.outputs().len();
if n_out % 3 != 0 || !(9..=12).contains(&n_out) {
return Err(FaceError::WrongModel {
expected: "InsightFace SCRFD",
detail: format!("expected 9 or 12 outputs, got {n_out}"),
});
}
let strides = n_out / 3;
// The check that actually distinguishes the models: score, box and
// landmark groups end in 1, 4 and 10 respectively. YuNet also has
// twelve outputs, so the count alone proves nothing.
for (group, expected_last) in [1_i64, 4, 10].into_iter().enumerate() {
for s in 0..strides {
let idx = group * strides + s;
let out = &session.outputs()[idx];
let last: Option<i64> =
out.dtype().tensor_shape().and_then(|d| d.last().copied());
if last != Some(expected_last) {
return Err(FaceError::WrongModel {
expected: "InsightFace SCRFD",
detail: format!(
"output '{}' last dim is {:?}, expected {expected_last}",
out.name(), last
),
});
}
}
}
Ok(Self { session, strides })
}
/// Number of stride levels this graph emits.
pub fn strides(&self) -> &'static [usize] {
&ALL_STRIDES[..self.strides]
}
/// The graph's declared input shape, for diagnosing a dynamic export.
pub fn input_shape(&self) -> Option<Vec<i64>> {
self.session
.inputs()
.first()?
.dtype()
.tensor_shape()
.map(|s| s.to_vec())
}
}