@camstack/addon-pipeline 1.2.608 → 1.2.610

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.
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.2.140",
6
+ version: "1.2.142",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_stream_broker_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.390",
21
+ version: "1.2.392",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.307",
36
+ version: "1.2.309",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.390",
39
+ version: "1.2.392",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.2.140",
48
+ version: "1.2.142",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.307",
84
+ version: "1.2.309",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CWLQBK5e.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BypETcj_.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.608",
3
+ "version": "1.2.610",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import ctypes
11
11
  import os
12
12
  import re
13
- from typing import Callable, Optional
13
+ from typing import Callable, Mapping, Optional
14
14
 
15
15
  _DEVICE = re.compile(r"^(usb|pci)(:\d+)?$")
16
16
 
@@ -32,6 +32,37 @@ def edgetpu_delegate_options(device: Optional[str]) -> Optional[dict[str, str]]:
32
32
  return {"device": dev}
33
33
 
34
34
 
35
+ # Where the hub image's installer has always put the delegate.
36
+ LEGACY_EDGETPU_LIB = "/data/deps/edgetpu/lib/libedgetpu.so.1"
37
+
38
+
39
+ def edgetpu_library_path(
40
+ env: "Mapping[str, str]",
41
+ platform: str,
42
+ exists: Optional[Callable[[str], bool]] = None,
43
+ ) -> str:
44
+ """The libedgetpu file this pool loads.
45
+
46
+ The same lookup as the TS probe (``chooseEdgetpuDelegate`` in
47
+ ``@camstack/types`` ``deps/edgetpu-delegate.ts``): ``CAMSTACK_EDGETPU_LIB``
48
+ when set (followed even if missing — ``load_delegate`` then names it); the
49
+ copy the deps downloader staged under ``<CAMSTACK_DATA>/deps/edgetpu/lib/``
50
+ (``libedgetpu.1.dylib`` on macOS, ``libedgetpu.so.1`` elsewhere); else the
51
+ hub's historical path, which on the hub image is the same file.
52
+ """
53
+ check = exists or os.path.exists
54
+ override = (env.get("CAMSTACK_EDGETPU_LIB") or "").strip()
55
+ if override:
56
+ return override
57
+ data_root = env.get("CAMSTACK_DATA") or env.get("CAMSTACK_DATA_DIR") or ""
58
+ if data_root:
59
+ name = "libedgetpu.1.dylib" if platform == "darwin" else "libedgetpu.so.1"
60
+ staged = os.path.join(data_root, "deps", "edgetpu", "lib", name)
61
+ if check(staged):
62
+ return staged
63
+ return LEGACY_EDGETPU_LIB
64
+
65
+
35
66
  # The libedgetpu "std" delegate links libusb even when it drives a PCIe Coral.
36
67
  # The installer (edgetpu-install.sh) drops this copy beside the delegate for an
37
68
  # image that lacks the system package.
@@ -830,12 +830,12 @@ def _init_runtime(runtime: str, device: str = "") -> None:
830
830
  # The delegate is created ONCE here and shared across all model slots (the
831
831
  # USB device is a single hardware context). See docs: the delegate's TF
832
832
  # version MUST match the interpreter — feranick libedgetpu tf2.17.1 pairs
833
- # with ai-edge-litert 2.1.6. Install: /data/deps/edgetpu-install.sh.
833
+ # with ai-edge-litert 2.1.6. Staged by the deps downloader beside the
834
+ # edgetpu requirements (`requirements-edgetpu.txt`), or edgetpu-install.sh.
834
835
  import threading
835
836
  from ai_edge_litert.interpreter import Interpreter, load_delegate
836
- lib_path = os.environ.get(
837
- "CAMSTACK_EDGETPU_LIB", "/data/deps/edgetpu/lib/libedgetpu.so.1",
838
- )
837
+ from edgetpu_device import edgetpu_library_path
838
+ lib_path = edgetpu_library_path(os.environ, sys.platform)
839
839
  # Multiple Corals (USB and/or PCIe) coexist and run concurrently, one
840
840
  # pool each. libedgetpu addresses a unit by device string; the TS side
841
841
  # sends the explicit ordinal ("usb:0", "usb:1", "pci:0", "pci:1") as the
@@ -0,0 +1,145 @@
1
+ """YOLOv9 Edge TPU build with ALL strides concatenated (the community Frigate export).
2
+
3
+ The EdgeTPU-compiled YOLOv9 detectors the Frigate community publishes
4
+ (`dbro/frigate-detector-edgetpu-yolo9`, releases v1.0 and v1.5) were read with
5
+ a flatbuffer dump of the files themselves (2026-10-22):
6
+
7
+ yolov9-s-relu6-best_320_int8_edgetpu.tflite (v1.0)
8
+ in : serving_default_images:0 [1, 320, 320, 3] uint8 scale 1/255 zp 0
9
+ out: PartitionedCall:1 [1, 2100, 17] int8 scale 0.027195 zp 19 (class LOGITS)
10
+ PartitionedCall:0 [1, 2100, 64] int8 scale 0.079690 zp -45 (DFL l,t,r,b × 16)
11
+ yolov9-s-relu6-tpumax_512_int8_edgetpu.tflite (v1.5)
12
+ out: [1, 5376, 64] int8, [1, 5376, 17] int8, and a THIRD [1, 5376, 1] int8
13
+
14
+ N is the three strides CONCATENATED, 8 then 16 then 32, each row-major
15
+ (2100 = 40² + 20² + 10²; 5376 = 64² + 32² + 16²) — not one tensor pair per
16
+ stride like the Scrypted export `test_yolo_split_dfl.py` pins. The anchor of
17
+ cell (gx, gy) is ((gx + 0.5) · s, (gy + 0.5) · s); a box is anchor ∓ ltrb · s.
18
+
19
+ Every fixture below is QUANTISED with the real file's scale/zero-point and
20
+ dequantised through `edgetpu_dequant` exactly as the pool's edgetpu predict
21
+ closure does; the expected boxes are computed by hand in the comments.
22
+
23
+ Pure lists — runs on CI's bare interpreter (no numpy).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ import os
29
+ import sys
30
+ import unittest
31
+
32
+ _HERE = os.path.dirname(os.path.abspath(__file__))
33
+ sys.path.insert(0, os.path.dirname(_HERE)) # edgetpu_dequant lives one level up
34
+
35
+ from edgetpu_dequant import dequant_edgetpu_output # noqa: E402
36
+ from yolo_split_dfl import postprocess_yolo_split_dfl # noqa: E402
37
+
38
+ # The real quantisation of yolov9-s-relu6-best_320_int8_edgetpu.tflite.
39
+ CLS_SCALE, CLS_ZP = 0.027195103466510773, 19
40
+ BOX_SCALE, BOX_ZP = 0.07969039678573608, -45
41
+
42
+ COCO17 = [
43
+ "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck",
44
+ "boat", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear",
45
+ ]
46
+
47
+ Q_HOT, Q_COLD = 127, -128 # int8 extremes: DFL bin logits 13.7 / -6.6 after dequant
48
+
49
+
50
+ def _dfl(l: int, t: int, r: int, b: int) -> list[int]:
51
+ out: list[int] = []
52
+ for bin_index in (l, t, r, b):
53
+ out += [Q_HOT if i == bin_index else Q_COLD for i in range(16)]
54
+ return out
55
+
56
+
57
+ def _sigmoid(q: int) -> float:
58
+ return 1.0 / (1.0 + math.exp(-((q - CLS_ZP) * CLS_SCALE)))
59
+
60
+
61
+ def _quantised_outputs(cells: dict[int, tuple[tuple[int, int, int, int], int, int]], n: int) -> tuple[list, list]:
62
+ """cells: candidate index → (ltrb bins, class id, quantised class logit)."""
63
+ box = [[_dfl(0, 0, 0, 0) for _ in range(n)]]
64
+ cls = [[[Q_COLD] * len(COCO17) for _ in range(n)]]
65
+ for idx, (ltrb, class_id, q) in cells.items():
66
+ box[0][idx] = _dfl(*ltrb)
67
+ cls[0][idx][class_id] = q
68
+ return box, cls
69
+
70
+
71
+ def _dequant_cls(cls: list) -> list:
72
+ return dequant_edgetpu_output(cls, dtype_name="int8", scale=CLS_SCALE, zero_point=CLS_ZP)
73
+
74
+
75
+ def _dequant_box(box: list) -> list:
76
+ return dequant_edgetpu_output(box, dtype_name="int8", scale=BOX_SCALE, zero_point=BOX_ZP)
77
+
78
+
79
+ CONFIG = {
80
+ "labels": COCO17,
81
+ "confidence": 0.5,
82
+ "preprocessMode": "letterbox",
83
+ "inputSize": 320,
84
+ "inputWidth": 320,
85
+ "inputHeight": 320,
86
+ "nmsIouThreshold": 0.45,
87
+ }
88
+
89
+ # 640×480 frame letterboxed to 320: scale 0.5, content 320×240, pad (0, 40).
90
+ ORIG_W, ORIG_H, SCALE, PAD = 640, 480, 0.5, (0, 40)
91
+
92
+ # Stride 16 (grid 20, offset 40² = 1600), cell (gx 5, gy 7) → idx 1600 + 7·20 + 5 = 1745.
93
+ # anchor (5.5·16, 7.5·16) = (88, 120); ltrb (2, 3, 4, 1) → (56, 72)-(152, 136) input px
94
+ # → original ((56-0)/0.5, (72-40)/0.5, (152-0)/0.5, (136-40)/0.5) = (112, 64, 304, 192). car.
95
+ CAR_IDX, CAR_Q = 1745, 99
96
+ # Its neighbour (gx 6) → idx 1746, anchor (104, 120); ltrb (3, 3, 3, 1) → the SAME box,
97
+ # a weaker score: NMS must keep only the car above.
98
+ DUP_IDX, DUP_Q = 1746, 60
99
+ # Stride 32 (grid 10, offset 1600 + 400 = 2000), cell (gx 3, gy 2) → idx 2000 + 2·10 + 3 = 2023.
100
+ # anchor (3.5·32, 2.5·32) = (112, 80); ltrb (1, 1, 2, 2) → (80, 48)-(176, 144) input px
101
+ # → original (160, 16, 352, 208). person.
102
+ PERSON_IDX, PERSON_Q = 2023, 127
103
+
104
+ CELLS = {
105
+ CAR_IDX: ((2, 3, 4, 1), 2, CAR_Q),
106
+ DUP_IDX: ((3, 3, 3, 1), 2, DUP_Q),
107
+ PERSON_IDX: ((1, 1, 2, 2), 0, PERSON_Q),
108
+ }
109
+
110
+
111
+ class YoloV9EdgeTpuConcatenatedStrides(unittest.TestCase):
112
+ def _decode(self, predictions: dict) -> list[dict]:
113
+ out = postprocess_yolo_split_dfl(predictions, CONFIG, ORIG_W, ORIG_H, SCALE, PAD)
114
+ self.assertEqual(out["kind"], "detections")
115
+ return sorted(out["detections"], key=lambda d: d["class"])
116
+
117
+ def _assert_expected(self, dets: list[dict]) -> None:
118
+ self.assertEqual([d["class"] for d in dets], ["car", "person"])
119
+ car, person = dets
120
+ self.assertEqual(car["bbox"], [112.0, 64.0, 304.0, 192.0])
121
+ self.assertEqual(person["bbox"], [160.0, 16.0, 352.0, 208.0])
122
+ self.assertEqual(car["score"], round(_sigmoid(CAR_Q), 4))
123
+ self.assertEqual(person["score"], round(_sigmoid(PERSON_Q), 4))
124
+
125
+ def test_v1_0_two_tensors_scores_first(self) -> None:
126
+ box, cls = _quantised_outputs(CELLS, 2100)
127
+ # Positional keys in the file's output order: scores, then boxes.
128
+ self._assert_expected(self._decode({"0": _dequant_cls(cls), "1": _dequant_box(box)}))
129
+
130
+ def test_v1_5_third_single_channel_tensor_is_not_the_class_tensor(self) -> None:
131
+ box, cls = _quantised_outputs(CELLS, 2100)
132
+ # The v1.5 files add a [1, N, 1] output AFTER the class tensor. Read as the
133
+ # class tensor it would score every candidate on ONE cold channel.
134
+ aux = [[[Q_COLD] for _ in range(2100)]]
135
+ preds = {"0": _dequant_box(box), "1": _dequant_cls(cls), "2": _dequant_cls(aux)}
136
+ self._assert_expected(self._decode(preds))
137
+
138
+ def test_a_concatenated_n_that_matches_no_input_size_is_refused(self) -> None:
139
+ box, cls = _quantised_outputs({}, 2101)
140
+ with self.assertRaises(ValueError):
141
+ self._decode({"0": _dequant_cls(cls), "1": _dequant_box(box)})
142
+
143
+
144
+ if __name__ == "__main__":
145
+ unittest.main()
@@ -1,10 +1,20 @@
1
- """YOLO split-head DFL postprocessor (Scrypted YOLOv9 TFLite / EdgeTPU).
1
+ """YOLO split-head DFL postprocessor (YOLOv9 TFLite / EdgeTPU).
2
2
 
3
- Scrypted's TFLite builds emit SIX tensors (3 strides × box+cls), not the
4
- ONNX ``[1, 7, 2100]`` packed layout:
3
+ Two split layouts reach it, neither the ONNX ``[1, 7, 2100]`` packed one:
5
4
 
6
- box: [1, H·W, 4·reg_max] with ``reg_max=16`` (DFL distances l,t,r,b)
7
- cls: [1, H·W, C] logits over person/vehicle/animal (C=3)
5
+ * Scrypted's TFLite builds emit SIX tensors (3 strides × box+cls):
6
+
7
+ box: [1, H·W, 4·reg_max] with ``reg_max=16`` (DFL distances l,t,r,b)
8
+ cls: [1, H·W, C] logits over person/vehicle/animal (C=3)
9
+
10
+ * The community Frigate EdgeTPU export (``dbro/frigate-detector-edgetpu-yolo9``,
11
+ read from the published files) emits ONE pair with the strides
12
+ CONCATENATED, 8 then 16 then 32, each row-major:
13
+
14
+ box: [1, N, 64] cls: [1, N, C] N = Σ (H/s)·(W/s) — 2100 at 320², 5376 at 512²
15
+
16
+ and its v1.5 files add a third ``[1, N, 1]`` tensor that is NOT the class
17
+ tensor: the widest non-box tensor of a given ``N`` is.
8
18
 
9
19
  Pairs are matched by shared spatial length ``N`` (1600/400/100 for 320²), not
10
20
  by positional key order — TFLite output order has drifted between exports.
@@ -110,7 +120,10 @@ def _pair_box_cls(predictions: dict) -> list[tuple[Any, Any]]:
110
120
  n, width = shape
111
121
  if width == 4 * REG_MAX:
112
122
  boxes[n] = tensor
113
- elif width > 0 and width != 4 * REG_MAX:
123
+ elif width > 0 and (n not in classes or width > _shape(classes[n])[1]):
124
+ # The WIDEST non-box tensor of this N is the class tensor: a
125
+ # one-channel sibling (the v1.5 Frigate export's [1, N, 1]) never
126
+ # displaces it, whatever the output order.
114
127
  classes[n] = tensor
115
128
  pairs: list[tuple[Any, Any]] = []
116
129
  for n, box in boxes.items():
@@ -128,6 +141,37 @@ def _pair_box_cls(predictions: dict) -> list[tuple[Any, Any]]:
128
141
  return pairs
129
142
 
130
143
 
144
+ CONCAT_STRIDES: tuple[int, ...] = (8, 16, 32)
145
+
146
+
147
+ def _concat_stride_cells(n: int, in_w: int, in_h: int) -> "list[tuple[int, int, int]] | None":
148
+ """``[(offset, stride, grid_w), …]`` when ``N`` is every stride of the input
149
+ concatenated (8, 16, 32 in that order), else ``None``."""
150
+ cells: list[tuple[int, int, int]] = []
151
+ offset = 0
152
+ for stride in CONCAT_STRIDES:
153
+ if in_w % stride or in_h % stride:
154
+ return None
155
+ cells.append((offset, stride, in_w // stride))
156
+ offset += (in_w // stride) * (in_h // stride)
157
+ return cells if offset == n else None
158
+
159
+
160
+ def _segments(
161
+ box: Any, cls: Any, in_w: int, in_h: int, input_size: int
162
+ ) -> list[tuple[Any, Any, int, int]]:
163
+ """``(box rows, cls rows, stride, grid_w)`` per stride of one box/cls pair."""
164
+ n = _shape(box)[0]
165
+ concat = _concat_stride_cells(n, in_w, in_h)
166
+ if concat is None:
167
+ return [(box, cls, _infer_stride(n, input_size), int(round(math.sqrt(n))))]
168
+ ends = [offset for offset, _, _ in concat[1:]] + [n]
169
+ return [
170
+ (box[offset:end], cls[offset:end], stride, grid_w)
171
+ for (offset, stride, grid_w), end in zip(concat, ends)
172
+ ]
173
+
174
+
131
175
  def _infer_stride(n: int, input_size: int) -> int:
132
176
  """N = (input_size / stride)² → stride = input_size / sqrt(N)."""
133
177
  side = int(round(math.sqrt(n)))
@@ -191,12 +235,17 @@ def postprocess_yolo_split_dfl(
191
235
  labels = config.get("labels") or ["person", "vehicle", "animal"]
192
236
  nms_iou = float(config.get("nmsIouThreshold", 0.45))
193
237
  input_size = int(config.get("inputSize") or config.get("inputWidth") or 320)
238
+ in_w = int(config.get("inputWidth") or input_size)
239
+ in_h = int(config.get("inputHeight") or input_size)
194
240
 
241
+ segments = [
242
+ seg
243
+ for box_pair, cls_pair in _pair_box_cls(predictions)
244
+ for seg in _segments(box_pair, cls_pair, in_w, in_h, input_size)
245
+ ]
195
246
  candidates: list[dict] = []
196
- for box_tensor, cls_tensor in _pair_box_cls(predictions):
247
+ for box_tensor, cls_tensor, stride, grid in segments:
197
248
  n = _shape(box_tensor)[0]
198
- stride = _infer_stride(n, input_size)
199
- grid = int(round(math.sqrt(n)))
200
249
  num_classes = _shape(cls_tensor)[1]
201
250
 
202
251
  for i in range(n):
@@ -9,16 +9,19 @@
9
9
  # and is numpy-2 native.
10
10
  ai-edge-litert==2.1.6
11
11
 
12
- # libedgetpu.so.1 (the Coral delegate) is a SYSTEM shared library — pip cannot
13
- # install it. It MUST be provided out-of-band and its TensorFlow version MUST
14
- # match the interpreter (stock Coral libedgetpu 16.0/grouper is built against old
15
- # TFLite and FAILS at invoke: "EdgeTpuDelegateForCustomOp failed to invoke").
16
- # The hub uses feranick's libedgetpu rebuilt against TF 2.17.1 (std/throttled),
17
- # installed under /data/deps/edgetpu/lib/libedgetpu.so.1 by the installer that
18
- # ships beside this file, `edgetpu-install.sh` (it also drops libusb-1.0.so.0
19
- # there: the std delegate links libusb even for a PCIe Coral, and the pool
20
- # preloads that copy). The path is overridable via the CAMSTACK_EDGETPU_LIB env
21
- # var (default /data/deps/edgetpu/lib/libedgetpu.so.1).
12
+ # libedgetpu (the Coral delegate) is a native shared library — pip cannot
13
+ # install it — and its TensorFlow version MUST match the interpreter (stock
14
+ # Coral libedgetpu 16.0/grouper is built against old TFLite and FAILS at invoke:
15
+ # "EdgeTpuDelegateForCustomOp failed to invoke"). The line below asks the deps
16
+ # downloader to stage feranick's libedgetpu rebuilt against TF 2.17.1 right
17
+ # after this file installs (`@camstack/types` `deps/edgetpu-delegate.ts`:
18
+ # pinned, SHA-256 verified; on Linux libusb-1.0.so.0 beside it, because the std
19
+ # delegate links libusb even for a PCIe Coral and the pool preloads that copy).
20
+ # It lands in <CAMSTACK_DATA>/deps/edgetpu/lib/ — /data/deps/edgetpu/lib on the
21
+ # hub image, where `edgetpu-install.sh` (beside this file) has always put it.
22
+ # Linux x64/arm64 and Apple Silicon only; Windows has no TF 2.17.1 build.
23
+ # CAMSTACK_EDGETPU_LIB overrides the path the pool loads.
24
+ # camstack-native-dep: edgetpu-delegate
22
25
  #
23
26
  # Host prerequisite, USB: /dev/bus/usb passed through plus a
24
27
  # `--device-cgroup-rule 'c 189:* rmw'` rule (the Coral re-enumerates its USB
@@ -14,7 +14,7 @@ import tempfile
14
14
  import unittest
15
15
  from unittest import mock
16
16
 
17
- from edgetpu_device import edgetpu_delegate_options, preload_libusb
17
+ from edgetpu_device import edgetpu_delegate_options, edgetpu_library_path, preload_libusb
18
18
 
19
19
 
20
20
  class EdgetpuDelegateOptions(unittest.TestCase):
@@ -70,5 +70,34 @@ class PreloadLibusb(unittest.TestCase):
70
70
  cdll.assert_called_once_with(path, mode=ctypes.RTLD_GLOBAL)
71
71
 
72
72
 
73
+ class EdgetpuLibraryPath(unittest.TestCase):
74
+ """The delegate the pool loads — the same lookup as the TS probe
75
+ (`chooseEdgetpuDelegate` in `@camstack/types`): override, then the copy
76
+ staged under the node data root, then the hub's historical path."""
77
+
78
+ LEGACY = "/data/deps/edgetpu/lib/libedgetpu.so.1"
79
+
80
+ def test_a_desktop_loads_the_dylib_staged_under_its_data_root(self) -> None:
81
+ staged = "/Users/me/CamStack/data/deps/edgetpu/lib/libedgetpu.1.dylib"
82
+ path = edgetpu_library_path(
83
+ {"CAMSTACK_DATA": "/Users/me/CamStack/data"}, "darwin", lambda p: p == staged
84
+ )
85
+ self.assertEqual(path, staged)
86
+
87
+ def test_a_linux_agent_with_another_data_root_loads_its_staged_copy(self) -> None:
88
+ staged = "/srv/cs/deps/edgetpu/lib/libedgetpu.so.1"
89
+ env = {"CAMSTACK_DATA_DIR": "/srv/cs"}
90
+ self.assertEqual(edgetpu_library_path(env, "linux", lambda p: p in (staged, self.LEGACY)), staged)
91
+
92
+ def test_the_override_wins_even_when_missing(self) -> None:
93
+ # The operator named a file: loading another build instead would hide it.
94
+ env = {"CAMSTACK_EDGETPU_LIB": "/opt/x.so", "CAMSTACK_DATA": "/d"}
95
+ self.assertEqual(edgetpu_library_path(env, "linux", lambda p: False), "/opt/x.so")
96
+
97
+ def test_nothing_staged_falls_back_to_the_historical_hub_path(self) -> None:
98
+ self.assertEqual(edgetpu_library_path({}, "linux", lambda p: False), self.LEGACY)
99
+ self.assertEqual(edgetpu_library_path({"CAMSTACK_DATA": "/d"}, "linux", lambda p: False), self.LEGACY)
100
+
101
+
73
102
  if __name__ == "__main__":
74
103
  unittest.main()