docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
152 lines
6.2 KiB
Rust
152 lines
6.2 KiB
Rust
//! TRACES: FR-MRG-8
|
||
//! The XFeat detector — the network under tract, and the decoder after it.
|
||
//!
|
||
//! Apache-2.0 weights (`models/LICENCE.md`), exported at a fixed shape by
|
||
//! `tools/export-xfeat.sh` and loaded through the same `dr-inference-engine`
|
||
//! `dr-segment` and `dr-face` use, so this adds no runtime and no C to the
|
||
//! tree; what runs it is the device's business (docs/dev/inference.md). ~300 ms
|
||
//! per frame on tract on the reference desktop, ~400 ms on the tablet
|
||
//! (S15.2, S15.4).
|
||
|
||
use crate::features::{decode_xfeat, DecodeOptions, Features, XFeatMaps, DESCRIPTOR_LEN};
|
||
use crate::image::Gray;
|
||
use crate::PanoError;
|
||
|
||
/// The two input shapes the shipped exports were made for: one landscape,
|
||
/// one portrait, the same weights. A frame is fitted into whichever
|
||
/// matches its aspect, so a portrait set does not spend half the
|
||
/// detector's width on padding — which is what the 6D fixture did before
|
||
/// the second export existed (512 × 768 of a 1024 × 768 input). A
|
||
/// different size is a different file (`tools/export-xfeat.sh`).
|
||
pub const INPUT_LANDSCAPE: (usize, usize) = (1024, 768);
|
||
pub const INPUT_PORTRAIT: (usize, usize) = (768, 1024);
|
||
|
||
/// The long edge of the detector's input, for callers sizing a proxy.
|
||
pub const INPUT_LONG_EDGE: usize = 1024;
|
||
|
||
#[cfg(feature = "embedded-model")]
|
||
const EMBEDDED_LANDSCAPE: &[u8] = include_bytes!("../../../models/keypoints/xfeat-1024.onnx");
|
||
#[cfg(feature = "embedded-model")]
|
||
const EMBEDDED_PORTRAIT: &[u8] = include_bytes!("../../../models/keypoints/xfeat-768.onnx");
|
||
|
||
/// A loaded detector: the network at both shapes.
|
||
pub struct XFeat {
|
||
landscape: dr_inference_engine::Model,
|
||
portrait: dr_inference_engine::Model,
|
||
pub options: DecodeOptions,
|
||
}
|
||
|
||
/// The bytes of both exports compiled into the binary, for whoever compiles
|
||
/// engines ahead of the first request (docs/dev/inference.md §6).
|
||
#[cfg(feature = "embedded-model")]
|
||
pub fn embedded_model_bytes() -> [&'static [u8]; 2] {
|
||
[EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT]
|
||
}
|
||
|
||
impl XFeat {
|
||
/// The weights compiled into the binary.
|
||
#[cfg(feature = "embedded-model")]
|
||
pub fn embedded() -> Result<Self, PanoError> {
|
||
Self::from_bytes(EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT)
|
||
}
|
||
|
||
/// From the two exports on disk.
|
||
pub fn from_paths(
|
||
landscape: &std::path::Path,
|
||
portrait: &std::path::Path,
|
||
) -> Result<Self, PanoError> {
|
||
let l = std::fs::read(landscape).map_err(PanoError::ModelRead)?;
|
||
let p = std::fs::read(portrait).map_err(PanoError::ModelRead)?;
|
||
Self::from_bytes(&l, &p)
|
||
}
|
||
|
||
pub fn from_bytes(landscape: &[u8], portrait: &[u8]) -> Result<Self, PanoError> {
|
||
use dr_inference_engine::{Form, Role};
|
||
Ok(XFeat {
|
||
landscape: dr_inference_engine::open(Role::Keypoints, Form::F32, landscape)?,
|
||
portrait: dr_inference_engine::open(Role::Keypoints, Form::F32, portrait)?,
|
||
options: DecodeOptions::default(),
|
||
})
|
||
}
|
||
|
||
/// Detect keypoints in an upright grayscale image.
|
||
///
|
||
/// The image is fitted into the network's input of matching aspect —
|
||
/// scaled down if larger, never up, and padded to the right and bottom
|
||
/// — and the keypoints come back in the coordinates of `image` itself,
|
||
/// so a caller that already scaled a frame to a proxy maps them on with
|
||
/// the scale it used and nothing else.
|
||
pub fn detect(&mut self, image: &Gray) -> Result<Features, PanoError> {
|
||
let ((in_w, in_h), model) = if image.height > image.width {
|
||
(INPUT_PORTRAIT, &self.portrait)
|
||
} else {
|
||
(INPUT_LANDSCAPE, &self.landscape)
|
||
};
|
||
let acquired = model.acquire()?;
|
||
let mut session = acquired.lock();
|
||
let (fitted, scale) = image.fitted(in_w, in_h);
|
||
let padded = fitted.padded(in_w, in_h);
|
||
|
||
let input =
|
||
ndarray::Array::from_shape_vec(ndarray::IxDyn(&[1, 1, in_h, in_w]), padded.data)
|
||
.expect("shape matches the buffer by construction");
|
||
let tensor = ort::value::Tensor::from_array(input).map_err(PanoError::Inference)?;
|
||
let outputs = session
|
||
.run(ort::inputs![tensor])
|
||
.map_err(PanoError::Inference)?;
|
||
|
||
let (w8, h8) = (in_w / 8, in_h / 8);
|
||
let expect = |i: usize, channels: usize| -> Result<Vec<f32>, PanoError> {
|
||
let (shape, data) = outputs[i]
|
||
.try_extract_tensor::<f32>()
|
||
.map_err(PanoError::Inference)?;
|
||
let dims: Vec<i64> = shape.iter().copied().collect();
|
||
if dims != [1, channels as i64, h8 as i64, w8 as i64] {
|
||
return Err(PanoError::Model(format!(
|
||
"output {i} is {dims:?}, expected [1, {channels}, {h8}, {w8}] — \
|
||
not the export this decoder was written for"
|
||
)));
|
||
}
|
||
Ok(data.to_vec())
|
||
};
|
||
let feats = expect(0, DESCRIPTOR_LEN)?;
|
||
let keypoints = expect(1, 65)?;
|
||
let heatmap = expect(2, 1)?;
|
||
|
||
let mut features = decode_xfeat(
|
||
&XFeatMaps {
|
||
feats: &feats,
|
||
keypoints: &keypoints,
|
||
heatmap: &heatmap,
|
||
width: w8,
|
||
height: h8,
|
||
},
|
||
&self.options,
|
||
);
|
||
|
||
// Back to the caller's image: drop anything the padding produced,
|
||
// undo the fit.
|
||
let border = self.options.border as f32;
|
||
let limit_x = fitted.width as f32 - border;
|
||
let limit_y = fitted.height as f32 - border;
|
||
let mut kept_kp = Vec::with_capacity(features.len());
|
||
let mut kept_desc = Vec::with_capacity(features.descriptors.len());
|
||
for (i, kp) in features.keypoints.iter().enumerate() {
|
||
if kp.x >= limit_x || kp.y >= limit_y {
|
||
continue;
|
||
}
|
||
kept_kp.push(crate::features::Keypoint {
|
||
x: (kp.x / scale as f32),
|
||
y: (kp.y / scale as f32),
|
||
score: kp.score,
|
||
});
|
||
kept_desc.extend_from_slice(features.descriptor(i));
|
||
}
|
||
features.keypoints = kept_kp;
|
||
features.descriptors = kept_desc;
|
||
features.width = image.width;
|
||
features.height = image.height;
|
||
Ok(features)
|
||
}
|
||
}
|