Vision (ONNX Runtime Web)
Visão computacional no dispositivo — direto no navegador, sem servidor de
inferência, sem upload da imagem pra lugar nenhum. O subpath
tempest-react-sdk/vision roda três tarefas clássicas em modelos ONNX:
classificação (que imagem é essa?), detecção (onde estão os objetos?) e
segmentação de instâncias (qual o contorno exato de cada objeto?) — mais o
pipeline fundido detect→classify num arquivo só.
A API é a mesma para todas: você cria um objeto com
await Tarefa.create(modelo, opções), chama predict(imagem) e recebe um array
de resultados — um por imagem. Aprende uma, sabe as quatro.
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);
}
Por que um subpath separado
As tarefas de visão não vêm do barrel principal. Você as importa de
tempest-react-sdk/vision:
import { Classifier, Detector, Segmenter } from "tempest-react-sdk/vision";
De onde vem esse módulo
O código de visão é vendorizado do
@mauriciobenjamin700/ort-vision-sdk-web
(MIT, mesmo autor do SDK). Em vez de você instalar um pacote a mais, ele já
vem dentro do tempest-react-sdk — basta importar do subpath /vision.
A API espelha 1-para-1 a do pacote original.
onnxruntime-web é peer dependency opcional
O motor que de fato roda os modelos .onnx — o
onnxruntime-web
— não vem junto. Ele é uma peer dependency opcional: o app o instala uma
vez, e o subpath de visão o reutiliza.
npm i onnxruntime-web
Sem o onnxruntime-web, a inferência não roda
Como o onnxruntime-web é peer dep opcional, o
npm install tempest-react-sdk não o traz. Se você importar de
tempest-react-sdk/vision sem ter rodado npm i onnxruntime-web, o build
quebra com Cannot find module 'onnxruntime-web'. Ele fica externalizado
no bundle do SDK — quem nunca importa do subpath /vision não paga esse peso
(mesmo padrão do recharts nos charts e dos adapters que injetam a dep).
Você precisa servir os arquivos .wasm
O onnxruntime-web carrega o runtime via WebAssembly. Os arquivos .wasm
correspondentes à mesma versão que você instalou têm que estar
acessíveis em runtime (servidos pelo seu bundler ou copiados pra pasta
pública). Versão do JS e dos .wasm desalinhadas é a causa nº 1 de "o modelo
não carrega". Cada bundler tem sua receita (no Vite, costuma-se copiar os
.wasm pra public/ e apontar ort.env.wasm.wasmPaths).
WebGPU primeiro, WASM como fallback
Por padrão o SDK tenta os execution providers nesta ordem:
["webgpu", "wasm"] (exportada como DEFAULT_PROVIDERS). O ORT-Web usa a
GPU via WebGPU quando o navegador/dispositivo suporta e cai
automaticamente para WASM (CPU) quando não. Você pode forçar a ordem
passando providers nas opções de create().
A imagem de entrada
Todas as tarefas aceitam o mesmo conjunto de entradas — o tipo ImageInput.
Você não precisa decodificar nada na mão; o SDK resolve para o formato canônico
interno (RGBImage, HWC RGB uint8).
| Entrada | Exemplo |
|---|---|
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) (formato canônico do SDK) |
File entra pela porta do Blob
O tipo ImageInput lista Blob, e File é uma subclasse de Blob — então
um File vindo de um <input type="file"> é aceito direto, sem conversão.
É o caminho natural pra "usuário escolheu uma foto".
Detector — onde estão os objetos
Detector roda modelos YOLO anchor-free (v8/v9/v10/v11/v12) e devolve uma caixa
por objeto encontrado.
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} objetos detectados`);
for (const d of result) {
console.log(d.name, d.confidence.toFixed(2), d.box.xyxy);
}
A forma do resultado
predict() sempre devolve uma Promise de um array de 1 elemento — um
envelope por imagem, espelhando o YOLO("img.jpg") do Ultralytics. Por isso o
[0] logo após o await:
const results = await det.predict(img); // DetectionResults[]
const result = results[0]; // DetectionResults
O envelope (DetectionResults) é iterável: percorrer com for...of dá uma
DetectionResult por objeto. Cada objeto traz nomes idiomáticos do estilo
Ultralytics e os nomes verbosos equivalentes — use o que preferir:
| Estilo Ultralytics | Nome verboso | Tipo | O que é |
|---|---|---|---|
d.cls |
d.classId |
number |
id numérico da classe |
d.name |
d.className |
string |
nome da classe (rótulo resolvido) |
d.conf |
d.confidence |
number |
confiança em [0, 1] |
d.box |
d.bbox |
BoundingBox |
a caixa delimitadora |
A BoundingBox expõe as coordenadas em vários formatos:
for (const d of result) {
d.box.xyxy; // [x1, y1, x2, y2] em pixels absolutos (readonly tuple)
d.box.xywh; // [cx, cy, w, h] com centro em (cx, cy)
d.box.asXywh(); // [x, y, w, h] com canto superior-esquerdo em (x, y)
d.box.xyxyn([result.origShape[0], result.origShape[1]]); // normalizado [0,1]
d.box.width;
d.box.height;
d.box.area;
}
Visão em massa: a coleção boxes
Pra desenhar tudo numa só passada (num canvas, por exemplo), em vez de
iterar use a view "numpy-style" result.boxes. Ela expõe arrays achatados:
boxes.xyxy (Float32Array de 4 * N), boxes.cls (Int32Array),
boxes.conf (Float32Array), além de boxes.xywh, boxes.xyxyn,
boxes.xywhn e boxes.length. E result.names mapeia id → nome, igual ao
model.names do Ultralytics.
Filtros e thresholds
const result = (
await det.predict(img, {
confThreshold: 0.4, // só mantém detecções com confiança ≥ 0.4
iouThreshold: 0.5, // IoU do non-maximum suppression
classes: [0, 2], // só "person" (0) e "car" (2)
})
)[0];
Os defaults (definidos no create()) são confThreshold: 0.25,
iouThreshold: 0.45, maxDetections: 300 e inputSize: [640, 640] — este
último só entra em cena quando o modelo não declara resolução (ver
A resolução vem do modelo).
raiseOnEmpty — quando "não achei nada" é erro
Por padrão, uma run que não encontra nada resolve com um envelope vazio:
olhar e não achar é uma inferência bem-sucedida. Quando o vazio deve parar o
fluxo em volta (um wizard que exige ao menos um documento na foto, por exemplo),
ligue raiseOnEmpty e trate o 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); // sempre ≥ 1 aqui
} catch (err) {
if (err instanceof NoDetectionsError) {
// NoDetectionsError: No detections in /images/doc.jpg: nothing cleared
// confThreshold=0.7.
console.warn(err.message);
}
}
A flag existe no create() e como override por chamada
(det.predict(img, { raiseOnEmpty: false })). Vale para Detector, Segmenter
e DetectClassify — os
três compartilham a mesma mensagem, que nomeia o threshold aplicado e, quando
existirem, a imagem e o filtro de classes que estreitaram a busca.
Vazio continua sendo o default — de propósito
Coleção vazia não é erro (é a mesma regra dos endpoints de listagem do
SDK). raiseOnEmpty é opt-in porque só quem conhece o fluxo em volta sabe
se zero linhas é resultado ou falha.
Classifier — que imagem é essa
Classifier aplica pré-processamento estilo ImageNet (224×224 por padrão,
normalização com média/desvio do ImageNet) e devolve a distribuição de
probabilidades. labels é opcional: sem ele valem os names que o export
gravou no .onnx (ver Rótulos vêm do modelo). Um
ResNet ImageNet baixado pronto normalmente não traz names, então aí você
passa a lista:
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
O envelope ClassificationResults expõe atalhos pro top-1 (cls, conf,
name) e a coleção probs com a distribuição completa:
| Acesso | Tipo | O que é |
|---|---|---|
result.cls |
number |
id da classe top-1 |
result.conf |
number |
confiança da classe top-1 |
result.name |
string |
nome da classe top-1 |
result.probs.top1 |
number |
id da classe mais provável |
result.probs.top5 |
Int32Array |
ids das 5 classes mais prováveis |
result.probs.data |
Float32Array |
vetor completo de probabilidades |
Pra truncar a lista por-classe ao top-K, passe topK em predict:
const result = (await clf.predict(img, { topK: 3 }))[0];
for (const p of result.probabilities) {
console.log(p.name, p.conf);
}
Segmenter — o contorno de cada objeto
Segmenter roda modelos YOLO-seg (v8-seg / v11-seg / ...) e devolve, além da
caixa, uma máscara binária por instância.
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); // máscara recortada na caixa
}
O envelope SegmentationResults é iterável (dá uma SegmentationResult por
instância) e ainda expõe duas views em massa:
result.boxes— a mesma view de caixas doDetector.result.masks— coleção iterável de máscaras binárias, cada uma recortada na caixa da sua instância (masks.length, e cada item temdata,width,height).
Cada SegmentationResult carrega os mesmos campos da detecção (cls/conf/
name/box + aliases) mais:
mask— a máscara binária (Mask, valores0/255, recortada na caixa).segmentedImage— o recorte original com o fundo zerado (pronto pra exibir).
DetectClassify — detectar e classificar num modelo só
O caso clássico de duas etapas: um detector acha os objetos, e um classificador
diz qual sub-categoria cada objeto é ("tem um pássaro aqui" → "é um
bem-te-vi"). Fazer isso com dois .onnx custa dois downloads, duas
inicializações de sessão (WASM/WebGPU) e uma ida-e-volta pelo JavaScript por
recorte — cortar, redimensionar e reempilhar as regiões antes do segundo modelo
ver qualquer coisa.
DetectClassify roda um .onnx fundido: os dois modelos mais a ponte de
crop-and-resize entre eles vivem no mesmo grafo. Um download, uma sessão,
nenhuma ida-e-volta.
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)); // o que o detector viu
console.log(d.classification?.name, d.classification?.conf); // a espécie
}
O arquivo fundido é construído em Python
A fusão é um passo de build do ort-vision-sdk em Python
(ort_vision_sdk.compose.fuse_detect_classify, 0.7.0+). O navegador só
roda o pipeline pronto. Carregar um .onnx comum aqui lança
FusionError com a mensagem apontando o caminho certo (usar
Detector/Classifier, ou fundir antes).
Nada é reconfigurado do lado do JavaScript: a resolução do letterbox, o tamanho
do crop, se a saída do classificador ainda precisa de softmax e os nomes de
classe das duas etapas são lidos do metadata ovs.* que a fusão gravou no
arquivo. Por isso um pipeline fundido uma vez se comporta igual nos dois
runtimes. Pra inspecionar isso na mão, readFusionSpec(session.metadata) devolve
o FusionSpec, e a task expõe o seu em pipeline.spec.
O envelope tem dois espaços de rótulo
DetectClassifyResults é iterável como o do Detector, e cada item é uma
DetectionResult normal (cls/conf/name/box + aliases, croppedImage)
com um campo a mais:
| Acesso | Tipo | O que é |
|---|---|---|
d.classification |
ClassificationResult \| null |
o que o classificador disse daquele recorte |
result.names |
Record<number, string> |
rótulos da etapa de detecção |
result.classifierNames |
Record<number, string> |
rótulos da etapa de classificação |
result.boxes |
Boxes |
a mesma view em massa do Detector |
Os dois mapas são separados porque as duas etapas respondem perguntas
diferentes, com espaços de classe sem relação entre si — colapsar num só perderia
uma das respostas. Para sobrescrever, labels vale pra detecção e
classifierLabels pra classificação.
Filtros do pipeline
const result = (
await pipeline.predict(img, {
confThreshold: 0.5, // filtra **além** do NMS já fixado na fusão
classes: [14], // só a classe 14 do detector
topK: 3, // trunca d.classification.probabilities
raiseOnEmpty: true, // vazio vira NoDetectionsError
})
)[0];
O threshold da fusão é um piso, não um teto
O NMS e o confThreshold do grafo foram fixados no momento da fusão.
confThreshold aqui só filtra mais — não dá pra afrouxar pra baixo do que
o arquivo já decidiu. Precisa de um piso menor? Refunda o pipeline em
Python.
A resolução vem do modelo
inputSize é opcional e serve de fallback. A resolução em que a task
pré-processa é lida do shape que o grafo .onnx declara:
const clf = await Classifier.create("/models/classify.onnx", { labels: LABELS });
console.log(clf.inputSize); // [224, 224] — lido do arquivo, não configurado
Por que isso não podia ficar na configuração
Um export -cls do Ultralytics sai em 224×224; um detector, em 640×640.
Alimentar o grafo com o tamanho errado faz o ORT abortar a run com
Got invalid dimensions for input: images ... Got: 640 Expected: 224 — e o
número só existe dentro do .onnx, então nenhuma constante, manifest ou env
var ao lado dele podia acertar sozinha.
Passar um inputSize que contradiz um grafo estático emite um aviso no console
e é ignorado: obedecer ali só trocaria um problema corrigível por uma execução
que falha. Em modelos com eixo dinâmico o seu valor vale, e o default da task é
o último recurso.
A sessão também expõe o que leu, e agora sabe se liberar:
console.log(clf.session.inputShape); // [1, 3, 224, 224] — null em eixo dinâmico
await clf.session.release(); // libera a sessão nativa
Telemetria honesta
task.inputSize é a resolução que a inferência realmente usou. Reportar
o valor configurado esconde justamente o bug que você está caçando.
Celular com pouca memória: release() não é opcional
O ORT copia o .onnx para o heap WASM e aloca grafo e pesos em cima dessa
cópia. Enquanto isso, os bytes que o SDK baixou para ler os metadados também
estão vivos no heap JS — um modelo de 5 MB custa 5 MB + 5 MB + pesos no mesmo
instante. O SDK lê os metadados antes de construir a sessão justamente para
esse buffer morrer o quanto antes (desde a v0.38.1 — antes disso ele sobrevivia
a toda a construção).
Quando a conta não fecha, o ORT desiste com Can't create a session. failed to
allocate a buffer of size N. Na ordem: carregue um modelo por vez (dois
create concorrentes dobram o pico), chame session.release() no que sai de
uso — soltar a referência JS não libera a sessão nativa — e decodifique a
foto já reduzida, que num celular pesa mais que os dois modelos juntos. Se
ainda não bastar, readMetadata: false com labels explícito tira o SDK do
caminho: o ORT busca o modelo sozinho e nada aqui segura os bytes (o tamanho de
entrada continua vindo do grafo; só os nomes das classes se perdem).
Os helpers puros por trás disso (spatialInputSize, resolveInputSize,
declaredShapesFrom) também são exportados, para quem monta o próprio pipeline
sem precisar importar tipos do onnxruntime-web.
Rótulos vêm do modelo
labels é opcional nas três tarefas. Omitido, valem os names que o export
gravou nos metadados do .onnx — o Ultralytics escreve
{0: 'deworm', 1: 'not_deworm'} — e só um modelo sem names cai no preset COCO
(detecção/segmentação) ou em class_<id> (classificação):
const det = await Detector.create("/models/detect.onnx");
console.log(det.labels); // ["ocular-mucosa"] — do modelo, não de um preset
console.log(det.numClasses); // 1 — deduzido do shape de saída (B, 4 + nc, N)
A precedência é a mesma do ort-vision-sdk em Python: o que você passa ganha,
depois os names do modelo, e por último o preset. Passar numClasses continua
validando os rótulos contra o modelo (LabelMapError se divergir).
Lista de rótulos à mão é o pior tipo de configuração
Ela não falha quando está errada — as predições só trocam de classe, e você
descobre olhando resultado. Isso também consertou um tropeço real: um detector
de uma classe falhava sem labels explícito, porque o default COCO de
80 nomes discordava da contagem de classes do modelo.
A sessão expõe o que leu do arquivo, para quem quer os dados crus:
console.log(det.session.metadata.task); // "detect" — o mapa que o export gravou
console.log(det.session.outputShape); // [1, 5, 8400] — null em eixo dinâmico
Os helpers puros também são exportados: readModelMetadata, modelNames,
detectionNumClasses e classificationNumClasses.
Um modelo informado por URL é baixado pelo SDK
O onnxruntime-web não expõe o mapa de metadados do modelo (diferente do
custom_metadata_map do Python), então os metadata_props são lidos dos
próprios bytes do .onnx. É o mesmo download único, e readMetadata: false
nas opções da sessão restaura o caminho anterior (o ORT busca a URL). Um fetch
que falha ainda entrega a URL ao ORT, para não transformar perda de metadados
em falha de carregamento.
Rótulos: presets, listas e dicts
Como o navegador não tem sistema de arquivos, o SDK não lê rótulos de um
caminho — você passa os nomes direto. A função resolveLabels (e o campo
labels de cada tarefa) aceita:
import { resolveLabels, COCO_CLASSES } from "tempest-react-sdk/vision";
resolveLabels("coco"); // preset → as 80 classes do COCO
resolveLabels(["gato", "cachorro"]); // array explícito, indexado por id
resolveLabels({ 0: "gato", 2: "pássaro" }); // dict esparso (lacunas viram class_1)
resolveLabels(null, { numClasses: 3 }); // auto: ["class_0", "class_1", "class_2"]
COCO_CLASSES; // o array readonly das 80 classes, em ordem canônica
Default de rótulos por tarefa
Omitir labels primeiro tenta os names do próprio modelo
(Rótulos vêm do modelo). Só quando o .onnx não
carrega nenhum é que Detector e Segmenter assumem o preset "coco" —
afinal os pesos YOLO mais comuns são treinados no COCO — e o Classifier gera
class_<id> a partir da contagem de classes lida do shape de saída. Passar
numClasses valida que a contagem de rótulos bate com a do modelo
(LabelMapError se divergir).
Hooks de câmera e luminância
Antes de rodar qualquer modelo você precisa de um frame — e de um frame que
preste. O subpath /vision traz três primitivas de navegador pra isso: abrir a
câmera, medir o brilho ao vivo e rejeitar capturas escuras demais. Elas são
genéricas (não dependem de nenhum modelo), mas moram no /vision porque é ali
que a captura acontece.
useCameraStream — abrir a câmera
Pede um MediaStream via getUserMedia, prende num <video> e expõe
status/error já classificados, pra você renderizar os estados de permissão e
erro sem decorar os nomes de DOMException. O stream é liberado sozinho no
unmount e no 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}>Tentar de novo</button>
</div>
);
}
return (
<video ref={videoRef} playsInline muted style={{ opacity: status === "ready" ? 1 : 0.4 }} />
);
}
Por padrão pede a câmera traseira (facingMode: "environment") em Full-HD —
o ideal pra tirar foto de algo à sua frente. Desktops caem na única câmera que
expõem. Pra sobrescrever, passe constraints:
const cam = useCameraStream({
constraints: { video: { facingMode: "user" }, audio: false }, // câmera frontal
});
O error.kind é um enum estável — mapeie-o pra UI, não pra error.message:
kind |
Quando acontece |
|---|---|
unsupported |
navegador sem getUserMedia (ou SSR). |
insecure |
página fora de HTTPS (contexto não seguro). |
permission-denied |
usuário negou (ou o SO bloqueou) o acesso. |
no-camera |
nenhum dispositivo de câmera / constraints impossíveis. |
in-use |
a câmera está presa por outro app. |
unknown |
qualquer outra falha (a mensagem original vem em message). |
Câmera só em contexto seguro
getUserMedia só funciona sob HTTPS (ou localhost). Numa origem
insegura o hook devolve status: "error" com kind: "insecure" — não é bug,
é política do navegador. As mensagens em error.message são em inglês;
traduza na sua camada de i18n se precisar.
computeImageLuminance + useLiveLuminance — medir o brilho
computeImageLuminance calcula a luminância média BT.709
(0.2126*R + 0.7152*G + 0.0722*B, escala 0..255) de um frame já
decodificado — <img>, <video>, <canvas>, ImageBitmap ou
OffscreenCanvas. Faz downsample até no máximo LUMINANCE_SAMPLE_MAX_EDGE
(256px) antes de ler os pixels — estatisticamente equivalente pra um threshold e
ordens de magnitude mais rápido que ler o frame inteiro.
import {
computeImageLuminance,
isLuminanceAcceptable,
LowLuminanceError,
} from "tempest-react-sdk/vision";
const luminance = computeImageLuminance(videoOrImageOrCanvas); // 0..255
if (!isLuminanceAcceptable(luminance, 70)) {
throw new LowLuminanceError(luminance, 70);
}
O threshold é seu
isLuminanceAcceptable(luminance, threshold) recebe o threshold
obrigatoriamente — o valor ideal depende do seu modelo, da luz em que ele
foi treinado e da taxa de rejeição aceitável. O SDK não crava um default.
LowLuminanceError carrega .luminance e .threshold pra você mostrar
feedback acionável.
Foto de celular: decodifique reduzido e meça o mesmo frame
Uma foto de 12 MP virá a ~48 MB de RGBA se você decodificar inteira — mais que
os dois modelos somados, e o pico onde o ORT começa a recusar sessão. Peça o
frame já reduzido e trabalhe nele; ImageBitmap é aceito tanto aqui quanto no
predict() das tasks, então o frame que você mede é o frame que você infere:
const frame = await createImageBitmap(photoBlob, {
resizeWidth: 1280,
resizeQuality: "high",
});
const luminance = computeImageLuminance(frame); // 0..255, sem outro decode
const result = (await det.predict(frame))[0];
frame.close(); // libera na hora, sem esperar o GC
As caixas voltam no espaço do frame reduzido — multiplique pelo fator de
escala se você persiste coordenadas na resolução original. E close() importa:
é a única forma determinística de devolver a memória.
Pra feedback ao vivo (barra de brilho, borda que muda de cor enquanto a
câmera está aberta), useLiveLuminance amostra o <video> num laço de
requestAnimationFrame, reaproveitando um único canvas offscreen:
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>Ambiente escuro — aproxime-se de uma luz.</p>}
</div>
);
}
Ele pausa sozinho quando enabled é false ou enquanto o vídeo ainda não está
pronto (readyState < 2), e é limitado por intervalMs (padrão 160, ~6 fps —
mais que suficiente pra UX).
Pré-visualizar o frame capturado: useObjectUrl
Depois de exportar o frame pra um Blob (canvas.toBlob(...)), use o
useObjectUrl (barrel principal, tempest-react-sdk) pra virar
um src de <img> sem vazar memória — ele cria o URL.createObjectURL e o
revoga sozinho quando o blob muda ou o componente desmonta.
import { useObjectUrl } from "tempest-react-sdk";
const previewUrl = useObjectUrl(capturedBlob);
return previewUrl ? <img src={previewUrl} alt="Prévia" /> : null;
warmup() — a primeira inferência não é representativa
A primeira run de uma sessão paga custos que nenhuma das seguintes paga: o
WebGPU compila os shaders nela, e o backend WASM materializa suas arenas. Num
celular isso vira "o primeiro frame demorou segundos, os outros levam dezenas de
milissegundos". warmup() roda o modelo uma vez num tensor de zeros, movendo
esse custo pra onde o usuário já está olhando um spinner:
const det = await Detector.create("/models/yolov8n.onnx", { labels: "coco" });
await det.warmup(); // ainda na tela de carregamento
// a partir daqui, cada predict() é tempo de inferência de verdade
const result = (await det.predict(frame))[0];
Disponível nas quatro tarefas. warmup(2) roda duas vezes — uma basta pro
WASM, e o WebGPU às vezes só assenta na segunda. Em DetectClassify é onde mais
rende: dois modelos e a ponte compilam juntos na primeira inferência.
Aqueça as duas etapas de uma análise, não só a primeira
Num app que detecta e depois classifica com dois modelos separados, cada sessão paga a sua própria primeira inferência. Aquecer só o detector deixa o custo do classificador exatamente onde ele mais aparece: no instante entre o usuário terminar de esperar e a resposta surgir.
await Promise.all([detector.warmup(), classifier.warmup()]);
Saindo da main thread (env.wasm.proxy)
O backend WASM roda na thread que chamou ele — e essa é a main thread. Então
criar a sessão e cada predict() travam a interface enquanto rodam. Medido
num desktop de 32 núcleos: um warmup() de detector + classificador congelou a
página por 805 ms. Num celular de 4 núcleos / 2 GB, uma análise leva 50 a
103 s.
O ONNX Runtime tem uma flag pra isso. Com env.wasm.proxy ligada, ele cria um
Web Worker próprio (onnxruntime-web-proxy-worker) e manda create, run e release
por postMessage. O mesmo warmup medido acima: pior frame de 18 ms, zero
frames acima de 50 ms.
Ligue uma vez, antes da primeira sessão:
import { env } from "onnxruntime-web";
import { Detector } from "tempest-react-sdk/vision";
env.wasm.proxy = true; // antes do primeiro Detector.create
const det = await Detector.create("/models/yolov8n.onnx", { labels: "coco" });
await det.warmup(); // agora aquece sem travar a tela de carregamento
const result = (await det.predict(frame))[0];
Antes da primeira sessão, não depois
O ORT lê env.wasm quando inicializa o runtime WASM, e isso acontece dentro
do primeiro InferenceSession.create. Setar a flag depois disso é ignorado em
silêncio — a inferência volta pra main thread sem nada avisar. Num app com
sessões lazy, o lugar seguro é o começo da função que constrói a sessão, não
um hook de boot que um refactor pode reordenar.
O worker não deixa nada mais barato
Ele muda onde o custo é pago, não o quanto. O heap WASM e a reserva de memória compartilhada do build pthread apenas mudam de thread: um aparelho que não consegue criar a sessão na main thread também não consegue no worker.
Precisa do onnxruntime-web >= 1.17 e desta versão do SDK
O proxy posta os tensores de entrada com os ArrayBuffers na transfer list,
o que destaca o buffer deste lado. As pipelines de pré-processamento
reusam um Float32Array entre chamadas, e até a 0.42.0 reentregavam o buffer
destacado — o ORT rejeitava com
Tensor's size(1228800) does not match data length(0). a cada duas
inferências. Corrigido nesta versão (vision vendorada 0.7.1).
Quanto tempo levou
Todo envelope traz um speed com o tempo de cada etapa do predict(), em
milissegundos:
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 são as mesmas três chaves do
Ultralytics, medidas nas mesmas fronteiras. load é o fetch/decode que o
predict() faz por dentro quando você passa uma URL ou Blob — com cache
frio costuma ser a maior fatia da chamada. Criar a tarefa
(Detector.create) não entra: é custo de inicialização, pago uma vez.
Para medir o pipeline inteiro do app — incluindo o que acontece entre dois
predict() — use o módulo perf, e dobre o speed para dentro do
relatório com profiler.mark("forward-pass", results[0].speed.inference).
O SpeedTimer também é exportado daqui, para quem quiser as mesmas fronteiras
do SDK em código próprio.
O preprocess de hoje é ~2x mais rápido que o de antes
As quatro tarefas pré-processam por uma pipeline fundida: um único
drawImage redimensiona (e, quando há padding, posiciona) o conteúdo, e um
laço só lê o RGBA resultante escrevendo float32 planar num buffer reusado
entre frames. Medido no Chromium, letterbox pra 640×640: 19,8 → 10,7 ms
(1920×1080), 13,8 → 7,8 ms (1280×720), 6,8 → 3,1 ms (640×480) — com saída
bit-idêntica à do caminho antigo.
São duas pipelines porque as tarefas querem coisas diferentes:
LetterboxPipeline preserva a proporção e preenche o resto (detecção e
segmentação precisam desfazer essa geometria depois), e ResizePipeline
estica direto até a entrada do modelo já normalizando com mean/std — o
que o Classifier faz, já que ele não mapeia nada de volta pra imagem
original. Os primitivos (letterbox, resize, normalize, toCHW, …)
continuam exportados; quem quiser o caminho fundido em código próprio usa a
pipeline (ou letterboxToTensorData/resizeToTensorData, as formas de uma
chamada só).
Classificar era o caminho caro até a 0.41.0
O Classifier era a única tarefa ainda na rota composta
(resize → normalize → toCHW): três varreduras e três alocações por
predict(), ~1,4 MB de lixo novo a cada imagem 224×224. Num aparelho perto
do teto de memória do ORT, isso era gerado no pior momento possível. Se você
fixou uma versão anterior por causa disso, esta é a que resolve.
Referência: o que mais o subpath exporta
As tarefas cobrem o caminho comum. Abaixo está o resto da superfície — o que você usa quando monta um pipeline próprio, roda um modelo com uma cabeça que o SDK não conhece, ou precisa tratar uma falha específica.
| Grupo | Exports |
|---|---|
| Sessão | OrtSession (carrega o .onnx, expõe metadata/inputName), resolveProviders, DEFAULT_PROVIDERS, VisionTask (base das tarefas), VERSION |
| Entrada | loadImage (qualquer ImageInput → RGBImage), normalize, toTensor, toFloat32/toFloat32Tensor, zeroTensorData, fromCv2/toCv2 (BGR ↔ RGB) |
| Preprocess fundido | LetterboxPipeline + letterboxToTensorData (detecção/segmentação), ResizePipeline + resizeToTensorData (classificação), writePlanarFloat32 (o laço planar compartilhado) |
| Decodificação | decodeYolo (cabeça anchor-free, v8→v12), decodeYoloAnchors (cabeça com âncoras), decodeYoloSeg, nms, batchedNms |
| Rótulos | resolveLabels, defaultLabels, parseNames, modelNames, readModelMetadata, COCO_CLASSES |
| Views em massa | Boxes, Masks, Probs — as coleções "numpy-style" por trás de result.boxes/.masks/.probs |
| Erros | OrtVisionError (base), ModelLoadError, ImageLoadError, InferenceError, LabelMapError, ProviderNotAvailableError, NoDetectionsError, FusionError |
| Contrato de fusão | 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 |
| Auxiliares | requireDetections (a checagem por trás do raiseOnEmpty), SpeedTimer, softmax, topK |
Cabeça com âncoras: decodeYoloAnchors
decodeYolo cobre as cabeças anchor-free (o padrão de v8 em diante).
Um modelo mais antigo — YOLOv5/v7, ou um export customizado que mantém as
âncoras — decodifica com decodeYoloAnchors. Passar a saída errada pra
função errada não dá erro: dá caixa em lugar nenhum.
Todos os erros descendem de OrtVisionError
Um catch (err) { if (err instanceof OrtVisionError) … } pega tudo que o
subpath lança, e as subclasses separam o que dá pra tratar: ModelLoadError
(URL errada, 404, arquivo corrompido) pede outra URL,
ProviderNotAvailableError pede outro provider, LabelMapError é
configuração sua, e InferenceError é o modelo recusando a entrada.
Paridade com o ort-vision-sdk em Python
Essa API espelha de propósito a do pacote Python
ort-vision-sdk do mesmo autor:
Classifier / Detector / Segmenter, predict() devolvendo uma lista de 1
resultado por imagem, e os mesmos nomes idiomáticos do Ultralytics
(cls/conf/name/box, boxes.xyxy, probs.top5). Quem porta código de
visão entre o backend Python e o frontend TypeScript reaproveita o modelo mental
quase sem atrito.
A paridade vai além da forma da API: o raiseOnEmpty existe dos dois lados com
a mesma mensagem (inclusive o threshold formatado igual — conf_threshold=1
não vira 1.0 de um lado só), e um pipeline fundido pelo compose do Python é
lido aqui a partir do metadata que ele mesmo gravou. Fundir uma vez, rodar nos
dois runtimes, com o mesmo resultado.
Recap
- Importe de
tempest-react-sdk/vision— subpath dedicado. O código é vendorizado do@mauriciobenjamin700/ort-vision-sdk-web(MIT), então já vem no SDK, sem pacote extra. - O
onnxruntime-webé peer dep opcional: rodenpm i onnxruntime-webe sirva os.wasmda mesma versão. Quem não importa do/visionnão paga o peso. Providers: WebGPU → WASM (DEFAULT_PROVIDERS). - Quatro tarefas, mesma forma:
await Tarefa.create(modelo, opções)→(await tarefa.predict(imagem))[0]. Opredictsempre devolve um array de 1 elemento (um envelope por imagem). DetectClassifyroda detector + classificador num.onnxfundido (construído pelocomposedo SDK Python): um download, uma sessão, e a sub-categoria de cada objeto emd.classification. Modelo sem metadata de fusão →FusionError.warmup()paga a compilação de shader / arenas antes do usuário — chame na tela de carregamento.raiseOnEmptytransforma resultado vazio emNoDetectionsErrorquando zero linhas deve parar o fluxo (default: envelope vazio).- Itere o envelope com
for...ofpra resultados por-instância:d.name/d.className,d.confidence/d.conf,d.box/d.bbox(com.xyxy/.xywh/.asXywh()/.xyxyn()). Ou use a view em massaresult.boxes(.xyxy/.cls/.conf) eresult.names. - Entradas aceitas: URL
string,Blob,File,HTMLImageElement, canvas (HTMLCanvasElement/OffscreenCanvas),ImageBitmap,ImageDataeRGBImage. - Rótulos via
resolveLabels/COCO_CLASSES: preset"coco", array, dict esparso ou auto-gerado.labelsé opcional nas três tarefas — omitido, valem osnamesdo próprio.onnx, e só um modelo sem eles cai no preset"coco"(det/seg) ou emclass_<id>(classificação). - A API espelha o
ort-vision-sdkem Python — mesmo modelo mental nos dois lados. - Pra capturar o frame:
useCameraStream(câmera traseira por padrão,error.kindestável,retry()),computeImageLuminance+isLuminanceAcceptable+LowLuminanceErrorpra checar o brilho (threshold obrigatório) euseLiveLuminancepro feedback ao vivo. Pra pré-visualizar oBlobcapturado,useObjectUrl(barrel principal).