Vision (ONNX Runtime Web)
On-device computer vision — right in the browser, with no inference server
and no image upload anywhere. The tempest-react-sdk/vision subpath runs three
classic tasks on ONNX models: classification (what is this image?),
detection (where are the objects?), and instance segmentation (what is
the exact outline of each object?) — plus the fused detect→classify pipeline
in a single file.
The API is the same for all of them: you create an object with
await Task.create(model, options), call predict(image), and get back an array
of results — one per image. Learn one, you know all four.
import { Detector } from "tempest-react-sdk/vision";
const det = await Detector.create("/models/yolov8n.onnx", { labels: "coco" });
const result = (await det.predict("/images/street.jpg"))[0];
for (const d of result) {
console.log(d.name, d.confidence, d.box.xyxy);
}
Why a separate subpath
The vision tasks don't come from the main barrel. You import them from
tempest-react-sdk/vision:
import { Classifier, Detector, Segmenter } from "tempest-react-sdk/vision";
Where this module comes from
The vision code is vendored from
@mauriciobenjamin700/ort-vision-sdk-web
(MIT, same author as this SDK). Instead of installing one more package, it
ships inside tempest-react-sdk — just import from the /vision
subpath. The API mirrors the original package 1-to-1.
onnxruntime-web is an optional peer dependency
The engine that actually runs the .onnx models — the
onnxruntime-web
package — does not come bundled. It's an optional peer dependency: the
app installs it once and the vision subpath reuses it.
npm i onnxruntime-web
Without onnxruntime-web, inference won't run
Because onnxruntime-web is an optional peer dep,
npm install tempest-react-sdk does not pull it in. If you import from
tempest-react-sdk/vision without running npm i onnxruntime-web, the build
breaks with Cannot find module 'onnxruntime-web'. It stays
externalized in the SDK bundle — apps that never import from /vision
pay nothing for it (the same pattern as recharts in charts and the
dependency-injecting adapters).
You must serve the .wasm files
onnxruntime-web loads the runtime via WebAssembly. The .wasm files for
the same version you installed must be reachable at runtime (served by
your bundler or copied to a public folder). A mismatch between the JS version
and the .wasm version is the #1 cause of "the model won't load." Each
bundler has its own recipe (with Vite, the usual approach is to copy the
.wasm files into public/ and point ort.env.wasm.wasmPaths at them).
WebGPU first, WASM as fallback
By default the SDK tries the execution providers in this order:
["webgpu", "wasm"] (exported as DEFAULT_PROVIDERS). ORT-Web uses the GPU
via WebGPU when the browser/device supports it and falls back
automatically to WASM (CPU) when it doesn't. You can force the order by
passing providers in the create() options.
The input image
All tasks accept the same set of inputs — the ImageInput type. You don't
have to decode anything by hand; the SDK resolves it to the internal canonical
format (RGBImage, HWC RGB uint8).
| Input | Example |
|---|---|
string (URL) |
det.predict("/images/cat.jpg") |
Blob |
det.predict(await (await fetch(url)).blob()) |
File |
det.predict(inputFile.files[0]) |
HTMLImageElement |
det.predict(document.querySelector("img")) |
HTMLCanvasElement |
det.predict(canvas) |
OffscreenCanvas |
det.predict(offscreen) |
ImageBitmap |
det.predict(await createImageBitmap(blob)) |
ImageData |
det.predict(ctx.getImageData(0, 0, w, h)) |
RGBImage |
det.predict(rgbImage) (the SDK's canonical form) |
File rides in through the Blob door
The ImageInput type lists Blob, and File is a subclass of Blob — so
a File from an <input type="file"> is accepted directly, no conversion.
That's the natural path for "the user picked a photo."
Detector — where are the objects
Detector runs anchor-free YOLO models (v8/v9/v10/v11/v12) and returns one box
per object found.
import { Detector } from "tempest-react-sdk/vision";
const det = await Detector.create("/models/yolov8n.onnx", { labels: "coco" });
const result = (await det.predict("/images/street.jpg"))[0];
console.log(`${result.length} objects detected`);
for (const d of result) {
console.log(d.name, d.confidence.toFixed(2), d.box.xyxy);
}
The shape of the result
predict() always returns a Promise of a 1-element array — one envelope per
image, mirroring Ultralytics' YOLO("img.jpg"). That's why the [0] right after
the await:
const results = await det.predict(img); // DetectionResults[]
const result = results[0]; // DetectionResults
The envelope (DetectionResults) is iterable: looping with for...of yields
one DetectionResult per object. Each object carries Ultralytics-style idiomatic
names and the equivalent verbose names — use whichever you prefer:
| Ultralytics style | Verbose name | Type | What it is |
|---|---|---|---|
d.cls |
d.classId |
number |
numeric class id |
d.name |
d.className |
string |
class name (resolved label) |
d.conf |
d.confidence |
number |
confidence in [0, 1] |
d.box |
d.bbox |
BoundingBox |
the bounding box |
The BoundingBox exposes coordinates in several formats:
for (const d of result) {
d.box.xyxy; // [x1, y1, x2, y2] in absolute pixels (readonly tuple)
d.box.xywh; // [cx, cy, w, h] with the center at (cx, cy)
d.box.asXywh(); // [x, y, w, h] with the top-left corner at (x, y)
d.box.xyxyn([result.origShape[0], result.origShape[1]]); // normalized [0,1]
d.box.width;
d.box.height;
d.box.area;
}
Bulk view: the boxes collection
To draw everything in one pass (onto a canvas, say), instead of iterating use
the numpy-style view result.boxes. It exposes flat arrays: boxes.xyxy
(Float32Array of 4 * N), boxes.cls (Int32Array), boxes.conf
(Float32Array), plus boxes.xywh, boxes.xyxyn, boxes.xywhn, and
boxes.length. And result.names maps id → name, just like Ultralytics'
model.names.
Filters and thresholds
const result = (
await det.predict(img, {
confThreshold: 0.4, // keep only detections with confidence ≥ 0.4
iouThreshold: 0.5, // IoU for non-maximum suppression
classes: [0, 2], // keep only "person" (0) and "car" (2)
})
)[0];
The defaults (set at create()) are confThreshold: 0.25, iouThreshold: 0.45,
maxDetections: 300, and inputSize: [640, 640] — the last one only kicks in
when the model declares no resolution (see
The resolution comes from the model).
raiseOnEmpty — when "found nothing" is an error
By default, a run that finds nothing resolves to an empty envelope: looking
and finding nothing is a successful inference. When empty should stop the
surrounding flow (a wizard that requires at least one document in the photo, say),
turn raiseOnEmpty on and handle NoDetectionsError:
import { Detector, NoDetectionsError } from "tempest-react-sdk/vision";
const det = await Detector.create("/models/yolov8n.onnx", {
confThreshold: 0.7,
raiseOnEmpty: true,
});
try {
const result = (await det.predict("/images/doc.jpg"))[0];
console.log(result.length); // always ≥ 1 here
} catch (err) {
if (err instanceof NoDetectionsError) {
// NoDetectionsError: No detections in /images/doc.jpg: nothing cleared
// confThreshold=0.7.
console.warn(err.message);
}
}
The flag lives on create() and as a per-call override
(det.predict(img, { raiseOnEmpty: false })). It works on Detector,
Segmenter, and DetectClassify
— all three share the same message, which names the threshold that applied plus
the image and the class filter whenever either narrowed the search.
Empty stays the default — on purpose
An empty collection is not an error (same rule as the SDK's listing
endpoints). raiseOnEmpty is opt-in because only the surrounding flow knows
whether zero rows is a result or a failure.
Classifier — what is this image
Classifier applies ImageNet-style preprocessing (224×224 by default,
normalization with ImageNet mean/std) and returns the probability distribution.
labels is optional: when omitted, the names the exporter baked into the
.onnx are used (see Labels come from the model).
An off-the-shelf ImageNet ResNet usually carries no names, so there you do
pass the list:
import { Classifier } from "tempest-react-sdk/vision";
const labels = await fetch("/models/imagenet-classes.json").then((r) => r.json());
const clf = await Classifier.create("/models/resnet50.onnx", { labels });
const result = (await clf.predict("/images/dog.jpg"))[0];
console.log(result.cls, result.conf, result.name); // top-1
console.log(result.probs.top5, result.probs.top5conf); // top-5
The ClassificationResults envelope exposes top-1 shortcuts (cls, conf,
name) and the probs collection with the full distribution:
| Access | Type | What it is |
|---|---|---|
result.cls |
number |
top-1 class id |
result.conf |
number |
top-1 class confidence |
result.name |
string |
top-1 class name |
result.probs.top1 |
number |
id of the most probable class |
result.probs.top5 |
Int32Array |
ids of the 5 most probable classes |
result.probs.data |
Float32Array |
the full probability vector |
To truncate the per-class list to top-K, pass topK to predict:
const result = (await clf.predict(img, { topK: 3 }))[0];
for (const p of result.probabilities) {
console.log(p.name, p.conf);
}
Segmenter — the outline of each object
Segmenter runs YOLO-seg models (v8-seg / v11-seg / ...) and returns, on top of
the box, a binary mask per instance.
import { Segmenter } from "tempest-react-sdk/vision";
const seg = await Segmenter.create("/models/yolov8n-seg.onnx", { labels: "coco" });
const result = (await seg.predict("/images/street.jpg"))[0];
for (const inst of result) {
console.log(inst.name, inst.conf, inst.box.xyxy);
console.log(inst.mask.width, inst.mask.height); // mask cropped to the box
}
The SegmentationResults envelope is iterable (yields one SegmentationResult
per instance) and also exposes two bulk views:
result.boxes— the same boxes view asDetector.result.masks— an iterable collection of binary masks, each cropped to its instance's box (masks.length, and each item hasdata,width,height).
Each SegmentationResult carries the same fields as a detection (cls/conf/
name/box + aliases) plus:
mask— the binary mask (Mask, values0/255, cropped to the box).segmentedImage— the original crop with the background zeroed out (ready to display).
DetectClassify — detect and classify in one model
The classic two-stage case: a detector finds the objects, and a classifier says
which sub-category each one is ("there is a bird here" → "it's a great
kiskadee"). Doing that with two .onnx files costs two downloads, two session
initializations (WASM/WebGPU), and a per-crop round trip through JavaScript to
slice, resize, and restack the regions before the second model sees anything.
DetectClassify runs a fused .onnx: both models plus the crop-and-resize
bridge between them live in one graph. One download, one session, no round trip.
import { DetectClassify } from "tempest-react-sdk/vision";
const pipeline = await DetectClassify.create("/models/birds-pipeline.onnx");
const result = (await pipeline.predict("/images/flock.jpg"))[0];
for (const d of result) {
console.log(d.name, d.conf.toFixed(2)); // what the detector saw
console.log(d.classification?.name, d.classification?.conf); // the species
}
The fused file is built in Python
Fusion is a build step of the Python ort-vision-sdk
(ort_vision_sdk.compose.fuse_detect_classify, 0.7.0+). The browser only
runs a ready pipeline. Loading a plain .onnx here throws FusionError,
with a message pointing at the right path (use Detector/Classifier, or
fuse first).
Nothing is reconfigured on the JavaScript side: the letterbox resolution, the
crop size, whether the classifier output still needs a softmax, and the class
names of both stages are read from the ovs.* metadata fusion wrote into the
file. That's why a pipeline fused once behaves identically in both runtimes. To
inspect it by hand, readFusionSpec(session.metadata) returns the FusionSpec,
and the task exposes its own as pipeline.spec.
The envelope carries two label spaces
DetectClassifyResults is iterable like Detector's, and each item is a regular
DetectionResult (cls/conf/name/box + aliases, croppedImage) with one
extra field:
| Access | Type | What it is |
|---|---|---|
d.classification |
ClassificationResult \| null |
what the classifier said about that crop |
result.names |
Record<number, string> |
labels of the detection stage |
result.classifierNames |
Record<number, string> |
labels of the classification stage |
result.boxes |
Boxes |
the same bulk view as Detector |
The two maps stay separate because the stages answer different questions over
unrelated class spaces — collapsing them into one would lose an answer. To
override, labels covers detection and classifierLabels covers classification.
Pipeline filters
const result = (
await pipeline.predict(img, {
confThreshold: 0.5, // filters **on top of** the NMS fixed at fusion time
classes: [14], // only detector class 14
topK: 3, // truncate d.classification.probabilities
raiseOnEmpty: true, // empty becomes NoDetectionsError
})
)[0];
The fusion threshold is a floor, not a ceiling
The graph's NMS and confThreshold were fixed at fusion time.
confThreshold here only filters further — you cannot loosen it below what
the file already decided. Need a lower floor? Re-fuse the pipeline in Python.
The resolution comes from the model
inputSize is optional and acts as a fallback. The resolution a task
preprocesses to is read from the shape the .onnx graph declares:
const clf = await Classifier.create("/models/classify.onnx", { labels: LABELS });
console.log(clf.inputSize); // [224, 224] — read from the file, not configured
Why this could not live in configuration
An Ultralytics -cls export comes out at 224×224; a detector, at 640×640.
Feeding the graph the wrong size makes ORT abort the run with
Got invalid dimensions for input: images ... Got: 640 Expected: 224 — and
the number only exists inside the .onnx, so no constant, manifest or env
var beside it could get it right on its own.
Passing an inputSize that contradicts a static graph logs a warning and is
ignored: obeying it there would only trade a fixable problem for a failed run.
On dynamic-axis models your value stands, and the task default is the last
resort.
The session also exposes what it read, and now knows how to free itself:
console.log(clf.session.inputShape); // [1, 3, 224, 224] — null on a dynamic axis
await clf.session.release(); // frees the native session
Honest telemetry
task.inputSize is the resolution inference actually used. Reporting the
configured value hides exactly the bug you are hunting.
Low-memory phones: release() is not optional
ORT copies the .onnx into its WASM heap and allocates the graph and the
weights on top of that copy. Meanwhile the bytes the SDK fetched to read the
metadata are alive in the JS heap too — a 5 MB model costs 5 MB + 5 MB + weights
at the same instant. The SDK reads the metadata before building the session
so that buffer dies as early as possible (since v0.38.1; before that it survived
the whole build).
When the numbers do not add up, ORT gives up with Can't create a session.
failed to allocate a buffer of size N. In order: load one model at a time
(two concurrent create calls double the peak), call session.release() on
whatever goes out of use — dropping the JS reference does not free the native
session — and decode the photo already downscaled, which on a phone weighs more
than both models together. If that is still not enough, readMetadata: false
with explicit labels takes the SDK out of the path: ORT fetches the model
itself and nothing here holds the bytes (the input size still comes from the
graph; only the class names are lost).
The pure helpers behind this (spatialInputSize, resolveInputSize,
declaredShapesFrom) are exported too, for anyone assembling their own pipeline
without importing onnxruntime-web types.
Labels come from the model
labels is optional on all three tasks. When omitted, the names the exporter
baked into the .onnx metadata are used — Ultralytics writes them as
{0: 'deworm', 1: 'not_deworm'} — and only a model carrying none falls back to
the COCO preset (detection/segmentation) or to class_<id> (classification):
const det = await Detector.create("/models/detect.onnx");
console.log(det.labels); // ["ocular-mucosa"] — from the model, not a preset
console.log(det.numClasses); // 1 — inferred from the (B, 4 + nc, N) output shape
Precedence matches the Python ort-vision-sdk: what you pass wins, then the
model's names, then the preset. Passing numClasses still validates the labels
against the model (LabelMapError if they disagree).
A hand-kept label list is the worst kind of configuration
It does not fail when it is wrong — the predictions just swap classes, and you
find out by reading results. This also fixed a real stumble: a single-class
detector failed without an explicit labels, because the 80-name COCO
default disagreed with the model's class count.
The session exposes what it read off the file, for whoever wants the raw data:
console.log(det.session.metadata.task); // "detect" — the map the exporter wrote
console.log(det.session.outputShape); // [1, 5, 8400] — null on a dynamic axis
The pure helpers are exported too: readModelMetadata, modelNames,
detectionNumClasses, and classificationNumClasses.
A model given as a URL is downloaded by the SDK
onnxruntime-web exposes no equivalent of Python's custom_metadata_map, so
metadata_props is read from the .onnx's own bytes. That is the same single
download either way, and readMetadata: false in the session options restores
the previous path (ORT fetches the URL). A fetch that fails still hands the URL
to ORT, so losing the metadata never becomes a load failure.
Labels: presets, lists, and dicts
Since the browser has no filesystem, the SDK does not read labels from a path
— you pass the names directly. The resolveLabels function (and each task's
labels field) accepts:
import { resolveLabels, COCO_CLASSES } from "tempest-react-sdk/vision";
resolveLabels("coco"); // preset → the 80 COCO classes
resolveLabels(["cat", "dog"]); // explicit array, indexed by id
resolveLabels({ 0: "cat", 2: "bird" }); // sparse dict (gaps become class_1)
resolveLabels(null, { numClasses: 3 }); // auto: ["class_0", "class_1", "class_2"]
COCO_CLASSES; // the readonly array of 80 classes, in canonical order
Label default per task
Omitting labels first tries the model's own names
(Labels come from the model). Only when the
.onnx carries none do Detector and Segmenter assume the "coco"
preset — after all, the most common YOLO weights are trained on COCO — and the
Classifier generates class_<id> from the class count read off the output
shape. Passing numClasses validates that the label count matches the model
(LabelMapError if they disagree).
Camera and luminance hooks
Before you can run any model you need a frame — and a decent one. The
/vision subpath ships three browser primitives for that: open the camera,
measure brightness live, and reject too-dark captures. They're generic (they
depend on no model), but they live under /vision because that's where capture
happens.
useCameraStream — open the camera
Requests a MediaStream via getUserMedia, attaches it to a <video>, and
exposes an already-classified status/error so you can render permission and
error states without memorizing DOMException names. The stream is released
automatically on unmount and on retry().
import { useCameraStream } from "tempest-react-sdk/vision";
function CameraView() {
const { status, error, videoRef, retry } = useCameraStream();
if (status === "error") {
return (
<div>
<p>{error?.message}</p>
<button onClick={retry}>Try again</button>
</div>
);
}
return (
<video ref={videoRef} playsInline muted style={{ opacity: status === "ready" ? 1 : 0.4 }} />
);
}
By default it asks for the rear camera (facingMode: "environment") at
Full-HD — ideal for photographing something in front of you. Desktops fall back
to the single camera they expose. To override, pass constraints:
const cam = useCameraStream({
constraints: { video: { facingMode: "user" }, audio: false }, // front camera
});
error.kind is a stable enum — map it to your UI, not error.message:
kind |
When it happens |
|---|---|
unsupported |
browser without getUserMedia (or SSR). |
insecure |
page served outside HTTPS (non-secure context). |
permission-denied |
the user (or the OS) denied access. |
no-camera |
no camera device / impossible constraints. |
in-use |
the camera is held by another app. |
unknown |
any other failure (the original message is in message). |
Camera requires a secure context
getUserMedia only works over HTTPS (or localhost). On an insecure
origin the hook returns status: "error" with kind: "insecure" — that's
not a bug, it's browser policy. The error.message strings are in
English; translate them in your i18n layer if needed.
computeImageLuminance + useLiveLuminance — measure brightness
computeImageLuminance computes the mean BT.709 luminance
(0.2126*R + 0.7152*G + 0.0722*B, on a 0..255 scale) of an already-decoded
frame — <img>, <video>, <canvas>, ImageBitmap, or OffscreenCanvas. It
downsamples to at most LUMINANCE_SAMPLE_MAX_EDGE (256px) before reading
pixels — statistically equivalent for a threshold and orders of magnitude faster
than reading the whole frame.
import {
computeImageLuminance,
isLuminanceAcceptable,
LowLuminanceError,
} from "tempest-react-sdk/vision";
const luminance = computeImageLuminance(videoOrImageOrCanvas); // 0..255
if (!isLuminanceAcceptable(luminance, 70)) {
throw new LowLuminanceError(luminance, 70);
}
The threshold is yours
isLuminanceAcceptable(luminance, threshold) takes threshold as a
required argument — the right value depends on your model, the lighting
it was trained on, and your acceptable reject rate. The SDK bakes in no
default. LowLuminanceError carries .luminance and .threshold so you can
surface actionable feedback.
Phone photos: decode downscaled and measure that same frame
A 12 MP photo decodes to ~48 MB of RGBA if you take it whole — more than both
models together, and the peak where ORT starts refusing to create a session.
Ask for the frame already downscaled and work on it; ImageBitmap is accepted
both here and by the tasks' predict(), so the frame you measure is the frame
you infer on:
const frame = await createImageBitmap(photoBlob, {
resizeWidth: 1280,
resizeQuality: "high",
});
const luminance = computeImageLuminance(frame); // 0..255, no second decode
const result = (await det.predict(frame))[0];
frame.close(); // hands the memory back now, not whenever the GC runs
Boxes come back in the downscaled frame's space — multiply by the scale factor
if you persist coordinates at the original resolution. And close() matters:
it is the only deterministic way to release that memory.
For live feedback (a brightness bar, a border that changes color while the
camera is open), useLiveLuminance samples the <video> on a
requestAnimationFrame loop, reusing a single offscreen canvas:
import { useCameraStream, useLiveLuminance, isLuminanceAcceptable } from "tempest-react-sdk/vision";
function BrightnessGuardedCamera() {
const { status, videoRef } = useCameraStream();
const luminance = useLiveLuminance(videoRef, { enabled: status === "ready" });
const bright = isLuminanceAcceptable(luminance, 70);
return (
<div style={{ border: `3px solid ${bright ? "green" : "orange"}` }}>
<video ref={videoRef} playsInline muted />
{!bright && <p>Dark environment — move closer to a light.</p>}
</div>
);
}
It pauses on its own when enabled is false or while the video isn't ready
yet (readyState < 2), and is throttled by intervalMs (default 160, ~6 fps —
plenty for UX).
Preview the captured frame: useObjectUrl
After exporting the frame to a Blob (canvas.toBlob(...)), use
useObjectUrl (main barrel, tempest-react-sdk) to turn it into
an <img> src without leaking memory — it creates the
URL.createObjectURL and revokes it automatically when the blob changes or
the component unmounts.
import { useObjectUrl } from "tempest-react-sdk";
const previewUrl = useObjectUrl(capturedBlob);
return previewUrl ? <img src={previewUrl} alt="Preview" /> : null;
warmup() — the first inference is not representative
The first run of a session pays costs none of the later ones do: WebGPU compiles
its shaders on it, and the WASM backend faults in its arenas. On a phone that
reads as "the first frame took seconds, the rest take tens of milliseconds".
warmup() runs the model once on a zero tensor, moving that cost to where the
user is already watching a spinner:
const det = await Detector.create("/models/yolov8n.onnx", { labels: "coco" });
await det.warmup(); // still on the loading screen
// from here on, every predict() is real inference time
const result = (await det.predict(frame))[0];
Available on all four tasks. warmup(2) runs it twice — one is enough for
WASM, and WebGPU sometimes settles on the second. It pays off most on
DetectClassify: two models plus the bridge compile together on that first
inference.
Warm both stages of an analysis, not just the first
In an app that detects and then classifies with two separate models, each session pays its own first inference. Warming only the detector leaves the classifier's cost exactly where it shows most: in the instant between the user finishing their wait and the answer appearing.
await Promise.all([detector.warmup(), classifier.warmup()]);
Getting off the main thread (env.wasm.proxy)
The WASM backend runs on whichever thread called it — and that thread is the main
one. So creating the session and every predict() block the UI while they
run. Measured on a 32-core desktop: one detector + classifier warmup() froze the
page for 805 ms. On a 4-core / 2 GB phone, a single analysis takes 50 to
103 s.
ONNX Runtime has a flag for this. With env.wasm.proxy on, it creates its own Web
Worker (onnxruntime-web-proxy-worker) and forwards create, run and release over
postMessage. Same warmup as above: worst frame 18 ms, zero frames over
50 ms.
Turn it on once, before the first session:
import { env } from "onnxruntime-web";
import { Detector } from "tempest-react-sdk/vision";
env.wasm.proxy = true; // before the first Detector.create
const det = await Detector.create("/models/yolov8n.onnx", { labels: "coco" });
await det.warmup(); // now it warms up without freezing the loading screen
const result = (await det.predict(frame))[0];
Before the first session, not after
ORT reads env.wasm when it initialises the WASM runtime, which happens
inside the first InferenceSession.create. Setting the flag after that is
silently ignored — inference goes back to the main thread with nothing to say
so. In an app with lazy sessions, the safe place is the top of the function
that builds the session, not a boot hook a later refactor can reorder.
The worker makes nothing cheaper
It changes where the cost is paid, not how much it is. The WASM heap and the pthread build's shared-memory reservation simply move threads: a device that cannot create the session on the main thread cannot create it in the worker either.
Needs onnxruntime-web >= 1.17 and this SDK version
The proxy posts the input tensors with their ArrayBuffers in the transfer
list, which detaches them on this side. The preprocessing pipelines reuse
one Float32Array across calls, and up to 0.42.0 they handed the detached
buffer back — ORT rejected it with
Tensor's size(1228800) does not match data length(0). on every other
inference. Fixed in this version (vendored vision 0.7.1).
How long it took
Every envelope carries a speed breakdown of the predict() call, in
milliseconds:
const results = await detector.predict(blob);
console.log(results[0].speed);
// { load: 84.2, preprocess: 11.7, inference: 118.9, postprocess: 6.4 }
preprocess / inference / postprocess are the same three keys Ultralytics
reports, measured over the same boundaries. load is the fetch/decode
predict() performs internally when you hand it a URL or Blob — on a cold
cache it is usually the largest slice of the call. Creating the task
(Detector.create) is not included: that is startup cost, paid once.
To measure your whole app pipeline — including what happens between two
predict() calls — use the perf module, and fold speed into the
report with profiler.mark("forward-pass", results[0].speed.inference).
SpeedTimer is exported from here too, for code that wants the SDK's exact
boundaries.
Today's preprocess is ~2x faster than it used to be
All four tasks preprocess through a fused pipeline: a single drawImage
resizes (and, where there is padding, positions) the content, plus one loop
that reads the resulting RGBA and writes planar float32 into a buffer reused
across frames. Measured in Chromium, letterboxing into 640×640: 19.8 → 10.7
ms (1920×1080), 13.8 → 7.8 ms (1280×720), 6.8 → 3.1 ms (640×480) — with
bit-identical output to the old path.
There are two pipelines because the tasks want different things:
LetterboxPipeline preserves aspect ratio and pads the rest (detection and
segmentation have to undo that geometry afterwards), while ResizePipeline
stretches straight to the model's input and normalizes with mean/std in
the same pass — what Classifier does, since it maps nothing back onto the
original image. The primitives (letterbox, resize, normalize, toCHW,
…) are still exported; code that wants the fused path of its own uses the
pipeline (or letterboxToTensorData/resizeToTensorData, the one-shot
forms).
Classifying was the expensive path until 0.41.0
Classifier was the one task still on the composable route
(resize → normalize → toCHW): three scans and three allocations per
predict(), ~1.4 MB of fresh garbage per 224×224 image. On a device near
ORT's memory ceiling that landed at the worst possible moment. If you pinned
an older version over this, that is the release that fixes it.
Reference: what else the subpath exports
The tasks cover the common path. Below is the rest of the surface — what you reach for when you build a pipeline of your own, run a model with a head the SDK does not know, or need to handle one specific failure.
| Group | Exports |
|---|---|
| Session | OrtSession (loads the .onnx, exposes metadata/inputName), resolveProviders, DEFAULT_PROVIDERS, VisionTask (task base class), VERSION |
| Input | loadImage (any ImageInput → RGBImage), normalize, toTensor, toFloat32/toFloat32Tensor, zeroTensorData, fromCv2/toCv2 (BGR ↔ RGB) |
| Fused preprocess | LetterboxPipeline + letterboxToTensorData (detect/segment), ResizePipeline + resizeToTensorData (classify), writePlanarFloat32 (the shared planar write) |
| Decoding | decodeYolo (anchor-free head, v8→v12), decodeYoloAnchors (anchor-based head), decodeYoloSeg, nms, batchedNms |
| Labels | resolveLabels, defaultLabels, parseNames, modelNames, readModelMetadata, COCO_CLASSES |
| Bulk views | Boxes, Masks, Probs — the "numpy-style" collections behind result.boxes/.masks/.probs |
| Errors | OrtVisionError (base), ModelLoadError, ImageLoadError, InferenceError, LabelMapError, ProviderNotAvailableError, NoDetectionsError, FusionError |
| Fusion contract | readFusionSpec, FusionSpec, CropSource, INPUT_IMAGE/INPUT_SOURCE/INPUT_SCALE/INPUT_PAD, OUTPUT_BOXES/OUTPUT_SCORES/OUTPUT_CLASSES/OUTPUT_PROBS/OUTPUT_NUM_DETECTIONS, METADATA_PREFIX, FUSION_KIND_DETECT_CLASSIFY |
| Helpers | requireDetections (the check behind raiseOnEmpty), SpeedTimer, softmax, topK |
Anchor-based head: decodeYoloAnchors
decodeYolo covers anchor-free heads (the default from v8 onward). An older
model — YOLOv5/v7, or a custom export that keeps its anchors — decodes with
decodeYoloAnchors. Feeding the wrong output to the wrong function does not
raise: it puts boxes nowhere.
Every error descends from OrtVisionError
One catch (err) { if (err instanceof OrtVisionError) … } catches
everything the subpath throws, and the subclasses separate what you can act
on: ModelLoadError (wrong URL, 404, corrupt file) wants another URL,
ProviderNotAvailableError wants another provider, LabelMapError is your
configuration, and InferenceError is the model rejecting the input.
Parity with the Python ort-vision-sdk
This API deliberately mirrors the Python
ort-vision-sdk package by the same
author: Classifier / Detector / Segmenter, a predict() that returns a
list of one result per image, and the same Ultralytics-style idiomatic names
(cls/conf/name/box, boxes.xyxy, probs.top5). Porting vision code
between the Python backend and the TypeScript frontend reuses the same mental
model with almost no friction.
Parity goes beyond API shape: raiseOnEmpty exists on both sides with the
same message (down to the threshold rendering the same — conf_threshold=1
does not become 1.0 on one side only), and a pipeline fused by Python's
compose is read here from the metadata it wrote itself. Fuse once, run under
both runtimes, get the same result.
Recap
- Import from
tempest-react-sdk/vision— a dedicated subpath. The code is vendored from@mauriciobenjamin700/ort-vision-sdk-web(MIT), so it ships in the SDK with no extra package. - The
onnxruntime-webis an optional peer dep: runnpm i onnxruntime-weband serve the matching.wasmfiles. Apps that don't import from/visionpay nothing. Providers: WebGPU → WASM (DEFAULT_PROVIDERS). - Four tasks, one shape:
await Task.create(model, options)→(await task.predict(image))[0].predictalways returns a 1-element array (one envelope per image). DetectClassifyruns detector + classifier from a single fused.onnx(built by the Python SDK'scompose): one download, one session, and each object's sub-category ind.classification. A model without fusion metadata throwsFusionError.warmup()pays the shader-compile / arena cost before the user does — call it on the loading screen.raiseOnEmptyturns an empty result intoNoDetectionsErrorwhen zero rows should stop the flow (default: empty envelope).- Iterate the envelope with
for...offor per-instance results:d.name/d.className,d.confidence/d.conf,d.box/d.bbox(with.xyxy/.xywh/.asXywh()/.xyxyn()). Or use the bulk viewresult.boxes(.xyxy/.cls/.conf) andresult.names. - Accepted inputs: URL
string,Blob,File,HTMLImageElement, canvases (HTMLCanvasElement/OffscreenCanvas),ImageBitmap,ImageData, andRGBImage. - Labels via
resolveLabels/COCO_CLASSES: the"coco"preset, an array, a sparse dict, or auto-generated.labelsis optional on all three tasks — when omitted, the.onnx's ownnameswin, and only a model carrying none falls back to the"coco"preset (det/seg) or toclass_<id>(classification). - The API mirrors the Python
ort-vision-sdk— the same mental model on both sides. - To capture the frame:
useCameraStream(rear camera by default, stableerror.kind,retry()),computeImageLuminance+isLuminanceAcceptable+LowLuminanceErrorto check brightness (threshold required), anduseLiveLuminancefor live feedback. To preview the capturedBlob, useuseObjectUrl(main barrel).