Skip to content

Commit 2a47468

Browse files
authored
refactor(web): extract JS results payload into payload module (#249)
Signed-off-by: Onuralp SEZER <onuralp@ultralytics.com>
1 parent 54caf0c commit 2a47468

3 files changed

Lines changed: 267 additions & 241 deletions

File tree

crates/web/src/lib.rs

Lines changed: 7 additions & 232 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//!
55
//! Runs YOLO ONNX models in the browser on WebGPU via ONNX Runtime Web (bridged
66
//! by [`ort-web`](https://ort.pyke.io/backends/web)), reusing the core crate's
7-
//! preprocessing/postprocessing so results match the native and Python paths.
7+
//! preprocessing/postprocessing so results match the native path.
88
//! The published npm package wraps this behind a small `YOLO` class. The whole
99
//! crate is gated to `wasm32`; elsewhere it compiles to an empty library.
1010
#![cfg(target_arch = "wasm32")]
@@ -26,14 +26,17 @@ use ultralytics_inference::postprocessing::postprocess;
2626
use ultralytics_inference::preprocessing::{
2727
preprocess_image_center_crop, preprocess_image_with_precision,
2828
};
29-
use ultralytics_inference::results::{Results, SemanticMask, Speed};
29+
use ultralytics_inference::results::Speed;
3030
use ultralytics_inference::visualizer::color::Color;
3131
use ultralytics_inference::visualizer::skeleton::{
3232
KPT_COLOR_INDICES, LIMB_COLOR_INDICES, SKELETON,
3333
};
3434
use ultralytics_inference::{InferenceConfig, Task};
3535

36+
use payload::JsResults;
37+
3638
mod onnx_meta;
39+
mod payload;
3740

3841
/// Default inference image size used when a model does not record `imgsz` in its
3942
/// metadata. Mirrors the native crate's fallback.
@@ -206,8 +209,7 @@ impl YoloModel {
206209
///
207210
/// Initializes the accelerated backend on first use, reads the embedded
208211
/// Ultralytics metadata from the model bytes, then commits an ONNX Runtime
209-
/// session on the requested execution provider. Equivalent to Python's
210-
/// `YOLO('model.onnx')`.
212+
/// session on the requested execution provider.
211213
///
212214
/// `device` is `"webgpu"` or `"cpu"` (the browser picks the GPU adapter
213215
/// automatically). WebGPU is registered with `error_on_failure`, so if the
@@ -452,236 +454,9 @@ fn to_js<T: Serialize>(value: &T, what: &str) -> Result<JsValue, JsError> {
452454
.map_err(|e| JsError::new(&format!("failed to serialize {what}: {e}")))
453455
}
454456

455-
/// One detected box, mirroring `Boxes` in the Ultralytics API (pixel `xyxy`).
456-
/// `color` is the Ultralytics palette color for the class (`#rrggbb`).
457-
#[derive(Serialize)]
458-
struct JsBox {
459-
x1: f32,
460-
y1: f32,
461-
x2: f32,
462-
y2: f32,
463-
conf: f32,
464-
cls: usize,
465-
name: String,
466-
color: String,
467-
}
468-
469-
/// One oriented box (`xywhr` + score), mirroring `Obb`.
470-
#[derive(Serialize)]
471-
struct JsObb {
472-
x: f32,
473-
y: f32,
474-
w: f32,
475-
h: f32,
476-
angle: f32,
477-
conf: f32,
478-
cls: usize,
479-
name: String,
480-
color: String,
481-
}
482-
483-
/// Keypoints for one detection (`[[x, y, conf], ...]`), with the instance color.
484-
#[derive(Serialize)]
485-
struct JsKeypoints {
486-
points: Vec<[f32; 3]>,
487-
color: String,
488-
}
489-
490-
/// Per-stage timing in ms, mirroring `Speed` (decode+letterbox / inference+sync / decode+NMS).
491-
#[derive(Serialize)]
492-
struct JsSpeed {
493-
preprocess: f64,
494-
inference: f64,
495-
postprocess: f64,
496-
}
497-
498-
/// Classification probabilities, mirroring `Probs`.
499-
#[derive(Serialize)]
500-
struct JsProbs {
501-
top1: usize,
502-
top5: Vec<usize>,
503-
top1conf: f32,
504-
top5conf: Vec<f32>,
505-
name: String,
506-
color: String,
507-
}
508-
509-
/// JS-facing results payload, mirroring the Ultralytics `Results` API. Per-task
510-
/// fields are empty/`null` when unused.
511-
#[derive(Serialize)]
512-
struct JsResults {
513-
task: String,
514-
width: u32,
515-
height: u32,
516-
boxes: Vec<JsBox>,
517-
obb: Vec<JsObb>,
518-
keypoints: Vec<JsKeypoints>,
519-
probs: Option<JsProbs>,
520-
/// Segment masks as a translucent colored `RGBA` overlay (`width*height*4`);
521-
/// empty for other tasks. A `Uint8Array`, drawable straight onto a canvas.
522-
#[serde(with = "serde_bytes")]
523-
masks: Vec<u8>,
524-
/// Semantic class id per pixel, little-endian `u16` (`width*height*2` bytes),
525-
/// row-major; empty for other tasks. The TS wrapper reinterprets it as a
526-
/// `Uint16Array`. The IGNORE sentinel `65535` marks background or
527-
/// class-filtered pixels.
528-
#[serde(with = "serde_bytes")]
529-
semantic: Vec<u8>,
530-
/// Per-stage timing in ms: `{ preprocess, inference, postprocess }`.
531-
speed: JsSpeed,
532-
}
533-
534-
impl JsResults {
535-
/// Convert core [`Results`] into the serializable JS payload, labeling it with
536-
/// the model's declared `task` (the caller already holds it, so there is no
537-
/// need to guess it back from which result fields are populated).
538-
fn from_results(r: &Results, task: Task) -> Self {
539-
// Class id -> display name and palette color, shared by every detection type.
540-
let name = |c: usize| r.names.get(&c).cloned().unwrap_or_default();
541-
let hex = |c: usize| Color::from_index(c).to_hex();
542-
543-
let boxes = r.boxes.as_ref().map_or_else(Vec::new, |b| {
544-
let (xyxy, conf, cls) = (b.xyxy(), b.conf(), b.cls());
545-
(0..b.len())
546-
.map(|i| {
547-
let c = cls[i] as usize;
548-
JsBox {
549-
x1: xyxy[[i, 0]],
550-
y1: xyxy[[i, 1]],
551-
x2: xyxy[[i, 2]],
552-
y2: xyxy[[i, 3]],
553-
conf: conf[i],
554-
cls: c,
555-
name: name(c),
556-
color: hex(c),
557-
}
558-
})
559-
.collect()
560-
});
561-
562-
let obb = r.obb.as_ref().map_or_else(Vec::new, |o| {
563-
let (xywhr, conf, cls) = (o.xywhr(), o.conf(), o.cls());
564-
(0..conf.len())
565-
.map(|i| {
566-
let c = cls[i] as usize;
567-
JsObb {
568-
x: xywhr[[i, 0]],
569-
y: xywhr[[i, 1]],
570-
w: xywhr[[i, 2]],
571-
h: xywhr[[i, 3]],
572-
angle: xywhr[[i, 4]],
573-
conf: conf[i],
574-
cls: c,
575-
name: name(c),
576-
color: hex(c),
577-
}
578-
})
579-
.collect()
580-
});
581-
582-
let keypoints = r.keypoints.as_ref().map_or_else(Vec::new, |k| {
583-
let (n, npt) = (k.data.shape()[0], k.data.shape()[1]);
584-
(0..n)
585-
.map(|i| JsKeypoints {
586-
points: (0..npt)
587-
.map(|j| [k.data[[i, j, 0]], k.data[[i, j, 1]], k.data[[i, j, 2]]])
588-
.collect(),
589-
// Skeleton uses the matching detection's color.
590-
color: boxes.get(i).map_or_else(|| hex(0), |b| b.color.clone()),
591-
})
592-
.collect()
593-
});
594-
595-
let probs = r.probs.as_ref().map(|p| JsProbs {
596-
top1: p.top1(),
597-
top5: p.top5(),
598-
top1conf: p.top1conf(),
599-
top5conf: p.top5conf(),
600-
name: name(p.top1()),
601-
color: hex(p.top1()),
602-
});
603-
604-
Self {
605-
task: task.as_str().to_owned(),
606-
width: r.orig_shape.1,
607-
height: r.orig_shape.0,
608-
boxes,
609-
obb,
610-
keypoints,
611-
probs,
612-
masks: build_mask_overlay(r),
613-
semantic: r.semantic_mask.as_ref().map_or_else(Vec::new, |sem| {
614-
let mut bytes = Vec::with_capacity(sem.data.len() * 2);
615-
for &v in &sem.data {
616-
bytes.extend_from_slice(&v.to_le_bytes());
617-
}
618-
bytes
619-
}),
620-
speed: JsSpeed {
621-
preprocess: r.speed.preprocess.unwrap_or(0.0),
622-
inference: r.speed.inference.unwrap_or(0.0),
623-
postprocess: r.speed.postprocess.unwrap_or(0.0),
624-
},
625-
}
626-
}
627-
}
628-
629-
/// Write one colored RGBA pixel at `(x, y)` into a `width`-wide buffer.
630-
fn put(buf: &mut [u8], w: usize, x: usize, y: usize, c: Color, a: u8) {
631-
let i = (y * w + x) * 4;
632-
buf[i..i + 4].copy_from_slice(&[c.0, c.1, c.2, a]);
633-
}
634-
635-
/// Build a translucent colored RGBA overlay (`width*height*4`) from the segment
636-
/// instance masks or the semantic class map, colored by the class palette.
637-
/// Empty for other tasks or if the mask resolution does not match the image.
638-
fn build_mask_overlay(r: &Results) -> Vec<u8> {
639-
let (h, w) = (r.orig_shape.0 as usize, r.orig_shape.1 as usize);
640-
let mut buf = vec![0u8; w * h * 4];
641-
642-
// Segment: per-instance binary masks, each colored by its detection class.
643-
if let (Some(masks), Some(boxes)) = (&r.masks, &r.boxes) {
644-
let (n, mh, mw) = masks.data.dim();
645-
if mh != h || mw != w {
646-
return Vec::new();
647-
}
648-
let cls = boxes.cls();
649-
for i in 0..n.min(cls.len()) {
650-
let c = Color::from_index(cls[i] as usize);
651-
for y in 0..h {
652-
for x in 0..w {
653-
if masks.data[[i, y, x]] > 0.5 {
654-
put(&mut buf, w, x, y, c, 140);
655-
}
656-
}
657-
}
658-
}
659-
return buf;
660-
}
661-
662-
// Semantic: per-pixel class map, blended 50/50 like the native renderer.
663-
if let Some(sem) = &r.semantic_mask {
664-
if sem.data.dim() != (h, w) {
665-
return Vec::new();
666-
}
667-
for y in 0..h {
668-
for x in 0..w {
669-
// Filtered-out classes carry the IGNORE sentinel; leave transparent.
670-
let cls = sem.data[[y, x]];
671-
if cls != SemanticMask::IGNORE {
672-
put(&mut buf, w, x, y, Color::from_index(cls as usize), 128);
673-
}
674-
}
675-
}
676-
return buf;
677-
}
678-
679-
Vec::new()
680-
}
681-
682457
/// The Ultralytics pose drawing scheme: skeleton connectivity plus the per-limb
683458
/// and per-keypoint palette colors. Lets the JS annotator draw pose exactly like
684-
/// the native/Python renderer without duplicating any palette.
459+
/// the native renderer without duplicating any palette.
685460
#[derive(Serialize)]
686461
struct PoseScheme {
687462
/// Keypoint index pairs connected by limbs.

0 commit comments

Comments
 (0)