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}