Custom Model Package Specification
This is the contract between a model package and the Pixano Inference core. The core ships no model: every model, first-party or yours, is a package that follows this specification and is plugged in at runtime. MUST, SHOULD and MAY are used in the RFC 2119 sense.
The reference implementation is
examples/numpy_detector,
a complete, framework-free detector; each rule below says how it meets it.
examples/yolo shows the
same contract around a third-party framework (ultralytics). The
Custom Models Guide is the tutorial version of this page.
examples/numpy_detector/
├── pyproject.toml # metadata, dependencies, entry point (§1)
├── uv.lock # the package's own lock file (§1.3)
├── LICENSE
├── src/pixano_numpy_detector/
│ ├── __init__.py
│ └── model.py # params + model class (§2, §3)
└── tests/test_model.py # discovery, params, predict (§6)
1. Package
1.1 One distribution per model. A model package MUST be an installable Python
distribution with its own pyproject.toml. It MAY contain several models of one family (the
SAM2 package registers an image and a video model). Its requires-python MUST overlap the
core's (>=3.10,<3.14).
1.2 Dependencies. The package MUST declare pixano-inference and every library it
imports, its ML framework included. It MUST NOT rely on a dependency another package happens
to install, and MUST NOT import another model package. PyTorch models MAY depend on
pixano-inference-torch for device, dtype and tensor helpers.
1.3 Environment. The package SHOULD keep its own uv.lock, so it is developed, tested
and served from its own environment, independent of the core's repository and of other
models. [tool.uv.sources] MAY point pixano-inference at a clone or a git ref of the core
for development.
1.4 Entry point. The package MUST declare one entry point per model module (or class) in
the pixano_inference.models group. The target is module or module:Class; importing it
MUST register the model class and its params (§2.2, §3). The server imports every entry point
of the group at startup, and Ray Serve workers import the model's module from the same
installed package, so nothing else is needed for a model to be found by name.
In the example (pyproject.toml):
[project]
name = "pixano-numpy-detector"
requires-python = ">=3.10,<3.14"
dependencies = ["pixano-inference[server] >= 0.7.1, < 0.8.0", "numpy >= 1.26.0, < 3.0.0", "Pillow >= 9.0.0", "pydantic >= 2.0.0, < 3.0.0"]
[project.entry-points."pixano_inference.models"]
numpy_detector = "pixano_numpy_detector.model"
2. Model class
2.1 Base class. A model MUST subclass exactly one capability base from
pixano_inference.models. The base fixes the capability, the HTTP route and the typed
input/output:
| Base class | Capability | Route | Input → Output |
|---|---|---|---|
DetectionModel |
detection |
/v1/inference/detection |
DetectionInput → DetectionOutput |
SegmentationModel |
segmentation |
/v1/inference/segmentation |
SegmentationInput → SegmentationOutput |
TrackingModel |
tracking |
/v1/inference/tracking |
TrackingInput → TrackingOutput |
VLMModel |
vlm |
/v1/inference/vlm |
VLMInput → VLMOutput |
NERModel |
ner |
/v1/inference/ner |
NERInput → NEROutput |
EmbeddingModel |
embedding |
/v1/inference/embedding |
EmbeddingInput → EmbeddingOutput |
A subclass of InferenceModel that is not one of these is rejected by ModelConfig.
A TrackingModel is either prompted or prompt-free. A prompted model (SAM2) receives
objects_ids with one keyframe per object and returns their masks. A prompt-free model
(tracking by detection, such as ByteTrack) receives only the video, optionally with classes
and box_threshold, creates the tracks itself and returns their boxes and scores. Both return
a TrackingOutput with one TrackedFrame per frame, each listing the TrackedObjects alive in
that frame: a track_id with a mask, or a box ([x1, y1, x2, y2] in pixels of the frame)
with a score and a class_name. A model that supports only one of the two request styles
MUST raise a ValueError on the other.
2.2 Registration. The class MUST be decorated with @register_model("Name"). Name is
what configs put in model_class; it MUST be unique across the installed packages (a second
registration of the same name raises, and that package fails to load).
2.3 Construction. The constructor is __init__(self, config: ModelDeploymentConfig), with
ModelDeploymentConfig imported from pixano_inference.configs. An override MUST call super().__init__(config) and MUST NOT load weights or import the
framework: the replica constructs the model and immediately calls load_model() (§2.4), so
the constructor only initializes attributes.
2.4 load_model(). Called once per replica, in the Serve actor's __init__. It MUST leave
the model ready for predict(). It reads its settings from self.config.model_params (a
dict, see §3) and its resources from self.config.resources (num_gpus, num_cpus). A
failure here fails the deployment: at startup with --config, or as the error of
POST /v1/models.
2.5 predict(input). MUST accept the base class's input type and return its output type,
synchronously. Each capability base class is InferenceModel[Input, Output] (for example
DetectionModel is InferenceModel[DetectionInput, DetectionOutput]), so a type checker
verifies the signature. Calls on one replica are serialized by the server, so the model need not be
thread-safe. Anything raised fails that request with a 500 error envelope (the exception is
logged server-side, its text is not returned); a call that outlives the deployment's
timeout_s fails with 504.
2.6 predict_batch(inputs). MAY be overridden. It is used instead of predict() only when
the deployment sets max_batch_size > 1, and MUST return one output per input, in order. The
default runs predict() sequentially.
2.7 unload() and metadata. unload() MAY release resources; the server calls it when a
replica is torn down. The metadata property MAY be extended with a dict; it is served by
the replica's get_metadata() handle method and is not part of the HTTP API.
2.8 Lazy framework imports. Importing the package MUST NOT import the ML framework.
Import it inside load_model() / predict() / unload(). Discovery imports every installed
model package, so an eager import slows every start; and a broken framework install would then
hide the whole package instead of failing one deployment with a clear error.
2.9 Media. Image fields (image in the detection, segmentation and embedding inputs) are a
URL, a data: URI or a server-local path. The model MUST resolve them with
pixano_inference.utils.media.convert_string_to_image (videos:
convert_string_video_to_bytes_or_path), which enforces the server's media policy: private
addresses are refused for URLs, local paths are allowed only under
PIXANO_INFERENCE_MEDIA_ROOTS, and sizes are capped.
2.10 Device. self.config.resources.num_gpus > 0 means the deployment was given a GPU;
the model chooses the device from it. pixano_inference_torch.resolve_device(self.config)
does this for PyTorch (CUDA, then Apple MPS, else CPU).
2.11 interface. A model MAY declare how it is called by overriding the interface
property with the descriptor of its capability, from pixano_inference.schemas (also exported
by pixano_inference.models). A capability's input is the union of what every model of that
capability might accept; the descriptor says what this model reads and returns, so a client
can decide how to call it and what to show for it: a prompted tracker and a tracker by detection
are both TrackingModels, but a client prompts the first and only picks classes for the second.
The server calls the property once per deployment, after load_model() (so a value that depends
on the loaded weights, such as a class set, MAY be read from the model), and publishes it as
interface on GET /v1/models and in the response of POST /v1/models. A model that does not
override it is listed with interface: null; declaring one requires pixano-inference >= 0.7.1.
| Capability | Descriptor | Fields |
|---|---|---|
tracking |
TrackingInterface |
prompts: what a keyframe may carry (points, box, mask, text; [] for none); prompt_free: accepts a request naming no object; classes: none, open or closed; class_names: the closed set; thresholds (box, text); interval: honours the propagation interval; outputs: what a tracked object carries (mask, box, score, class) |
detection |
DetectionInterface |
classes: open (detects the names the request gives) or closed (its own set); class_names; thresholds (box, text); outputs (box, score, class, mask) |
segmentation |
SegmentationInterface |
prompts; multimask: several candidate masks per prompt; embeddings: returns and accepts the image embedding; outputs (mask, score, logits) |
vlm |
VLMInterface |
prompt: accepted forms (text, messages); images: {min, max} images per request (max: null for no limit) |
embedding |
EmbeddingInterface |
modalities (image, text); dim: vector size, null when unknown |
ner |
NERInterface |
entity_types: the labels it returns, null when unknown |
Fields are camelCase on the wire (promptFree, classNames), and every descriptor carries its
capability, which is how a client tells them apart.
In the example (model.py), 2.1 to 2.5, 2.9 and 2.11; no framework, so 2.8 and 2.10 are moot:
@register_model("NumpyDetector")
class NumpyDetector(DetectionModel):
def load_model(self) -> None:
self._threshold = int(self.config.model_params.get("threshold", 20))
@property
def interface(self) -> DetectionInterface:
return DetectionInterface(classes="closed", class_names=["object"], outputs=["box", "score", "class"])
def predict(self, input: DetectionInput) -> DetectionOutput:
image = np.asarray(convert_string_to_image(input.image), dtype=np.int16)
...
return DetectionOutput(boxes=[box], scores=[score], classes=["object"], masks=None)
In the yolo example, 2.8 and 2.10: from ultralytics import YOLO sits inside load_model(),
which picks "cuda" only when num_gpus > 0 and CUDA is available, and unload() frees the
model and the CUDA cache. Its 2.11 reads class_names from the loaded weights (model.names),
and YOLOByteTrackModel declares prompt_free=True with no prompts, which is how a client
tells it apart from a prompted tracker such as SAM2.
3. Parameters
3.1 Typed params. A model SHOULD define a params class: a subclass of
pixano_inference.configs.BaseModelParams, registered with
@register_model_params("Name") under the model's registered name. BaseModelParams forbids
unknown fields and requires path (a checkpoint id or location; give it a default if the
model has none). With a params class registered, ModelConfig(model_params={...}) validates
the dict, applies the defaults and rejects typos, and a config that omits model_params
entirely gets the defaults.
3.2 Without typed params. model_params is passed through as given. load_model() then
owns validation.
3.3 In the replica. self.config.model_params is always a plain dict: the params dumped
by the config, never the params object.
In the example:
@register_model_params("NumpyDetector")
class NumpyDetectorParams(BaseModelParams):
path: str = "numpy-detector" # no checkpoint: names the deployment when `name` is omitted
threshold: int = Field(default=20, ge=0)
4. Configuration
A deployment config is a Python file with a models list of ModelConfig; the server loads
it with --config, or receives the same fields through POST /v1/models after startup.
| Field | Meaning |
|---|---|
name |
Unique deployment name (the model a client sends). Defaults to the last segment of path. |
model_class |
The registered name (§2.2), as a string: no import needed. |
model_params |
A params object or a dict, validated as in §3. |
deployment |
DeploymentConfig: num_gpus, num_cpus, min_replicas, max_replicas, max_batch_size, timeout_s, … |
from pixano_inference.configs import DeploymentConfig, ModelConfig
models = [
ModelConfig(
name="numpy-detector",
model_class="NumpyDetector",
model_params={"threshold": 20},
deployment=DeploymentConfig(num_gpus=0, num_cpus=1),
),
]
5. Distribution
5.1 Installed, not shipped. The package MUST be installed in the environment the server
runs in (Ray Serve workers import it from there). Loose source directories and PYTHONPATH
are not an integration mechanism.
5.2 Any source. Publishing is optional. The package installs from PyPI or a private index,
from a git URL (#subdirectory= for a monorepo), from a local directory, or from a directory
of wheels; uv pip install also follows the package's [tool.uv.sources].
5.3 Core version. The package SHOULD constrain pixano-inference to the versions whose
contract it was written against (>= 0.7.1, < 0.8.0 for one that declares its interface, >= 0.7.0
otherwise), as the first-party packages do.
It SHOULD depend on the server extra (pixano-inference[server]), so that installing the
package yields an environment that can serve it; the base install holds the model API and the
schemas but no server.
6. Conformance
A package conforms when the following hold. The example's tests/test_model.py covers the
first three; the yolo example's tests/test_discovery.py covers the fourth.
| Check | How |
|---|---|
| Discovered through its entry point | load_plugin_models()["loaded"] contains the entry point; ModelClassRegistry.has("Name") |
| Params validate and default | ModelConfig(name="x", model_class="Name").to_deployment_config().model_params |
predict() returns the capability's output |
Call the model directly on a small input |
| Importing the package loads no framework | python -c "import sys, my_pkg; print([m for m in ('torch',) if m in sys.modules])" |
| Lock file matches the metadata | uv lock --check --project <package> |
| Serves end to end | Start the server from the package's environment and call the route |