modelport-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ """The top-level `modelport.json` manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import Field, model_validator
12
+
13
+ from .base import StrictModel
14
+ from .files import FileRef
15
+ from .postprocess import ClassificationPostprocess, DetectionPostprocess
16
+ from .tensors import InputSpec, OutputSpec
17
+
18
+ SCHEMA_VERSION = "modelport/0.1"
19
+ MANIFEST_FILENAME = "modelport.json"
20
+
21
+ _ID_PATTERN = r"^[a-z0-9][a-z0-9._-]*$"
22
+ _SEMVER_PATTERN = (
23
+ r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
24
+ r"(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
25
+ )
26
+
27
+
28
+ class Task(StrEnum):
29
+ """What the model does. Decides which Dart task API can load it."""
30
+
31
+ IMAGE_CLASSIFICATION = "image-classification"
32
+ OBJECT_DETECTION = "object-detection"
33
+ TEXT_GENERATION = "text-generation"
34
+
35
+
36
+ TENSOR_TASKS = frozenset({Task.IMAGE_CLASSIFICATION, Task.OBJECT_DETECTION})
37
+
38
+
39
+ class Runtime(StrEnum):
40
+ """Inference engine that runs a variant."""
41
+
42
+ ONNX = "onnx"
43
+ EXECUTORCH = "executorch"
44
+ LLAMACPP = "llamacpp"
45
+
46
+
47
+ class Tolerance(StrictModel):
48
+ """How close two outputs must be: |a - b| <= atol + rtol * |b|."""
49
+
50
+ atol: float = Field(default=1e-3, ge=0)
51
+ rtol: float = Field(default=1e-3, ge=0)
52
+
53
+
54
+ class Variant(StrictModel):
55
+ """One concrete form of the model, such as ONNX fp32 or GGUF Q4_K_M."""
56
+
57
+ id: str = Field(pattern=_ID_PATTERN, examples=["onnx-fp32", "gguf-q4_k_m"])
58
+ runtime: Runtime
59
+ backend: str | None = Field(
60
+ default=None, description="Engine backend, such as 'xnnpack' or 'coreml'."
61
+ )
62
+ precision: str = Field(pattern=r"^[a-z0-9_]+$", examples=["fp32", "fp16", "int8", "q4_k_m"])
63
+ file: FileRef = Field(description="The main model file.")
64
+ extra_files: list[FileRef] = Field(
65
+ default_factory=list,
66
+ description="Files the main file needs, such as ONNX external weights.",
67
+ )
68
+ min_ram_mb: int | None = Field(
69
+ default=None, gt=0, description="Skip this variant on devices with less RAM."
70
+ )
71
+ tolerance: Tolerance | None = Field(
72
+ default=None, description="Overrides the golden tolerance, for example for int8."
73
+ )
74
+
75
+
76
+ class GenerationDefaults(StrictModel):
77
+ """Default sampling settings. Apps can override each one per request."""
78
+
79
+ temperature: float = Field(default=0.7, ge=0, le=2)
80
+ top_p: float = Field(default=0.9, gt=0, le=1)
81
+ top_k: int | None = Field(default=None, gt=0)
82
+ max_tokens: int = Field(default=512, gt=0)
83
+ repeat_penalty: float | None = Field(default=None, gt=0)
84
+
85
+
86
+ class LlmConfig(StrictModel):
87
+ """Settings for text-generation models."""
88
+
89
+ context_length: int = Field(
90
+ gt=0,
91
+ description=(
92
+ "Context window to allocate on device. Keep it at or below the trained context; "
93
+ "smaller windows use less RAM."
94
+ ),
95
+ )
96
+ chat_template: Literal["from_gguf"] = Field(
97
+ default="from_gguf",
98
+ description="Where the chat template comes from. Templates are never stored as code here.",
99
+ )
100
+ defaults: GenerationDefaults = Field(default_factory=GenerationDefaults)
101
+
102
+
103
+ class Golden(StrictModel):
104
+ """Saved inputs and expected outputs, used to prove a device matches Python.
105
+
106
+ Each file holds raw little-endian tensor data in the dtype and shape of the tensor
107
+ with the same name.
108
+ """
109
+
110
+ inputs: dict[str, FileRef] = Field(min_length=1)
111
+ outputs: dict[str, FileRef] = Field(min_length=1)
112
+ tolerance: Tolerance = Field(default_factory=Tolerance)
113
+
114
+
115
+ def _require_schema_key(schema: dict[str, Any]) -> None:
116
+ """Files must always state their spec version, even though Python fills a default."""
117
+ required = schema.setdefault("required", [])
118
+ if "schema" not in required:
119
+ required.insert(0, "schema")
120
+
121
+
122
+ class Manifest(StrictModel):
123
+ """Everything an app needs to download, run, and interpret a model."""
124
+
125
+ model_config = StrictModel.model_config | {"json_schema_extra": _require_schema_key}
126
+
127
+ schema_version: Literal["modelport/0.1"] = Field(default=SCHEMA_VERSION, alias="schema")
128
+ id: str = Field(pattern=_ID_PATTERN, examples=["mobilenet_v3_small"])
129
+ version: str = Field(pattern=_SEMVER_PATTERN, examples=["1.0.0"])
130
+ task: Task
131
+ license: str = Field(min_length=1, description="SPDX license identifier of the weights.")
132
+ name: str | None = Field(default=None, description="Human-friendly name.")
133
+ description: str | None = None
134
+ source: str | None = Field(default=None, examples=["torchvision:mobilenet_v3_small"])
135
+ variants: list[Variant] = Field(min_length=1)
136
+ inputs: list[InputSpec] = Field(default_factory=list)
137
+ outputs: list[OutputSpec] = Field(default_factory=list)
138
+ llm: LlmConfig | None = None
139
+ golden: Golden | None = None
140
+
141
+ @model_validator(mode="after")
142
+ def _check_consistency(self) -> Manifest:
143
+ _require_unique("variant id", [v.id for v in self.variants])
144
+ _require_unique("input name", [i.name for i in self.inputs])
145
+ _require_unique("output name", [o.name for o in self.outputs])
146
+
147
+ if self.task in TENSOR_TASKS:
148
+ self._check_tensor_task()
149
+ self._check_detection()
150
+ else:
151
+ self._check_text_generation()
152
+
153
+ if self.golden is not None:
154
+ self._check_golden(self.golden)
155
+ return self
156
+
157
+ def _check_tensor_task(self) -> None:
158
+ if not self.inputs or not self.outputs:
159
+ raise ValueError(f"task '{self.task}' needs at least one input and one output")
160
+ if self.llm is not None:
161
+ raise ValueError(f"task '{self.task}' must not have an 'llm' section")
162
+ if any(v.runtime is Runtime.LLAMACPP for v in self.variants):
163
+ raise ValueError("runtime 'llamacpp' is only for task 'text-generation'")
164
+ if not any(i.preprocess is not None for i in self.inputs):
165
+ raise ValueError(f"task '{self.task}' needs an input with image preprocessing")
166
+ wanted = (
167
+ ClassificationPostprocess
168
+ if self.task is Task.IMAGE_CLASSIFICATION
169
+ else DetectionPostprocess
170
+ )
171
+ if not any(isinstance(o.postprocess, wanted) for o in self.outputs):
172
+ raise ValueError(
173
+ f"task '{self.task}' needs an output with '{wanted().type}' postprocess"
174
+ )
175
+
176
+ def _check_text_generation(self) -> None:
177
+ if self.llm is None:
178
+ raise ValueError("task 'text-generation' needs an 'llm' section")
179
+ if any(v.runtime is not Runtime.LLAMACPP for v in self.variants):
180
+ raise ValueError("task 'text-generation' supports only runtime 'llamacpp' in spec 0.1")
181
+ if self.golden is not None:
182
+ raise ValueError("golden tests are only for tensor tasks")
183
+
184
+ def _check_detection(self) -> None:
185
+ names = {o.name for o in self.outputs}
186
+ for output in self.outputs:
187
+ post = output.postprocess
188
+ if not isinstance(post, DetectionPostprocess) or post.boxes_output is None:
189
+ continue
190
+ if post.boxes_output not in names or post.boxes_output == output.name:
191
+ raise ValueError(
192
+ f"output '{output.name}' boxes_output '{post.boxes_output}' "
193
+ "must name another output"
194
+ )
195
+
196
+ def _check_golden(self, golden: Golden) -> None:
197
+ input_names = {i.name for i in self.inputs}
198
+ output_names = {o.name for o in self.outputs}
199
+ for name in golden.inputs:
200
+ if name not in input_names:
201
+ raise ValueError(f"golden input '{name}' is not a model input")
202
+ for name in golden.outputs:
203
+ if name not in output_names:
204
+ raise ValueError(f"golden output '{name}' is not a model output")
205
+
206
+ def files(self) -> list[FileRef]:
207
+ """Every file the manifest points to, in a stable order."""
208
+ refs: list[FileRef] = []
209
+ for variant in self.variants:
210
+ refs.append(variant.file)
211
+ refs.extend(variant.extra_files)
212
+ for output in self.outputs:
213
+ labels = getattr(output.postprocess, "labels", None)
214
+ if labels is not None:
215
+ refs.append(labels)
216
+ if self.golden is not None:
217
+ refs.extend(self.golden.inputs.values())
218
+ refs.extend(self.golden.outputs.values())
219
+ return refs
220
+
221
+ def to_json(self) -> str:
222
+ """Serialize the way the CLI writes `modelport.json`."""
223
+ return self.model_dump_json(by_alias=True, exclude_none=True, indent=2) + "\n"
224
+
225
+ @classmethod
226
+ def from_file(cls, path: str | Path) -> Manifest:
227
+ """Load and validate a `modelport.json` file.
228
+
229
+ Files must state their spec version, so a missing `schema` key is an error here
230
+ even though code that builds a manifest in Python gets it filled in.
231
+ """
232
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
233
+ if not isinstance(data, dict) or "schema" not in data:
234
+ raise ValueError(f"{path}: missing 'schema' key, expected \"{SCHEMA_VERSION}\"")
235
+ return cls.model_validate(data)
236
+
237
+
238
+ def _require_unique(label: str, values: list[str]) -> None:
239
+ duplicates = sorted(value for value, count in Counter(values).items() if count > 1)
240
+ if duplicates:
241
+ raise ValueError(f"duplicate {label}: {', '.join(duplicates)}")
@@ -0,0 +1,79 @@
1
+ """Rules for turning raw output tensors into results people can use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import Field, model_validator
8
+
9
+ from .base import StrictModel
10
+ from .files import FileRef
11
+
12
+
13
+ class ClassificationPostprocess(StrictModel):
14
+ """Scores over a fixed set of classes, for example ImageNet logits of shape [1, 1000]."""
15
+
16
+ type: Literal["classification"] = "classification"
17
+ activation: Literal["softmax", "sigmoid", "none"] = Field(
18
+ default="softmax", description="Applied to raw scores before ranking."
19
+ )
20
+ labels: FileRef | None = Field(
21
+ default=None, description="UTF-8 text file with one class name per line."
22
+ )
23
+ top_k: int = Field(default=5, gt=0, description="How many results to return by default.")
24
+
25
+
26
+ class DetectionPostprocess(StrictModel):
27
+ """Boxes with class scores. Two layouts are supported.
28
+
29
+ * `rows` (YOLO style): this output is [1, N, 4 + objectness + classes].
30
+ * `detr` (DETR, YOLOS, RT-DETR style): this output holds class scores
31
+ [1, N, classes], and `boxes_output` names the [1, N, 4] boxes output.
32
+ """
33
+
34
+ type: Literal["detection"] = "detection"
35
+ format: Literal["rows", "detr"] = "rows"
36
+ boxes_output: str | None = Field(
37
+ default=None, description="For format 'detr': the output with the [1, N, 4] boxes."
38
+ )
39
+ activation: Literal["softmax", "sigmoid", "none"] = Field(
40
+ default="none", description="Applied to class scores before thresholding."
41
+ )
42
+ background_class: bool = Field(
43
+ default=False,
44
+ description="Whether the last class means 'no object' and is ignored, as in DETR.",
45
+ )
46
+ box_format: Literal["xyxy", "cxcywh"] = Field(
47
+ default="cxcywh", description="How the four box values describe a box."
48
+ )
49
+ normalized: bool = Field(
50
+ default=False,
51
+ description="true: box values are 0-1 fractions of the input size; false: input pixels.",
52
+ )
53
+ has_objectness: bool = Field(
54
+ default=False, description="For format 'rows': an objectness score follows the box."
55
+ )
56
+ score_threshold: float = Field(default=0.25, ge=0, le=1)
57
+ iou_threshold: float = Field(
58
+ default=0.45,
59
+ ge=0,
60
+ le=1,
61
+ description="Non-max suppression overlap threshold. 1 turns suppression off.",
62
+ )
63
+ max_detections: int = Field(default=100, gt=0)
64
+ labels: FileRef | None = None
65
+
66
+ @model_validator(mode="after")
67
+ def _check_format(self) -> DetectionPostprocess:
68
+ if self.format == "detr" and not self.boxes_output:
69
+ raise ValueError("format 'detr' needs 'boxes_output'")
70
+ if self.format == "rows" and self.boxes_output:
71
+ raise ValueError("'boxes_output' is only for format 'detr'")
72
+ if self.format == "detr" and self.has_objectness:
73
+ raise ValueError("'has_objectness' is only for format 'rows'")
74
+ return self
75
+
76
+
77
+ Postprocess = Annotated[
78
+ ClassificationPostprocess | DetectionPostprocess, Field(discriminator="type")
79
+ ]
@@ -0,0 +1,49 @@
1
+ """Generate the JSON Schema for `modelport.json` from the Pydantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
10
+ from pydantic_core import core_schema
11
+
12
+ from .models import Manifest
13
+
14
+ SCHEMA_URL = (
15
+ "https://raw.githubusercontent.com/ayanparvaiz/modelport/main/spec/manifest.schema.json"
16
+ )
17
+
18
+
19
+ class _ManifestSchemaGenerator(GenerateJsonSchema):
20
+ """Optional fields are written by leaving them out, never as null."""
21
+
22
+ def nullable_schema(self, schema: core_schema.NullableSchema) -> JsonSchemaValue:
23
+ return self.generate_inner(schema["schema"])
24
+
25
+ def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaValue:
26
+ json_schema = super().default_schema(schema)
27
+ if "default" in json_schema and json_schema["default"] is None:
28
+ del json_schema["default"]
29
+ return json_schema
30
+
31
+
32
+ def manifest_json_schema() -> dict[str, Any]:
33
+ """The JSON Schema (draft 2020-12) that every `modelport.json` must satisfy."""
34
+ schema = Manifest.model_json_schema(
35
+ by_alias=True, mode="validation", schema_generator=_ManifestSchemaGenerator
36
+ )
37
+ return {
38
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
39
+ "$id": SCHEMA_URL,
40
+ **schema,
41
+ }
42
+
43
+
44
+ def render_schema() -> str:
45
+ return json.dumps(manifest_json_schema(), indent=2, ensure_ascii=False) + "\n"
46
+
47
+
48
+ def write_schema(path: str | Path) -> None:
49
+ Path(path).write_text(render_schema(), encoding="utf-8")
@@ -0,0 +1,158 @@
1
+ """Tensor descriptions and the rules for turning raw data into model inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import Literal
7
+
8
+ from pydantic import Field, field_validator, model_validator
9
+
10
+ from .base import StrictModel
11
+ from .postprocess import Postprocess
12
+
13
+ TensorLayout = Literal["NCHW", "NHWC"]
14
+
15
+
16
+ class DType(StrEnum):
17
+ """Element type of a tensor."""
18
+
19
+ FLOAT32 = "float32"
20
+ FLOAT16 = "float16"
21
+ INT64 = "int64"
22
+ INT32 = "int32"
23
+ INT8 = "int8"
24
+ UINT8 = "uint8"
25
+ BOOL = "bool"
26
+
27
+
28
+ class TensorSpec(StrictModel):
29
+ """Name, element type, and shape of one model input or output."""
30
+
31
+ name: str = Field(min_length=1, description="Tensor name as the engine knows it.")
32
+ dtype: DType
33
+ shape: list[int] = Field(
34
+ description="Dimensions. Use -1 for a dimension that is only known at run time.",
35
+ examples=[[1, 3, 224, 224]],
36
+ )
37
+
38
+ @field_validator("shape")
39
+ @classmethod
40
+ def _check_dims(cls, value: list[int]) -> list[int]:
41
+ if any(dim == 0 or dim < -1 for dim in value):
42
+ raise ValueError("every dimension must be a positive integer or -1")
43
+ return value
44
+
45
+
46
+ class ResizeSpec(StrictModel):
47
+ """How to resize an image. Set exactly one of `shorter_side` or `size`."""
48
+
49
+ model_config = StrictModel.model_config | {
50
+ "json_schema_extra": {"oneOf": [{"required": ["shorter_side"]}, {"required": ["size"]}]}
51
+ }
52
+
53
+ shorter_side: int | None = Field(
54
+ default=None,
55
+ gt=0,
56
+ description="Scale so the shorter side has this length, keeping the aspect ratio.",
57
+ )
58
+ size: tuple[int, int] | None = Field(
59
+ default=None, description="Exact output size as [height, width]. Ignores aspect ratio."
60
+ )
61
+ method: Literal["bilinear", "nearest", "bicubic"] = "bilinear"
62
+ antialias: bool = Field(
63
+ default=False,
64
+ description=(
65
+ "true: PIL-compatible resampling that filters when shrinking (bilinear or bicubic). "
66
+ "false: plain half-pixel bilinear in floating point, or nearest."
67
+ ),
68
+ )
69
+
70
+ @field_validator("size")
71
+ @classmethod
72
+ def _check_size(cls, value: tuple[int, int] | None) -> tuple[int, int] | None:
73
+ if value is not None and min(value) <= 0:
74
+ raise ValueError("size must be positive")
75
+ return value
76
+
77
+ @model_validator(mode="after")
78
+ def _check_exactly_one(self) -> ResizeSpec:
79
+ if (self.shorter_side is None) == (self.size is None):
80
+ raise ValueError("set exactly one of 'shorter_side' or 'size'")
81
+ if self.method == "bicubic" and not self.antialias:
82
+ raise ValueError("bicubic resize needs antialias: true in spec 0.1")
83
+ if self.method == "nearest" and self.antialias:
84
+ raise ValueError("nearest resize cannot use antialias")
85
+ return self
86
+
87
+
88
+ class ImagePreprocess(StrictModel):
89
+ """Turn a decoded image into a tensor.
90
+
91
+ Steps run in this order: resize, center crop, channel order, multiply by `scale`,
92
+ subtract `mean`, divide by `std`, then arrange in the input's layout.
93
+ """
94
+
95
+ type: Literal["image"] = "image"
96
+ resize: ResizeSpec
97
+ center_crop: tuple[int, int] | None = Field(
98
+ default=None, description="Crop [height, width] from the center after resizing."
99
+ )
100
+ color: Literal["RGB", "BGR"] = "RGB"
101
+ scale: float = Field(default=1 / 255, gt=0, description="Multiplier applied to 0-255 pixels.")
102
+ mean: tuple[float, float, float] = (0.0, 0.0, 0.0)
103
+ std: tuple[float, float, float] = (1.0, 1.0, 1.0)
104
+
105
+ @field_validator("std")
106
+ @classmethod
107
+ def _check_std(cls, value: tuple[float, float, float]) -> tuple[float, float, float]:
108
+ if min(value) <= 0:
109
+ raise ValueError("std values must be positive")
110
+ return value
111
+
112
+ @property
113
+ def output_size(self) -> tuple[int, int] | None:
114
+ """Final [height, width] if it is fixed, otherwise None."""
115
+ if self.center_crop is not None:
116
+ return self.center_crop
117
+ return self.resize.size
118
+
119
+
120
+ class InputSpec(TensorSpec):
121
+ """A model input, with optional preprocessing rules."""
122
+
123
+ layout: TensorLayout | None = Field(
124
+ default=None, description="Dimension order for image tensors."
125
+ )
126
+ preprocess: ImagePreprocess | None = None
127
+
128
+ @model_validator(mode="after")
129
+ def _check_image_input(self) -> InputSpec:
130
+ if self.preprocess is None:
131
+ return self
132
+ if self.layout is None:
133
+ raise ValueError(f"input '{self.name}' has image preprocessing but no layout")
134
+ if len(self.shape) != 4:
135
+ raise ValueError(f"image input '{self.name}' must have 4 dimensions")
136
+ if self.dtype not in (DType.FLOAT32, DType.FLOAT16, DType.UINT8):
137
+ raise ValueError(f"image input '{self.name}' must be float32, float16, or uint8")
138
+ if self.layout == "NCHW":
139
+ channels, height, width = self.shape[1], self.shape[2], self.shape[3]
140
+ else:
141
+ height, width, channels = self.shape[1], self.shape[2], self.shape[3]
142
+ if channels not in (-1, 3):
143
+ raise ValueError(f"image input '{self.name}' must have 3 channels")
144
+ size = self.preprocess.output_size
145
+ if size is not None:
146
+ for expected, actual, label in ((size[0], height, "height"), (size[1], width, "width")):
147
+ if actual != -1 and actual != expected:
148
+ raise ValueError(
149
+ f"input '{self.name}' {label} is {actual}, "
150
+ f"but preprocessing produces {expected}"
151
+ )
152
+ return self
153
+
154
+
155
+ class OutputSpec(TensorSpec):
156
+ """A model output, with optional rules for turning it into results."""
157
+
158
+ postprocess: Postprocess | None = None
modelport/pack.py ADDED
@@ -0,0 +1,48 @@
1
+ """Finalize a bundle after manual edits: refresh hashes and find stray files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .bundle import Bundle
9
+ from .errors import ModelPortError
10
+ from .manifest import MANIFEST_FILENAME, Manifest
11
+
12
+ # Files that may sit next to the manifest without being listed in it.
13
+ ALLOWED_EXTRA = {MANIFEST_FILENAME, "README.md", ".gitattributes"}
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PackResult:
18
+ manifest: Manifest
19
+ changed: list[str]
20
+ stray: list[str]
21
+ total_bytes: int
22
+
23
+
24
+ def pack_bundle(path: str | Path) -> PackResult:
25
+ bundle = Bundle(path)
26
+ if not bundle.manifest_path.is_file():
27
+ raise ModelPortError(f"{bundle.manifest_path} not found")
28
+ before = bundle.read_manifest()
29
+ missing = [p for p in bundle.problems(before) if p.endswith(": missing")]
30
+ if missing:
31
+ raise ModelPortError("files listed in the manifest are missing:\n " + "\n ".join(missing))
32
+
33
+ after = bundle.refresh(before)
34
+ old = {ref.path: ref for ref in before.files() if ref.path}
35
+ changed = sorted(ref.path for ref in after.files() if ref.path and old.get(ref.path) != ref)
36
+ if after != before:
37
+ bundle.write_manifest(after)
38
+
39
+ listed = {ref.path for ref in after.files() if ref.path}
40
+ stray = sorted(
41
+ p.relative_to(bundle.root).as_posix()
42
+ for p in bundle.root.rglob("*")
43
+ if p.is_file()
44
+ and p.relative_to(bundle.root).as_posix() not in listed
45
+ and p.relative_to(bundle.root).as_posix() not in ALLOWED_EXTRA
46
+ )
47
+ total = sum(ref.size for ref in after.files() if ref.path)
48
+ return PackResult(after, changed, stray, total)
modelport/pipeline.py ADDED
@@ -0,0 +1,109 @@
1
+ """The export pipeline: SourceModel in, verified-ready bundle folder out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from PIL import Image
10
+
11
+ from .bundle import Bundle
12
+ from .exporters import ExportError, get_exporter
13
+ from .golden import dtype_of, make_golden, sample_image
14
+ from .manifest import (
15
+ ClassificationPostprocess,
16
+ DetectionPostprocess,
17
+ Manifest,
18
+ OutputSpec,
19
+ Task,
20
+ Variant,
21
+ )
22
+ from .sources import SourceModel
23
+
24
+
25
+ def export_bundle(
26
+ source: SourceModel,
27
+ out_dir: str | Path,
28
+ targets: Sequence[str],
29
+ *,
30
+ image: Image.Image | None = None,
31
+ version: str = "1.0.0",
32
+ overwrite: bool = False,
33
+ ) -> tuple[Bundle, Manifest]:
34
+ """Write `<out_dir>/<id>/` with model files, labels, golden data, and modelport.json."""
35
+ if not targets:
36
+ raise ExportError("choose at least one export target")
37
+ root = Path(out_dir) / source.id
38
+ if root.exists() and any(root.iterdir()):
39
+ if not overwrite:
40
+ raise ExportError(f"{root} already exists. Use --force to replace it.")
41
+ shutil.rmtree(root)
42
+ bundle = Bundle(root)
43
+ # Fail fast on unknown targets before doing any work.
44
+ exporters = [get_exporter(target) for target in dict.fromkeys(targets)]
45
+
46
+ labels = None
47
+ if source.labels:
48
+ labels = bundle.write_text("labels.txt", "\n".join(source.labels) + "\n")
49
+ golden = make_golden(source, bundle, image or sample_image())
50
+
51
+ variants = []
52
+ for exporter in exporters:
53
+ exported = exporter(source, bundle)
54
+ variants.append(
55
+ Variant(
56
+ id=exported.id,
57
+ runtime=exported.runtime,
58
+ backend=exported.backend,
59
+ precision=exported.precision,
60
+ file=bundle.add(exported.main_file),
61
+ extra_files=[bundle.add(path) for path in exported.extra_files],
62
+ )
63
+ )
64
+
65
+ outputs = []
66
+ for index, name in enumerate(source.output_names):
67
+ array = golden.outputs[name]
68
+ postprocess: ClassificationPostprocess | DetectionPostprocess | None = None
69
+ if index == 0 and source.task is Task.IMAGE_CLASSIFICATION:
70
+ classes = int(array.shape[-1])
71
+ _check_labels(source, classes)
72
+ postprocess = ClassificationPostprocess(labels=labels, top_k=min(source.top_k, classes))
73
+ elif index == 0 and source.task is Task.OBJECT_DETECTION:
74
+ if source.detection is None:
75
+ raise ExportError("object detection sources must describe their outputs")
76
+ detection = source.detection
77
+ classes = int(array.shape[-1])
78
+ if detection.format == "rows":
79
+ classes -= 4 + (1 if detection.has_objectness else 0)
80
+ elif detection.background_class:
81
+ classes -= 1
82
+ _check_labels(source, classes)
83
+ postprocess = detection.model_copy(update={"labels": labels})
84
+ outputs.append(
85
+ OutputSpec(
86
+ name=name, dtype=dtype_of(array), shape=list(array.shape), postprocess=postprocess
87
+ )
88
+ )
89
+
90
+ manifest = Manifest(
91
+ id=source.id,
92
+ version=version,
93
+ task=source.task,
94
+ license=source.license,
95
+ name=source.name,
96
+ description=source.description,
97
+ source=source.source,
98
+ variants=variants,
99
+ inputs=source.inputs,
100
+ outputs=outputs,
101
+ golden=golden.golden,
102
+ )
103
+ bundle.write_manifest(manifest)
104
+ return bundle, manifest
105
+
106
+
107
+ def _check_labels(source: SourceModel, classes: int) -> None:
108
+ if source.labels and len(source.labels) != classes:
109
+ raise ExportError(f"model has {classes} classes but {len(source.labels)} labels were given")