Skip to content

Commit 54caf0c

Browse files
authored
refactor(web): extract ONNX metadata parser into onnx_meta (#248)
Signed-off-by: Onuralp SEZER <onuralp@ultralytics.com>
1 parent 30d893c commit 54caf0c

2 files changed

Lines changed: 122 additions & 102 deletions

File tree

crates/web/src/lib.rs

Lines changed: 27 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ use ultralytics_inference::visualizer::skeleton::{
3333
};
3434
use ultralytics_inference::{InferenceConfig, Task};
3535

36+
mod onnx_meta;
37+
3638
/// Default inference image size used when a model does not record `imgsz` in its
3739
/// metadata. Mirrors the native crate's fallback.
3840
const DEFAULT_IMGSZ: usize = 640;
@@ -154,7 +156,7 @@ async fn ensure_backend(ort_base_url: Option<String>, webgpu: bool) -> Result<()
154156
ort_web::api(feature).await
155157
}
156158
}
157-
.map_err(|e| JsError::new(&format!("failed to initialize ort-web backend: {e}")))?;
159+
.map_err(err_ctx("failed to initialize ort-web backend"))?;
158160
ort::set_api(api);
159161
BACKEND.with(|b| *b.borrow_mut() = Some(requested));
160162
Ok(())
@@ -167,7 +169,7 @@ async fn ensure_backend(ort_base_url: Option<String>, webgpu: bool) -> Result<()
167169
/// from the model protobuf, rebuild the `key: value` YAML the native path uses,
168170
/// and hand it to the shared parser.
169171
fn build_metadata(model_bytes: &[u8]) -> Result<ModelMetadata, JsError> {
170-
let props = parse_metadata_props(model_bytes);
172+
let props = onnx_meta::parse_metadata_props(model_bytes);
171173
if props.is_empty() {
172174
return Err(JsError::new(
173175
"no metadata found in ONNX model. Export it with Ultralytics \
@@ -180,92 +182,7 @@ fn build_metadata(model_bytes: &[u8]) -> Result<ModelMetadata, JsError> {
180182
.map(|(k, v)| format!("{k}: {v}"))
181183
.collect::<Vec<_>>()
182184
.join("\n");
183-
ModelMetadata::from_yaml_str(&combined)
184-
.map_err(|e| JsError::new(&format!("failed to parse model metadata: {e}")))
185-
}
186-
187-
/// Read a protobuf base-128 varint at `pos`, advancing it. Returns `None` on a
188-
/// truncated/oversized value.
189-
fn read_varint(buf: &[u8], pos: &mut usize) -> Option<u64> {
190-
let mut result = 0u64;
191-
let mut shift = 0u32;
192-
loop {
193-
let byte = *buf.get(*pos)?;
194-
*pos += 1;
195-
result |= u64::from(byte & 0x7f) << shift;
196-
if byte & 0x80 == 0 {
197-
return Some(result);
198-
}
199-
shift += 7;
200-
if shift >= 64 {
201-
return None;
202-
}
203-
}
204-
}
205-
206-
/// Read the next protobuf field at `pos`, advancing it. Returns the field number
207-
/// and, for length-delimited fields (wire type 2), the payload bytes; varint and
208-
/// fixed-width fields are skipped and yield `None` payload. Returns `None` at the
209-
/// end of the buffer or on a malformed field.
210-
fn read_field<'a>(buf: &'a [u8], pos: &mut usize) -> Option<(u64, Option<&'a [u8]>)> {
211-
let tag = read_varint(buf, pos)?;
212-
let payload = match tag & 7 {
213-
0 => {
214-
read_varint(buf, pos)?;
215-
None
216-
}
217-
1 => {
218-
*pos += 8;
219-
None
220-
}
221-
5 => {
222-
*pos += 4;
223-
None
224-
}
225-
2 => {
226-
let len = read_varint(buf, pos)? as usize;
227-
let sub = buf.get(*pos..*pos + len)?;
228-
*pos += len;
229-
Some(sub)
230-
}
231-
_ => return None,
232-
};
233-
Some((tag >> 3, payload))
234-
}
235-
236-
/// Extract `ModelProto.metadata_props` (field 14, repeated
237-
/// `StringStringEntryProto`) into a key/value map. Other fields (including the
238-
/// large graph) are skipped without being decoded.
239-
fn parse_metadata_props(buf: &[u8]) -> HashMap<String, String> {
240-
let mut map = HashMap::new();
241-
let mut pos = 0;
242-
while let Some((field, payload)) = read_field(buf, &mut pos) {
243-
if field == 14
244-
&& let Some(sub) = payload
245-
&& let Some((key, value)) = parse_string_string_entry(sub)
246-
{
247-
map.insert(key, value);
248-
}
249-
}
250-
map
251-
}
252-
253-
/// Parse a `StringStringEntryProto` (field 1 = key, field 2 = value).
254-
fn parse_string_string_entry(buf: &[u8]) -> Option<(String, String)> {
255-
let mut pos = 0;
256-
let mut key = None;
257-
let mut value = None;
258-
while let Some((field, payload)) = read_field(buf, &mut pos) {
259-
if let Some(sub) = payload {
260-
let text = String::from_utf8_lossy(sub).into_owned();
261-
match field {
262-
1 => key = Some(text),
263-
2 => value = Some(text),
264-
_ => {}
265-
}
266-
}
267-
}
268-
Some((key?, value.unwrap_or_default()))
185+
ModelMetadata::from_yaml_str(&combined).map_err(err_ctx("failed to parse model metadata"))
269186
}
270187

271188
/// A loaded YOLO model ready for inference in the browser.
@@ -322,7 +239,7 @@ impl YoloModel {
322239
#[wasm_bindgen(getter)]
323240
#[must_use]
324241
pub fn task(&self) -> String {
325-
format!("{:?}", self.metadata.task).to_lowercase()
242+
self.metadata.task.as_str().to_owned()
326243
}
327244

328245
/// The active device: `"webgpu"` or `"cpu"` (the fallback when WebGPU is
@@ -339,8 +256,7 @@ impl YoloModel {
339256
/// Returns a JS error only if serialization fails (not expected).
340257
#[wasm_bindgen(getter)]
341258
pub fn names(&self) -> Result<JsValue, JsError> {
342-
serde_wasm_bindgen::to_value(&*self.metadata.names)
343-
.map_err(|e| JsError::new(&format!("failed to serialize names: {e}")))
259+
to_js(&*self.metadata.names, "names")
344260
}
345261

346262
/// Run inference on a single encoded image (JPEG or PNG bytes).
@@ -359,8 +275,7 @@ impl YoloModel {
359275
iou: f32,
360276
classes: Option<Vec<u32>>,
361277
) -> Result<JsValue, JsError> {
362-
let dynimg = image::load_from_memory(&image)
363-
.map_err(|e| JsError::new(&format!("failed to decode image: {e}")))?;
278+
let dynimg = image::load_from_memory(&image).map_err(err_ctx("failed to decode image"))?;
364279
self.run(dynimg, conf, iou, classes).await
365280
}
366281

@@ -411,7 +326,7 @@ impl YoloModel {
411326
builder
412327
.commit_from_memory(bytes)
413328
.await
414-
.map_err(|e| JsError::new(&format!("failed to load model from bytes: {e}")))
329+
.map_err(err_ctx("failed to load model from bytes"))
415330
}
416331

417332
/// Finish constructing a model from a committed session and its parsed
@@ -456,7 +371,7 @@ impl YoloModel {
456371
let rgb = dynimg.to_rgb8();
457372
let (w, h) = rgb.dimensions();
458373
let orig_img = Array3::from_shape_vec((h as usize, w as usize, 3), rgb.into_raw())
459-
.map_err(|e| JsError::new(&format!("failed to build image array: {e}")))?;
374+
.map_err(err_ctx("failed to build image array"))?;
460375

461376
// Classification uses center-crop (like Ultralytics); all other tasks
462377
// use letterbox. Both share the f32 NCHW output.
@@ -474,11 +389,11 @@ impl YoloModel {
474389
.session
475390
.run_async(ort::inputs![input.view()], &run_options)
476391
.await
477-
.map_err(|e| JsError::new(&format!("inference failed: {e}")))?;
392+
.map_err(err_ctx("inference failed"))?;
478393
// Outputs live in the ONNX Runtime wasm context; copy them back to Rust.
479394
sync_outputs(&mut outputs)
480395
.await
481-
.map_err(|e| JsError::new(&format!("failed to sync outputs: {e}")))?;
396+
.map_err(err_ctx("failed to sync outputs"))?;
482397

483398
// Borrow each output's data directly (no copy, important for the large
484399
// semantic logits, ~160 MB) and feed it to the shared postprocessor while
@@ -515,8 +430,7 @@ impl YoloModel {
515430
self.metadata.kpt_shape,
516431
);
517432
let payload = JsResults::from_results(&results, self.metadata.task);
518-
serde_wasm_bindgen::to_value(&payload)
519-
.map_err(|e| JsError::new(&format!("failed to serialize results: {e}")))
433+
to_js(&payload, "results")
520434
}
521435
}
522436

@@ -526,6 +440,18 @@ fn map_ort<M>(e: ort::Error<M>) -> JsError {
526440
JsError::new(&format!("ort error: {e}"))
527441
}
528442

443+
/// Build a `.map_err` closure that prefixes any displayable error with `context`,
444+
/// so call sites read `.map_err(err_ctx("inference failed"))`.
445+
fn err_ctx<E: std::fmt::Display>(context: &'static str) -> impl FnOnce(E) -> JsError {
446+
move |e| JsError::new(&format!("{context}: {e}"))
447+
}
448+
449+
/// Serialize a value to a `JsValue`, mapping a failure to a JS error naming `what`.
450+
fn to_js<T: Serialize>(value: &T, what: &str) -> Result<JsValue, JsError> {
451+
serde_wasm_bindgen::to_value(value)
452+
.map_err(|e| JsError::new(&format!("failed to serialize {what}: {e}")))
453+
}
454+
529455
/// One detected box, mirroring `Boxes` in the Ultralytics API (pixel `xyxy`).
530456
/// `color` is the Ultralytics palette color for the class (`#rrggbb`).
531457
#[derive(Serialize)]
@@ -676,7 +602,7 @@ impl JsResults {
676602
});
677603

678604
Self {
679-
task: format!("{task:?}").to_lowercase(),
605+
task: task.as_str().to_owned(),
680606
width: r.orig_shape.1,
681607
height: r.orig_shape.0,
682608
boxes,
@@ -784,8 +710,7 @@ pub fn pose_palette() -> Result<JsValue, JsError> {
784710
.map(|&i| Color::from_pose_index(i).to_hex())
785711
.collect(),
786712
};
787-
serde_wasm_bindgen::to_value(&scheme)
788-
.map_err(|e| JsError::new(&format!("failed to serialize pose palette: {e}")))
713+
to_js(&scheme, "pose palette")
789714
}
790715

791716
/// Install a panic hook that logs Rust panics to the browser console.

crates/web/src/onnx_meta.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
2+
3+
//! Minimal ONNX `ModelProto` reader for the Ultralytics `metadata_props`.
4+
//!
5+
//! `ort-web` does not implement ONNX session metadata retrieval, so the browser
6+
//! path reads the `metadata_props` (key/value pairs such as `task`, `names`,
7+
//! `imgsz`) straight from the model protobuf. Only field 14 is decoded; the large
8+
//! graph fields are skipped without being parsed. These helpers are pure
9+
//! (`&[u8] -> map`) and carry no wasm/JS types.
10+
11+
use std::collections::HashMap;
12+
13+
/// Read a protobuf base-128 varint at `pos`, advancing it. Returns `None` on a
14+
/// truncated/oversized value.
15+
fn read_varint(buf: &[u8], pos: &mut usize) -> Option<u64> {
16+
let mut result = 0u64;
17+
let mut shift = 0u32;
18+
loop {
19+
let byte = *buf.get(*pos)?;
20+
*pos += 1;
21+
result |= u64::from(byte & 0x7f) << shift;
22+
if byte & 0x80 == 0 {
23+
return Some(result);
24+
}
25+
shift += 7;
26+
if shift >= 64 {
27+
return None;
28+
}
29+
}
30+
}
31+
32+
/// Read the next protobuf field at `pos`, advancing it. Returns the field number
33+
/// and, for length-delimited fields (wire type 2), the payload bytes; varint and
34+
/// fixed-width fields are skipped and yield `None` payload. Returns `None` at the
35+
/// end of the buffer or on a malformed field.
36+
fn read_field<'a>(buf: &'a [u8], pos: &mut usize) -> Option<(u64, Option<&'a [u8]>)> {
37+
let tag = read_varint(buf, pos)?;
38+
let payload = match tag & 7 {
39+
0 => {
40+
read_varint(buf, pos)?;
41+
None
42+
}
43+
1 => {
44+
*pos += 8;
45+
None
46+
}
47+
5 => {
48+
*pos += 4;
49+
None
50+
}
51+
2 => {
52+
let len = read_varint(buf, pos)? as usize;
53+
let sub = buf.get(*pos..*pos + len)?;
54+
*pos += len;
55+
Some(sub)
56+
}
57+
_ => return None,
58+
};
59+
Some((tag >> 3, payload))
60+
}
61+
62+
/// Extract `ModelProto.metadata_props` (field 14, repeated
63+
/// `StringStringEntryProto`) into a key/value map. Other fields (including the
64+
/// large graph) are skipped without being decoded.
65+
pub(crate) fn parse_metadata_props(buf: &[u8]) -> HashMap<String, String> {
66+
let mut map = HashMap::new();
67+
let mut pos = 0;
68+
while let Some((field, payload)) = read_field(buf, &mut pos) {
69+
if field == 14
70+
&& let Some(sub) = payload
71+
&& let Some((key, value)) = parse_string_string_entry(sub)
72+
{
73+
map.insert(key, value);
74+
}
75+
}
76+
map
77+
}
78+
79+
/// Parse a `StringStringEntryProto` (field 1 = key, field 2 = value).
80+
fn parse_string_string_entry(buf: &[u8]) -> Option<(String, String)> {
81+
let mut pos = 0;
82+
let mut key = None;
83+
let mut value = None;
84+
while let Some((field, payload)) = read_field(buf, &mut pos) {
85+
if let Some(sub) = payload {
86+
let text = String::from_utf8_lossy(sub).into_owned();
87+
match field {
88+
1 => key = Some(text),
89+
2 => value = Some(text),
90+
_ => {}
91+
}
92+
}
93+
}
94+
Some((key?, value.unwrap_or_default()))
95+
}

0 commit comments

Comments
 (0)