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]. A ponte aplica a normalização do seu
classificador logo em seguida, com os mesmos parâmetros que a tarefa
Classifier usaria:
fuse_detect_classify(
det, clf, "pipeline.onnx",
mean=(0.485, 0.456, 0.406), # padrão: ImageNet
std=(0.229, 0.224, 0.225), # padrão: ImageNet
input_scale=1.0, # 255.0 se o seu modelo espera 0..255
)
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.
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.
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.
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.