Skip to main content

mecha10_models/services/model_service/
catalog.rs

1//! Model catalog schema, static compiled-in list, and HuggingFace preprocessor config parsing
2//!
3//! Types for the public model catalog's entries, the auto-quantization config attached to a
4//! catalog entry, the built-in preprocessing presets, and the schema used to parse a
5//! HuggingFace `preprocessor_config.json`.
6//!
7//! The catalog itself used to be a TOML file (`model_catalog.toml`) compiled directly into
8//! this CLI binary via `include_str!`, then later a live `GET /api/models/catalog` call to
9//! `mecha10-api` (`mecha10-industries/mecha10-api`'s `models::registry::catalog` module). Both
10//! of `mecha10-api`'s public model routes (`/api/models/catalog` and
11//! `/api/downloads/model/{repo}/{filename}`) have since been removed - the catalog is nothing
12//! but a list of public HuggingFace repos, so there's no reason to route either the listing or
13//! the actual model bytes through `mecha10-api` at all. [`catalog_entries`] is now the single
14//! source of truth: a static, compiled-in `Vec`, mirrored from the same public models every
15//! entry already names on HuggingFace Hub (`download::pull_from_repo` fetches the artifact
16//! itself directly from `https://huggingface.co/{repo}/resolve/main/{filename}`). No network
17//! call is needed to list or resolve a catalog model by name anymore.
18
19use anyhow::Result;
20use mecha10_core::model::PreprocessingConfig;
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24fn entry(
25    name: &str,
26    description: &str,
27    task: &str,
28    repo: &str,
29    filename: &str,
30    preprocessing_preset: Option<&str>,
31    classes: &[&str],
32) -> ModelCatalogEntry {
33    ModelCatalogEntry {
34        name: name.to_string(),
35        description: description.to_string(),
36        task: task.to_string(),
37        repo: repo.to_string(),
38        filename: filename.to_string(),
39        preprocessing_preset: preprocessing_preset.map(|p| p.to_string()),
40        classes: classes.iter().map(|c| c.to_string()).collect(),
41        quantize: None,
42        input_size: None,
43    }
44}
45
46/// The public model catalog - a static, compiled-in list of public HuggingFace repos. This is
47/// the sole source of truth (see the module doc comment); no network call, caching, or
48/// fallback logic is involved in producing it.
49pub fn catalog_entries() -> Vec<ModelCatalogEntry> {
50    vec![
51        entry(
52            "yolov8n-face",
53            "YOLOv8 Nano - Fast face detection",
54            "object-detection",
55            "deepghs/yolo-face",
56            "yolov8n-face/model.onnx",
57            Some("yolo"),
58            &["face"],
59        ),
60        entry(
61            "yolov8m-face",
62            "YOLOv8 Medium - Better accuracy face detection",
63            "object-detection",
64            "deepghs/yolo-face",
65            "yolov8m-face/model.onnx",
66            Some("yolo"),
67            &["face"],
68        ),
69        entry(
70            "yolov8n",
71            "YOLOv8 Nano - Fast object detection (80 COCO classes)",
72            "object-detection",
73            "deepghs/yolos",
74            "yolov8n/model.onnx",
75            Some("yolo"),
76            &[],
77        ),
78        entry(
79            "yolov8s",
80            "YOLOv8 Small - Balanced speed/accuracy (80 COCO classes)",
81            "object-detection",
82            "deepghs/yolos",
83            "yolov8s/model.onnx",
84            Some("yolo"),
85            &[],
86        ),
87        entry(
88            "yolov8m",
89            "YOLOv8 Medium - Better accuracy (80 COCO classes)",
90            "object-detection",
91            "deepghs/yolos",
92            "yolov8m/model.onnx",
93            Some("yolo"),
94            &[],
95        ),
96        entry(
97            "yolov8l",
98            "YOLOv8 Large - High accuracy (80 COCO classes)",
99            "object-detection",
100            "deepghs/yolos",
101            "yolov8l/model.onnx",
102            Some("yolo"),
103            &[],
104        ),
105        entry(
106            "yolov8x",
107            "YOLOv8 Extra-Large - Best accuracy (80 COCO classes)",
108            "object-detection",
109            "deepghs/yolos",
110            "yolov8x/model.onnx",
111            Some("yolo"),
112            &[],
113        ),
114        entry(
115            "mobilenet-v2",
116            "MobileNetV2 - Fast image classification (1000 ImageNet classes)",
117            "image-classification",
118            "onnx-community/mobilenet_v2_0.75_160",
119            "onnx/model.onnx",
120            Some("imagenet"),
121            &[],
122        ),
123        entry(
124            "resnet18",
125            "ResNet-18 - Image classification (1000 ImageNet classes)",
126            "image-classification",
127            "onnx-community/resnet-18",
128            "onnx/model.onnx",
129            Some("imagenet"),
130            &[],
131        ),
132        ModelCatalogEntry {
133            // OSNet's export fixes a non-square 256x128 (h, w) input, unlike the generic
134            // ImageNet 224x224 square default `extract_input_size_from_onnx` falls back to
135            // when it can't introspect the ONNX graph - which, for this model, is always
136            // (that extraction is still a stub). Without this override, DeepSORT's reference
137            // embedding extraction sends the model a [1,3,224,224] tensor and the ONNX
138            // runtime rejects it outright.
139            input_size: Some([128, 256]),
140            ..entry(
141                "osnet_x0_25",
142                "OSNet x0.25 - Lightweight person re-identification features for DeepSORT (512-dim, MSMT17)",
143                "feature-extraction",
144                "anriha/osnet_x0_25_msmt17",
145                "osnet_x0_25_msmt17.onnx",
146                Some("imagenet"),
147                &[],
148            )
149        },
150    ]
151}
152
153/// Model catalog entry, as returned by [`catalog_entries`]
154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155pub struct ModelCatalogEntry {
156    /// Model identifier (e.g., "yolov8n-face", "mobilenet-v2")
157    #[schemars(example = &"yolov8n-face")]
158    pub name: String,
159    /// Human-readable description
160    #[schemars(example = &"YOLOv8 Nano - Fast face detection")]
161    pub description: String,
162    /// Task type (e.g., "object-detection", "image-classification")
163    #[schemars(example = &"object-detection")]
164    pub task: String,
165    /// HuggingFace repository (e.g., "deepghs/yolo-face")
166    #[schemars(example = &"deepghs/yolo-face")]
167    pub repo: String,
168    /// Filename within the repo (e.g., "yolov8n-face/model.onnx")
169    #[schemars(example = &"yolov8n-face/model.onnx")]
170    pub filename: String,
171
172    /// Optional preprocessing preset hint (e.g., "imagenet", "yolo", "coco")
173    #[serde(default)]
174    #[schemars(example = &"yolo")]
175    pub preprocessing_preset: Option<String>,
176
177    /// Optional small class lists (e.g., ["face"] for face detection)
178    #[serde(default)]
179    #[schemars(example = vec!["face".to_string()])]
180    pub classes: Vec<String>,
181
182    /// Optional automatic quantization configuration
183    #[serde(default)]
184    #[schemars(example = Some(QuantizeConfig { enabled: true, method: "dynamic_int8".to_string() }))]
185    pub quantize: Option<QuantizeConfig>,
186
187    /// Explicit `[width, height]` override for the model's input tensor size - takes
188    /// precedence over auto-detection (HuggingFace `preprocessor_config.json`, then ONNX
189    /// graph introspection) in `preprocessing::auto_detect_preprocessing`. Most entries leave
190    /// this `None`; it exists for models where auto-detection is unreliable, e.g. a non-square
191    /// re-id backbone that would otherwise silently fall back to the generic 224x224 default.
192    #[serde(default)]
193    #[schemars(example = Some([128_u32, 256_u32]))]
194    pub input_size: Option<[u32; 2]>,
195}
196
197/// Quantization configuration for automatic INT8 conversion
198#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
199pub struct QuantizeConfig {
200    /// Enable automatic quantization after download
201    #[schemars(example = true)]
202    pub enabled: bool,
203    /// Quantization method (currently only "dynamic_int8" supported)
204    #[schemars(example = &"dynamic_int8")]
205    pub method: String,
206}
207
208#[derive(Debug, Clone, Copy)]
209pub enum PreprocessingPreset {
210    ImageNet,
211    Yolo,
212    Coco,
213    Zero255,
214}
215
216impl PreprocessingPreset {
217    /// Parse preset from string name
218    pub fn from_name(name: &str) -> Result<Self> {
219        match name.to_lowercase().as_str() {
220            "imagenet" => Ok(Self::ImageNet),
221            "yolo" => Ok(Self::Yolo),
222            "coco" => Ok(Self::Coco),
223            "zero255" | "0-255" => Ok(Self::Zero255),
224            _ => anyhow::bail!("Unknown preprocessing preset: {}", name),
225        }
226    }
227
228    /// Get preprocessing configuration for this preset
229    pub fn to_config(self) -> PreprocessingConfig {
230        match self {
231            Self::ImageNet | Self::Coco => PreprocessingConfig {
232                mean: [0.485, 0.456, 0.406],
233                std: [0.229, 0.224, 0.225],
234                channel_order: "RGB".to_string(),
235            },
236            Self::Yolo => PreprocessingConfig {
237                mean: [0.0, 0.0, 0.0],
238                std: [255.0, 255.0, 255.0],
239                channel_order: "RGB".to_string(),
240            },
241            Self::Zero255 => PreprocessingConfig {
242                mean: [0.0, 0.0, 0.0],
243                std: [1.0, 1.0, 1.0],
244                channel_order: "RGB".to_string(),
245            },
246        }
247    }
248}
249
250#[cfg(feature = "model-download")]
251#[derive(Debug, Clone, Deserialize)]
252pub(super) struct HFPreprocessorConfig {
253    #[serde(default)]
254    pub(super) image_mean: Option<Vec<f32>>,
255    #[serde(default)]
256    pub(super) image_std: Option<Vec<f32>>,
257    #[serde(default)]
258    pub(super) size: Option<HFSize>,
259    #[serde(default)]
260    pub(super) crop_size: Option<HFSize>,
261}
262
263#[cfg(feature = "model-download")]
264#[derive(Debug, Clone, Deserialize)]
265#[serde(untagged)]
266pub(super) enum HFSize {
267    Dict { height: u32, width: u32 },
268    ShortestEdge { shortest_edge: u32 },
269    Single(u32),
270}
271
272#[cfg(feature = "model-download")]
273impl HFPreprocessorConfig {
274    pub(super) fn to_preprocessing(&self) -> PreprocessingConfig {
275        PreprocessingConfig {
276            mean: [
277                self.image_mean.as_ref().and_then(|v| v.first()).copied().unwrap_or(0.0),
278                self.image_mean.as_ref().and_then(|v| v.get(1)).copied().unwrap_or(0.0),
279                self.image_mean.as_ref().and_then(|v| v.get(2)).copied().unwrap_or(0.0),
280            ],
281            std: [
282                self.image_std.as_ref().and_then(|v| v.first()).copied().unwrap_or(1.0),
283                self.image_std.as_ref().and_then(|v| v.get(1)).copied().unwrap_or(1.0),
284                self.image_std.as_ref().and_then(|v| v.get(2)).copied().unwrap_or(1.0),
285            ],
286            channel_order: "RGB".to_string(),
287        }
288    }
289
290    pub(super) fn input_size(&self) -> Option<[u32; 2]> {
291        if let Some(crop_size) = &self.crop_size {
292            return match crop_size {
293                HFSize::Dict { height, width } => Some([*width, *height]),
294                HFSize::ShortestEdge { shortest_edge } => Some([*shortest_edge, *shortest_edge]),
295                HFSize::Single(s) => Some([*s, *s]),
296            };
297        }
298
299        match &self.size {
300            Some(HFSize::Dict { height, width }) => Some([*width, *height]),
301            Some(HFSize::ShortestEdge { shortest_edge }) => Some([*shortest_edge, *shortest_edge]),
302            Some(HFSize::Single(s)) => Some([*s, *s]),
303            None => None,
304        }
305    }
306}
307
308#[cfg(test)]
309mod catalog_entries_tests {
310    use super::*;
311
312    #[test]
313    fn catalog_entries_are_non_empty_and_uniquely_named() {
314        let entries = catalog_entries();
315        assert!(!entries.is_empty());
316
317        let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
318        names.sort();
319        names.dedup();
320        assert_eq!(names.len(), entries.len(), "catalog entry names must be unique");
321    }
322
323    #[test]
324    fn yolov8n_has_no_classes_override_meaning_default_coco() {
325        let entries = catalog_entries();
326        let yolov8n = entries.iter().find(|e| e.name == "yolov8n").expect("yolov8n entry");
327        assert!(yolov8n.classes.is_empty());
328    }
329
330    #[test]
331    fn face_models_carry_an_explicit_small_class_list() {
332        let entries = catalog_entries();
333        let face_model = entries
334            .iter()
335            .find(|e| e.name == "yolov8n-face")
336            .expect("yolov8n-face entry");
337        assert_eq!(face_model.classes, vec!["face".to_string()]);
338    }
339
340    #[test]
341    fn osnet_carries_an_explicit_non_square_input_size_override() {
342        let entries = catalog_entries();
343        let osnet = entries
344            .iter()
345            .find(|e| e.name == "osnet_x0_25")
346            .expect("osnet_x0_25 entry");
347        assert_eq!(osnet.input_size, Some([128, 256]));
348    }
349
350    #[test]
351    fn every_entry_resolves_to_a_valid_preprocessing_preset() {
352        let entries = catalog_entries();
353        for entry in &entries {
354            let preset = entry
355                .preprocessing_preset
356                .as_deref()
357                .unwrap_or_else(|| panic!("entry '{}' has no preprocessing_preset", entry.name));
358            PreprocessingPreset::from_name(preset).unwrap_or_else(|_| {
359                panic!(
360                    "entry '{}' has an invalid preprocessing preset '{}'",
361                    entry.name, preset
362                )
363            });
364        }
365    }
366}