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
modelport/sources/hf.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Hugging Face transformers image classifiers, from the Hub or a local folder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from ..errors import MissingDependencyError
|
|
10
|
+
from ..manifest import DetectionPostprocess, DType, ImagePreprocess, InputSpec, ResizeSpec, Task
|
|
11
|
+
from . import SourceError, SourceModel, model_id
|
|
12
|
+
|
|
13
|
+
# PIL resampling codes used by transformers image processors.
|
|
14
|
+
_RESAMPLE: dict[int, Literal["nearest", "bilinear", "bicubic"]] = {
|
|
15
|
+
0: "nearest",
|
|
16
|
+
2: "bilinear",
|
|
17
|
+
3: "bicubic",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_hf(repo: str, *, license: str | None = None, image_size: int | None = None) -> SourceModel:
|
|
22
|
+
"""Load an image classifier or object detector with its image processor's preprocessing."""
|
|
23
|
+
try:
|
|
24
|
+
import torch
|
|
25
|
+
from transformers import AutoConfig, AutoImageProcessor, AutoModelForImageClassification
|
|
26
|
+
except ImportError as error:
|
|
27
|
+
raise MissingDependencyError("hf: sources", "hf") from error
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
architectures = AutoConfig.from_pretrained(repo).architectures or []
|
|
31
|
+
except (OSError, ValueError) as error:
|
|
32
|
+
raise SourceError(f"could not load '{repo}': {error}") from error
|
|
33
|
+
if any(name.endswith("ForObjectDetection") for name in architectures):
|
|
34
|
+
return _load_detector(repo, license=license, image_size=image_size)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
processor = AutoImageProcessor.from_pretrained(repo)
|
|
38
|
+
model = AutoModelForImageClassification.from_pretrained(repo).eval()
|
|
39
|
+
except (OSError, ValueError) as error:
|
|
40
|
+
raise SourceError(f"could not load '{repo}' as an image classifier: {error}") from error
|
|
41
|
+
|
|
42
|
+
preprocess, (height, width) = preprocess_from_processor(processor)
|
|
43
|
+
config = model.config
|
|
44
|
+
labels = [str(config.id2label[i]) for i in range(config.num_labels)]
|
|
45
|
+
|
|
46
|
+
if license is None and not Path(repo).is_dir():
|
|
47
|
+
license = _hub_license(repo)
|
|
48
|
+
if license is None:
|
|
49
|
+
raise SourceError(f"no license found for '{repo}'. Pass it with --license, e.g. MIT")
|
|
50
|
+
|
|
51
|
+
return SourceModel(
|
|
52
|
+
module=_logits_only(model),
|
|
53
|
+
example_inputs=(torch.zeros(1, 3, height, width),),
|
|
54
|
+
id=model_id(repo),
|
|
55
|
+
task=Task.IMAGE_CLASSIFICATION,
|
|
56
|
+
license=license,
|
|
57
|
+
inputs=[
|
|
58
|
+
InputSpec(
|
|
59
|
+
name="pixel_values",
|
|
60
|
+
dtype=DType.FLOAT32,
|
|
61
|
+
shape=[1, 3, height, width],
|
|
62
|
+
layout="NCHW",
|
|
63
|
+
preprocess=preprocess,
|
|
64
|
+
)
|
|
65
|
+
],
|
|
66
|
+
output_names=["logits"],
|
|
67
|
+
labels=labels,
|
|
68
|
+
name=repo.rstrip("/").split("/")[-1],
|
|
69
|
+
description=f"Hugging Face {type(model).__name__} from {repo}.",
|
|
70
|
+
source=f"hf:{repo}",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _size_value(size: Any, key: str) -> int | None:
|
|
75
|
+
value = size.get(key) if isinstance(size, dict) else getattr(size, key, None)
|
|
76
|
+
return int(value) if value else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def preprocess_from_processor(processor: Any) -> tuple[ImagePreprocess, tuple[int, int]]:
|
|
80
|
+
"""Translate a transformers image processor into a manifest preprocessing rule.
|
|
81
|
+
|
|
82
|
+
Returns the rule and the final (height, width) of the input tensor.
|
|
83
|
+
"""
|
|
84
|
+
resample = getattr(processor, "resample", 2)
|
|
85
|
+
code = int(getattr(resample, "value", resample))
|
|
86
|
+
method = _RESAMPLE.get(code)
|
|
87
|
+
if method is None:
|
|
88
|
+
raise SourceError(f"unsupported resample mode {resample!r}")
|
|
89
|
+
antialias = method != "nearest" # transformers resizes with PIL or antialiased torchvision
|
|
90
|
+
|
|
91
|
+
size = processor.size
|
|
92
|
+
height, width = _size_value(size, "height"), _size_value(size, "width")
|
|
93
|
+
shortest = _size_value(size, "shortest_edge")
|
|
94
|
+
crop_pct = getattr(processor, "crop_pct", None)
|
|
95
|
+
crop: tuple[int, int] | None = None
|
|
96
|
+
|
|
97
|
+
if height and width:
|
|
98
|
+
resize = ResizeSpec(size=(height, width), method=method, antialias=antialias)
|
|
99
|
+
final = (height, width)
|
|
100
|
+
elif shortest and crop_pct:
|
|
101
|
+
# ConvNext style: below 384, resize to shortest / crop_pct and crop back.
|
|
102
|
+
if shortest < 384:
|
|
103
|
+
resize = ResizeSpec(
|
|
104
|
+
shorter_side=int(shortest / crop_pct), method=method, antialias=antialias
|
|
105
|
+
)
|
|
106
|
+
crop = (shortest, shortest)
|
|
107
|
+
else:
|
|
108
|
+
resize = ResizeSpec(size=(shortest, shortest), method=method, antialias=antialias)
|
|
109
|
+
final = (shortest, shortest)
|
|
110
|
+
elif shortest:
|
|
111
|
+
resize = ResizeSpec(shorter_side=shortest, method=method, antialias=antialias)
|
|
112
|
+
crop_size = getattr(processor, "crop_size", None)
|
|
113
|
+
if not (getattr(processor, "do_center_crop", False) and crop_size):
|
|
114
|
+
raise SourceError("this processor gives a variable input size; a center crop is needed")
|
|
115
|
+
crop = (_size_value(crop_size, "height") or 0, _size_value(crop_size, "width") or 0)
|
|
116
|
+
final = crop
|
|
117
|
+
else:
|
|
118
|
+
raise SourceError(f"unsupported image processor size {size!r}")
|
|
119
|
+
|
|
120
|
+
if crop is None and getattr(processor, "do_center_crop", False):
|
|
121
|
+
crop_size = getattr(processor, "crop_size", None)
|
|
122
|
+
if crop_size:
|
|
123
|
+
crop = (_size_value(crop_size, "height") or 0, _size_value(crop_size, "width") or 0)
|
|
124
|
+
final = crop
|
|
125
|
+
|
|
126
|
+
do_rescale = getattr(processor, "do_rescale", True)
|
|
127
|
+
do_normalize = getattr(processor, "do_normalize", True)
|
|
128
|
+
preprocess = ImagePreprocess(
|
|
129
|
+
resize=resize,
|
|
130
|
+
center_crop=crop,
|
|
131
|
+
scale=float(processor.rescale_factor) if do_rescale else 1.0,
|
|
132
|
+
mean=_triple(processor.image_mean, "image_mean") if do_normalize else (0.0, 0.0, 0.0),
|
|
133
|
+
std=_triple(processor.image_std, "image_std") if do_normalize else (1.0, 1.0, 1.0),
|
|
134
|
+
)
|
|
135
|
+
return preprocess, final
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _triple(values: Any, name: str) -> tuple[float, float, float]:
|
|
139
|
+
numbers = [float(v) for v in values]
|
|
140
|
+
if len(numbers) != 3:
|
|
141
|
+
raise SourceError(f"{name} must have 3 values, got {len(numbers)}")
|
|
142
|
+
return numbers[0], numbers[1], numbers[2]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _hub_license(repo: str) -> str | None:
|
|
146
|
+
try:
|
|
147
|
+
from huggingface_hub import model_info
|
|
148
|
+
|
|
149
|
+
card = model_info(repo).card_data
|
|
150
|
+
except Exception:
|
|
151
|
+
return None
|
|
152
|
+
license = getattr(card, "license", None) if card is not None else None
|
|
153
|
+
if isinstance(license, list):
|
|
154
|
+
license = license[0] if license else None
|
|
155
|
+
return str(license) if license else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _logits_only(model: Any) -> Any:
|
|
159
|
+
"""Wrap a transformers model so forward(pixel_values) returns only the logits tensor."""
|
|
160
|
+
import torch
|
|
161
|
+
|
|
162
|
+
class LogitsOnly(torch.nn.Module):
|
|
163
|
+
def __init__(self, inner: Any) -> None:
|
|
164
|
+
super().__init__()
|
|
165
|
+
self.inner = inner
|
|
166
|
+
|
|
167
|
+
def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
|
168
|
+
return self.inner(pixel_values=pixel_values).logits
|
|
169
|
+
|
|
170
|
+
return LogitsOnly(model).eval()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _load_detector(repo: str, *, license: str | None, image_size: int | None) -> SourceModel:
|
|
174
|
+
"""DETR-family detectors (DETR, YOLOS, RT-DETR, D-FINE) exported at a fixed square size."""
|
|
175
|
+
import torch
|
|
176
|
+
from transformers import AutoImageProcessor, AutoModelForObjectDetection
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
processor = AutoImageProcessor.from_pretrained(repo)
|
|
180
|
+
model = AutoModelForObjectDetection.from_pretrained(repo).eval()
|
|
181
|
+
except (OSError, ValueError) as error:
|
|
182
|
+
raise SourceError(f"could not load '{repo}' as an object detector: {error}") from error
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
preprocess, (height, width) = preprocess_from_processor(processor)
|
|
186
|
+
except SourceError:
|
|
187
|
+
# Detectors usually accept any size; export at a fixed square instead.
|
|
188
|
+
size = image_size or _size_value(processor.size, "shortest_edge") or 512
|
|
189
|
+
fixed = SimpleNamespace(
|
|
190
|
+
resample=getattr(processor, "resample", 2),
|
|
191
|
+
do_rescale=getattr(processor, "do_rescale", True) is not False,
|
|
192
|
+
rescale_factor=getattr(processor, "rescale_factor", 1 / 255),
|
|
193
|
+
do_normalize=getattr(processor, "do_normalize", True) is not False,
|
|
194
|
+
image_mean=processor.image_mean,
|
|
195
|
+
image_std=processor.image_std,
|
|
196
|
+
size={"height": size, "width": size},
|
|
197
|
+
do_center_crop=False,
|
|
198
|
+
)
|
|
199
|
+
preprocess, (height, width) = preprocess_from_processor(fixed)
|
|
200
|
+
if image_size and (height, width) != (image_size, image_size):
|
|
201
|
+
raise SourceError(f"{repo} has a fixed input size of {height}x{width}")
|
|
202
|
+
|
|
203
|
+
wrapped = _detector_outputs(model)
|
|
204
|
+
example = torch.zeros(1, 3, height, width)
|
|
205
|
+
with torch.no_grad():
|
|
206
|
+
logits, _ = wrapped(example)
|
|
207
|
+
config = model.config
|
|
208
|
+
classes = int(logits.shape[-1])
|
|
209
|
+
background = classes == config.num_labels + 1
|
|
210
|
+
labels = [str(config.id2label[i]) for i in range(config.num_labels)]
|
|
211
|
+
|
|
212
|
+
if license is None and not Path(repo).is_dir():
|
|
213
|
+
license = _hub_license(repo)
|
|
214
|
+
if license is None:
|
|
215
|
+
raise SourceError(f"no license found for '{repo}'. Pass it with --license, e.g. MIT")
|
|
216
|
+
|
|
217
|
+
return SourceModel(
|
|
218
|
+
module=wrapped,
|
|
219
|
+
example_inputs=(example,),
|
|
220
|
+
id=model_id(repo),
|
|
221
|
+
task=Task.OBJECT_DETECTION,
|
|
222
|
+
license=license,
|
|
223
|
+
inputs=[
|
|
224
|
+
InputSpec(
|
|
225
|
+
name="pixel_values",
|
|
226
|
+
dtype=DType.FLOAT32,
|
|
227
|
+
shape=[1, 3, height, width],
|
|
228
|
+
layout="NCHW",
|
|
229
|
+
preprocess=preprocess,
|
|
230
|
+
)
|
|
231
|
+
],
|
|
232
|
+
output_names=["logits", "pred_boxes"],
|
|
233
|
+
labels=labels,
|
|
234
|
+
name=repo.rstrip("/").split("/")[-1],
|
|
235
|
+
description=f"Hugging Face {type(model).__name__} from {repo}, at {width}x{height}.",
|
|
236
|
+
source=f"hf:{repo}",
|
|
237
|
+
detection=DetectionPostprocess(
|
|
238
|
+
format="detr",
|
|
239
|
+
boxes_output="pred_boxes",
|
|
240
|
+
activation="softmax" if background else "sigmoid",
|
|
241
|
+
background_class=background,
|
|
242
|
+
box_format="cxcywh",
|
|
243
|
+
normalized=True,
|
|
244
|
+
score_threshold=0.5,
|
|
245
|
+
# DETR-style models predict each object once, so no suppression is needed.
|
|
246
|
+
iou_threshold=1.0,
|
|
247
|
+
max_detections=int(logits.shape[1]),
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _detector_outputs(model: Any) -> Any:
|
|
253
|
+
"""Wrap a detector so forward(pixel_values) returns (logits, pred_boxes)."""
|
|
254
|
+
import torch
|
|
255
|
+
|
|
256
|
+
class DetectorOutputs(torch.nn.Module):
|
|
257
|
+
def __init__(self, inner: Any) -> None:
|
|
258
|
+
super().__init__()
|
|
259
|
+
self.inner = inner
|
|
260
|
+
|
|
261
|
+
def forward(self, pixel_values: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
262
|
+
out = self.inner(pixel_values=pixel_values)
|
|
263
|
+
return out.logits, out.pred_boxes
|
|
264
|
+
|
|
265
|
+
return DetectorOutputs(model).eval()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""torchvision classification models with their ImageNet preprocessing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from ..errors import MissingDependencyError
|
|
8
|
+
from ..manifest import DType, ImagePreprocess, InputSpec, ResizeSpec, Task
|
|
9
|
+
from . import SourceError, SourceModel
|
|
10
|
+
|
|
11
|
+
_METHODS: dict[str, Literal["bilinear", "bicubic", "nearest"]] = {
|
|
12
|
+
"bilinear": "bilinear",
|
|
13
|
+
"bicubic": "bicubic",
|
|
14
|
+
"nearest": "nearest",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_torchvision(name: str, *, pretrained: bool = True) -> SourceModel:
|
|
19
|
+
"""Load a torchvision classifier.
|
|
20
|
+
|
|
21
|
+
`pretrained=False` keeps the weights' metadata but skips the download. Tests use it.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
import torch
|
|
25
|
+
import torchvision.models as models
|
|
26
|
+
except ImportError as error:
|
|
27
|
+
raise MissingDependencyError("torchvision: sources", "torchvision") from error
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
weights = models.get_model_weights(name)["DEFAULT"]
|
|
31
|
+
except ValueError as error:
|
|
32
|
+
raise SourceError(f"torchvision has no model named '{name}'") from error
|
|
33
|
+
|
|
34
|
+
transform = weights.transforms()
|
|
35
|
+
if type(transform).__name__ != "ImageClassification":
|
|
36
|
+
raise SourceError(
|
|
37
|
+
f"torchvision:{name} is a {type(transform).__name__} model. "
|
|
38
|
+
"Only image classification is supported for now."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
method = _METHODS.get(transform.interpolation.value)
|
|
42
|
+
if method is None:
|
|
43
|
+
raise SourceError(f"unsupported interpolation {transform.interpolation}")
|
|
44
|
+
crop = int(transform.crop_size[0])
|
|
45
|
+
preprocess = ImagePreprocess(
|
|
46
|
+
# torchvision resizes PIL images with PIL, which always antialiases.
|
|
47
|
+
resize=ResizeSpec(
|
|
48
|
+
shorter_side=int(transform.resize_size[0]),
|
|
49
|
+
method=method,
|
|
50
|
+
antialias=method != "nearest",
|
|
51
|
+
),
|
|
52
|
+
center_crop=(crop, crop),
|
|
53
|
+
mean=tuple(transform.mean),
|
|
54
|
+
std=tuple(transform.std),
|
|
55
|
+
)
|
|
56
|
+
module = models.get_model(name, weights=weights if pretrained else None).eval()
|
|
57
|
+
return SourceModel(
|
|
58
|
+
module=module,
|
|
59
|
+
example_inputs=(torch.zeros(1, 3, crop, crop),),
|
|
60
|
+
id=name.lower(),
|
|
61
|
+
task=Task.IMAGE_CLASSIFICATION,
|
|
62
|
+
# torchvision code and its pretrained weights are released under BSD-3-Clause.
|
|
63
|
+
license="BSD-3-Clause",
|
|
64
|
+
inputs=[
|
|
65
|
+
InputSpec(
|
|
66
|
+
name="pixel_values",
|
|
67
|
+
dtype=DType.FLOAT32,
|
|
68
|
+
shape=[1, 3, crop, crop],
|
|
69
|
+
layout="NCHW",
|
|
70
|
+
preprocess=preprocess,
|
|
71
|
+
)
|
|
72
|
+
],
|
|
73
|
+
output_names=["logits"],
|
|
74
|
+
labels=list(weights.meta["categories"]),
|
|
75
|
+
name=name,
|
|
76
|
+
description=f"torchvision {name} with {weights.name} weights.",
|
|
77
|
+
source=f"torchvision:{name}",
|
|
78
|
+
)
|
modelport/verify.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Check every variant of a bundle against its golden data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .bundle import Bundle
|
|
11
|
+
from .errors import MissingDependencyError, ModelPortError
|
|
12
|
+
from .golden import golden_arrays
|
|
13
|
+
from .manifest import ClassificationPostprocess, Tolerance
|
|
14
|
+
from .runtimes import RuntimeNotSupported, run_variant
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VerifyError(ModelPortError):
|
|
18
|
+
"""The bundle cannot be verified at all."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class OutputCheck:
|
|
23
|
+
name: str
|
|
24
|
+
max_abs_diff: float
|
|
25
|
+
cosine: float
|
|
26
|
+
within_tolerance: bool
|
|
27
|
+
top1_match: bool | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class VariantResult:
|
|
32
|
+
variant_id: str
|
|
33
|
+
runtime: str
|
|
34
|
+
tolerance: Tolerance
|
|
35
|
+
outputs: list[OutputCheck] = field(default_factory=list)
|
|
36
|
+
skipped: str | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def passed(self) -> bool:
|
|
40
|
+
return self.skipped is None and all(
|
|
41
|
+
o.within_tolerance and o.top1_match is not False for o in self.outputs
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compare(
|
|
46
|
+
name: str, got: np.ndarray, expected: np.ndarray, tolerance: Tolerance, classify: bool
|
|
47
|
+
) -> OutputCheck:
|
|
48
|
+
if got.shape != expected.shape:
|
|
49
|
+
raise VerifyError(f"output '{name}' has shape {got.shape}, golden has {expected.shape}")
|
|
50
|
+
a = got.astype(np.float64).ravel()
|
|
51
|
+
b = expected.astype(np.float64).ravel()
|
|
52
|
+
diff = np.abs(a - b)
|
|
53
|
+
within = bool(np.all(diff <= tolerance.atol + tolerance.rtol * np.abs(b)))
|
|
54
|
+
norms = np.linalg.norm(a) * np.linalg.norm(b)
|
|
55
|
+
cosine = float(np.dot(a, b) / norms) if norms > 0 else float(np.array_equal(a, b))
|
|
56
|
+
top1 = None
|
|
57
|
+
if classify:
|
|
58
|
+
top1 = bool(np.array_equal(got.argmax(axis=-1), expected.argmax(axis=-1)))
|
|
59
|
+
return OutputCheck(name, float(diff.max(initial=0.0)), cosine, within, top1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def verify_bundle(path: str | Path) -> list[VariantResult]:
|
|
63
|
+
bundle = Bundle(path)
|
|
64
|
+
if not bundle.manifest_path.is_file():
|
|
65
|
+
raise VerifyError(f"{bundle.manifest_path} not found")
|
|
66
|
+
manifest = bundle.read_manifest()
|
|
67
|
+
problems = bundle.problems(manifest)
|
|
68
|
+
if problems:
|
|
69
|
+
raise VerifyError("bundle files do not match the manifest:\n " + "\n ".join(problems))
|
|
70
|
+
if manifest.golden is None:
|
|
71
|
+
raise VerifyError(f"{manifest.id} has no golden data to verify against")
|
|
72
|
+
|
|
73
|
+
inputs, expected = golden_arrays(bundle, manifest)
|
|
74
|
+
classify = {
|
|
75
|
+
o.name for o in manifest.outputs if isinstance(o.postprocess, ClassificationPostprocess)
|
|
76
|
+
}
|
|
77
|
+
results = []
|
|
78
|
+
for variant in manifest.variants:
|
|
79
|
+
tolerance = variant.tolerance or manifest.golden.tolerance
|
|
80
|
+
try:
|
|
81
|
+
got = run_variant(bundle, manifest, variant, inputs)
|
|
82
|
+
except (MissingDependencyError, RuntimeNotSupported) as error:
|
|
83
|
+
results.append(
|
|
84
|
+
VariantResult(variant.id, variant.runtime, tolerance, skipped=str(error))
|
|
85
|
+
)
|
|
86
|
+
continue
|
|
87
|
+
checks = [
|
|
88
|
+
compare(name, got[name], want, tolerance, name in classify)
|
|
89
|
+
for name, want in expected.items()
|
|
90
|
+
]
|
|
91
|
+
results.append(VariantResult(variant.id, variant.runtime, tolerance, checks))
|
|
92
|
+
return results
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: modelport-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Prepare PyTorch, Hugging Face, and GGUF models for Flutter apps: export, verify, pack, publish.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ayanparvaiz/modelport
|
|
6
|
+
Project-URL: Documentation, https://ayanparvaiz.github.io/modelport/
|
|
7
|
+
Project-URL: Issues, https://github.com/ayanparvaiz/modelport/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/ayanparvaiz/modelport/blob/main/python/CHANGELOG.md
|
|
9
|
+
Author: Ayan Parvaiz
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,dart,executorch,flutter,gguf,mobile,on-device,onnx
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: numpy>=1.26
|
|
24
|
+
Requires-Dist: pillow>=10
|
|
25
|
+
Requires-Dist: pydantic>=2.7
|
|
26
|
+
Requires-Dist: rich>=13
|
|
27
|
+
Requires-Dist: typer>=0.12
|
|
28
|
+
Provides-Extra: executorch
|
|
29
|
+
Requires-Dist: executorch>=1.0; extra == 'executorch'
|
|
30
|
+
Requires-Dist: torch>=2.9; extra == 'executorch'
|
|
31
|
+
Provides-Extra: gguf
|
|
32
|
+
Requires-Dist: gguf>=0.17; extra == 'gguf'
|
|
33
|
+
Provides-Extra: hf
|
|
34
|
+
Requires-Dist: huggingface-hub>=0.30; extra == 'hf'
|
|
35
|
+
Requires-Dist: torch>=2.9; extra == 'hf'
|
|
36
|
+
Requires-Dist: transformers>=4.50; extra == 'hf'
|
|
37
|
+
Provides-Extra: onnx
|
|
38
|
+
Requires-Dist: onnx>=1.17; extra == 'onnx'
|
|
39
|
+
Requires-Dist: onnxruntime>=1.20; extra == 'onnx'
|
|
40
|
+
Requires-Dist: onnxscript>=0.5; extra == 'onnx'
|
|
41
|
+
Requires-Dist: torch>=2.9; extra == 'onnx'
|
|
42
|
+
Provides-Extra: torchvision
|
|
43
|
+
Requires-Dist: torch>=2.9; extra == 'torchvision'
|
|
44
|
+
Requires-Dist: torchvision>=0.24; extra == 'torchvision'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# modelport-cli
|
|
48
|
+
|
|
49
|
+
Prepare models for Flutter apps. Part of [ModelPort](https://github.com/ayanparvaiz/modelport).
|
|
50
|
+
|
|
51
|
+
Install `modelport-cli`; the command and the Python package are both called `modelport`.
|
|
52
|
+
|
|
53
|
+
> **Status: 0.1.0, alpha.** Works end to end and is tested on a real Android phone. The spec may still change before 1.0.
|
|
54
|
+
|
|
55
|
+
The CLI converts PyTorch, Hugging Face, and torchvision models into mobile formats, checks that the converted model gives the same output as the original, and writes a `modelport.json` manifest that the ModelPort Dart packages read.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install "modelport-cli[onnx,torchvision]"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Extra | Adds | Needed for |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `onnx` | onnx, onnxruntime, onnxscript, torch | ONNX export, quantize, verify |
|
|
66
|
+
| `executorch` | executorch, torch | ExecuTorch export and verify |
|
|
67
|
+
| `torchvision` | torchvision, torch | `torchvision:` sources |
|
|
68
|
+
| `hf` | transformers, huggingface_hub, torch | `hf:` sources and `publish` |
|
|
69
|
+
| `gguf` | gguf | Inspecting GGUF files |
|
|
70
|
+
|
|
71
|
+
Run `modelport doctor` to see what is installed.
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
modelport export torchvision:mobilenet_v3_small --target onnx,executorch
|
|
77
|
+
modelport quantize dist/mobilenet_v3_small --fp16 --int8
|
|
78
|
+
modelport verify dist/mobilenet_v3_small
|
|
79
|
+
modelport publish dist/mobilenet_v3_small --hf your-name/mobilenet_v3_small
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`verify` runs every variant on the saved golden input and compares it with PyTorch's output:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
variant ┃ output ┃ max diff ┃ cosine ┃ top-1 ┃ result
|
|
86
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━━
|
|
87
|
+
onnx-fp32 │ logits │ 3.34e-05 │ 1.000000 │ same │ pass
|
|
88
|
+
executorch-xnnpack-fp32 │ logits │ 3.34e-05 │ 1.000000 │ same │ pass
|
|
89
|
+
onnx-fp16 │ logits │ 1.05e-01 │ 0.999952 │ same │ pass
|
|
90
|
+
onnx-int8 │ logits │ 1.27e-01 │ 0.999933 │ same │ pass
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Commands
|
|
94
|
+
|
|
95
|
+
| Command | What it does |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `export SOURCE` | Convert a model and write a bundle with `modelport.json`, labels, and golden data. |
|
|
98
|
+
| `quantize BUNDLE` | Add `--fp16` and `--int8` ONNX variants and record how far each drifts. |
|
|
99
|
+
| `verify BUNDLE` | Run every variant on the golden input and compare with the expected output. |
|
|
100
|
+
| `pack BUNDLE` | Refresh sizes and hashes after manual edits and list unlisted files. |
|
|
101
|
+
| `publish BUNDLE --hf org/name` | Upload to the Hugging Face Hub. Log in first with `hf auth login`. |
|
|
102
|
+
| `inspect FILE` | Show inputs, outputs, and metadata of an `.onnx`, `.pte`, or `.gguf` file. |
|
|
103
|
+
| `validate MANIFEST...` | Check manifests against the spec. |
|
|
104
|
+
| `schema` | Print the manifest JSON Schema. |
|
|
105
|
+
| `doctor` | Check Python, optional packages, and disk space. |
|
|
106
|
+
|
|
107
|
+
## Sources
|
|
108
|
+
|
|
109
|
+
| Source | Example |
|
|
110
|
+
|---|---|
|
|
111
|
+
| torchvision classifier | `torchvision:efficientnet_b0` |
|
|
112
|
+
| Hugging Face image classifier | `hf:facebook/deit-tiny-patch16-224` |
|
|
113
|
+
| Your own model | `file:my_model.py:build` |
|
|
114
|
+
|
|
115
|
+
For `file:`, write a function that returns a `SourceModel`:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import torch
|
|
119
|
+
from modelport.manifest import DType, ImagePreprocess, InputSpec, ResizeSpec, Task
|
|
120
|
+
from modelport.sources import SourceModel
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build() -> SourceModel:
|
|
124
|
+
net = MyNet()
|
|
125
|
+
net.load_state_dict(torch.load("weights.pt", weights_only=True))
|
|
126
|
+
return SourceModel(
|
|
127
|
+
module=net.eval(),
|
|
128
|
+
example_inputs=(torch.zeros(1, 3, 224, 224),),
|
|
129
|
+
id="my_net",
|
|
130
|
+
task=Task.IMAGE_CLASSIFICATION,
|
|
131
|
+
license="MIT",
|
|
132
|
+
inputs=[
|
|
133
|
+
InputSpec(
|
|
134
|
+
name="pixel_values",
|
|
135
|
+
dtype=DType.FLOAT32,
|
|
136
|
+
shape=[1, 3, 224, 224],
|
|
137
|
+
layout="NCHW",
|
|
138
|
+
preprocess=ImagePreprocess(resize=ResizeSpec(size=(224, 224))),
|
|
139
|
+
)
|
|
140
|
+
],
|
|
141
|
+
output_names=["logits"],
|
|
142
|
+
labels=["cat", "dog"],
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`file:` runs the code in that file, so only use files you trust.
|
|
147
|
+
|
|
148
|
+
## Notes on quantization
|
|
149
|
+
|
|
150
|
+
- `--fp16` halves the size and keeps inputs and outputs in float32.
|
|
151
|
+
- `--int8` quantizes only MatMul and Gemm weights. Dynamic int8 on convolutions changed MobileNetV3's top-1 class in testing, so convolution-heavy models shrink less. Transformers shrink to about a third.
|
|
152
|
+
- Each new variant gets a tolerance of twice its measured error, so devices are checked against a realistic bar.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
modelport/__init__.py,sha256=6pDHHCifeMBbty1n-8CVWnv7hpqG75oK_H4gsqGMsmA,73
|
|
2
|
+
modelport/bundle.py,sha256=VjkKeBwDQfyNGLzsUk-NlkoN3iXlhlXBAG5AG9aCaN4,3278
|
|
3
|
+
modelport/cli.py,sha256=jfhfcqzl3RdVkimipNKnQy6mdeKrdiWmKW4ZEJrq_do,19994
|
|
4
|
+
modelport/doctor.py,sha256=Xqt6tz9QdxIvMmrtntBTMmK2o0iKil1PmvzuZ9h7QCk,2633
|
|
5
|
+
modelport/errors.py,sha256=8M2nQDETQT2P_zghN-3zeLCBpSA60asB0xJIrHzD8pY,562
|
|
6
|
+
modelport/gguf_import.py,sha256=bF3R8zvzBZoFqyBrXEpjEMOzCVwXPtREcVxFOk31KZM,6807
|
|
7
|
+
modelport/golden.py,sha256=zFPcD-MYSMmJcJAa9Sh6lwN8P1D8oXrerY4D5cCRXNo,4161
|
|
8
|
+
modelport/hashing.py,sha256=fIIZ_XNK1neFFjbxRKheZ6hwTY-pFuYpC0FG_1t3-Go,411
|
|
9
|
+
modelport/pack.py,sha256=yRYfslaBLEBoBynzvFeFVUUShblx5EGj_LqIiA0vdiA,1682
|
|
10
|
+
modelport/pipeline.py,sha256=HdzUS3xdleY-Zxe3mkNPTaib5E_Oo72RbVoFVKW6cqs,3787
|
|
11
|
+
modelport/preprocess.py,sha256=NzZOD-4cgdx8uBFEBnMPNCTz4zS5BzO-Li3vlwxszHY,5114
|
|
12
|
+
modelport/publish.py,sha256=7YzEbt3uZHU119-5M4IMY-99pMPSTi2JffDwCJ0aaCE,6853
|
|
13
|
+
modelport/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
modelport/quantize.py,sha256=E1mBOlIBaE64uNF-kcCWzN_lJbC-5LGreT6kBZ-cZkE,4779
|
|
15
|
+
modelport/runtimes.py,sha256=wAd_-I1e_8XyyWow4KPJi1I2wWUcnMpSdjGAEFrbEJA,2211
|
|
16
|
+
modelport/verify.py,sha256=GMvXQr0RwSSEgjcZOOOX7cjrb_gkqXJk-I401OJGEZ8,3184
|
|
17
|
+
modelport/codegen/__init__.py,sha256=DjDmljKF8DEeHiIJyuW72xARY2ipZ57u_JpHZ1SvTpc,38
|
|
18
|
+
modelport/codegen/dart.py,sha256=DsGA4jXZxkxBKnBnOt1rk3gCdZnNfsgCY_BnbRMBe00,7036
|
|
19
|
+
modelport/exporters/__init__.py,sha256=l7eLwe-YGqYbkQGFusVGWAdEXFkz1CLQNJNalM-ipMU,1488
|
|
20
|
+
modelport/exporters/executorch.py,sha256=Oeyw6zE4Me9X5jYEquhptoeZBNpgZ8OesS130-ZWeyQ,1744
|
|
21
|
+
modelport/exporters/onnx.py,sha256=c9QeMZhloWVIJITkX5G3lG5pHJREheXYgpfDal9TLf8,2429
|
|
22
|
+
modelport/inspection/__init__.py,sha256=AgOK7o-dub3uX18fmCbNtL6B4eLTUhwNCh048erxhAU,2263
|
|
23
|
+
modelport/inspection/executorch.py,sha256=uGytgcUAq2hmV-MHXWPkgrfIoTynoYgnoPg31sW96Ho,1385
|
|
24
|
+
modelport/inspection/gguf.py,sha256=wWHj-Wcf3_ty_xzcNYXMMDYJMBdYcE6_0y9L5dgz1Ww,1362
|
|
25
|
+
modelport/inspection/onnx.py,sha256=sM9KDxNOLtoW6v_YfiUUydjWqGY2vAVB0uzAm0VAXRs,1791
|
|
26
|
+
modelport/manifest/__init__.py,sha256=9AHphUN9Xn_VOZivTYqQs1N7ZF7op6i2sHdr6R_EXOQ,841
|
|
27
|
+
modelport/manifest/base.py,sha256=f7iLoqm-ZRN1Y9HciAnAXREHaaiC3QmD_wzg-uAWpUA,586
|
|
28
|
+
modelport/manifest/files.py,sha256=RQ7cD3ox1s2loYqrFZlalCjy-lwrfJMYQZDAtmK8tII,2150
|
|
29
|
+
modelport/manifest/models.py,sha256=-ibROqatIlHeGusZ7cNSsuQAeiMY9YXq4qd4UxN-JDs,9394
|
|
30
|
+
modelport/manifest/postprocess.py,sha256=9Pm0QtycS3XP-fFqLl-m4wZ0DAfeq3lm4HXRQYJJzNs,3046
|
|
31
|
+
modelport/manifest/schema.py,sha256=Tw_Few0kpkWCdnNJwafPSHp5B-xvGWXEten6D-WhxZg,1586
|
|
32
|
+
modelport/manifest/tensors.py,sha256=aT6Rq80Q9h7zT1oKSWSFMVo-tjg5XTLle2ysh8XT5zc,5776
|
|
33
|
+
modelport/sources/__init__.py,sha256=rPRFaB1XhxCG79Nc11_XW_D86sfmH-zFEidbqCPkPc0,2700
|
|
34
|
+
modelport/sources/file.py,sha256=kxaYd5aC2QzskrOLX6fsffOT-4_oX_5qs8xJrcUD7wU,1672
|
|
35
|
+
modelport/sources/hf.py,sha256=kPhIY9KbAdgv_tNc2dDBfgJxQ2ieBKFf3z12osEzQiI,10536
|
|
36
|
+
modelport/sources/torchvision.py,sha256=wFxfoRY2POeyoY7YSy1TCEnQa1Q_T00Lo16IW_kfQ_Y,2735
|
|
37
|
+
modelport_cli-0.1.0.dist-info/METADATA,sha256=rDUkuPZxeU5yb0kKNtaQcs-TirKElIMe6Yte76EJVw0,6406
|
|
38
|
+
modelport_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
39
|
+
modelport_cli-0.1.0.dist-info/entry_points.txt,sha256=63cpIl0-7KbhdI3Q24NDPfW9jW68A_wIhV1cLPy_p9E,48
|
|
40
|
+
modelport_cli-0.1.0.dist-info/licenses/LICENSE,sha256=yVuuHRzgI17MzTVgt3LsHvuX80innw--CmNPDCzO_iw,11358
|
|
41
|
+
modelport_cli-0.1.0.dist-info/RECORD,,
|