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