Skip to main content

qsm_core/models/
mod.rs

1//! Deep-learning model registry and weight management.
2//!
3//! Several QSM stages have deep-learning implementations (BFRnet, QSMnet,
4//! xQSM, χ-sepnet, …). QSM-Core runs them via **ONNX** rather than bundling a
5//! Python/PyTorch/TensorFlow runtime. The trained weights are *not* vendored in
6//! this crate — they are fetched on first use and cached on disk.
7//!
8//! This module is the single source of truth for *which* models exist and
9//! *where/how* to obtain their weights. It is split into three layers:
10//!
11//! - **Registry (always compiled).** [`ModelSpec`] / [`WeightFile`] describe each
12//!   model and its weight files (URL, SHA-256, size, license). [`all_models`] /
13//!   [`find_model`] query the table. This layer has no heavy dependencies and is
14//!   safe to expose through the WASM bindings so a JavaScript host (e.g. qsmbly)
15//!   can discover download URLs and fetch weights itself.
16//! - **Download & cache (`download` feature).** [`download::ensure_model`] fetches
17//!   any missing weight files over HTTP, verifies their SHA-256, and stores them
18//!   in a local cache. This is the path a native host (e.g. QSMxT) uses to
19//!   "download on use". WASM hosts skip this layer.
20//! - **Inference (`onnx` feature).** Runs an ONNX graph with the pure-Rust
21//!   [`tract`](https://docs.rs/tract-onnx) engine. The entry points take the
22//!   model as a **byte buffer** ([`onnx::OnnxModel::load`]) rather than a path,
23//!   so the *same* inference code runs natively and in WASM. A native host feeds
24//!   bytes from the download cache; a WASM host (e.g. qsmbly) fetches the weights
25//!   in JavaScript and passes the bytes back into WASM to run.
26//!
27//! The registry stays runtime-agnostic: `tract` is the default portable engine,
28//! but a host is free to read a model's URLs from the registry and run it any
29//! other way.
30//!
31//! ### Weight resolution order (native)
32//!
33//! 1. `$QSM_MODEL_DIR/<file>` — an explicit directory of local weight files
34//!    (bring-your-own-weights; also how gated models like χ-sepnet are supplied).
35//! 2. The on-disk cache ([`cache_dir`]), if the file is present and its SHA-256
36//!    matches.
37//! 3. Download from [`WeightFile::url`] into the cache (`download` feature).
38
39use std::path::PathBuf;
40
41mod registry;
42pub use registry::{all_models, find_model};
43
44#[cfg(feature = "download")]
45pub mod download;
46
47#[cfg(feature = "onnx")]
48pub mod onnx;
49
50/// The reconstruction stage a model implements.
51#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ModelStage {
54    /// Wrapped/unwrapped phase → local tissue field (joint unwrap + background removal).
55    PhaseToField,
56    /// Total field → local tissue field (background field removal).
57    BackgroundRemoval,
58    /// Local tissue field → susceptibility (dipole inversion).
59    DipoleInversion,
60    /// Total field or phase → susceptibility in one network (single-step).
61    SingleStep,
62    /// Inputs → paramagnetic/diamagnetic susceptibility (χ+ / χ−).
63    ChiSeparation,
64    /// Magnitude image → binary brain mask (brain extraction).
65    BrainExtraction,
66}
67
68/// The framework the weights were originally trained in (before ONNX export).
69#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum Framework {
72    /// Authored/exported directly as ONNX.
73    Onnx,
74    /// PyTorch `.pth`/`.pt` → exported with `torch.onnx.export`.
75    PyTorch,
76    /// TensorFlow/Keras → exported with `tf2onnx`.
77    TensorFlow,
78    /// MATLAB Deep Learning Toolbox → exported with `exportONNXNetwork`.
79    Matlab,
80}
81
82/// Whether a model's weights are hosted and ready to run.
83#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum WeightStatus {
86    /// Weights are converted, hosted at [`WeightFile::url`], and ready to fetch.
87    Available,
88    /// A recognized target whose ONNX weights are not yet converted/hosted.
89    /// [`WeightFile::url`]/[`WeightFile::sha256`] may be empty. Listed so hosts
90    /// can surface the roadmap and accept bring-your-own-weights via
91    /// `$QSM_MODEL_DIR`.
92    Pending,
93}
94
95/// One weight file a model needs at inference time (an exported `.onnx`, plus
96/// any auxiliary file such as normalization statistics).
97#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
98#[derive(Clone, Copy, Debug)]
99pub struct WeightFile {
100    /// Filename used as the local cache key. Also the name looked up under
101    /// `$QSM_MODEL_DIR`.
102    pub name: &'static str,
103    /// Direct download URL (OSF/Hugging Face/…). Empty while [`WeightStatus::Pending`].
104    pub url: &'static str,
105    /// Lowercase hex SHA-256 of the file, for integrity and cache validation.
106    /// Empty while [`WeightStatus::Pending`].
107    pub sha256: &'static str,
108    /// File size in bytes (0 if not yet known).
109    pub bytes: u64,
110}
111
112/// A deep-learning QSM model and everything needed to obtain and run it.
113#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
114#[derive(Clone, Copy, Debug)]
115pub struct ModelSpec {
116    /// Stable identifier, e.g. `"bfrnet"`, `"qsmnet"`. Used by [`find_model`] and
117    /// as the env-var suffix `$QSM_MODEL_DIR`.
118    pub id: &'static str,
119    /// Human-readable name, e.g. `"BFRnet"`.
120    pub name: &'static str,
121    /// Reconstruction stage this model implements.
122    pub stage: ModelStage,
123    /// Whether the weights are hosted and ready.
124    pub status: WeightStatus,
125    /// Framework the weights came from (before ONNX export).
126    pub origin: Framework,
127    /// One-line description of the method.
128    pub description: &'static str,
129    /// Reference (citation / DOI / arXiv).
130    pub paper: &'static str,
131    /// Upstream code repository.
132    pub source: &'static str,
133    /// License / redistribution note for the weights.
134    pub license: &'static str,
135    /// ONNX weight file(s) required at inference time.
136    pub files: &'static [WeightFile],
137    /// ONNX graph input tensor name(s), in the order the runner feeds them.
138    pub inputs: &'static [&'static str],
139    /// ONNX graph output tensor name(s).
140    pub outputs: &'static [&'static str],
141    /// Spatial dimensions must be zero-padded to a multiple of this before
142    /// inference (`0`/`1` = no constraint). Fully-convolutional nets with
143    /// pooling need e.g. 8 or 16.
144    pub size_divisor: u32,
145}
146
147impl ModelSpec {
148    /// `true` if every weight file has a non-empty URL and hash (i.e. hosted).
149    pub fn is_available(&self) -> bool {
150        self.status == WeightStatus::Available
151            && self.files.iter().all(|f| !f.url.is_empty() && !f.sha256.is_empty())
152    }
153}
154
155/// Root directory for cached weight files.
156///
157/// Resolved from, in order: `$QSM_MODEL_CACHE`, `$XDG_CACHE_HOME/qsm-rs/models`,
158/// `$HOME/.cache/qsm-rs/models`, else `./.qsm-rs-models`.
159pub fn cache_dir() -> PathBuf {
160    if let Ok(dir) = std::env::var("QSM_MODEL_CACHE") {
161        return PathBuf::from(dir);
162    }
163    if let Ok(dir) = std::env::var("XDG_CACHE_HOME") {
164        return PathBuf::from(dir).join("qsm-rs").join("models");
165    }
166    if let Ok(home) = std::env::var("HOME") {
167        return PathBuf::from(home).join(".cache").join("qsm-rs").join("models");
168    }
169    PathBuf::from(".qsm-rs-models")
170}
171
172/// The path a weight file would occupy in the cache (whether or not it exists).
173pub fn cache_path(file: &WeightFile) -> PathBuf {
174    cache_dir().join(file.name)
175}
176
177/// Find an already-present copy of `file` without downloading, honoring the
178/// `$QSM_MODEL_DIR` override first, then the cache. Returns `None` if absent.
179///
180/// This is std-only and works even without the `download` feature, so a host
181/// can supply bring-your-own-weights for pending or gated models.
182pub fn resolve_local(file: &WeightFile) -> Option<PathBuf> {
183    if let Ok(dir) = std::env::var("QSM_MODEL_DIR") {
184        let p = PathBuf::from(dir).join(file.name);
185        if p.is_file() {
186            return Some(p);
187        }
188    }
189    let p = cache_path(file);
190    if p.is_file() {
191        Some(p)
192    } else {
193        None
194    }
195}
196
197/// Read the bytes of a model's primary (first) weight file for native inference.
198///
199/// Resolution: a locally-supplied file (`$QSM_MODEL_DIR` / cache) is used first;
200/// otherwise, with the `download` feature, it is fetched and cached. Returns a
201/// human-readable error if the file is neither local nor downloadable.
202///
203/// WASM hosts do not call this — they obtain the bytes in JavaScript (using the
204/// registry's URLs) and pass them straight to [`onnx::OnnxModel::load`].
205pub fn primary_weight_bytes(spec: &ModelSpec) -> Result<Vec<u8>, String> {
206    let file = spec
207        .files
208        .first()
209        .ok_or_else(|| format!("model '{}' has no weight files", spec.id))?;
210    weight_file_bytes(spec.id, file)
211}
212
213/// Read every weight file of a model, in registry order, for native inference.
214///
215/// Multi-file models (e.g. NeXtQSM's BFR + VJP U-Nets) need all pieces; the order
216/// matches [`ModelSpec::files`], which the per-model glue relies on. Same
217/// local-first/download resolution as [`primary_weight_bytes`].
218pub fn all_weight_bytes(spec: &ModelSpec) -> Result<Vec<Vec<u8>>, String> {
219    if spec.files.is_empty() {
220        return Err(format!("model '{}' has no weight files", spec.id));
221    }
222    spec.files.iter().map(|f| weight_file_bytes(spec.id, f)).collect()
223}
224
225/// Convenience: resolve a model **by id** and read its primary weight file. Combines
226/// [`find_model`] + [`primary_weight_bytes`] so callers (pipeline runners, external
227/// hosts) don't repeat the lookup+fetch boilerplate. Errors if the id is unknown.
228pub fn primary_weight(id: &str) -> Result<Vec<u8>, String> {
229    let spec = find_model(id).ok_or_else(|| format!("'{id}' not in model registry"))?;
230    primary_weight_bytes(spec)
231}
232
233/// Convenience: resolve a model **by id** and read all its weight files, in registry
234/// order (for multi-file models like NeXtQSM). See [`all_weight_bytes`].
235pub fn weights(id: &str) -> Result<Vec<Vec<u8>>, String> {
236    let spec = find_model(id).ok_or_else(|| format!("'{id}' not in model registry"))?;
237    all_weight_bytes(spec)
238}
239
240/// Pre-fetch (download + cache) all of a model's weight files, reporting per-file
241/// progress via `on_progress(file_name, downloaded_bytes, total_bytes)`.
242///
243/// Intended for hosts (e.g. QSMxT) that want to show a download bar before running
244/// inference: call this first, then the normal `primary_weight`/`weights` path finds the
245/// cached files and does no further network I/O. Files already present (cache or
246/// `$QSM_MODEL_DIR`) are skipped silently. Unknown `id` (a non-model / classical
247/// algorithm) is a no-op — returns `Ok(())` so callers can invoke it unconditionally.
248pub fn prefetch_with_progress(
249    id: &str,
250    #[allow(unused_variables)] on_progress: &mut dyn FnMut(&str, u64, u64),
251) -> Result<(), String> {
252    let Some(spec) = find_model(id) else { return Ok(()) };
253    for file in spec.files {
254        if resolve_local(file).is_some() {
255            continue;
256        }
257        #[cfg(feature = "download")]
258        {
259            download::ensure_file_with_progress(id, file, &mut |done, total| {
260                on_progress(file.name, done, total)
261            })
262            .map_err(|e| e.to_string())?;
263        }
264        #[cfg(not(feature = "download"))]
265        {
266            return Err(format!(
267                "weights for '{id}' ('{}') are not local; build qsm-core with the 'download' \
268                 feature or set $QSM_MODEL_DIR",
269                file.name
270            ));
271        }
272    }
273    Ok(())
274}
275
276/// Resolve one weight file to its bytes (local override / cache, then download).
277fn weight_file_bytes(id: &str, file: &WeightFile) -> Result<Vec<u8>, String> {
278    if let Some(path) = resolve_local(file) {
279        return std::fs::read(&path).map_err(|e| format!("reading {}: {e}", path.display()));
280    }
281
282    #[cfg(feature = "download")]
283    {
284        download::ensure_file(id, file)
285            .map_err(|e| e.to_string())
286            .and_then(|p| std::fs::read(&p).map_err(|e| format!("reading {}: {e}", p.display())))
287    }
288    #[cfg(not(feature = "download"))]
289    {
290        Err(format!(
291            "weights for '{id}' ('{}') not found locally. Set $QSM_MODEL_DIR to a directory \
292             containing it, or build with the 'download' feature to fetch it.",
293            file.name
294        ))
295    }
296}