@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/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"