@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.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +71 -74
- package/dist/detection-pipeline/index.mjs +70 -73
- package/dist/{dist-ClE6_i0p.js → dist-CK8Ur-OS.js} +104 -6
- package/dist/{dist-CrjLGrkj.mjs → dist-CL531uVA.mjs} +104 -6
- package/dist/motion-wasm/index.js +9 -41
- package/dist/motion-wasm/index.mjs +9 -41
- package/dist/pipeline-runner/index.js +222 -656
- package/dist/pipeline-runner/index.mjs +211 -647
- package/dist/recorder/index.js +2 -2
- package/dist/recorder/index.mjs +2 -2
- package/dist/remote-restream-BeHi78PZ.mjs +25 -0
- package/dist/remote-restream-CO36Sr30.js +36 -0
- package/dist/{step-definitions-dCghpdRo.mjs → step-definitions-1d3_vQ6S.mjs} +44 -2
- package/dist/{step-definitions-C9kUXgC7.js → step-definitions-DHAxruAQ.js} +44 -2
- package/dist/stream-broker/_stub.js +282 -222
- 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
- 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
- package/dist/stream-broker/{hostInit-Di9WUNlQ.mjs → hostInit-DEaqJpGA.mjs} +1 -1
- package/dist/stream-broker/index.js +877 -37
- package/dist/stream-broker/index.mjs +874 -34
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Ca93F1js.js → MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CDqgb-WA.js → MotionZonesSettings-NcxxQN8r-CmZbzLQi.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BlxPhY_w.js → PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js} +1 -1
- package/embed-dist/assets/{index-B23riUw8.js → index-DOsK8CZb.js} +10 -10
- package/embed-dist/index.html +1 -1
- package/package.json +7 -5
- package/python/inference_pool.py +172 -28
- package/python/postprocessors/__init__.py +2 -0
- package/python/postprocessors/ssd.py +128 -0
- package/python/requirements-edgetpu.txt +23 -0
- package/python/test_inference_pool_layout.py +16 -1
- package/python/test_inference_pool_preprocess.py +65 -0
- package/dist/remote-source-plane-B28iGi7h.mjs +0 -1001
- package/dist/remote-source-plane-BAqEHSgm.js +0 -1030
- package/dist/{model-download-service-C-IHWnXx-BPy6aoAx.mjs → model-download-service-C-IHWnXx-3Mmeob3l.mjs} +1 -1
- package/dist/{model-download-service-C-IHWnXx-BLXzxpRB.js → model-download-service-C-IHWnXx-D326YNnt.js} +1 -1
package/embed-dist/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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",
|
package/python/inference_pool.py
CHANGED
|
@@ -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
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
795
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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)
|