@camstack/addon-pipeline 1.1.25 → 1.1.26
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/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/audio-codec-ffmpeg/index.js +1 -1
- package/dist/audio-codec-ffmpeg/index.mjs +1 -1
- package/dist/decoder-ffmpeg/index.js +1 -1
- package/dist/decoder-ffmpeg/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +148 -26
- package/dist/detection-pipeline/index.mjs +148 -26
- package/dist/{dist-BecXbIzJ.mjs → dist-CgEP_0OL.mjs} +730 -14
- package/dist/{dist-wzWBZ26C.js → dist-DAIlCdAx.js} +730 -14
- package/dist/{frame-handle-plane-DGg0Aevs.mjs → frame-handle-plane-Dq20KtKL.mjs} +1 -1
- package/dist/{frame-handle-plane-BAKiW6t4.js → frame-handle-plane-DtTRX_0n.js} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +38 -9
- package/dist/pipeline-runner/index.mjs +38 -9
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CWPU9tbs.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BJK0-svt.mjs +26 -0
- package/dist/stream-broker/{hostInit-B7Fx3R_6.mjs → hostInit-DyLqyJaS.mjs} +2 -2
- package/dist/stream-broker/index.js +2 -2
- package/dist/stream-broker/index.mjs +2 -2
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CPkQfKnu.js → MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DlLMQBag.js → MotionZonesSettings-NcxxQN8r-CoLjNiUN.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-kNcauIAN.js → PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js} +1 -1
- package/embed-dist/assets/index-C-pL8ETk.js +81 -0
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/python/inference_pool.py +522 -27
- package/python/test_inference_pool_backpressure.py +121 -0
- package/python/test_inference_pool_coreml_cache.py +416 -0
- package/python/test_inference_pool_device_selection.py +256 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-wfWYFiTT.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-lpFwIH9e.mjs +0 -26
- package/embed-dist/assets/index-xpPLFfsT.js +0 -80
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Unit tests for the per-model in-flight bound (ModelBackpressure).
|
|
2
|
+
|
|
3
|
+
Pure-python: stubs numpy / PIL / postprocessors in sys.modules so the module
|
|
4
|
+
imports without the ML runtime installed (inference_pool uses
|
|
5
|
+
`from __future__ import annotations`, so annotations never touch the stubs).
|
|
6
|
+
|
|
7
|
+
Run: python3 -m unittest test_inference_pool_backpressure -v
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import types
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Stub the heavy third-party imports BEFORE importing inference_pool.
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
if "numpy" not in sys.modules:
|
|
20
|
+
sys.modules["numpy"] = types.ModuleType("numpy")
|
|
21
|
+
|
|
22
|
+
if "PIL" not in sys.modules:
|
|
23
|
+
_pil = types.ModuleType("PIL")
|
|
24
|
+
_pil_image = types.ModuleType("PIL.Image")
|
|
25
|
+
_pil.Image = _pil_image
|
|
26
|
+
sys.modules["PIL"] = _pil
|
|
27
|
+
sys.modules["PIL.Image"] = _pil_image
|
|
28
|
+
|
|
29
|
+
if "postprocessors" not in sys.modules:
|
|
30
|
+
_pp = types.ModuleType("postprocessors")
|
|
31
|
+
_pp.POSTPROCESSORS = {}
|
|
32
|
+
sys.modules["postprocessors"] = _pp
|
|
33
|
+
|
|
34
|
+
from inference_pool import MAX_PENDING_PER_MODEL, ModelBackpressure
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ModelBackpressureTest(unittest.TestCase):
|
|
38
|
+
def test_runs_immediately_below_max_running(self) -> None:
|
|
39
|
+
bp = ModelBackpressure(max_running=2, max_pending=2)
|
|
40
|
+
self.assertEqual(bp.admit(0, "a"), (["a"], []))
|
|
41
|
+
self.assertEqual(bp.admit(0, "b"), (["b"], []))
|
|
42
|
+
|
|
43
|
+
def test_queues_when_running_slots_full(self) -> None:
|
|
44
|
+
bp = ModelBackpressure(max_running=1, max_pending=2)
|
|
45
|
+
self.assertEqual(bp.admit(0, "a"), (["a"], []))
|
|
46
|
+
# Running slot taken — next arrivals wait, nothing dropped yet.
|
|
47
|
+
self.assertEqual(bp.admit(0, "b"), ([], []))
|
|
48
|
+
self.assertEqual(bp.admit(0, "c"), ([], []))
|
|
49
|
+
|
|
50
|
+
def test_drops_oldest_beyond_max_pending(self) -> None:
|
|
51
|
+
bp = ModelBackpressure(max_running=1, max_pending=2)
|
|
52
|
+
bp.admit(0, "running")
|
|
53
|
+
bp.admit(0, "old")
|
|
54
|
+
bp.admit(0, "mid")
|
|
55
|
+
# Pending full (old, mid) — a new arrival sheds the OLDEST.
|
|
56
|
+
to_run, to_drop = bp.admit(0, "new")
|
|
57
|
+
self.assertEqual(to_run, [])
|
|
58
|
+
self.assertEqual(to_drop, ["old"])
|
|
59
|
+
# And again — "mid" is now the oldest.
|
|
60
|
+
to_run, to_drop = bp.admit(0, "newer")
|
|
61
|
+
self.assertEqual(to_drop, ["mid"])
|
|
62
|
+
|
|
63
|
+
def test_complete_promotes_oldest_pending(self) -> None:
|
|
64
|
+
bp = ModelBackpressure(max_running=1, max_pending=2)
|
|
65
|
+
bp.admit(0, "a")
|
|
66
|
+
bp.admit(0, "b")
|
|
67
|
+
bp.admit(0, "c")
|
|
68
|
+
# "a" finishes — "b" (oldest waiting) takes the freed slot.
|
|
69
|
+
self.assertEqual(bp.complete(0), ["b"])
|
|
70
|
+
self.assertEqual(bp.complete(0), ["c"])
|
|
71
|
+
self.assertEqual(bp.complete(0), [])
|
|
72
|
+
|
|
73
|
+
def test_slot_freed_by_complete_is_reusable(self) -> None:
|
|
74
|
+
bp = ModelBackpressure(max_running=1, max_pending=1)
|
|
75
|
+
bp.admit(0, "a")
|
|
76
|
+
self.assertEqual(bp.complete(0), [])
|
|
77
|
+
# Slot free again — the next arrival runs immediately.
|
|
78
|
+
self.assertEqual(bp.admit(0, "b"), (["b"], []))
|
|
79
|
+
|
|
80
|
+
def test_models_are_bounded_independently(self) -> None:
|
|
81
|
+
bp = ModelBackpressure(max_running=1, max_pending=0)
|
|
82
|
+
self.assertEqual(bp.admit(0, "m0-a"), (["m0-a"], []))
|
|
83
|
+
# Model 0 saturated, but model 1 has its own slots.
|
|
84
|
+
self.assertEqual(bp.admit(1, "m1-a"), (["m1-a"], []))
|
|
85
|
+
# Model 0 overload does not shed model 1 frames.
|
|
86
|
+
_, dropped = bp.admit(0, "m0-b")
|
|
87
|
+
self.assertEqual(dropped, ["m0-b"])
|
|
88
|
+
self.assertEqual(bp.complete(1), [])
|
|
89
|
+
|
|
90
|
+
def test_zero_pending_rejects_overload_arrival_immediately(self) -> None:
|
|
91
|
+
bp = ModelBackpressure(max_running=1, max_pending=0)
|
|
92
|
+
bp.admit(0, "a")
|
|
93
|
+
to_run, to_drop = bp.admit(0, "b")
|
|
94
|
+
self.assertEqual(to_run, [])
|
|
95
|
+
self.assertEqual(to_drop, ["b"])
|
|
96
|
+
|
|
97
|
+
def test_running_count_never_goes_negative(self) -> None:
|
|
98
|
+
bp = ModelBackpressure(max_running=1, max_pending=1)
|
|
99
|
+
# Spurious complete on an idle model must not corrupt the state.
|
|
100
|
+
self.assertEqual(bp.complete(0), [])
|
|
101
|
+
self.assertEqual(bp.admit(0, "a"), (["a"], []))
|
|
102
|
+
self.assertEqual(bp.admit(0, "b"), ([], []))
|
|
103
|
+
|
|
104
|
+
def test_minimum_bounds_are_enforced(self) -> None:
|
|
105
|
+
# max_running clamps to >= 1 so a model can always make progress.
|
|
106
|
+
bp = ModelBackpressure(max_running=0, max_pending=-3)
|
|
107
|
+
self.assertEqual(bp.admit(0, "a"), (["a"], []))
|
|
108
|
+
# max_pending clamps to >= 0 — overload arrival sheds immediately.
|
|
109
|
+
to_run, to_drop = bp.admit(0, "b")
|
|
110
|
+
self.assertEqual(to_run, [])
|
|
111
|
+
self.assertEqual(to_drop, ["b"])
|
|
112
|
+
|
|
113
|
+
def test_default_pending_constant_is_small(self) -> None:
|
|
114
|
+
# The whole point is that queue wait stays bounded to a couple of
|
|
115
|
+
# frames — a large buffer would re-create the invisible latency.
|
|
116
|
+
self.assertGreaterEqual(MAX_PENDING_PER_MODEL, 1)
|
|
117
|
+
self.assertLessEqual(MAX_PENDING_PER_MODEL, 4)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
unittest.main()
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Unit tests for the CoreML persistent compiled-model cache in inference_pool.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- _coreml_cache_paths — `.coreml-cache/<stem>.mlmodelc` + stamp resolution.
|
|
5
|
+
- _coreml_model_fingerprint — file/.mlpackage-dir fingerprinting + JSON
|
|
6
|
+
round-trip stability.
|
|
7
|
+
- _coreml_stamp_matches — pure cache-validity decision.
|
|
8
|
+
- _persist_coreml_compiled — atomic copy + stamp-written-LAST ordering.
|
|
9
|
+
- _acquire_coreml_model — HIT/MISS/invalidation flow with a fake `ct`, and
|
|
10
|
+
the HARD SAFETY guarantee: every cache failure falls back to the plain
|
|
11
|
+
`ct.models.MLModel(path)` load (pre-cache behavior).
|
|
12
|
+
|
|
13
|
+
Pure-python: stubs numpy / PIL / postprocessors in sys.modules so the module
|
|
14
|
+
imports without the ML runtime installed (inference_pool uses
|
|
15
|
+
`from __future__ import annotations`, so annotations never touch the stubs).
|
|
16
|
+
|
|
17
|
+
Run: python3 -m unittest test_inference_pool_coreml_cache -v
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import types
|
|
26
|
+
import unittest
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Stub the heavy third-party imports BEFORE importing inference_pool.
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
if "numpy" not in sys.modules:
|
|
33
|
+
sys.modules["numpy"] = types.ModuleType("numpy")
|
|
34
|
+
|
|
35
|
+
if "PIL" not in sys.modules:
|
|
36
|
+
_pil = types.ModuleType("PIL")
|
|
37
|
+
_pil_image = types.ModuleType("PIL.Image")
|
|
38
|
+
_pil.Image = _pil_image
|
|
39
|
+
sys.modules["PIL"] = _pil
|
|
40
|
+
sys.modules["PIL.Image"] = _pil_image
|
|
41
|
+
|
|
42
|
+
if "postprocessors" not in sys.modules:
|
|
43
|
+
_pp = types.ModuleType("postprocessors")
|
|
44
|
+
_pp.POSTPROCESSORS = {}
|
|
45
|
+
sys.modules["postprocessors"] = _pp
|
|
46
|
+
|
|
47
|
+
from inference_pool import (
|
|
48
|
+
COREML_CACHE_DIR_NAME,
|
|
49
|
+
COREML_CACHE_STAMP_VERSION,
|
|
50
|
+
_acquire_coreml_model,
|
|
51
|
+
_coreml_cache_paths,
|
|
52
|
+
_coreml_model_fingerprint,
|
|
53
|
+
_coreml_stamp_matches,
|
|
54
|
+
_persist_coreml_compiled,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write(path: str, content: bytes = b"x") -> None:
|
|
59
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
60
|
+
with open(path, "wb") as fh:
|
|
61
|
+
fh.write(content)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _make_mlpackage(root: str, name: str = "yolo.mlpackage") -> str:
|
|
65
|
+
pkg = os.path.join(root, name)
|
|
66
|
+
_write(os.path.join(pkg, "Manifest.json"), b"{}")
|
|
67
|
+
_write(os.path.join(pkg, "Data", "com.apple.CoreML", "model.mlmodel"), b"weights")
|
|
68
|
+
return pkg
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CoremlCachePathsTest(unittest.TestCase):
|
|
72
|
+
def test_mlpackage_maps_to_dot_cache_sibling(self) -> None:
|
|
73
|
+
cache, stamp = _coreml_cache_paths("/models/yolo.mlpackage")
|
|
74
|
+
self.assertEqual(cache, os.path.join("/models", COREML_CACHE_DIR_NAME, "yolo.mlmodelc"))
|
|
75
|
+
self.assertEqual(stamp, os.path.join("/models", COREML_CACHE_DIR_NAME, "yolo.stamp.json"))
|
|
76
|
+
|
|
77
|
+
def test_plain_mlmodel_file_uses_stem(self) -> None:
|
|
78
|
+
cache, stamp = _coreml_cache_paths("/m/face.mlmodel")
|
|
79
|
+
self.assertEqual(cache, os.path.join("/m", COREML_CACHE_DIR_NAME, "face.mlmodelc"))
|
|
80
|
+
self.assertEqual(stamp, os.path.join("/m", COREML_CACHE_DIR_NAME, "face.stamp.json"))
|
|
81
|
+
|
|
82
|
+
def test_relative_path_is_absolutized(self) -> None:
|
|
83
|
+
cache, _ = _coreml_cache_paths("yolo.mlpackage")
|
|
84
|
+
self.assertTrue(os.path.isabs(cache))
|
|
85
|
+
|
|
86
|
+
def test_different_models_get_distinct_entries(self) -> None:
|
|
87
|
+
a, _ = _coreml_cache_paths("/models/yolo9.mlpackage")
|
|
88
|
+
b, _ = _coreml_cache_paths("/models/yolo11.mlpackage")
|
|
89
|
+
self.assertNotEqual(a, b)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CoremlModelFingerprintTest(unittest.TestCase):
|
|
93
|
+
def test_directory_fingerprint_counts_files_and_bytes(self) -> None:
|
|
94
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
95
|
+
pkg = _make_mlpackage(tmp)
|
|
96
|
+
fp = _coreml_model_fingerprint(pkg)
|
|
97
|
+
self.assertEqual(fp["kind"], "dir")
|
|
98
|
+
self.assertEqual(fp["files"], 2)
|
|
99
|
+
self.assertEqual(fp["size"], len(b"{}") + len(b"weights"))
|
|
100
|
+
self.assertGreater(fp["mtimeNs"], 0)
|
|
101
|
+
|
|
102
|
+
def test_plain_file_fingerprint(self) -> None:
|
|
103
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
104
|
+
path = os.path.join(tmp, "m.mlmodel")
|
|
105
|
+
_write(path, b"abc")
|
|
106
|
+
fp = _coreml_model_fingerprint(path)
|
|
107
|
+
self.assertEqual(fp["kind"], "file")
|
|
108
|
+
self.assertEqual(fp["files"], 1)
|
|
109
|
+
self.assertEqual(fp["size"], 3)
|
|
110
|
+
|
|
111
|
+
def test_content_change_changes_fingerprint(self) -> None:
|
|
112
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
113
|
+
pkg = _make_mlpackage(tmp)
|
|
114
|
+
before = _coreml_model_fingerprint(pkg)
|
|
115
|
+
_write(os.path.join(pkg, "Data", "com.apple.CoreML", "model.mlmodel"), b"NEW weights!")
|
|
116
|
+
after = _coreml_model_fingerprint(pkg)
|
|
117
|
+
self.assertNotEqual(before, after)
|
|
118
|
+
|
|
119
|
+
def test_fingerprint_survives_json_round_trip(self) -> None:
|
|
120
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
121
|
+
pkg = _make_mlpackage(tmp)
|
|
122
|
+
fp = _coreml_model_fingerprint(pkg)
|
|
123
|
+
self.assertEqual(json.loads(json.dumps(fp)), fp)
|
|
124
|
+
|
|
125
|
+
def test_missing_path_raises(self) -> None:
|
|
126
|
+
# The caller (_acquire_coreml_model) catches this and falls back.
|
|
127
|
+
with self.assertRaises(OSError):
|
|
128
|
+
_coreml_model_fingerprint("/nonexistent/model.mlpackage")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CoremlStampMatchesTest(unittest.TestCase):
|
|
132
|
+
FP = {"kind": "dir", "files": 2, "size": 9, "mtimeNs": 123}
|
|
133
|
+
|
|
134
|
+
def _stamp(self) -> dict:
|
|
135
|
+
return {"version": COREML_CACHE_STAMP_VERSION, "fingerprint": dict(self.FP)}
|
|
136
|
+
|
|
137
|
+
def test_matching_stamp_validates(self) -> None:
|
|
138
|
+
self.assertTrue(_coreml_stamp_matches(self._stamp(), dict(self.FP)))
|
|
139
|
+
|
|
140
|
+
def test_none_or_non_dict_rejected(self) -> None:
|
|
141
|
+
self.assertFalse(_coreml_stamp_matches(None, dict(self.FP)))
|
|
142
|
+
self.assertFalse(_coreml_stamp_matches("stamp", dict(self.FP)))
|
|
143
|
+
self.assertFalse(_coreml_stamp_matches([], dict(self.FP)))
|
|
144
|
+
|
|
145
|
+
def test_wrong_version_rejected(self) -> None:
|
|
146
|
+
stamp = self._stamp()
|
|
147
|
+
stamp["version"] = COREML_CACHE_STAMP_VERSION + 1
|
|
148
|
+
self.assertFalse(_coreml_stamp_matches(stamp, dict(self.FP)))
|
|
149
|
+
|
|
150
|
+
def test_changed_fingerprint_rejected(self) -> None:
|
|
151
|
+
changed = dict(self.FP)
|
|
152
|
+
changed["mtimeNs"] = 999
|
|
153
|
+
self.assertFalse(_coreml_stamp_matches(self._stamp(), changed))
|
|
154
|
+
|
|
155
|
+
def test_missing_fingerprint_rejected(self) -> None:
|
|
156
|
+
self.assertFalse(
|
|
157
|
+
_coreml_stamp_matches({"version": COREML_CACHE_STAMP_VERSION}, dict(self.FP)),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PersistCoremlCompiledTest(unittest.TestCase):
|
|
162
|
+
def _compiled_src(self, root: str) -> str:
|
|
163
|
+
src = os.path.join(root, "tmp-compiled.mlmodelc")
|
|
164
|
+
_write(os.path.join(src, "coremldata.bin"), b"compiled")
|
|
165
|
+
_write(os.path.join(src, "model.espresso.net"), b"net")
|
|
166
|
+
return src
|
|
167
|
+
|
|
168
|
+
def test_persists_dir_and_stamp(self) -> None:
|
|
169
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
170
|
+
src = self._compiled_src(tmp)
|
|
171
|
+
cache = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.mlmodelc")
|
|
172
|
+
stamp = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.stamp.json")
|
|
173
|
+
fp = {"kind": "dir", "files": 1, "size": 1, "mtimeNs": 1}
|
|
174
|
+
_persist_coreml_compiled(src, cache, stamp, fp)
|
|
175
|
+
self.assertTrue(os.path.isfile(os.path.join(cache, "coremldata.bin")))
|
|
176
|
+
self.assertTrue(os.path.isfile(os.path.join(cache, "model.espresso.net")))
|
|
177
|
+
with open(stamp, "r", encoding="utf-8") as fh:
|
|
178
|
+
data = json.load(fh)
|
|
179
|
+
self.assertTrue(_coreml_stamp_matches(data, fp))
|
|
180
|
+
# No tmp leftovers.
|
|
181
|
+
leftovers = [n for n in os.listdir(os.path.dirname(cache)) if ".tmp-" in n]
|
|
182
|
+
self.assertEqual(leftovers, [])
|
|
183
|
+
|
|
184
|
+
def test_overwrites_stale_cache(self) -> None:
|
|
185
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
186
|
+
src = self._compiled_src(tmp)
|
|
187
|
+
cache = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.mlmodelc")
|
|
188
|
+
stamp = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.stamp.json")
|
|
189
|
+
_write(os.path.join(cache, "old.bin"), b"stale")
|
|
190
|
+
_write(stamp, b"{}")
|
|
191
|
+
fp = {"kind": "dir", "files": 1, "size": 1, "mtimeNs": 2}
|
|
192
|
+
_persist_coreml_compiled(src, cache, stamp, fp)
|
|
193
|
+
self.assertFalse(os.path.exists(os.path.join(cache, "old.bin")))
|
|
194
|
+
self.assertTrue(os.path.isfile(os.path.join(cache, "coremldata.bin")))
|
|
195
|
+
|
|
196
|
+
def test_missing_source_raises_and_leaves_no_stamp(self) -> None:
|
|
197
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
198
|
+
cache = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.mlmodelc")
|
|
199
|
+
stamp = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.stamp.json")
|
|
200
|
+
with self.assertRaises(OSError):
|
|
201
|
+
_persist_coreml_compiled(
|
|
202
|
+
os.path.join(tmp, "missing"), cache, stamp,
|
|
203
|
+
{"kind": "dir", "files": 0, "size": 0, "mtimeNs": 0},
|
|
204
|
+
)
|
|
205
|
+
# Stamp is written LAST — a failed copy must never leave one.
|
|
206
|
+
self.assertFalse(os.path.exists(stamp))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Fake coremltools for _acquire_coreml_model flow tests
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _FakeSpec:
|
|
215
|
+
"""Sentinel spec object; identity is asserted in the tests."""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _FakeMLModel:
|
|
219
|
+
def __init__(self, compiled_dir: str) -> None:
|
|
220
|
+
self._compiled_dir = compiled_dir
|
|
221
|
+
self._spec = _FakeSpec()
|
|
222
|
+
|
|
223
|
+
def get_spec(self) -> _FakeSpec:
|
|
224
|
+
return self._spec
|
|
225
|
+
|
|
226
|
+
def get_compiled_model_path(self) -> str:
|
|
227
|
+
return self._compiled_dir
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _FakeCompiledMLModel:
|
|
231
|
+
def __init__(self, path: str, compute_units: object) -> None:
|
|
232
|
+
self.path = path
|
|
233
|
+
self.compute_units = compute_units
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class _FakeCt:
|
|
237
|
+
"""Minimal stand-in for the coremltools module surface the helper uses."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, tmp: str, fail_compiled: bool = False, fail_load_spec: bool = False) -> None:
|
|
240
|
+
self.tmp = tmp
|
|
241
|
+
self.mlmodel_calls: list[tuple[str, object]] = []
|
|
242
|
+
self.compiled_calls: list[tuple[str, object]] = []
|
|
243
|
+
self.load_spec_calls: list[str] = []
|
|
244
|
+
self.loaded_spec = _FakeSpec()
|
|
245
|
+
|
|
246
|
+
fake = self
|
|
247
|
+
|
|
248
|
+
class _MLModel:
|
|
249
|
+
def __new__(cls, path: str, compute_units: object = None) -> _FakeMLModel:
|
|
250
|
+
fake.mlmodel_calls.append((path, compute_units))
|
|
251
|
+
compiled = os.path.join(fake.tmp, f"compiled-{len(fake.mlmodel_calls)}.mlmodelc")
|
|
252
|
+
_write(os.path.join(compiled, "coremldata.bin"), b"compiled")
|
|
253
|
+
return _FakeMLModel(compiled)
|
|
254
|
+
|
|
255
|
+
class _CompiledMLModel:
|
|
256
|
+
def __new__(cls, path: str, compute_units: object = None) -> _FakeCompiledMLModel:
|
|
257
|
+
fake.compiled_calls.append((path, compute_units))
|
|
258
|
+
if fail_compiled:
|
|
259
|
+
raise RuntimeError("CompiledMLModel load failed")
|
|
260
|
+
return _FakeCompiledMLModel(path, compute_units)
|
|
261
|
+
|
|
262
|
+
def _load_spec(path: str) -> _FakeSpec:
|
|
263
|
+
fake.load_spec_calls.append(path)
|
|
264
|
+
if fail_load_spec:
|
|
265
|
+
raise RuntimeError("load_spec failed")
|
|
266
|
+
return fake.loaded_spec
|
|
267
|
+
|
|
268
|
+
self.models = types.SimpleNamespace(MLModel=_MLModel, CompiledMLModel=_CompiledMLModel)
|
|
269
|
+
self.utils = types.SimpleNamespace(load_spec=_load_spec)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class AcquireCoremlModelTest(unittest.TestCase):
|
|
273
|
+
CU = object()
|
|
274
|
+
|
|
275
|
+
def test_first_load_is_miss_that_compiles_and_persists(self) -> None:
|
|
276
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
277
|
+
pkg = _make_mlpackage(tmp)
|
|
278
|
+
ct = _FakeCt(tmp)
|
|
279
|
+
model, spec = _acquire_coreml_model(ct, pkg, self.CU)
|
|
280
|
+
# MISS: plain MLModel compile, spec from model.get_spec().
|
|
281
|
+
self.assertIsInstance(model, _FakeMLModel)
|
|
282
|
+
self.assertIs(spec, model.get_spec())
|
|
283
|
+
self.assertEqual(ct.mlmodel_calls, [(pkg, self.CU)])
|
|
284
|
+
self.assertEqual(ct.compiled_calls, [])
|
|
285
|
+
# Persisted for the next spawn.
|
|
286
|
+
cache, stamp = _coreml_cache_paths(pkg)
|
|
287
|
+
self.assertTrue(os.path.isfile(os.path.join(cache, "coremldata.bin")))
|
|
288
|
+
self.assertTrue(os.path.isfile(stamp))
|
|
289
|
+
|
|
290
|
+
def test_second_load_is_hit_with_no_recompile(self) -> None:
|
|
291
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
292
|
+
pkg = _make_mlpackage(tmp)
|
|
293
|
+
_acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed the cache
|
|
294
|
+
ct = _FakeCt(tmp)
|
|
295
|
+
model, spec = _acquire_coreml_model(ct, pkg, self.CU)
|
|
296
|
+
# HIT: CompiledMLModel over the cache path; spec via load_spec
|
|
297
|
+
# from the SOURCE .mlpackage; NO MLModel compile.
|
|
298
|
+
cache, _ = _coreml_cache_paths(pkg)
|
|
299
|
+
self.assertIsInstance(model, _FakeCompiledMLModel)
|
|
300
|
+
self.assertEqual(model.path, cache)
|
|
301
|
+
self.assertIs(model.compute_units, self.CU)
|
|
302
|
+
self.assertIs(spec, ct.loaded_spec)
|
|
303
|
+
self.assertEqual(ct.load_spec_calls, [pkg])
|
|
304
|
+
self.assertEqual(ct.mlmodel_calls, [])
|
|
305
|
+
|
|
306
|
+
def test_changed_model_invalidates_cache_and_recompiles(self) -> None:
|
|
307
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
308
|
+
pkg = _make_mlpackage(tmp)
|
|
309
|
+
_acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
|
|
310
|
+
_write(
|
|
311
|
+
os.path.join(pkg, "Data", "com.apple.CoreML", "model.mlmodel"),
|
|
312
|
+
b"retrained weights (longer)",
|
|
313
|
+
)
|
|
314
|
+
ct = _FakeCt(tmp)
|
|
315
|
+
model, spec = _acquire_coreml_model(ct, pkg, self.CU)
|
|
316
|
+
self.assertIsInstance(model, _FakeMLModel)
|
|
317
|
+
self.assertIs(spec, model.get_spec())
|
|
318
|
+
self.assertEqual(ct.compiled_calls, [])
|
|
319
|
+
self.assertEqual(len(ct.mlmodel_calls), 1)
|
|
320
|
+
# Cache rewritten: a third spawn hits again.
|
|
321
|
+
ct3 = _FakeCt(tmp)
|
|
322
|
+
model3, _ = _acquire_coreml_model(ct3, pkg, self.CU)
|
|
323
|
+
self.assertIsInstance(model3, _FakeCompiledMLModel)
|
|
324
|
+
|
|
325
|
+
def test_compiled_load_failure_falls_back_to_plain_mlmodel(self) -> None:
|
|
326
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
327
|
+
pkg = _make_mlpackage(tmp)
|
|
328
|
+
_acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
|
|
329
|
+
ct = _FakeCt(tmp, fail_compiled=True)
|
|
330
|
+
model, spec = _acquire_coreml_model(ct, pkg, self.CU)
|
|
331
|
+
# HARD SAFETY: CompiledMLModel raising → plain MLModel path.
|
|
332
|
+
self.assertIsInstance(model, _FakeMLModel)
|
|
333
|
+
self.assertIs(spec, model.get_spec())
|
|
334
|
+
self.assertEqual(ct.mlmodel_calls, [(pkg, self.CU)])
|
|
335
|
+
|
|
336
|
+
def test_load_spec_failure_falls_back_to_plain_mlmodel(self) -> None:
|
|
337
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
338
|
+
pkg = _make_mlpackage(tmp)
|
|
339
|
+
_acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
|
|
340
|
+
ct = _FakeCt(tmp, fail_load_spec=True)
|
|
341
|
+
model, spec = _acquire_coreml_model(ct, pkg, self.CU)
|
|
342
|
+
self.assertIsInstance(model, _FakeMLModel)
|
|
343
|
+
self.assertIs(spec, model.get_spec())
|
|
344
|
+
|
|
345
|
+
def test_corrupt_stamp_json_falls_back_to_plain_mlmodel(self) -> None:
|
|
346
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
347
|
+
pkg = _make_mlpackage(tmp)
|
|
348
|
+
_acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
|
|
349
|
+
_, stamp = _coreml_cache_paths(pkg)
|
|
350
|
+
_write(stamp, b"not json {{{")
|
|
351
|
+
ct = _FakeCt(tmp)
|
|
352
|
+
model, _ = _acquire_coreml_model(ct, pkg, self.CU)
|
|
353
|
+
self.assertIsInstance(model, _FakeMLModel)
|
|
354
|
+
self.assertEqual(ct.compiled_calls, [])
|
|
355
|
+
|
|
356
|
+
def test_stamp_without_cache_dir_falls_back(self) -> None:
|
|
357
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
358
|
+
pkg = _make_mlpackage(tmp)
|
|
359
|
+
_acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
|
|
360
|
+
cache, _ = _coreml_cache_paths(pkg)
|
|
361
|
+
import shutil as _shutil
|
|
362
|
+
_shutil.rmtree(cache)
|
|
363
|
+
ct = _FakeCt(tmp)
|
|
364
|
+
model, _ = _acquire_coreml_model(ct, pkg, self.CU)
|
|
365
|
+
self.assertIsInstance(model, _FakeMLModel)
|
|
366
|
+
self.assertEqual(ct.compiled_calls, [])
|
|
367
|
+
|
|
368
|
+
def test_persist_failure_still_returns_compiled_model(self) -> None:
|
|
369
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
370
|
+
pkg = _make_mlpackage(tmp)
|
|
371
|
+
ct = _FakeCt(tmp)
|
|
372
|
+
|
|
373
|
+
def _boom() -> str:
|
|
374
|
+
raise RuntimeError("no compiled path")
|
|
375
|
+
|
|
376
|
+
# Force get_compiled_model_path to fail AFTER the compile — the
|
|
377
|
+
# already-loaded model must still be returned (cache is best
|
|
378
|
+
# effort only).
|
|
379
|
+
orig_new = ct.models.MLModel.__new__
|
|
380
|
+
|
|
381
|
+
class _MLModelNoCompiledPath:
|
|
382
|
+
def __new__(cls, path: str, compute_units: object = None) -> _FakeMLModel:
|
|
383
|
+
m = orig_new(ct.models.MLModel, path, compute_units)
|
|
384
|
+
m.get_compiled_model_path = _boom
|
|
385
|
+
return m
|
|
386
|
+
|
|
387
|
+
ct.models = types.SimpleNamespace(
|
|
388
|
+
MLModel=_MLModelNoCompiledPath, CompiledMLModel=ct.models.CompiledMLModel,
|
|
389
|
+
)
|
|
390
|
+
model, spec = _acquire_coreml_model(ct, pkg, self.CU)
|
|
391
|
+
self.assertIsInstance(model, _FakeMLModel)
|
|
392
|
+
self.assertIs(spec, model.get_spec())
|
|
393
|
+
_, stamp = _coreml_cache_paths(pkg)
|
|
394
|
+
self.assertFalse(os.path.exists(stamp))
|
|
395
|
+
|
|
396
|
+
def test_missing_model_path_raises_like_pre_cache_behavior(self) -> None:
|
|
397
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
398
|
+
missing = os.path.join(tmp, "gone.mlpackage")
|
|
399
|
+
|
|
400
|
+
class _RaisingMLModel:
|
|
401
|
+
def __new__(cls, path: str, compute_units: object = None) -> object:
|
|
402
|
+
raise FileNotFoundError(path)
|
|
403
|
+
|
|
404
|
+
ct = _FakeCt(tmp)
|
|
405
|
+
ct.models = types.SimpleNamespace(
|
|
406
|
+
MLModel=_RaisingMLModel, CompiledMLModel=ct.models.CompiledMLModel,
|
|
407
|
+
)
|
|
408
|
+
# Pre-cache, MLModel(path) raised for a missing model and the
|
|
409
|
+
# load/replace command handlers reported it — the cache layer
|
|
410
|
+
# must surface the SAME failure, not swallow it.
|
|
411
|
+
with self.assertRaises(FileNotFoundError):
|
|
412
|
+
_acquire_coreml_model(ct, missing, self.CU)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
if __name__ == "__main__":
|
|
416
|
+
unittest.main()
|