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.
- modelport/__init__.py +3 -0
- modelport/bundle.py +83 -0
- modelport/cli.py +527 -0
- modelport/codegen/__init__.py +1 -0
- modelport/codegen/dart.py +227 -0
- modelport/doctor.py +88 -0
- modelport/errors.py +18 -0
- modelport/exporters/__init__.py +55 -0
- modelport/exporters/executorch.py +46 -0
- modelport/exporters/onnx.py +63 -0
- modelport/gguf_import.py +197 -0
- modelport/golden.py +108 -0
- modelport/hashing.py +17 -0
- modelport/inspection/__init__.py +87 -0
- modelport/inspection/executorch.py +32 -0
- modelport/inspection/gguf.py +42 -0
- modelport/inspection/onnx.py +44 -0
- modelport/manifest/__init__.py +39 -0
- modelport/manifest/base.py +22 -0
- modelport/manifest/files.py +59 -0
- modelport/manifest/models.py +241 -0
- modelport/manifest/postprocess.py +79 -0
- modelport/manifest/schema.py +49 -0
- modelport/manifest/tensors.py +158 -0
- modelport/pack.py +48 -0
- modelport/pipeline.py +109 -0
- modelport/preprocess.py +121 -0
- modelport/publish.py +191 -0
- modelport/py.typed +0 -0
- modelport/quantize.py +120 -0
- modelport/runtimes.py +52 -0
- modelport/sources/__init__.py +85 -0
- modelport/sources/file.py +46 -0
- modelport/sources/hf.py +265 -0
- modelport/sources/torchvision.py +78 -0
- modelport/verify.py +92 -0
- modelport_cli-0.1.0.dist-info/METADATA +152 -0
- modelport_cli-0.1.0.dist-info/RECORD +41 -0
- modelport_cli-0.1.0.dist-info/WHEEL +4 -0
- modelport_cli-0.1.0.dist-info/entry_points.txt +2 -0
- modelport_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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")
|