Pipelines fundidos (detecção → classificação)
Você tem dois modelos. Um detector acha os objetos; um classificador diz de que subtipo cada objeto é. O fluxo natural é encadear:
detections = detector.predict("rebanho.jpg")[0]
for d in detections:
sub = classifier.predict(d.cropped_image)[0] # 😐 uma sessão a mais, uma volta a mais
Isso funciona — e custa caro. São duas sessões, dois carregamentos de modelo, e uma ida e volta pelo Python (ou pelo JavaScript) para cada recorte: fatiar, redimensionar, empilhar em batch, chamar o segundo runtime. No celular ou numa aba de navegador, essa volta costuma dominar o tempo total.
O módulo ort_vision_sdk.compose remove as duas coisas. Ele reescreve os dois
.onnx num único grafo: o detector, uma ponte que recorta e redimensiona as
caixas, e o classificador. Um arquivo, uma sessão, um carregamento — e os
recortes nunca saem do runtime.
flowchart LR
A[imagem<br/>letterbox] --> B[detector]
B --> C[NMS]
C --> D[RoiAlign<br/>recorta + redimensiona]
A --> D
D --> E[normaliza]
E --> F[classificador]
F --> G[probs]
C --> H[boxes / scores / classes]
Fundir é passo de build, rodar não é
A fusão precisa da biblioteca onnx (o extra [compose]), porque ela
reescreve protobufs. O arquivo resultante é um .onnx comum: rodar não
precisa de nada além do onnxruntime que o SDK já traz — inclusive no
navegador, onde o SDK web carrega exatamente o mesmo arquivo.
Instalando o extra
pip install "ort-vision-sdk[compose]"
Fundindo os dois modelos
from ort_vision_sdk.compose import fuse_detect_classify
fuse_detect_classify(
"yolov8n.onnx", # detector: cabeça YOLO anchor-free (v8..v26)
"resnet18.onnx", # classificador: uma entrada NCHW, uma saída (batch, classes)
"pipeline.onnx", # onde gravar o modelo fundido
max_detections=20, # quantas caixas o pipeline reporta por imagem
conf_threshold=0.25, # limiar de score, gravado dentro do NMS do grafo
iou_threshold=0.45, # limiar de IoU, idem
)
Pronto. pipeline.onnx é um modelo autocontido.
A fusão se valida sozinha
Antes de retornar, fuse_detect_classify roda o grafo fundido uma vez no
ONNX Runtime. Isso é o que pega o erro mais comum — um classificador cujo
grafo só aceita o batch com que foi exportado (um Reshape com 1 fixo lá
dentro). Você descobre na hora de fundir, não em produção. Passe
validate=False para pular.
Rodando
from ort_vision_sdk import DetectClassify
pipeline = DetectClassify("pipeline.onnx")
result = pipeline.predict("rebanho.jpg")[0]
for d in result:
print(d.name, d.conf, d.box.xyxy) # o que o detector achou
print(d.classification.name, d.classification.conf) # o que o classificador disse
import { DetectClassify } from "@mauriciobenjamin700/ort-vision-sdk-web";
const pipeline = await DetectClassify.create("/models/pipeline.onnx");
const result = (await pipeline.predict("/images/rebanho.jpg"))[0];
for (const d of result) {
console.log(d.name, d.conf, d.box.xyxy);
console.log(d.classification?.name, d.classification?.conf);
}
Você não repetiu nenhuma configuração ao carregar. Resolução do letterbox, tamanho do recorte, se a saída ainda precisa de softmax, os nomes das classes dos dois estágios — tudo isso foi decidido na fusão e gravado nos metadados do próprio arquivo. É a mesma ideia de O modelo manda, levada ao pipeline inteiro.
Dois espaços de rótulo, separados
Um detector que acha sheep alimentando um classificador que responde
famacha_3 não compartilha id de classe nenhum com ele. Por isso o envelope
carrega dois mapas, e a resposta do classificador mora no seu próprio campo:
result.names # {0: 'sheep', 1: 'goat'} — estágio de detecção
result.classifier_names # {0: 'famacha_1', 1: 'famacha_2', ...} — estágio de classificação
d = result[0]
d.cls, d.name # classe do detector
d.classification.cls # classe do classificador — outro espaço, outro id
Não compare d.cls com d.classification.cls
São perguntas diferentes: que tipo de objeto é esse e de que subcategoria esse objeto é. Juntar os dois num campo só perderia uma das respostas.
Escolhendo de onde vêm os recortes
Esta é a decisão que mais muda o resultado.
Recorta do próprio tensor 640×640 já letterboxado. O grafo tem uma entrada só e é o mais simples de operar.
fuse_detect_classify(det, clf, "pipeline.onnx", crop_source="detector_input")
O custo: um objeto pequeno é classificado a partir da cópia reduzida. Uma caixa de 40×40 px dentro do letterbox de 640 vira um recorte de 224×224 ampliado a partir de 40×40 pixels de detalhe real.
Adiciona uma segunda entrada em resolução nativa. A ponte desfaz o letterbox dentro do grafo e recorta da imagem original.
fuse_detect_classify(det, clf, "pipeline.onnx", crop_source="original")
São dois tensores para alimentar — mas o SDK monta os dois para você, e continua sendo uma sessão e um carregamento. Use esta opção quando o classificador depende de detalhe fino (textura, cor de mucosa, lesão pequena).
As caixas são idênticas nos dois modos
O grafo sempre reporta as caixas no espaço letterboxado do detector, e o
runtime desfaz essa transformação do mesmo jeito nos dois casos. Trocar o
crop_source muda a qualidade do recorte, nunca as coordenadas.
Quantas caixas o pipeline reporta
Por padrão o pipeline tem um número fixo de linhas: max_detections. Sobras
são preenchidas com zeros, e a saída num_detections diz quantas são reais — o
runtime já ignora o resto para você.
fuse_detect_classify(det, clf, "pipeline.onnx", max_detections=20) # 20 linhas, sempre
Isso é o que mantém todos os shapes do grafo estáticos, e shapes estáticos
são o que TensorRT, NNAPI e WebGPU precisam para compilar o modelo. É também o
que elimina o caso de zero detecções: o classificador sempre recebe K
recortes, nunca um batch vazio que alguns providers se recusam a executar.
O preço é rodar o classificador K vezes mesmo quando há 2 objetos. Se isso
pesar mais que a compilação estática, use o modo dinâmico:
fuse_detect_classify(det, clf, "pipeline.onnx", max_detections=None)
O que o modo dinâmico exige
O classificador precisa ter sido exportado com eixo de batch dinâmico, e precisa tolerar um batch de zero linhas (o que acontece quando nada passa do limiar). A validação da fusão testa exatamente esse caso, então você descobre na hora se o seu modelo não aguenta.
Normalização do classificador
O recorte sai do grafo em [0, 1]. O que tem que acontecer depois disso depende
inteiramente de como o seu classificador foi treinado — e as duas famílias
mais comuns discordam:
| Família | Espera | Preset |
|---|---|---|
| torchvision / timm | tensor normalizado com média e desvio do ImageNet | "imagenet" |
Ultralytics (YOLO(...).export()) |
[0, 1] cru, sem normalização nenhuma |
"ultralytics" |
Por isso o padrão é normalization="auto": a fusão lê os metadados do
classificador e escolhe sozinha.
from ort_vision_sdk.compose import fuse_detect_classify
# Nada a decidir: o arquivo diz o que ele é.
fuse_detect_classify(det, "yolo11s-cls.onnx", "pipeline.onnx")
Todo export do Ultralytics carrega author: Ultralytics e task: classify no
próprio .onnx. Esse par é determinístico, e o SDK já lia esse bloco para
aproveitar os nomes de classe — então a informação necessária para acertar
sempre esteve dentro do arquivo que você entrega.
O modo de falha que isto elimina
Até a 0.8.0 o padrão era ImageNet para todo mundo. Fundir um classificador
Ultralytics com esse padrão insere um Sub/Div que o modelo nunca viu no
treino — e nada reclama: não levanta exceção, não emite warning, e
validate=True passa (ele confere forma, não semântica). O grafo roda,
devolve probs com a forma certa e classifica pior. Sem medir concordância
contra a rota não-fundida, não há como perceber.
Escolhendo à mão
fuse_detect_classify(det, clf, "pipeline.onnx", normalization="imagenet")
fuse_detect_classify(det, clf, "pipeline.onnx", normalization="ultralytics")
fuse_detect_classify(det, clf, "pipeline.onnx", normalization="none")
"none" é a mesma aritmética que "ultralytics" — identidade — com um nome que
diz "este modelo quer [0, 1] cru" em vez de citar um fornecedor.
Valores próprios
Um modelo cuja normalização nenhum preset descreve recebe os números direto:
fuse_detect_classify(
det, clf, "pipeline.onnx",
mean=(0.5, 0.5, 0.5),
std=(0.5, 0.5, 0.5),
input_scale=1.0, # 255.0 se o seu modelo espera 0..255
)
mean e std são independentes
Passar só um dos dois deixa o outro no valor do preset que o auto
escolheria — passar um mean não zera o desvio para 1 pelas suas costas.
Passar mean/std junto com normalization levanta ValueError: são duas
respostas para a mesma pergunta, e adivinhar qual delas você quis dizer é como um
grafo acaba normalizado de um jeito e com metadado dizendo outro.
E se o classificador for um export Ultralytics e você pedir uma normalização que
não é identidade, sai um UserWarning — o grafo é construído do mesmo jeito, mas
você fica sabendo.
O que ficou registrado no arquivo
A escolha vai para os metadados do modelo fundido, em
ovs.classifier_normalization:
python -c "import onnx; print({k.key: k.value for k in onnx.load('pipeline.onnx').metadata_props}['ovs.classifier_normalization'])"
# ultralytics
Uma vez fundida, a normalização é um punhado de nós Sub/Div no meio do
grafo — invisível. "Que preprocessamento este pipeline assume?" é a primeira
pergunta quando um modelo fundido classifica pior que a cascata que ele
substituiu, e agora o arquivo responde.
Passos que seriam identidade (mean zero, std unitário, escala 1.0) não viram
nós — um classificador que quer o recorte cru em [0, 1] não paga aritmética
nenhuma.
Três estágios: segmentando dentro do recorte
Às vezes o recorte não basta: você quer classificar só o objeto, sem o fundo que a caixa inevitavelmente traz junto. Para isso existe um terceiro estágio.
from ort_vision_sdk.compose import fuse_detect_segment_classify
fuse_detect_segment_classify(
"yolov8n.onnx",
"unet.onnx",
"resnet18.onnx",
"pipeline.onnx",
max_detections=10,
)
O grafo resultante faz, num arquivo só: detecta → recorta → segmenta cada recorte → multiplica o recorte pela máscara → classifica.
flowchart LR
A["images"] --> B["detector"]
B --> C["NMS + RoiAlign"]
C --> D["recortes"]
D --> E["segmentador"]
E --> F["máscara binária"]
D --> G["Mul"]
F --> G
G --> H["classificador"]
F --> I["masks"]
H --> J["probs"]
Isto não é para um YOLO-seg
Um export YOLO-seg já detecta e segmenta num modelo só. Colocar um detector separado na frente dele duplica trabalho. Os três estágios são para quando o segmentador é uma rede imagem→máscara comum (U-Net e parentes), que não tem detector próprio e trabalha melhor num recorte apertado do que no quadro inteiro.
Lendo as máscaras
from ort_vision_sdk import DetectClassify
pipeline = DetectClassify("pipeline.onnx")
result = pipeline.predict("foto.jpg")[0]
for d in result.detections:
print(d.name, d.conf, d.mask.shape) # (altura_da_caixa, largura_da_caixa)
d.mask chega já no formato da caixa, binária (0/255) uint8 — o mesmo
contrato que o Segmenter entrega. Quem já trata a máscara de um segmentador
trata esta sem mudar nada.
No grafo, a máscara vive no espaço do recorte
A saída masks do .onnx tem forma (K, 1, crop_h, crop_w), porque é onde
o segmentador a calculou. Remapear para a imagem original é um resize por
instância, com geometria diferente a cada caixa — a task faz isso para você
(vizinho mais próximo, já que os valores são binários), e quem dirige o
grafo na mão faz a partir da linha de boxes correspondente.
O que dá para ajustar
| Parâmetro | Para quê |
|---|---|
mask_threshold |
Probabilidade a partir da qual um pixel é primeiro plano (default 0.5). |
mask_channel |
Qual canal do segmentador é o primeiro plano. 0 para cabeça de um canal. |
mask_activation |
"sigmoid" (um canal) ou "softmax" (canais competindo). None decide pelo grafo. |
apply_mask |
False reporta as máscaras sem gatear o classificador — use quando ele foi treinado em recortes sem máscara. |
segmenter_mean / segmenter_std |
Normalização do segmentador, independente da do classificador. |
Os dois últimos estágios compartilham o crop_size
A máscara é calculada no espaço do recorte e multiplica esse recorte diretamente. Se o segmentador e o classificador declararem resoluções diferentes, a fusão recusa e diz quais são as duas, em vez de reamostrar por trás e entregar uma máscara desalinhada do que ela descreve.
Verificando que a máscara chegou ao classificador
O teste que o SDK usa para isso vale como receita: fixe um recorte de cor
uniforme e compare as probabilidades com apply_mask=True e False.
Mascarar metade de um recorte vermelho puro leva a média do canal de 1,0
para 0,5 — um número que só aparece se a máscara realmente multiplicou.
Quando não detectar nada é um erro
Um pipeline que não acha nada devolve um envelope vazio — o classificador nem
chega a ter linha para responder. Se o passo seguinte depende de ter alguma
coisa ali, raise_on_empty transforma isso em exceção, exatamente como no
Detector:
pipeline = DetectClassify("pipeline.onnx", raise_on_empty=True)
pipeline.predict("pasto-vazio.jpg") # -> NoDetectionsError
O limiar citado na mensagem é o efetivo: o maior entre o que foi congelado
no NMS do grafo na fusão e qualquer conf_threshold mais estrito passado na
chamada.
Quando fundir compensa
Fundir não é sempre mais rápido. A intuição diz que sim — um artefato, uma sessão, sem ida e volta pelo host — mas o custo de verdade depende de onde o tempo está, e num pipeline de detecção ele quase nunca está na ponte.
Estes números vêm de um pipeline real (detecção de mucosa ocular a 640 → classificação a 224, i9-13900F de 12 núcleos, RTX 4070 Ti SUPER, onnxruntime 1.24.3), 200 passadas cronometradas com 20 de aquecimento, mediana de 20 repetições:
| Device | Topologia | Sessões | Latência | vs cascata |
|---|---|---|---|---|
| CPU | cascata .onnx, 6+6 threads |
2 | 33,06 ms | 1,00× |
| CPU | fundido | 1 | 50,65 ms | 0,65× |
| CPU | cascata .pt (torch) |
2 | 69,45 ms | 0,48× |
| GPU | cascata .onnx, 6+6 threads |
2 | 5,97 ms | 1,00× |
| GPU | fundido | 1 | 5,74 ms | 1,04× |
| GPU | cascata .pt (torch) |
2 | 11,24 ms | 0,53× |
Na CPU o grafo fundido foi 53 % mais lento que duas sessões bem configuradas. Na GPU empatou.
Por que o teto é baixo
A quebra por estágio da cascata na CPU explica tudo:
| Estágio | ms | % do total |
|---|---|---|
| detector | 25,79 | 78,0 % |
recorte (cv2 no host) |
1,49 | 4,5 % |
| classificador | 5,00 | 15,1 % |
A fusão só pode remover o recorte no host e um repasse entre sessões — 4,5 %
de teto. E os ~68 nós da ponte (NMS, TopK, Pad, clamp, RoiAlign) custam
+17,6 ms na CPU, bem mais que o cv2.resize que substituem. Na GPU esses nós
são baratos e o empate reaparece.
Capacidade tem preço
Uma ponte mínima escrita à mão (ArgMax + RoiAlign, 22 nós, sem NMS, sem
padding, um objeto só) marcava 0,99× nessa mesma máquina. A diferença entre
0,99× e 0,65× é o preço de NMS com limiar, TopK, padding para forma
estática e clamp — justo pelo que compra, mas grande na CPU.
A armadilha que inverte a conclusão
Cascata mal configurada faz a fusão parecer 5× mais rápida
A primeira medição desse estudo deu cascata 148 ms contra fundido 30 ms. O número era falso: o detector sozinho marcava 110 ms dentro da cascata, e o grafo fundido contém exatamente o mesmo detector.
A causa é o ONNX Runtime dar a cada InferenceSession o seu próprio pool
de intra-op. Numa cascata os dois estágios se alternam, e o pool que
acabou de terminar fica em spin-wait roubando núcleos do que está rodando:
| Configuração | Total (CPU) |
|---|---|
| 12 + 12 threads | 140,6 ms |
12 + 12, intra_op.allow_spinning = 0 |
49,4 ms |
| 6 + 6 threads | 33,7 ms |
O grafo fundido tem um pool e é imune a isso por construção — então
comparar contra uma cascata mal configurada dá à fusão uma vitória que ela
não conquistou. A baseline honesta divide o orçamento de threads entre as
sessões. (torch não sofre disso: torch.set_num_threads configura um pool
único para o processo.)
Reproduzindo a cascata numericamente
Para o grafo fundido reproduzir uma cascata que recorta com cv2.INTER_LINEAR:
sampling_ratio=1— resample bilinear simples. O default0adapta ao tamanho da caixa e faz anti-aliasing: melhor em geral, mas amostra diferente de umcv2.resize.coordinate_transformation_mode="half_pixel"noRoiAlign, que é o que a ponte já usa: reproduz ocv2.INTER_LINEARcom erro absoluto médio de 4·10⁻⁵ numa imagem em[0, 1], contra 185× mais nooutput_half_pixel.
Com os dois, a concordância de predição entre fundido e cascata ficou em 0,982 (3 divergências em 169 amostras, todas com confiança entre 0,47 e 0,60 — em cima da fronteira de decisão), delta mediano de probabilidade 3,4·10⁻⁵, e caixa selecionada idêntica (0,000 px).
Recomendação
- Compensa por portabilidade e operação: um artefato, uma sessão, formas estáticas (TensorRT/NNAPI/WebGPU), o mesmo arquivo rodando no SDK web.
- Não compensa por latência na CPU, e empata na GPU, quando a alternativa é uma cascata com o orçamento de threads dividido.
- Antes de medir, olhe a quebra por estágio com
timings: se o detector é ~80 % do custo, o teto do ganho da fusão é o que sobra.
Limites que valem saber
- A cabeça precisa ser YOLO anchor-free — saída
(1, 4 + nc, N), a mesma família que oDetectoraceita. Cabeças com objectness explícito (v5/v6/v7) ou com NMS embutido (v10 end2end) são recusadas com uma mensagem clara em vez de lerem os canais errados em silêncio. - Os limiares ficam congelados no grafo.
conf_thresholdeiou_thresholdvão para dentro do nó de NMS. No runtime você pode filtrar mais (predict(img, conf_threshold=0.6)), nunca menos — para baixar o limiar, funda de novo. - O NMS fundido pontua todas as classes. O decodificador Python colapsa cada
âncora no seu
argmaxantes de suprimir; oNonMaxSuppressiondo ONNX avalia cada classe de forma independente. Uma âncora que passa do limiar em duas classes vira duas linhas aqui e uma linha lá. - Opsets são reconciliados para cima. Se os dois modelos foram exportados em
versões diferentes, o mais antigo é convertido — e o piso é o opset 16, exigido
pelo
RoiAlignda ponte. boxesé a caixa que foi classificada. Uma caixa que sai do quadro é recortada aos limites da imagem antes de virar recorte, e é essa versão recortada que sai emboxes— desenhar o retângulo e olhar o recorte mostram a mesma região. Até a 0.8.0 as duas divergiam:boxesreportava a caixa inteira e oRoiAlignrecebia a recortada. Linhas de padding continuam zeradas.
Recapitulando
fuse_detect_classifytransforma detector + classificador em um.onnx.- Fundir precisa do extra
[compose]; rodar não precisa de nada a mais. DetectClassify(Python e Web) carrega o arquivo e se configura sozinho a partir dos metadados gravados na fusão.crop_source="original"custa uma entrada a mais e devolve a resolução nativa ao classificador.max_detectionsfixo mantém os shapes estáticos;Nonetroca isso por menos trabalho por execução.
Próximos passos
- Detecção — a cabeça que o estágio de detecção precisa ter.
- Classificação — a normalização que o segundo estágio espera.
- O modelo manda — por que a configuração mora no arquivo.
- API Python e API Web.