@camstack/addon-pipeline 1.1.56 → 1.1.57

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.
Files changed (39) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +71 -74
  4. package/dist/detection-pipeline/index.mjs +70 -73
  5. package/dist/{dist-ClE6_i0p.js → dist-CK8Ur-OS.js} +104 -6
  6. package/dist/{dist-CrjLGrkj.mjs → dist-CL531uVA.mjs} +104 -6
  7. package/dist/motion-wasm/index.js +9 -41
  8. package/dist/motion-wasm/index.mjs +9 -41
  9. package/dist/pipeline-runner/index.js +222 -656
  10. package/dist/pipeline-runner/index.mjs +211 -647
  11. package/dist/recorder/index.js +2 -2
  12. package/dist/recorder/index.mjs +2 -2
  13. package/dist/remote-restream-BeHi78PZ.mjs +25 -0
  14. package/dist/remote-restream-CO36Sr30.js +36 -0
  15. package/dist/{step-definitions-dCghpdRo.mjs → step-definitions-1d3_vQ6S.mjs} +44 -2
  16. package/dist/{step-definitions-C9kUXgC7.js → step-definitions-DHAxruAQ.js} +44 -2
  17. package/dist/stream-broker/_stub.js +282 -222
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D4xH4Tm-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cm_B-hQT.mjs} +1 -1
  19. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D2z_vRMH.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Ba7Eyd47.mjs} +1 -1
  20. package/dist/stream-broker/{hostInit-Di9WUNlQ.mjs → hostInit-DEaqJpGA.mjs} +1 -1
  21. package/dist/stream-broker/index.js +877 -37
  22. package/dist/stream-broker/index.mjs +874 -34
  23. package/dist/stream-broker/remoteEntry.js +1 -1
  24. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Ca93F1js.js → MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js} +1 -1
  25. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CDqgb-WA.js → MotionZonesSettings-NcxxQN8r-CmZbzLQi.js} +1 -1
  26. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BlxPhY_w.js → PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js} +1 -1
  27. package/embed-dist/assets/{index-B23riUw8.js → index-DOsK8CZb.js} +10 -10
  28. package/embed-dist/index.html +1 -1
  29. package/package.json +7 -5
  30. package/python/inference_pool.py +172 -28
  31. package/python/postprocessors/__init__.py +2 -0
  32. package/python/postprocessors/ssd.py +128 -0
  33. package/python/requirements-edgetpu.txt +23 -0
  34. package/python/test_inference_pool_layout.py +16 -1
  35. package/python/test_inference_pool_preprocess.py +65 -0
  36. package/dist/remote-source-plane-B28iGi7h.mjs +0 -1001
  37. package/dist/remote-source-plane-BAqEHSgm.js +0 -1030
  38. package/dist/{model-download-service-C-IHWnXx-BPy6aoAx.mjs → model-download-service-C-IHWnXx-3Mmeob3l.mjs} +1 -1
  39. package/dist/{model-download-service-C-IHWnXx-BLXzxpRB.js → model-download-service-C-IHWnXx-D326YNnt.js} +1 -1
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>CamStack Embed</title>
7
- <script type="module" crossorigin src="./assets/index-B23riUw8.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-DOsK8CZb.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-Bmpl9O1K.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.1.56",
3
+ "version": "1.1.57",
4
4
  "description": "CamStack 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",
@@ -70,7 +70,8 @@
70
70
  "entry": "./dist/pipeline-runner/index.js",
71
71
  "execution": {
72
72
  "placement": "any-node",
73
- "heapProfile": "heavy"
73
+ "heapProfile": "heavy",
74
+ "group": "detection"
74
75
  },
75
76
  "capabilities": [
76
77
  {
@@ -102,7 +103,8 @@
102
103
  ],
103
104
  "execution": {
104
105
  "placement": "any-node",
105
- "heapProfile": "heavy"
106
+ "heapProfile": "heavy",
107
+ "group": "detection"
106
108
  },
107
109
  "protected": true,
108
110
  "icon": "assets/icon.svg",
@@ -117,7 +119,8 @@
117
119
  "entry": "./dist/motion-wasm/index.js",
118
120
  "execution": {
119
121
  "placement": "any-node",
120
- "heapProfile": "heavy"
122
+ "heapProfile": "heavy",
123
+ "group": "detection"
121
124
  },
122
125
  "capabilities": [
123
126
  {
@@ -256,7 +259,6 @@
256
259
  },
257
260
  "devDependencies": {
258
261
  "@camstack/sdk": "*",
259
- "@camstack/shm-ring": "*",
260
262
  "@camstack/types": "*",
261
263
  "@camstack/ui-library": "*",
262
264
  "@module-federation/vite": "^1.16.9",
@@ -440,6 +440,27 @@ def _init_runtime(runtime: str) -> None:
440
440
  elif runtime == "onnxruntime":
441
441
  import onnxruntime as ort
442
442
  _runtime_lib = ort
443
+ elif runtime == "edgetpu":
444
+ # Coral USB Edge TPU via LiteRT (ai-edge-litert) + the libedgetpu delegate.
445
+ # The delegate is created ONCE here and shared across all model slots (the
446
+ # USB device is a single hardware context). See docs: the delegate's TF
447
+ # version MUST match the interpreter — feranick libedgetpu tf2.17.1 pairs
448
+ # with ai-edge-litert 2.1.6. Install: /data/deps/edgetpu-install.sh.
449
+ import threading
450
+ from ai_edge_litert.interpreter import Interpreter, load_delegate
451
+ lib_path = os.environ.get(
452
+ "CAMSTACK_EDGETPU_LIB", "/data/deps/edgetpu/lib/libedgetpu.so.1",
453
+ )
454
+ delegate = load_delegate(lib_path)
455
+ _runtime_lib = {
456
+ "Interpreter": Interpreter,
457
+ "delegate": delegate,
458
+ # invoke() on one interpreter is not re-entrant, and the EdgeTPU is a
459
+ # single-context device — serialise all predicts through one lock.
460
+ "lock": threading.Lock(),
461
+ }
462
+ sys.stderr.write(f"EdgeTPU: delegate loaded from {lib_path}\n")
463
+ sys.stderr.flush()
443
464
  else:
444
465
  raise ValueError(f"Unknown runtime: {runtime}")
445
466
 
@@ -679,6 +700,43 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
679
700
  slot.model = session
680
701
  slot.predict_fn = predict
681
702
 
703
+ elif _runtime == "edgetpu":
704
+ Interpreter = _runtime_lib["Interpreter"]
705
+ delegate = _runtime_lib["delegate"]
706
+ lock = _runtime_lib["lock"]
707
+ interp = Interpreter(path, experimental_delegates=[delegate])
708
+ interp.allocate_tensors()
709
+ in_det = interp.get_input_details()[0]
710
+ out_dets = interp.get_output_details()
711
+ _, in_h, in_w, _ = (int(x) for x in in_det["shape"])
712
+ in_dtype = np.dtype(in_det["dtype"]).name # 'uint8' | 'int8' | 'float32'
713
+ slot.config["_input_name"] = in_det["name"]
714
+ slot.config["_edgetpu_in_dtype"] = in_dtype
715
+ slot.config["inputWidth"] = in_w
716
+ slot.config["inputHeight"] = in_h
717
+ slot.config.setdefault("inputSize", max(in_w, in_h))
718
+ in_index = int(in_det["index"])
719
+ out_index = [int(o["index"]) for o in out_dets]
720
+ sys.stderr.write(
721
+ f"EdgeTPU: loaded {os.path.basename(path)} in={in_w}x{in_h} "
722
+ f"dtype={in_dtype} outputs={len(out_dets)}\n"
723
+ )
724
+ sys.stderr.flush()
725
+
726
+ def predict(
727
+ inp_dict: dict,
728
+ _interp=interp, _lock=lock, _in=in_index, _outs=out_index,
729
+ ) -> dict:
730
+ tensor = next(iter(inp_dict.values()))
731
+ with _lock:
732
+ _interp.set_tensor(_in, tensor)
733
+ _interp.invoke()
734
+ # Positional keys — the ssd postprocessor reads "0".."3".
735
+ return {str(i): _interp.get_tensor(idx) for i, idx in enumerate(_outs)}
736
+
737
+ slot.model = interp
738
+ slot.predict_fn = predict
739
+
682
740
  slot.loaded = True
683
741
 
684
742
 
@@ -732,8 +790,68 @@ def _is_channels_last(input_shape) -> bool:
732
790
  return last in (1, 3) and second is not None and second not in (1, 3)
733
791
 
734
792
 
793
+ def _resolve_input_dims(config: dict) -> tuple[int, int, int]:
794
+ """Resolve (width, height, channels) for the model input.
795
+
796
+ Explicit `inputWidth`/`inputHeight` (threaded from the catalog's
797
+ `inputSize.{width,height}`) win; they fall back to the square `inputSize`
798
+ (the max side) for legacy square models. `inputChannels` defaults to 3
799
+ (RGB); 1 selects the grayscale CTC recognizer path. Pure — no numpy/PIL.
800
+ """
801
+ input_size = int(config.get("inputSize", 640))
802
+ width = int(config.get("inputWidth") or input_size)
803
+ height = int(config.get("inputHeight") or input_size)
804
+ channels = int(config.get("inputChannels") or 3)
805
+ return width, height, channels
806
+
807
+
808
+ def _to_model_tensor(arr: "np.ndarray", channels_last: bool) -> "np.ndarray":
809
+ """Build the batched RGB model-input tensor from an HWC float array in [0,1].
810
+
811
+ - ``channels_last`` (ArcFace/CLIP NHWC) → ``[1,H,W,C]`` untransposed.
812
+ - otherwise → standard detector NCHW ``[1,C,H,W]``.
813
+
814
+ Grayscale CTC recognizers do NOT use this — they need aspect-preserving
815
+ resize + edge padding, see {@link _preprocess_ctc_gray}. Pure numpy.
816
+ """
817
+ if channels_last:
818
+ return arr[np.newaxis].astype(np.float32)
819
+ return arr.transpose(2, 0, 1)[np.newaxis].astype(np.float32)
820
+
821
+
822
+ def _preprocess_ctc_gray(img: "Image.Image", input_w: int, input_h: int) -> "np.ndarray":
823
+ """EasyOCR-accurate preprocess for a fixed-width grayscale CTC recognizer.
824
+
825
+ Mirrors EasyOCR's AlignCollate + NormalizePAD exactly:
826
+ 1. grayscale by luminance (PIL ``convert('L')``),
827
+ 2. resize to height ``input_h`` PRESERVING aspect ratio (width capped at
828
+ ``input_w``) with BICUBIC — the recognizer was trained on undistorted
829
+ glyphs; stretching the crop to the full ``input_w`` warps glyph aspect
830
+ ~4x and the model reads noise (low-confidence garbage),
831
+ 3. normalize to ``[-1,1]`` (``(x/255 - 0.5)/0.5``),
832
+ 4. right-pad the width up to ``input_w`` by replicating the last column
833
+ (EasyOCR NormalizePAD edge padding — NOT zeros).
834
+
835
+ Returns a ``[1,1,input_h,input_w]`` float32 tensor. Pure numpy/PIL.
836
+ """
837
+ gray = img.convert("L")
838
+ ow, oh = gray.size
839
+ ratio = (ow / oh) if oh > 0 else 1.0
840
+ resized_w = int(np.ceil(input_h * ratio))
841
+ resized_w = max(1, min(input_w, resized_w))
842
+ gray = gray.resize((resized_w, input_h), Image.BICUBIC)
843
+ arr = np.asarray(gray, dtype=np.float32) / 255.0 # (H, resized_w)
844
+ arr = (arr - 0.5) / 0.5 # [-1, 1]
845
+ if resized_w < input_w:
846
+ edge = arr[:, resized_w - 1 : resized_w]
847
+ pad = np.repeat(edge, input_w - resized_w, axis=1)
848
+ arr = np.concatenate([arr, pad], axis=1)
849
+ return arr[np.newaxis, np.newaxis].astype(np.float32) # (1, 1, H, W)
850
+
851
+
735
852
  def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int, int]]:
736
853
  input_size = config.get("inputSize", 640)
854
+ input_w, input_h, input_channels = _resolve_input_dims(config)
737
855
  # Bench frames have _bench_frame_id tag → use preprocess cache
738
856
  bench_fid = getattr(img, '_bench_frame_id', None)
739
857
  if bench_fid is not None:
@@ -764,23 +882,22 @@ def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int,
764
882
  input_dict[input_name] = canvas
765
883
  arr = None # not used downstream for imageType — postprocess runs on predictions
766
884
  elif input_shape is not None and len(input_shape) == 4:
767
- if preprocess_mode == "letterbox":
768
- arr, scale_val, pad = letterbox(img, input_size)
769
- else:
770
- arr = resize_image(img, input_size, input_size)
885
+ if input_channels == 1:
886
+ # Grayscale CTC recognizer EasyOCR aspect-preserving preprocess.
887
+ input_arr = _preprocess_ctc_gray(img, input_w, input_h)
771
888
  scale_val = 1.0
772
889
  pad = (0, 0)
773
- _, _, _, w_or_c = input_shape
774
- if w_or_c in (1, 3):
775
- input_arr = arr[np.newaxis].astype(np.float32)
776
890
  else:
777
- input_arr = arr.transpose(2, 0, 1)[np.newaxis].astype(np.float32)
778
- if input_shape[1] == 1 and input_shape[2] != input_shape[3]:
779
- gray = np.mean(arr, axis=2)
780
- target_h, target_w = input_shape[2], input_shape[3]
781
- gray_img = Image.fromarray((gray * 255).astype(np.uint8), mode="L")
782
- gray_img = gray_img.resize((target_w, target_h), Image.BILINEAR)
783
- input_arr = np.array(gray_img, dtype=np.float32)[np.newaxis, np.newaxis] / 255.0
891
+ if preprocess_mode == "letterbox":
892
+ arr, scale_val, pad = letterbox(img, input_size)
893
+ else:
894
+ arr = resize_image(img, input_w, input_h)
895
+ scale_val = 1.0
896
+ pad = (0, 0)
897
+ # NHWC vs NCHW from the model's declared last dim (1/3
898
+ # channels-last), exactly as before for 3-channel models.
899
+ _, _, _, w_or_c = input_shape
900
+ input_arr = _to_model_tensor(arr, w_or_c in (1, 3))
784
901
  input_dict[input_name] = input_arr
785
902
  else:
786
903
  input_arr = arr.transpose(2, 0, 1)[np.newaxis].astype(np.float32)
@@ -791,25 +908,52 @@ def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int,
791
908
  input_dict["iouThreshold"] = 0.45
792
909
  if "confidenceThreshold" in input_names:
793
910
  input_dict["confidenceThreshold"] = config.get("confidence", 0.25)
794
- else:
795
- # OpenVINO / ONNX always need float CHW tensor.
911
+ elif _runtime == "edgetpu":
912
+ # EdgeTPU models are FULL-INTEGER quantized: feed raw uint8 (or int8)
913
+ # pixels in NHWC [1,H,W,C] — NO float /255 normalization (that is baked
914
+ # into the model's quantization params).
915
+ input_name = config.get("_input_name", "images")
916
+ in_w = int(config.get("inputWidth") or config.get("inputSize", 300))
917
+ in_h = int(config.get("inputHeight") or config.get("inputSize", 300))
796
918
  if preprocess_mode == "letterbox":
797
- arr, scale_val, pad = letterbox(img, input_size)
919
+ canvas, scale_val, pad = letterbox_image(img, max(in_w, in_h))
920
+ rgb = canvas
921
+ else:
922
+ rgb = img if img.size == (in_w, in_h) else img.resize((in_w, in_h), Image.BILINEAR)
923
+ scale_val, pad = 1.0, (0, 0)
924
+ pix = np.asarray(rgb, dtype=np.uint8)
925
+ if pix.ndim == 2:
926
+ pix = np.stack([pix] * 3, axis=-1)
927
+ if config.get("_edgetpu_in_dtype") == "int8":
928
+ input_dict[input_name] = (pix.astype(np.int16) - 128).astype(np.int8)[np.newaxis]
798
929
  else:
799
- arr = resize_image(img, input_size, input_size)
930
+ input_dict[input_name] = pix[np.newaxis] # uint8 NHWC
931
+ else:
932
+ # OpenVINO / ONNX — always need a float tensor.
933
+ input_name = config.get("_input_name", "images")
934
+ if input_channels == 1:
935
+ # Grayscale CTC recognizer (EasyOCR plate-OCR): aspect-preserving
936
+ # resize + edge-pad + [-1,1] norm → [1,1,H,W]. NOT the squared
937
+ # 3-channel RGB tensor (which the runtime rejected — the reason
938
+ # plate recognition never ran).
939
+ input_dict[input_name] = _preprocess_ctc_gray(img, input_w, input_h)
800
940
  scale_val = 1.0
801
941
  pad = (0, 0)
802
- input_name = config.get("_input_name", "images")
803
- # Feed the layout the model actually declares. NHWC models (ArcFace
804
- # face-embedding [N,112,112,3], CLIP) must NOT be NCHW-transposed —
805
- # a strict backend (NPU) throws on the wrong shape and the embedding
806
- # is never produced (the multi-day face-collection outage). Unknown
807
- # shape → NCHW (the prior behaviour), so detectors are unaffected.
808
- if _is_channels_last(config.get("_input_shape")):
809
- input_arr = arr[np.newaxis].astype(np.float32)
810
942
  else:
811
- input_arr = arr.transpose(2, 0, 1)[np.newaxis].astype(np.float32)
812
- input_dict[input_name] = input_arr
943
+ if preprocess_mode == "letterbox":
944
+ arr, scale_val, pad = letterbox(img, input_size)
945
+ else:
946
+ arr = resize_image(img, input_w, input_h)
947
+ scale_val = 1.0
948
+ pad = (0, 0)
949
+ # Feed the layout the model actually declares. NHWC models (ArcFace
950
+ # face-embedding [N,112,112,3], CLIP) must NOT be NCHW-transposed —
951
+ # a strict backend (NPU) throws on the wrong shape and the embedding
952
+ # is never produced (the multi-day face-collection outage). Unknown
953
+ # shape → NCHW (the prior behaviour), so detectors are unaffected.
954
+ input_dict[input_name] = _to_model_tensor(
955
+ arr, _is_channels_last(config.get("_input_shape"))
956
+ )
813
957
 
814
958
  result = (input_dict, scale_val, pad)
815
959
  if bench_fid is not None:
@@ -12,6 +12,7 @@ from .softmax import postprocess_softmax
12
12
  from .ctc import postprocess_ctc
13
13
  from .saliency import postprocess_saliency
14
14
  from .yamnet import postprocess_yamnet
15
+ from .ssd import postprocess_ssd
15
16
 
16
17
  POSTPROCESSORS = {
17
18
  "yolo": postprocess_yolo,
@@ -23,4 +24,5 @@ POSTPROCESSORS = {
23
24
  "ctc": postprocess_ctc,
24
25
  "saliency": postprocess_saliency,
25
26
  "yamnet": postprocess_yamnet,
27
+ "ssd": postprocess_ssd,
26
28
  }
@@ -0,0 +1,128 @@
1
+ """SSD / EfficientDet-Lite postprocessor (TFLite_Detection_PostProcess op).
2
+
3
+ Used by the EdgeTPU (Coral USB Edge TPU) runtime with the Coral model-zoo
4
+ SSD MobileNet V2 and EfficientDet-Lite detectors. Those models embed the
5
+ ``TFLite_Detection_PostProcess`` op, so the 4 output tensors are ALREADY
6
+ decoded (boxes + per-box class + score, post-NMS):
7
+
8
+ output 0 boxes [1, N, 4] (ymin, xmin, ymax, xmax) normalised 0..1
9
+ output 1 classes [1, N] float class indices (COCO 90-class map)
10
+ output 2 scores [1, N] float 0..1
11
+ output 3 count [1] number of valid detections
12
+
13
+ The inference_pool ``edgetpu`` predict closure returns them keyed by their
14
+ positional output index ("0".."3"). Output matches the yolo postprocessor:
15
+ ``{"kind": "detections", "detections": [{"class", "score", "bbox":[x1,y1,x2,y2]}]}``
16
+ with pixel bboxes in ORIGINAL frame coordinates.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+
22
+ # Coral COCO 90-class label map (github.com/google-coral/test_data coco_labels.txt).
23
+ # The "n/a" placeholders keep the raw model class indices aligned and are skipped.
24
+ COCO_90 = [
25
+ "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck",
26
+ "boat", "traffic light", "fire hydrant", "n/a", "stop sign", "parking meter",
27
+ "bench", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear",
28
+ "zebra", "giraffe", "n/a", "backpack", "umbrella", "n/a", "n/a", "handbag",
29
+ "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", "kite",
30
+ "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
31
+ "bottle", "n/a", "wine glass", "cup", "fork", "knife", "spoon", "bowl",
32
+ "banana", "apple", "sandwich", "orange", "broccoli", "carrot", "hot dog",
33
+ "pizza", "donut", "cake", "chair", "couch", "potted plant", "bed", "n/a",
34
+ "dining table", "n/a", "n/a", "toilet", "n/a", "tv", "laptop", "mouse",
35
+ "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink",
36
+ "refrigerator", "n/a", "book", "clock", "vase", "scissors", "teddy bear",
37
+ "hair drier", "toothbrush",
38
+ ]
39
+
40
+
41
+ def _split_outputs(predictions: dict) -> tuple:
42
+ """Resolve (boxes, classes, scores, count) from the raw prediction dict.
43
+
44
+ Primary path: positional keys "0".."3" (the edgetpu predict closure).
45
+ Fallback: shape-based identification for name-keyed / reordered exports.
46
+ """
47
+ if all(k in predictions for k in ("0", "1", "2", "3")):
48
+ return (
49
+ np.asarray(predictions["0"]),
50
+ np.asarray(predictions["1"]),
51
+ np.asarray(predictions["2"]),
52
+ np.asarray(predictions["3"]),
53
+ )
54
+ vals = [np.asarray(v) for v in predictions.values()]
55
+ boxes = count = None
56
+ two_d: list[np.ndarray] = []
57
+ for v in vals:
58
+ if v.ndim >= 2 and v.shape[-1] == 4:
59
+ boxes = v
60
+ elif v.ndim == 1 or (v.ndim == 2 and v.shape[-1] == 1):
61
+ count = v
62
+ elif v.ndim == 2:
63
+ two_d.append(v)
64
+ # TFLite_Detection_PostProcess emits classes BEFORE scores.
65
+ classes = two_d[0] if len(two_d) > 0 else None
66
+ scores = two_d[1] if len(two_d) > 1 else None
67
+ return boxes, classes, scores, count
68
+
69
+
70
+ def postprocess_ssd(
71
+ predictions: dict,
72
+ config: dict,
73
+ orig_w: int,
74
+ orig_h: int,
75
+ scale: float,
76
+ pad: tuple,
77
+ ) -> dict:
78
+ """Decode SSD/EfficientDet postprocess outputs to original-frame detections."""
79
+ conf_threshold = float(config.get("confidence", 0.4) or 0.4)
80
+ input_size = int(config.get("inputSize", 300) or 300)
81
+
82
+ boxes, classes, scores, count = _split_outputs(predictions)
83
+ if boxes is None or scores is None:
84
+ return {"kind": "detections", "detections": []}
85
+
86
+ boxes = boxes.reshape(-1, 4)
87
+ scores = scores.reshape(-1)
88
+ classes = classes.reshape(-1) if classes is not None else np.zeros_like(scores)
89
+ n = len(scores)
90
+ if count is not None and count.size:
91
+ n = min(n, int(count.reshape(-1)[0]))
92
+ n = min(n, len(boxes), len(classes))
93
+
94
+ pad_x, pad_y = pad
95
+ is_letterbox = not (abs(scale - 1.0) < 1e-6 and pad_x == 0 and pad_y == 0)
96
+
97
+ detections = []
98
+ for i in range(n):
99
+ score = float(scores[i])
100
+ if score < conf_threshold:
101
+ continue
102
+ ymin, xmin, ymax, xmax = (float(v) for v in boxes[i])
103
+ if is_letterbox:
104
+ # Invert letterbox: normalised -> input px -> remove pad -> unscale.
105
+ x1 = (xmin * input_size - pad_x) / scale
106
+ y1 = (ymin * input_size - pad_y) / scale
107
+ x2 = (xmax * input_size - pad_x) / scale
108
+ y2 = (ymax * input_size - pad_y) / scale
109
+ else:
110
+ # Plain resize: normalised coords map linearly to the original frame.
111
+ x1, y1, x2, y2 = xmin * orig_w, ymin * orig_h, xmax * orig_w, ymax * orig_h
112
+ x1 = max(0.0, min(x1, orig_w))
113
+ x2 = max(0.0, min(x2, orig_w))
114
+ y1 = max(0.0, min(y1, orig_h))
115
+ y2 = max(0.0, min(y2, orig_h))
116
+ if x2 <= x1 or y2 <= y1:
117
+ continue
118
+ cls = int(classes[i])
119
+ label = COCO_90[cls] if 0 <= cls < len(COCO_90) else str(cls)
120
+ if label == "n/a":
121
+ continue
122
+ detections.append({
123
+ "class": label,
124
+ "score": round(score, 4),
125
+ "bbox": [round(x1, 1), round(y1, 1), round(x2, 1), round(y2, 1)],
126
+ })
127
+
128
+ return {"kind": "detections", "detections": detections}
@@ -0,0 +1,23 @@
1
+ # EdgeTPU (Coral USB Edge TPU) backend.
2
+ #
3
+ # Interpreter: ai-edge-litert (LiteRT) — numpy-2 native, cp39-cp312 wheels.
4
+ # We use LiteRT (NOT tflite-runtime / pycoral) because:
5
+ # * pycoral official wheels cap at Python 3.9; the hub venv is 3.12.
6
+ # * feranick's tflite-runtime cp312 wheel is compiled against numpy 1.x and
7
+ # crashes under the hub's numpy 2.x (OpenVINO needs numpy<2.4).
8
+ # * ai-edge-litert exposes tflite_runtime-compatible Interpreter + load_delegate
9
+ # and is numpy-2 native.
10
+ ai-edge-litert==2.1.6
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 persistent
18
+ # installer /data/deps/edgetpu-install.sh. The path is overridable via the
19
+ # CAMSTACK_EDGETPU_LIB env var (default /data/deps/edgetpu/lib/libedgetpu.so.1).
20
+ #
21
+ # Host prerequisite: the container must have /dev/bus/usb passed through plus a
22
+ # `--device-cgroup-rule 'c 189:* rmw'` rule (the Coral re-enumerates its USB
23
+ # minor after firmware init, so a fixed --device does not survive).
@@ -18,7 +18,22 @@ for _mod in ("numpy", "PIL", "PIL.Image"):
18
18
  if not hasattr(sys.modules["PIL"], "Image"):
19
19
  sys.modules["PIL"].Image = types.ModuleType("PIL.Image")
20
20
 
21
- from inference_pool import _is_channels_last # noqa: E402
21
+ from inference_pool import _is_channels_last, _resolve_input_dims # noqa: E402
22
+
23
+
24
+ def test_resolve_input_dims_defaults_to_square_rgb() -> None:
25
+ # Legacy square model: no explicit width/height/channels → (size, size, 3).
26
+ assert _resolve_input_dims({"inputSize": 640}) == (640, 640, 3)
27
+
28
+
29
+ def test_resolve_input_dims_explicit_non_square_grayscale() -> None:
30
+ # Plate-OCR VGG: 384×64, 1 channel — the shape the OCR runtime demands.
31
+ cfg = {"inputSize": 384, "inputWidth": 384, "inputHeight": 64, "inputChannels": 1}
32
+ assert _resolve_input_dims(cfg) == (384, 64, 1)
33
+
34
+
35
+ def test_resolve_input_dims_falls_back_to_size_when_dims_missing() -> None:
36
+ assert _resolve_input_dims({"inputSize": 320}) == (320, 320, 3)
22
37
 
23
38
 
24
39
  def test_arcface_nhwc_is_channels_last() -> None:
@@ -0,0 +1,65 @@
1
+ """Numeric tests for the preprocess tensor builders — `_to_model_tensor`
2
+ (RGB NHWC/NCHW) and `_preprocess_ctc_gray` (EasyOCR grayscale plate-OCR).
3
+
4
+ Requires real numpy + PIL (the embedded portable Python the pool runs on). On a
5
+ minimal sandbox without them the whole module is skipped — the pure branch-
6
+ selection logic is covered separately in `test_inference_pool_layout.py`.
7
+ """
8
+ import pytest
9
+
10
+ np = pytest.importorskip("numpy")
11
+ PIL = pytest.importorskip("PIL")
12
+ from PIL import Image # noqa: E402
13
+
14
+ from inference_pool import _to_model_tensor, _preprocess_ctc_gray # noqa: E402
15
+
16
+
17
+ def _hwc(h: int, w: int) -> "np.ndarray":
18
+ return (np.arange(h * w * 3, dtype=np.float32) % 255 / 255.0).reshape(h, w, 3)
19
+
20
+
21
+ def test_rgb_detector_is_nchw() -> None:
22
+ out = _to_model_tensor(_hwc(640, 640), channels_last=False)
23
+ assert out.shape == (1, 3, 640, 640)
24
+ assert out.dtype == np.float32
25
+
26
+
27
+ def test_rgb_embedder_is_nhwc_when_channels_last() -> None:
28
+ # ArcFace/CLIP: keep [1,H,W,C] — never transpose.
29
+ out = _to_model_tensor(_hwc(112, 112), channels_last=True)
30
+ assert out.shape == (1, 112, 112, 3)
31
+
32
+
33
+ def test_ctc_gray_shape_is_nchw_single_channel() -> None:
34
+ # Plate-OCR: model input [1, 1, 64, 384].
35
+ img = Image.new("RGB", (200, 60), (128, 128, 128))
36
+ out = _preprocess_ctc_gray(img, input_w=384, input_h=64)
37
+ assert out.shape == (1, 1, 64, 384)
38
+ assert out.dtype == np.float32
39
+
40
+
41
+ def test_ctc_gray_is_normalized_to_minus_one_one() -> None:
42
+ # A flat mid-gray (128) maps to ~0 under (x/255-0.5)/0.5.
43
+ img = Image.new("RGB", (200, 60), (128, 128, 128))
44
+ out = _preprocess_ctc_gray(img, 384, 64)
45
+ assert abs(float(out.mean())) < 0.02
46
+ white = _preprocess_ctc_gray(Image.new("RGB", (200, 60), (255, 255, 255)), 384, 64)
47
+ black = _preprocess_ctc_gray(Image.new("RGB", (200, 60), (0, 0, 0)), 384, 64)
48
+ assert np.allclose(white, 1.0, atol=1e-3)
49
+ assert np.allclose(black, -1.0, atol=1e-3)
50
+
51
+
52
+ def test_ctc_gray_preserves_aspect_and_edge_pads() -> None:
53
+ # A 128 wide × 64 tall crop → ratio 2.0 → resized_w = ceil(64*2)=128 ≤ 384,
54
+ # so the right 384-128=256 columns are edge-replicated (equal to col 127).
55
+ img = Image.new("RGB", (128, 64), (200, 200, 200))
56
+ out = _preprocess_ctc_gray(img, 384, 64)[0, 0] # (64, 384)
57
+ # Padded region replicates the last real column → constant across width here.
58
+ assert np.allclose(out[:, 128:], out[:, 127:128], atol=1e-4)
59
+
60
+
61
+ def test_ctc_gray_caps_resized_width_at_input_w() -> None:
62
+ # Very wide crop (ratio > 6) → resized_w capped at input_w (no overflow).
63
+ img = Image.new("RGB", (800, 64), (50, 50, 50))
64
+ out = _preprocess_ctc_gray(img, 384, 64)
65
+ assert out.shape == (1, 1, 64, 384)