@camstack/addon-model-studio 1.0.1
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.
- package/dist/index.js +4 -0
- package/dist/index.mjs +2 -0
- package/dist/model-studio.addon.js +21656 -0
- package/dist/model-studio.addon.mjs +21632 -0
- package/package.json +77 -0
- package/python/__tests__/test_convert_lib.py +31 -0
- package/python/convert.py +69 -0
- package/python/convert_lib.py +79 -0
- package/python/requirements.txt +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@camstack/addon-model-studio",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Custom detection model registry, conversion & distribution for CamStack",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"camstack",
|
|
7
|
+
"addon",
|
|
8
|
+
"camstack-addon",
|
|
9
|
+
"model",
|
|
10
|
+
"detection",
|
|
11
|
+
"registry"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/camstack/server"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"module": "./dist/index.mjs",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.mjs",
|
|
25
|
+
"require": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"camstack": {
|
|
30
|
+
"displayName": "Model Studio",
|
|
31
|
+
"addons": [
|
|
32
|
+
{
|
|
33
|
+
"id": "model-studio",
|
|
34
|
+
"category": "system",
|
|
35
|
+
"name": "Model Studio",
|
|
36
|
+
"version": "1.0.0",
|
|
37
|
+
"description": "Register, manipulate and distribute custom detection models",
|
|
38
|
+
"entry": "./dist/index.js",
|
|
39
|
+
"execution": {
|
|
40
|
+
"placement": "any-node"
|
|
41
|
+
},
|
|
42
|
+
"capabilities": [
|
|
43
|
+
{
|
|
44
|
+
"name": "custom-model-registry"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "model-convert"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"python": {
|
|
51
|
+
"requirements": "./python/requirements.txt"
|
|
52
|
+
},
|
|
53
|
+
"color": "#8b5cf6"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
"files": [
|
|
58
|
+
"dist",
|
|
59
|
+
"python"
|
|
60
|
+
],
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "vite build",
|
|
63
|
+
"typecheck": "tsc --noEmit",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"publish": "npm publish --access public"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@camstack/types": "*"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@camstack/types": "*",
|
|
72
|
+
"typescript": "~6.0.3",
|
|
73
|
+
"vite": "^8.0.11",
|
|
74
|
+
"vitest": "^3.0.0",
|
|
75
|
+
"zod": "^4.3.6"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
ov = pytest.importorskip("openvino") # skip where openvino is absent
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from convert_lib import export_openvino, validate_openvino_ir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _tiny_onnx(path: str) -> None:
|
|
12
|
+
# Build a 1-op ONNX (Relu) with NAMED input/output via onnx helpers.
|
|
13
|
+
import onnx
|
|
14
|
+
from onnx import helper, TensorProto
|
|
15
|
+
x = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 3, 8, 8])
|
|
16
|
+
y = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 3, 8, 8])
|
|
17
|
+
node = helper.make_node("Relu", ["input"], ["output"])
|
|
18
|
+
graph = helper.make_graph([node], "tiny", [x], [y])
|
|
19
|
+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
|
|
20
|
+
onnx.save(model, path)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_export_and_validate_named_tensors():
|
|
24
|
+
with tempfile.TemporaryDirectory() as d:
|
|
25
|
+
onnx_path = os.path.join(d, "tiny.onnx")
|
|
26
|
+
xml_path = os.path.join(d, "tiny.xml")
|
|
27
|
+
_tiny_onnx(onnx_path)
|
|
28
|
+
export_openvino(onnx_path, xml_path, fp16=True)
|
|
29
|
+
assert os.path.exists(xml_path)
|
|
30
|
+
assert os.path.exists(xml_path.replace(".xml", ".bin"))
|
|
31
|
+
validate_openvino_ir(xml_path) # must not raise — tensors are named
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Runtime conversion subprocess. Invoked as: python convert.py <job.json>
|
|
2
|
+
|
|
3
|
+
job.json: { onnxPath, outDir, modelId, imgsz, targets: [...], calibDir? }
|
|
4
|
+
stdout: one JSON object per line — progress {"phase","pct","detail"} then a
|
|
5
|
+
final {"result": {"artifacts": [...]}} line. Errors -> stderr, non-zero exit."""
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import convert_lib as cl
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def emit(obj: dict) -> None:
|
|
14
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
15
|
+
sys.stdout.flush()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> int:
|
|
19
|
+
job = json.loads(Path(sys.argv[1]).read_text())
|
|
20
|
+
onnx_path = job["onnxPath"]
|
|
21
|
+
out_dir = Path(job["outDir"])
|
|
22
|
+
model_id = job["modelId"]
|
|
23
|
+
imgsz = int(job["imgsz"])
|
|
24
|
+
artifacts = []
|
|
25
|
+
|
|
26
|
+
for target in job["targets"]:
|
|
27
|
+
fmt = target["format"]
|
|
28
|
+
if fmt == "openvino":
|
|
29
|
+
for precision in target["precisions"]:
|
|
30
|
+
emit({"phase": "convert", "pct": 0, "detail": f"openvino/{precision}"})
|
|
31
|
+
suffix = "" if precision == "fp16" else f"-{precision}"
|
|
32
|
+
xml = out_dir / f"camstack-{model_id}{suffix}.xml"
|
|
33
|
+
if precision == "fp16":
|
|
34
|
+
cl.export_openvino(onnx_path, str(xml), fp16=True)
|
|
35
|
+
else:
|
|
36
|
+
calib = job.get("calibDir")
|
|
37
|
+
if not calib:
|
|
38
|
+
emit({"phase": "skip", "detail": "int8: no calibration set"})
|
|
39
|
+
continue
|
|
40
|
+
cl.quantize_openvino_int8(onnx_path, str(xml), calib, imgsz)
|
|
41
|
+
emit({"phase": "validate", "detail": f"openvino/{precision}"})
|
|
42
|
+
cl.validate_openvino_ir(str(xml))
|
|
43
|
+
bin_path = xml.with_suffix(".bin")
|
|
44
|
+
size_mb = (xml.stat().st_size + bin_path.stat().st_size) / 1e6
|
|
45
|
+
artifacts.append({
|
|
46
|
+
"format": "openvino", "precision": precision, "sizeMB": round(size_mb, 2),
|
|
47
|
+
"validated": True, "files": [xml.name, bin_path.name],
|
|
48
|
+
})
|
|
49
|
+
elif fmt == "coreml":
|
|
50
|
+
emit({"phase": "convert", "pct": 0, "detail": "coreml"})
|
|
51
|
+
pkg = out_dir / f"camstack-{model_id}.mlpackage"
|
|
52
|
+
cl.export_coreml_from_onnx(onnx_path, str(pkg), imgsz)
|
|
53
|
+
cl.validate_coreml(str(pkg))
|
|
54
|
+
size_mb = sum(f.stat().st_size for f in pkg.rglob("*") if f.is_file()) / 1e6
|
|
55
|
+
artifacts.append({
|
|
56
|
+
"format": "coreml", "sizeMB": round(size_mb, 2),
|
|
57
|
+
"validated": True, "files": [pkg.name],
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
emit({"result": {"artifacts": artifacts}})
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
try:
|
|
66
|
+
sys.exit(main())
|
|
67
|
+
except Exception as exc: # noqa: BLE001 — surface to the parent on stderr
|
|
68
|
+
sys.stderr.write(f"convert.py failed: {exc}\n")
|
|
69
|
+
sys.exit(1)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared model converter: ONNX -> OpenVINO IR (fp16/int8/CoreML) + validation.
|
|
2
|
+
|
|
3
|
+
Used by both the runtime model-convert cap (convert.py) and the offline
|
|
4
|
+
scripts/build-camstack-models.py. Conversion ALWAYS starts from ONNX via
|
|
5
|
+
ov.convert_model (never the Ultralytics OV exporter, which emits unnamed
|
|
6
|
+
tensors that break the runtime's named-output postprocess)."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def export_openvino(onnx_path: str, out_xml: str, *, fp16: bool) -> None:
|
|
12
|
+
import openvino as ov
|
|
13
|
+
ov_model = ov.convert_model(onnx_path)
|
|
14
|
+
Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
ov.save_model(ov_model, out_xml, compress_to_fp16=fp16)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_openvino_ir(xml_path: str) -> None:
|
|
19
|
+
"""Compile the IR and assert every input/output tensor carries a name.
|
|
20
|
+
This is the exact defect (unnamed tensors) that broke 14/26 catalog IRs."""
|
|
21
|
+
import openvino as ov
|
|
22
|
+
core = ov.Core()
|
|
23
|
+
model = core.read_model(xml_path)
|
|
24
|
+
compiled = core.compile_model(model, "CPU")
|
|
25
|
+
for port in list(compiled.inputs) + list(compiled.outputs):
|
|
26
|
+
try:
|
|
27
|
+
name = port.get_any_name()
|
|
28
|
+
except Exception as exc: # ov raises when a tensor has no names
|
|
29
|
+
raise ValueError(f"IR {xml_path} has an unnamed tensor: {exc}") from exc
|
|
30
|
+
if not name:
|
|
31
|
+
raise ValueError(f"IR {xml_path} has an empty-named tensor")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def quantize_openvino_int8(
|
|
35
|
+
onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
|
|
36
|
+
) -> None:
|
|
37
|
+
import cv2
|
|
38
|
+
import numpy as np
|
|
39
|
+
import openvino as ov
|
|
40
|
+
import nncf
|
|
41
|
+
|
|
42
|
+
calib = Path(calib_dir)
|
|
43
|
+
images = sorted(
|
|
44
|
+
p for p in calib.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png"}
|
|
45
|
+
)[:max_samples]
|
|
46
|
+
if not images:
|
|
47
|
+
raise ValueError(f"no calibration images in {calib_dir}")
|
|
48
|
+
|
|
49
|
+
def preprocess(path: Path):
|
|
50
|
+
img = cv2.imread(str(path))
|
|
51
|
+
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
52
|
+
h, w = img.shape[:2]
|
|
53
|
+
scale = imgsz / max(h, w)
|
|
54
|
+
nh, nw = int(round(h * scale)), int(round(w * scale))
|
|
55
|
+
resized = cv2.resize(img, (nw, nh))
|
|
56
|
+
canvas = np.full((imgsz, imgsz, 3), 114, dtype=np.uint8)
|
|
57
|
+
canvas[:nh, :nw] = resized
|
|
58
|
+
chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0
|
|
59
|
+
return chw[np.newaxis, ...]
|
|
60
|
+
|
|
61
|
+
dataset = nncf.Dataset(images, lambda p: preprocess(p))
|
|
62
|
+
ov_model = ov.convert_model(onnx_path)
|
|
63
|
+
quantized = nncf.quantize(ov_model, dataset, subset_size=len(images))
|
|
64
|
+
Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
ov.save_model(quantized, out_xml)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def export_coreml_from_onnx(onnx_path: str, out_pkg: str, imgsz: int) -> None:
|
|
69
|
+
"""ONNX -> CoreML mlpackage via coremltools (darwin only)."""
|
|
70
|
+
import coremltools as ct
|
|
71
|
+
mlmodel = ct.converters.onnx.convert(model=onnx_path) if hasattr(ct.converters, "onnx") \
|
|
72
|
+
else ct.convert(onnx_path, minimum_deployment_target=ct.target.iOS16)
|
|
73
|
+
Path(out_pkg).parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
mlmodel.save(out_pkg)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_coreml(pkg_path: str) -> None:
|
|
78
|
+
import coremltools as ct
|
|
79
|
+
ct.models.MLModel(pkg_path) # load; raises if malformed
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Cross-platform base + platform-gated conversion backends.
|
|
2
|
+
#
|
|
3
|
+
# openvino has NO arm64-macOS wheel (Intel/x86 Linux/Windows only), so installing
|
|
4
|
+
# it unconditionally aborts the WHOLE pip install on an Apple-Silicon agent and
|
|
5
|
+
# model-studio crash-loops on boot. coremltools is macOS-only. pip evaluates
|
|
6
|
+
# these environment markers per host, so a Mac agent installs the CoreML backend
|
|
7
|
+
# and skips OpenVINO, while a Linux/Windows agent does the reverse — convert_lib
|
|
8
|
+
# imports each backend lazily, matching whatever this host provides.
|
|
9
|
+
numpy
|
|
10
|
+
opencv-python-headless
|
|
11
|
+
onnx
|
|
12
|
+
openvino>=2025,<2026 ; platform_system != "Darwin"
|
|
13
|
+
nncf>=2.14 ; platform_system != "Darwin"
|
|
14
|
+
coremltools>=8.0,<9 ; platform_system == "Darwin"
|