Skip to main content

mecha10_models/services/model_service/
catalog.rs

1//! Model catalog schema, live fetch, 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!`. `mecha10-api` now serves this same table live over
9//! HTTP (`GET /api/models/catalog` server-side, reached via `{mecha10_api_base_url}/models/catalog`
10//! client-side since that base URL already bakes in `/api` - public/unauthenticated,
11//! `mecha10-industries/mecha10-api`'s `models::registry::catalog` module) - that's the single
12//! source of truth now, so [`fetch_catalog`] always fetches it fresh. There is no bundled/
13//! compiled-in fallback: a project with no network access to `mecha10-api` can't resolve
14//! catalog models at all, same as it already couldn't resolve a `"registry"`-sourced model
15//! without network access.
16
17use anyhow::{Context, Result};
18use mecha10_core::model::PreprocessingConfig;
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::time::Duration;
22
23/// `GET {mecha10_api_base_url}/models/catalog` - the live public model catalog. Public,
24/// unauthenticated, no caching here (every call is a fresh HTTP round-trip) and no fallback -
25/// see the module doc comment. A bounded 10s timeout keeps this failing fast (rather than
26/// hanging) when `mecha10-api` is unreachable - e.g. `ModelService::info`/`list_installed`
27/// treat this call as best-effort and need it to fail quickly, not stall. Goes through
28/// [`mecha10_cli_core::paths::urls::mecha10_api_path`] (same helper `download::pull_from_repo`
29/// uses) rather than hand-joining `mecha10_api_base_url()` with the path itself - that used to
30/// be two independent, inconsistent `format!`s and is exactly how this endpoint ended up
31/// requested at `.../api/api/models/catalog` once already.
32pub async fn fetch_catalog() -> Result<Vec<ModelCatalogEntry>> {
33    let url = mecha10_cli_core::paths::urls::mecha10_api_path("/models/catalog");
34
35    let client = reqwest::Client::builder()
36        .user_agent("mecha10-cli")
37        .timeout(Duration::from_secs(10))
38        .build()
39        .context("Failed to build HTTP client")?;
40
41    let response = client
42        .get(&url)
43        .send()
44        .await
45        .context(format!("Failed to fetch model catalog from {url}"))?
46        .error_for_status()
47        .context(format!("Model catalog request to {url} failed"))?;
48
49    response
50        .json::<Vec<ModelCatalogEntry>>()
51        .await
52        .context(format!("Malformed model catalog response from {url}"))
53}
54
55/// Model catalog entry, as served by `GET /api/models/catalog`
56#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
57pub struct ModelCatalogEntry {
58    /// Model identifier (e.g., "yolov8n-face", "mobilenet-v2")
59    #[schemars(example = &"yolov8n-face")]
60    pub name: String,
61    /// Human-readable description
62    #[schemars(example = &"YOLOv8 Nano - Fast face detection")]
63    pub description: String,
64    /// Task type (e.g., "object-detection", "image-classification")
65    #[schemars(example = &"object-detection")]
66    pub task: String,
67    /// HuggingFace repository (e.g., "deepghs/yolo-face")
68    #[schemars(example = &"deepghs/yolo-face")]
69    pub repo: String,
70    /// Filename within the repo (e.g., "yolov8n-face/model.onnx")
71    #[schemars(example = &"yolov8n-face/model.onnx")]
72    pub filename: String,
73
74    /// Optional preprocessing preset hint (e.g., "imagenet", "yolo", "coco")
75    #[serde(default)]
76    #[schemars(example = &"yolo")]
77    pub preprocessing_preset: Option<String>,
78
79    /// Optional small class lists (e.g., ["face"] for face detection)
80    #[serde(default)]
81    #[schemars(example = vec!["face".to_string()])]
82    pub classes: Vec<String>,
83
84    /// Optional automatic quantization configuration
85    #[serde(default)]
86    #[schemars(example = Some(QuantizeConfig { enabled: true, method: "dynamic_int8".to_string() }))]
87    pub quantize: Option<QuantizeConfig>,
88
89    /// Explicit `[width, height]` override for the model's input tensor size - takes
90    /// precedence over auto-detection (HuggingFace `preprocessor_config.json`, then ONNX
91    /// graph introspection) in `preprocessing::auto_detect_preprocessing`. Most entries leave
92    /// this `None`; it exists for models where auto-detection is unreliable, e.g. a non-square
93    /// re-id backbone that would otherwise silently fall back to the generic 224x224 default.
94    #[serde(default)]
95    #[schemars(example = Some([128_u32, 256_u32]))]
96    pub input_size: Option<[u32; 2]>,
97}
98
99/// Quantization configuration for automatic INT8 conversion
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101pub struct QuantizeConfig {
102    /// Enable automatic quantization after download
103    #[schemars(example = true)]
104    pub enabled: bool,
105    /// Quantization method (currently only "dynamic_int8" supported)
106    #[schemars(example = &"dynamic_int8")]
107    pub method: String,
108}
109
110#[derive(Debug, Clone, Copy)]
111pub enum PreprocessingPreset {
112    ImageNet,
113    Yolo,
114    Coco,
115    Zero255,
116}
117
118impl PreprocessingPreset {
119    /// Parse preset from string name
120    pub fn from_name(name: &str) -> Result<Self> {
121        match name.to_lowercase().as_str() {
122            "imagenet" => Ok(Self::ImageNet),
123            "yolo" => Ok(Self::Yolo),
124            "coco" => Ok(Self::Coco),
125            "zero255" | "0-255" => Ok(Self::Zero255),
126            _ => anyhow::bail!("Unknown preprocessing preset: {}", name),
127        }
128    }
129
130    /// Get preprocessing configuration for this preset
131    pub fn to_config(self) -> PreprocessingConfig {
132        match self {
133            Self::ImageNet | Self::Coco => PreprocessingConfig {
134                mean: [0.485, 0.456, 0.406],
135                std: [0.229, 0.224, 0.225],
136                channel_order: "RGB".to_string(),
137            },
138            Self::Yolo => PreprocessingConfig {
139                mean: [0.0, 0.0, 0.0],
140                std: [255.0, 255.0, 255.0],
141                channel_order: "RGB".to_string(),
142            },
143            Self::Zero255 => PreprocessingConfig {
144                mean: [0.0, 0.0, 0.0],
145                std: [1.0, 1.0, 1.0],
146                channel_order: "RGB".to_string(),
147            },
148        }
149    }
150}
151
152#[cfg(feature = "model-download")]
153#[derive(Debug, Clone, Deserialize)]
154pub(super) struct HFPreprocessorConfig {
155    #[serde(default)]
156    pub(super) image_mean: Option<Vec<f32>>,
157    #[serde(default)]
158    pub(super) image_std: Option<Vec<f32>>,
159    #[serde(default)]
160    pub(super) size: Option<HFSize>,
161    #[serde(default)]
162    pub(super) crop_size: Option<HFSize>,
163}
164
165#[cfg(feature = "model-download")]
166#[derive(Debug, Clone, Deserialize)]
167#[serde(untagged)]
168pub(super) enum HFSize {
169    Dict { height: u32, width: u32 },
170    ShortestEdge { shortest_edge: u32 },
171    Single(u32),
172}
173
174#[cfg(feature = "model-download")]
175impl HFPreprocessorConfig {
176    pub(super) fn to_preprocessing(&self) -> PreprocessingConfig {
177        PreprocessingConfig {
178            mean: [
179                self.image_mean.as_ref().and_then(|v| v.first()).copied().unwrap_or(0.0),
180                self.image_mean.as_ref().and_then(|v| v.get(1)).copied().unwrap_or(0.0),
181                self.image_mean.as_ref().and_then(|v| v.get(2)).copied().unwrap_or(0.0),
182            ],
183            std: [
184                self.image_std.as_ref().and_then(|v| v.first()).copied().unwrap_or(1.0),
185                self.image_std.as_ref().and_then(|v| v.get(1)).copied().unwrap_or(1.0),
186                self.image_std.as_ref().and_then(|v| v.get(2)).copied().unwrap_or(1.0),
187            ],
188            channel_order: "RGB".to_string(),
189        }
190    }
191
192    pub(super) fn input_size(&self) -> Option<[u32; 2]> {
193        if let Some(crop_size) = &self.crop_size {
194            return match crop_size {
195                HFSize::Dict { height, width } => Some([*width, *height]),
196                HFSize::ShortestEdge { shortest_edge } => Some([*shortest_edge, *shortest_edge]),
197                HFSize::Single(s) => Some([*s, *s]),
198            };
199        }
200
201        match &self.size {
202            Some(HFSize::Dict { height, width }) => Some([*width, *height]),
203            Some(HFSize::ShortestEdge { shortest_edge }) => Some([*shortest_edge, *shortest_edge]),
204            Some(HFSize::Single(s)) => Some([*s, *s]),
205            None => None,
206        }
207    }
208}