The model decides
Two things about an .onnx live inside the file: the resolution it accepts
as input, and the class names it emits. Restating those numbers in configuration
is where the classic headache comes from — the configuration ages, the file gets
re-exported, and the two drift apart with nobody noticing.
The SDK reads both from the model. This page shows how, and what is left for you to configure. 🚀
The problem: 640 against 224
An Ultralytics detection export comes out at 640×640. A classification export
(-cls) comes out at 224×224 — that is the tool's default. If your code
assumes 640 for both, the classifier dies mid-inference:
Got invalid dimensions for input: images for the following indices
index: 2 Got: 640 Expected: 224
There is no way to see that coming from the outside
The number lives in the graph. No configuration, manifest or constant
sitting next to the file is the source of truth about it — only the .onnx
itself is.
The fix: ask the graph
You configure nothing:
from ort_vision_sdk import Classifier
clf = Classifier("classify.onnx")
print(clf.input_size)
#> (224, 224)
The SDK read the shape the graph declares ([1, 3, 224, 224]) and will
preprocess at that resolution. A detector in the same program resolves its own:
from ort_vision_sdk import Detector
det = Detector("detect.onnx")
print(det.input_size)
#> (640, 640)
Read back what actually ran
task.input_size is the resolution inference really used — handy for
logs and telemetry, where reporting the configured value hides exactly the
bug you are hunting.
What if I pass input_size?
The graph wins, and the SDK says so:
clf = Classifier("classify.onnx", input_size=(640, 640))
#> UserWarning: The model declares a 224x224 input; ignoring the requested
#> 640x640, which ONNX Runtime would reject.
print(clf.input_size)
#> (224, 224)
That is deliberate. A static shape is not a preference: it is the only thing ONNX Runtime will accept. Obeying you there would just trade a fixable problem for a failed run.
When input_size still matters
A model exported with dynamic axes (dynamic=True in Ultralytics) declares
height and width as symbols, so it accepts many resolutions. The graph then has
nothing to say, and your value stands:
clf = Classifier("dynamic.onnx", input_size=(384, 384))
print(clf.input_size)
#> (384, 384)
Precedence, in one line
static graph → what you passed → the task default (224 for classification, 640 for detection/segmentation).
Labels straight from the model
Ultralytics writes names into the .onnx metadata — the dict[int, str] map
from class id to name. A hand-maintained list beside it can be reordered by
accident, and the effect is the worst kind: nothing fails, predictions simply
swap classes.
With no labels, the SDK uses what the model declares:
from ort_vision_sdk import Detector
det = Detector("detect.onnx")
print(det.labels)
#> ('ocular-mucosa',)
This also fixes an old stumble
A custom detector used to fail without an explicit labels: the default
was the 80-name COCO preset, which disagreed with the model's class count.
Now it resolves its own name.
Precedence follows the same idea:
# 1) What you pass always wins
det = Detector("detect.onnx", labels=["mucosa"])
# 2) No labels: the model's `names`
det = Detector("detect.onnx")
# 3) Model without `names`: "class_0", "class_1", ...
# (the COCO preset only applies when the model really has 80 classes)
Safe parsing
The value is read with ast.literal_eval, so malformed — or hostile —
metadata is rejected, never executed. A map that is not a dict of
contiguous integers starting at zero is discarded whole, rather than applied
halfway.
Reading the metadata yourself
The full map is available on the session:
from ort_vision_sdk import Classifier
clf = Classifier("classify.onnx")
print(clf.session.metadata["task"])
#> classify
print(clf.session.input_shape)
#> (1, 3, 224, 224)
And the pure helpers behind all of this are public, for anyone assembling their own pipeline:
from ort_vision_sdk import model_names, resolve_input_size, spatial_input_size
spatial_input_size((1, 3, 224, 224))
#> (224, 224)
spatial_input_size((1, 3, "h", "w"))
#> None
resolve_input_size(graph_shape=(1, 3, 224, 224), requested=None, fallback=(640, 640))
#> (224, 224)
model_names({"names": "{0: 'deworm', 1: 'not_deworm'}"})
#> {0: 'deworm', 1: 'not_deworm'}
Technical details: backends that cannot read metadata
A backend that only forwards tensors to a native runtime (an Android bridge,
onnxruntime-web through Pyodide) cannot read the metadata map. That is why
the capability is a separate protocol, MetadataBackend, instead of a
required member of InferenceBackend: tasks probe with read_metadata()
and simply get nothing when the backend does not offer it. Backends written
before this keep working.
Recap
- The input resolution comes from the graph;
input_sizeis a fallback for dynamic-axis models. - Passing a size that contradicts a static graph raises a
UserWarningand is ignored — ORT would reject it anyway. task.input_sizetells you what inference actually used.- With no
labels, names come from the model metadata (Ultralytics'names), falling back toclass_<id>when the model carries none — or to the COCO preset when the model really does have 80 classes. session.metadata,session.input_shapeand thespatial_input_size/resolve_input_size/model_nameshelpers are public.
See also
- Python guide — accepted inputs, providers, async inference.
- Web (browser) — the same behavior in the TypeScript SDK.
- Inference backends — the
InferenceBackendprotocol.