Skip to main content

chess_corners_ml/
lib.rs

1#![warn(missing_docs)]
2//! ONNX-backed ML refiner for ChESS corner candidates.
3//!
4//! `chess-corners-ml` is a support crate that provides ONNX inference
5//! for the `chess-corners` facade's optional `ml-refiner` feature. It
6//! is published to crates.io as a dependency of `chess-corners`, but
7//! it is not designed as a standalone API: its surface follows the
8//! facade's ML-refiner needs and remains pre-1.0 (`0.x`), so it may
9//! change in minor releases.
10//!
11//! This crate provides [`MlModel`], a thin wrapper around a
12//! [tract-onnx](https://docs.rs/tract-onnx) runtime that predicts
13//! subpixel `(dx, dy)` offsets for each corner candidate from a
14//! normalized intensity patch.
15//!
16//! # Intended use
17//!
18//! This crate is not meant to be used directly. It is consumed by the
19//! `chess-corners` facade crate when the `ml-refiner` feature is
20//! enabled. With the feature on, set the active ChESS refiner to
21//! `ChessRefiner::Ml` and call `Detector::detect` to route through
22//! the ML refiner.
23//!
24//! # Embedded model
25//!
26//! When the optional `embed-model` feature is enabled, the ONNX model
27//! and its external data file are compiled into the binary via
28//! `include_bytes!` and extracted to a temporary directory on first
29//! use. The extraction is thread-safe and idempotent (write-then-rename
30//! with byte-match skip).
31//!
32//! # Performance note
33//!
34//! ML refinement is significantly slower than the geometric refiners
35//! (~24 ms vs <1 ms for 77 corners on a 640×480 image). Use it only
36//! when maximum subpixel accuracy is required and throughput allows.
37
38use anyhow::{anyhow, Context, Result};
39use std::path::{Path, PathBuf};
40#[cfg(feature = "embed-model")]
41use std::sync::{Mutex, OnceLock};
42use tract_onnx::prelude::tract_ndarray::{Array4, Ix2};
43use tract_onnx::prelude::*;
44
45/// Specifies where [`MlModel::load`] should read the ONNX model from.
46#[derive(Clone, Debug)]
47pub enum ModelSource {
48    /// Load from an explicit filesystem path to the `.onnx` file.
49    /// A `fixtures/meta.json` sidecar next to the model's parent directory
50    /// is read to determine the patch size; falls back to the compiled-in
51    /// default (21 px) when absent.
52    Path(PathBuf),
53    /// Use the model compiled into the binary via the `embed-model`
54    /// Cargo feature. Returns an error when that feature is not enabled.
55    EmbeddedDefault,
56}
57
58/// Loaded and optimised ONNX model for corner refinement.
59///
60/// The model accepts a batch of `f32` intensity patches with shape
61/// `[N, 1, patch_size, patch_size]` (values in `[0, 1]`) and returns
62/// `[N, 3]` with columns `[dx, dy, conf_logit]`. Only `dx` and `dy`
63/// are currently used; `conf_logit` is ignored.
64pub struct MlModel {
65    model: TypedRunnableModel<TypedModel>,
66    patch_size: usize,
67    // `SymbolScope` owns the `Symbol` object for the dynamic batch
68    // dimension "N". Dropping it before `model` would leave the compiled
69    // graph with a dangling reference to the scope's internal table, so
70    // this field must be kept alive for the lifetime of `MlModel` even
71    // though it is never explicitly read after construction.
72    #[allow(dead_code)]
73    symbols: SymbolScope,
74}
75
76impl MlModel {
77    /// Load and optimise an ONNX model from the given source.
78    ///
79    /// For [`ModelSource::EmbeddedDefault`] the `embed-model` Cargo
80    /// feature must be enabled; an error is returned otherwise.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the model file cannot be read, the ONNX
85    /// graph is malformed, or tract optimisation / compilation fails.
86    pub fn load(source: ModelSource) -> Result<Self> {
87        let (model_path, patch_size) = match source {
88            ModelSource::Path(path) => {
89                let patch_size =
90                    patch_size_from_meta_path(&path).unwrap_or_else(default_patch_size);
91                (path, patch_size)
92            }
93            ModelSource::EmbeddedDefault => {
94                #[cfg(feature = "embed-model")]
95                {
96                    let patch_size = patch_size_from_meta_bytes(EMBED_META_JSON)
97                        .unwrap_or_else(|_| default_patch_size());
98                    let path = embedded_model_path()?;
99                    (path, patch_size)
100                }
101                #[cfg(not(feature = "embed-model"))]
102                {
103                    return Err(anyhow!(
104                        "embedded model support disabled; enable feature \"embed-model\""
105                    ));
106                }
107            }
108        };
109
110        let mut model = tract_onnx::onnx()
111            .model_for_path(&model_path)
112            .with_context(|| format!("load ONNX model from {}", model_path.display()))?;
113        let symbols = SymbolScope::default();
114        let batch = symbols.sym("N");
115        let shape = tvec!(
116            batch.to_dim(),
117            1.to_dim(),
118            (patch_size as i64).to_dim(),
119            (patch_size as i64).to_dim()
120        );
121        model
122            .set_input_fact(0, InferenceFact::dt_shape(f32::datum_type(), shape))
123            .context("set ML refiner input fact")?;
124        let model = model
125            .into_optimized()
126            .context("optimize ONNX model")?
127            .into_runnable()
128            .context("make ONNX model runnable")?;
129
130        Ok(Self {
131            model,
132            patch_size,
133            symbols,
134        })
135    }
136
137    /// Side length (in pixels) of the square intensity patch the model expects.
138    pub fn patch_size(&self) -> usize {
139        self.patch_size
140    }
141
142    /// Run inference on a flat batch of intensity patches.
143    ///
144    /// `patches` must contain exactly `batch * patch_size * patch_size`
145    /// `f32` values in `[N, 1, H, W]` order (values in `[0, 1]`).
146    /// Returns one `[dx, dy, conf_logit]` triple per input patch.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the slice length does not match
151    /// `batch * patch_size²`, if the ONNX output shape is unexpected,
152    /// or if tract inference fails.
153    pub fn infer_batch(&self, patches: &[f32], batch: usize) -> Result<Vec<[f32; 3]>> {
154        if batch == 0 {
155            return Ok(Vec::new());
156        }
157        let patch_area = self.patch_size * self.patch_size;
158        let expected = batch * patch_area;
159        if patches.len() != expected {
160            return Err(anyhow!(
161                "expected {} floats (batch {} * patch {}x{}), got {}",
162                expected,
163                batch,
164                self.patch_size,
165                self.patch_size,
166                patches.len()
167            ));
168        }
169
170        let input = Array4::from_shape_vec(
171            (batch, 1, self.patch_size, self.patch_size),
172            patches.to_vec(),
173        )
174        .context("reshape input patches")?
175        .into_tensor();
176        let result = self
177            .model
178            .run(tvec!(input.into_tvalue()))
179            .context("run ONNX inference")?;
180        let output = result[0]
181            .to_array_view::<f32>()
182            .context("read ONNX output")?
183            .into_dimensionality::<Ix2>()
184            .context("reshape ONNX output")?;
185
186        if output.ncols() != 3 {
187            return Err(anyhow!(
188                "expected output shape [N,3], got [N,{}]",
189                output.ncols()
190            ));
191        }
192
193        let mut out = Vec::with_capacity(batch);
194        for row in output.outer_iter() {
195            out.push([row[0], row[1], row[2]]);
196        }
197        Ok(out)
198    }
199}
200
201fn patch_size_from_meta_bytes(bytes: &[u8]) -> Result<usize> {
202    let meta: serde_json::Value =
203        serde_json::from_slice(bytes).context("parse ML refiner meta.json")?;
204    let size = meta
205        .get("patch_size")
206        .and_then(|v| v.as_u64())
207        .ok_or_else(|| anyhow!("meta.json missing patch_size"))?;
208    Ok(size as usize)
209}
210
211fn patch_size_from_meta_path(path: &Path) -> Option<usize> {
212    let meta_path = path.parent()?.join("fixtures").join("meta.json");
213    let bytes = std::fs::read(meta_path).ok()?;
214    patch_size_from_meta_bytes(&bytes).ok()
215}
216
217fn default_patch_size() -> usize {
218    #[cfg(feature = "embed-model")]
219    {
220        patch_size_from_meta_bytes(EMBED_META_JSON).unwrap_or(21)
221    }
222    #[cfg(not(feature = "embed-model"))]
223    {
224        21
225    }
226}
227
228#[cfg(feature = "embed-model")]
229const EMBED_ONNX_NAME: &str = "chess_refiner_v4.onnx";
230#[cfg(feature = "embed-model")]
231const EMBED_ONNX_DATA_NAME: &str = "chess_refiner_v4.onnx.data";
232
233#[cfg(feature = "embed-model")]
234const EMBED_ONNX: &[u8] = include_bytes!(concat!(
235    env!("CARGO_MANIFEST_DIR"),
236    "/assets/ml/chess_refiner_v4.onnx"
237));
238#[cfg(feature = "embed-model")]
239const EMBED_ONNX_DATA: &[u8] = include_bytes!(concat!(
240    env!("CARGO_MANIFEST_DIR"),
241    "/assets/ml/chess_refiner_v4.onnx.data"
242));
243#[cfg(feature = "embed-model")]
244const EMBED_META_JSON: &[u8] = include_bytes!(concat!(
245    env!("CARGO_MANIFEST_DIR"),
246    "/assets/ml/fixtures/v4/meta.json"
247));
248
249#[cfg(feature = "embed-model")]
250fn embedded_model_path() -> Result<PathBuf> {
251    // Serializing the write phase across threads in this process is load
252    // bearing. Without it, parallel `#[test]` runs all entered
253    // `write_if_changed`, the second `std::fs::write` truncated the
254    // file to 0 bytes mid-rewrite, and a concurrent `tract_onnx`
255    // model load saw an empty `.data` slice and panicked
256    // (`range start index 768 out of range for slice of length 0`).
257    //
258    // For cross-process races (e.g. `cargo test -p A` and
259    // `cargo test -p B` sharing `/tmp/chess_corners_ml/`), the
260    // atomic write-then-rename in `write_if_changed` ensures the
261    // file is either at its old contents or at its new contents,
262    // never partially written.
263    //
264    // `OnceLock::get_or_try_init` (which would express this directly) is
265    // still nightly-only (`once_cell_try`), and this crate ships to
266    // stable-toolchain consumers, so init is hand-rolled as a
267    // double-checked lock: `PATH.get()` is the fast, lock-free path once
268    // initialized; `INIT_LOCK` serializes the (rare) first-time write so
269    // a temp-dir I/O failure returns `Err` instead of panicking.
270    static PATH: OnceLock<PathBuf> = OnceLock::new();
271    static INIT_LOCK: Mutex<()> = Mutex::new(());
272
273    if let Some(path) = PATH.get() {
274        return Ok(path.clone());
275    }
276
277    let _guard = INIT_LOCK
278        .lock()
279        .unwrap_or_else(|poisoned| poisoned.into_inner());
280    if let Some(path) = PATH.get() {
281        return Ok(path.clone());
282    }
283
284    let dir = std::env::temp_dir().join("chess_corners_ml");
285    std::fs::create_dir_all(&dir).context("create ML model temp dir")?;
286    let onnx_path = dir.join(EMBED_ONNX_NAME);
287    let data_path = dir.join(EMBED_ONNX_DATA_NAME);
288    // Write `.data` before `.onnx` so tract never sees an `.onnx`
289    // that references a missing or partially-written `.data`.
290    write_if_changed(&data_path, EMBED_ONNX_DATA).context("write embedded ONNX data")?;
291    write_if_changed(&onnx_path, EMBED_ONNX).context("write embedded ONNX model")?;
292    // `set` cannot fail: `INIT_LOCK` is still held, and `get()` was
293    // just re-checked above.
294    let _ = PATH.set(onnx_path.clone());
295    Ok(onnx_path)
296}
297
298/// Write `data` to `path` only if the file doesn't already contain
299/// the same bytes. Uses write-then-rename so concurrent readers see
300/// either the old contents or the new contents — never a truncated /
301/// partially-written file. Cheap-out via the byte-match check avoids
302/// rewriting unchanged files across re-runs in a shared temp dir.
303#[cfg(feature = "embed-model")]
304fn write_if_changed(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
305    if let Ok(meta) = std::fs::metadata(path) {
306        if meta.len() == data.len() as u64 {
307            if let Ok(existing) = std::fs::read(path) {
308                if existing == data {
309                    return Ok(());
310                }
311            }
312        }
313    }
314    let tmp = path.with_extension("tmp");
315    std::fs::write(&tmp, data)?;
316    std::fs::rename(&tmp, path)
317}
318
319#[cfg(all(test, feature = "embed-model"))]
320mod tests {
321    use super::write_if_changed;
322
323    #[test]
324    fn write_if_changed_rewrites_same_size_changed_bytes() {
325        let dir = tempfile::tempdir().expect("tempdir");
326        let path = dir.path().join("model.bin");
327
328        write_if_changed(&path, b"abc").expect("initial write");
329        write_if_changed(&path, b"xyz").expect("rewrite same-size bytes");
330
331        let bytes = std::fs::read(&path).expect("read rewritten bytes");
332        assert_eq!(bytes, b"xyz");
333    }
334}