Ir para o conteúdo

Meia precisão (FP16)

Um export half=True do Ultralytics ocupa metade do arquivo: 5,11 MB contra 10,11 MB num detector, 10,41 MB contra 20,78 MB num classificador. Num navegador, isso decide se a página abre.

Este guia mostra como usar esses arquivos nos dois SDKs — e o que o SDK faz por baixo, porque a parte interessante é onde a precisão não pode ser meia.

Exportando

from ultralytics import YOLO

YOLO("yolo11n.pt").export(format="onnx", imgsz=640, half=True, opset=19)
YOLO("yolo11s-cls.pt").export(format="onnx", imgsz=224, half=True, opset=19)

O .onnx resultante declara a entrada como tensor(float16). É essa declaração que o SDK lê.

Python: nada muda

from ort_vision_sdk import Detector

det = Detector("yolo11n_fp16.onnx", labels="coco")
result = det.predict("foto.jpg")[0]

for d in result:
    print(d.name, d.conf, d.box.xyxy)

Mesmo código de sempre. O que mudou por dentro:

from ort_vision_sdk import Detector

det = Detector("yolo11n_fp16.onnx")
print(det.session.input_dtype)  # tensor(float16)
print(det.input_dtype)          # float16  (o dtype NumPy do feed)

Por que o pré-processamento continua em float32

(valor / 255 - mean) / std em meia precisão perde exatamente as pequenas diferenças que a normalização existe para preservar. O SDK pré-processa em float32 e converte uma vez, na fronteira do feed — contra o tipo que cada entrada declara.

Web: igual, com um requisito de navegador

import { Detector } from "@mauriciobenjamin700/ort-vision-sdk-web";

const det = await Detector.create("/models/yolo11n_fp16.onnx");
const result = (await det.predict("/images/foto.jpg"))[0];

for (const d of result) console.log(d.className, d.confidence, d.bbox.asXyxy());

O ONNX Runtime exige um Float16Array nativo para um tensor half — os mesmos bits num Uint16Array são recusados. Onde o navegador não tem esse construtor, o modelo é recusado já em create():

ModelLoadError: This model declares half-precision input(s) [images], but this
environment has no Float16Array, which ONNX Runtime requires for a float16
tensor. Use a float32 export of the model, or run in a browser that supports
Float16Array.

Cheque antes de oferecer o download do modelo menor

import { hasFloat16Array } from "@mauriciobenjamin700/ort-vision-sdk-web";

const model = hasFloat16Array() ? "/models/det_fp16.onnx" : "/models/det.onnx";

session.inputMetadata não serve para isso

No onnxruntime-web 1.20.1 ele é undefined. O tipo declarado é lido do próprio arquivo .onnx, no mesmo passe que já lê os nomes de classe — mesmo download, nenhuma requisição nova.

Pipelines fundidos: float16 nas pontas, float32 no meio

Fundir dois estágios FP16 não produz um grafo todo em meia precisão, e isso não é preferência:

O NonMaxSuppression do ONNX só existe em float32

O operador é definido para T = tensor(float). Não há variante float16. Uma ponte inteiramente em meia precisão é impossível de construir.

Então a fusão casta nas costuras, e só nelas:

flowchart LR
    A["images<br/>float32"] -->|Cast| B["detector<br/>float16"]
    B -->|Cast| C["ponte: NMS + RoiAlign<br/>float32"]
    C -->|Cast| D["classificador<br/>float16"]
    D -->|Cast| E["probs<br/>float32"]
    A -.->|RoiAlign recorta daqui| C
from ort_vision_sdk.compose import fuse_detect_classify

fuse_detect_classify(
    detector_onnx="det_fp16.onnx",
    classifier_onnx="clf_fp16.onnx",
    output_path="fused_fp16.onnx",
    image_size=640,
    crop_size=224,
    max_detections=1,
)

A entrada pública do grafo fundido continua float32, que é o que todo pipeline de pré-processamento produz — e é de lá que o RoiAlign recorta, mantendo a geometria em precisão simples.

O que dá para conferir no artefato

import onnx

fused = onnx.load("fused_fp16.onnx")
print([n.name for n in fused.graph.node if n.op_type == "Cast"][:4])
# ['ovs_bind_input', 'ovs_cast_detector_output', 'ovs_cast_crops', 'ovs_bind_probs']

O que a meia precisão custa

As saídas voltam para float32 antes de qualquer decodificação, e há um motivo medido para isso:

Coordenada Espaçamento em float16
320 px 0,25 px
640 px 0,5 px
1280 px 1,0 px
2048 px 2,0 px

Em float16, 640.3 é 640.5. Decodificar caixas nesse tipo quantizaria cada coordenada antes do NMS e do retorno às coordenadas da imagem original — por isso o SDK alarga a saída na fronteira, e você nunca vê meia precisão numa BoundingBox.

No WASM, FP16 compra bytes — não tempo

Não existe kernel de meia precisão no backend WASM: o cast é trabalho a mais. Medido num pipeline fundido real (2 baterias × 30 repetições, máquina ociosa, medianas reproduzindo entre 0,4 % e 1,1 %):

Variante Mediana Arquivo
fp32 fundido 63,2 / 62,7 ms 30,92 MB
fp16 fundido 87,5 / 88,2 ms 15,54 MB

+39 % de latência para 1,99× menos bytes. Isso inverte a expectativa que FP16 cria em servidor. Escolha FP16 no navegador quando o gargalo é download/memória, não quando é tempo de inferência. Com WebGPU a pergunta é outra — e onde não há adaptador, não há resposta melhor.

Onde a diferença aparece de verdade

Os pesos em meia precisão mudam as ativações, e portanto as confianças, nas casas decimais. A classe prevista raramente muda; o número ao lado dela muda um pouco. Se o seu limiar está calibrado na terceira casa, recalibre depois de trocar para FP16.

Recap

  • Exporte com half=True, use o arquivo normalmente: nenhuma mudança de código nos dois SDKs. 🚀
  • O SDK lê o tipo que o grafo declara, converte o feed na fronteira e alarga a saída antes de decodificar.
  • No navegador, Float16Array é obrigatório; sem ele o modelo é recusado em create(), com mensagem dizendo isso.
  • Num pipeline fundido, os estágios ficam em float16 e a ponte em float32 — o NonMaxSuppression não aceita outra coisa.