@camstack/addon-pipeline 1.2.325 → 1.2.327

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 (42) hide show
  1. package/THIRD_PARTY_MODELS.md +29 -55
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/{default-detection-model-4nJVRIT6.js → default-detection-model-CBIYdAJ5.js} +97 -483
  5. package/dist/{default-detection-model-lWMQATmQ.mjs → default-detection-model-hql6BX9-.mjs} +98 -478
  6. package/dist/detection-pipeline/index.js +10 -5
  7. package/dist/detection-pipeline/index.mjs +10 -5
  8. package/dist/{dist-CbRb0Zyj.mjs → dist-B9LnBPz9.mjs} +122 -76
  9. package/dist/{dist-DlgXwnqU.js → dist-BBZwYfSN.js} +122 -76
  10. package/dist/motion-wasm/index.js +1 -1
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-CZ5DXcvq.mjs → node-Ci0E-0VP.mjs} +1 -1
  13. package/dist/{node-s3wZnnJg.js → node-mJQBUiEc.js} +1 -1
  14. package/dist/pipeline-runner/index.js +41 -14
  15. package/dist/pipeline-runner/index.mjs +41 -15
  16. package/dist/{process-memory-CJsA4yRc.mjs → process-memory-B63Jlpev.mjs} +1 -1
  17. package/dist/{process-memory-C-mZ1LNi.js → process-memory-DgEfKdtC.js} +1 -1
  18. package/dist/recorder/index.js +10 -10
  19. package/dist/recorder/index.mjs +10 -10
  20. package/dist/{segment-demux-js-BbM0_Fmz.js → segment-demux-js-C2ufKMu0.js} +1 -1
  21. package/dist/{segment-demux-js-CuwXgwpj.mjs → segment-demux-js-DzwHW4Ey.mjs} +1 -1
  22. package/dist/stream-broker/_stub.js +2 -2
  23. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BobJEk9e.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B4fPzS-8.mjs} +3 -3
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BDGzcgLP.mjs +26 -0
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BpXPNINd.mjs +26 -0
  26. package/dist/stream-broker/demux-worker-child.js +1 -1
  27. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  28. package/dist/stream-broker/{hostInit-C_x1pkx-.mjs → hostInit-Cg4IdtE5.mjs} +3 -3
  29. package/dist/stream-broker/index.js +5 -5
  30. package/dist/stream-broker/index.mjs +5 -5
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/package.json +1 -1
  33. package/python/edgetpu_dequant.py +55 -0
  34. package/python/inference_pool.py +19 -3
  35. package/python/postprocessors/__init__.py +2 -0
  36. package/python/postprocessors/scrfd.py +2 -0
  37. package/python/postprocessors/test_ssd.py +5 -6
  38. package/python/postprocessors/test_yolo_split_dfl.py +135 -0
  39. package/python/postprocessors/yolo_split_dfl.py +255 -0
  40. package/python/test_dequant_edgetpu.py +65 -0
  41. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-NkeMtp-v.mjs +0 -26
  42. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-mEJkrfvj.mjs +0 -26
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.303",
39
+ version: "1.2.305",
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.120",
48
+ version: "1.2.121",
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.230",
84
+ version: "1.2.232",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-DlgXwnqU.js");
7
- const require_node = require("../node-s3wZnnJg.js");
6
+ const require_dist = require("../dist-BBZwYfSN.js");
7
+ const require_node = require("../node-mJQBUiEc.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-BbM0_Fmz.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-C2ufKMu0.js");
12
12
  let node_os = require("node:os");
13
13
  node_os = require_chunk.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
@@ -9791,9 +9791,9 @@ function extractAudioTrack(parsed) {
9791
9791
  * Resolve a track control URL against the session-level control / RTSP base URL.
9792
9792
  *
9793
9793
  * Cameras vary wildly:
9794
- * - Absolute: "rtsp://192.168.x.x/trackID=1"
9794
+ * - Absolute: "rtsp://192.168.10.100/trackID=1"
9795
9795
  * - Relative: "trackID=1" or "track1"
9796
- * - Session control: "rtsp://192.168.x.x/stream1" with track "trackID=1"
9796
+ * - Session control: "rtsp://192.168.10.100/stream1" with track "trackID=1"
9797
9797
  */
9798
9798
  function resolveTrackUrl(baseUrl, sessionControl, trackControl) {
9799
9799
  if (!trackControl) return baseUrl;
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $ as declareLogChannel, Bn as record, Cn as isEvent, Ct as maskUrlCredentials, En as nodePin, Et as memoizePinnedFfmpeg, H as addonWidgetsSourceCapability, Hn as union, In as literal, J as cameraStreamsCapability, K as batteryCapability, Ln as number, Lt as rankStreams, M as RATE_CONTROL_TIGHT, Mn as array, Nn as boolean, On as parseProfileBrokerId, Pn as discriminatedUnion, Q as createLogChannelsProvider, Qt as streamBrokerCapability, R as RingBuffer, Rn as object, S as EncodeProfileSchema, T as G711_SCALE_CORRECTION_DB, Tn as makeSourceBrokerId, Un as EventCategory, Vn as string, Wt as resolveEgressDecodeHwAccel, X as clipExportFailureOf, Z as createHwAccelCache, at as egressTranscodeSharingKey, bt as loadContributionCapability, c as CLIP_STILL_REASON_CODE_HEADER, cn as invocationFromEncodeProfile, dn as DeviceType, fn as errMsg, gn as CamProfileSchema, gt as gifFromMp4Args, hn as CAM_PROFILE_ORDER, ht as gifDeriveSpeed, j as RATE_CONTROL_RELAXED, jn as _enum, ln as isSoftwareDecode, mn as BaseAddon, nn as webrtcSessionCapability, o as BatteryStatusSchema, on as buildFfmpegArgs$1, ot as egressTransportFromRequest, pn as BOOT_RECOVERY_BACKOFF_MS, pt as ffmpegRate, rn as AUDIO_PRESETS, s as CLIP_BROKER_PACED_RATES, tt as deriveBatteryPresence, un as DeviceFeature, vn as createEvent, wn as makeProfileBrokerId, xn as expandAudioChunkToF32le, xt as logChannelsCapability } from "../dist-CbRb0Zyj.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-CZ5DXcvq.mjs";
2
+ import { $ as declareLogChannel, Bn as record, Cn as isEvent, Ct as maskUrlCredentials, En as nodePin, Et as memoizePinnedFfmpeg, H as addonWidgetsSourceCapability, Hn as union, In as literal, J as cameraStreamsCapability, K as batteryCapability, Ln as number, Lt as rankStreams, M as RATE_CONTROL_TIGHT, Mn as array, Nn as boolean, On as parseProfileBrokerId, Pn as discriminatedUnion, Q as createLogChannelsProvider, Qt as streamBrokerCapability, R as RingBuffer, Rn as object, S as EncodeProfileSchema, T as G711_SCALE_CORRECTION_DB, Tn as makeSourceBrokerId, Un as EventCategory, Vn as string, Wt as resolveEgressDecodeHwAccel, X as clipExportFailureOf, Z as createHwAccelCache, at as egressTranscodeSharingKey, bt as loadContributionCapability, c as CLIP_STILL_REASON_CODE_HEADER, cn as invocationFromEncodeProfile, dn as DeviceType, fn as errMsg, gn as CamProfileSchema, gt as gifFromMp4Args, hn as CAM_PROFILE_ORDER, ht as gifDeriveSpeed, j as RATE_CONTROL_RELAXED, jn as _enum, ln as isSoftwareDecode, mn as BaseAddon, nn as webrtcSessionCapability, o as BatteryStatusSchema, on as buildFfmpegArgs$1, ot as egressTransportFromRequest, pn as BOOT_RECOVERY_BACKOFF_MS, pt as ffmpegRate, rn as AUDIO_PRESETS, s as CLIP_BROKER_PACED_RATES, tt as deriveBatteryPresence, un as DeviceFeature, vn as createEvent, wn as makeProfileBrokerId, xn as expandAudioChunkToF32le, xt as logChannelsCapability } from "../dist-B9LnBPz9.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Ci0E-0VP.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-CuwXgwpj.mjs";
7
+ import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-DzwHW4Ey.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -9787,9 +9787,9 @@ function extractAudioTrack(parsed) {
9787
9787
  * Resolve a track control URL against the session-level control / RTSP base URL.
9788
9788
  *
9789
9789
  * Cameras vary wildly:
9790
- * - Absolute: "rtsp://192.168.x.x/trackID=1"
9790
+ * - Absolute: "rtsp://192.168.10.100/trackID=1"
9791
9791
  * - Relative: "trackID=1" or "track1"
9792
- * - Session control: "rtsp://192.168.x.x/stream1" with track "trackID=1"
9792
+ * - Session control: "rtsp://192.168.10.100/stream1" with track "trackID=1"
9793
9793
  */
9794
9794
  function resolveTrackUrl(baseUrl, sessionControl, trackControl) {
9795
9795
  if (!trackControl) return baseUrl;
@@ -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-BobJEk9e.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B4fPzS-8.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.325",
3
+ "version": "1.2.327",
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",
@@ -0,0 +1,55 @@
1
+ """Dequant of EdgeTPU / TFLite integer output tensors.
2
+
3
+ Kept free of numpy so the bare-interpreter CI path
4
+ (``scripts/test-python-pool.sh``) can pin the contract. The edgetpu predict
5
+ closure in ``inference_pool.py`` calls this for every output; float32 tensors
6
+ (SSD ``TFLite_Detection_PostProcess``) pass through unchanged.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def _is_integer_dtype(dtype_name: str) -> bool:
14
+ name = dtype_name.lower()
15
+ return name in ("uint8", "int8", "u8", "i8") or name.endswith("uint8") or name.endswith("int8")
16
+
17
+
18
+ def dequant_edgetpu_output(
19
+ value: Any,
20
+ *,
21
+ dtype_name: str,
22
+ scale: float,
23
+ zero_point: int,
24
+ ) -> Any:
25
+ """Return ``value`` dequantised to float when it is an integer tensor.
26
+
27
+ ``value`` is a scalar, a nested list (``ndarray.tolist()``), or anything
28
+ that exposes ``tolist``. Float dtypes are returned unchanged. A zero or
29
+ missing scale becomes a plain float cast — we never invent a scale.
30
+ """
31
+ if hasattr(value, "tolist") and not isinstance(value, (list, tuple)):
32
+ # numpy / TFLite tensor — decide from dtype_name, then walk lists.
33
+ value = value.tolist()
34
+
35
+ if not _is_integer_dtype(dtype_name):
36
+ return value
37
+
38
+ if scale is None or float(scale) <= 0.0:
39
+ return _cast_floats(value)
40
+
41
+ s = float(scale)
42
+ zp = int(zero_point)
43
+ return _scale_tree(value, s, zp)
44
+
45
+
46
+ def _cast_floats(value: Any) -> Any:
47
+ if isinstance(value, (list, tuple)):
48
+ return [_cast_floats(v) for v in value]
49
+ return float(value)
50
+
51
+
52
+ def _scale_tree(value: Any, scale: float, zero_point: int) -> Any:
53
+ if isinstance(value, (list, tuple)):
54
+ return [_scale_tree(v, scale, zero_point) for v in value]
55
+ return (float(value) - zero_point) * scale
@@ -1413,14 +1413,30 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1413
1413
 
1414
1414
  def predict(
1415
1415
  inp_dict: dict,
1416
- _interp=interp, _lock=lock, _in=in_index, _outs=out_index,
1416
+ _interp=interp, _lock=lock, _in=in_index, _outs=out_index, _details=out_dets,
1417
1417
  ) -> dict:
1418
+ from edgetpu_dequant import dequant_edgetpu_output
1419
+
1418
1420
  tensor = next(iter(inp_dict.values()))
1419
1421
  with _lock:
1420
1422
  _interp.set_tensor(_in, tensor)
1421
1423
  _interp.invoke()
1422
- # Positional keys — the ssd postprocessor reads "0".."3".
1423
- return {str(i): _interp.get_tensor(idx) for i, idx in enumerate(_outs)}
1424
+ # Positional keys — ssd reads "0".."3"; yolo-split-dfl pairs by
1425
+ # shape. Integer outputs are dequantised here so every
1426
+ # postprocessor sees float32 (SSD Detection_PostProcess is
1427
+ # already float and passes through).
1428
+ result = {}
1429
+ for i, idx in enumerate(_outs):
1430
+ raw = _interp.get_tensor(idx)
1431
+ detail = _details[i]
1432
+ q = detail.get("quantization") or (0.0, 0)
1433
+ scale = float(q[0]) if q else 0.0
1434
+ zp = int(q[1]) if q else 0
1435
+ dtype_name = str(np.dtype(detail["dtype"]).name)
1436
+ result[str(i)] = dequant_edgetpu_output(
1437
+ raw, dtype_name=dtype_name, scale=scale, zero_point=zp
1438
+ )
1439
+ return result
1424
1440
 
1425
1441
  slot.model = interp
1426
1442
  slot.predict_fn = predict
@@ -17,6 +17,7 @@ from .ssd import postprocess_ssd
17
17
  from .rfdetr import postprocess_rfdetr
18
18
  from .yolonas import postprocess_yolonas
19
19
  from .yunet import postprocess_yunet
20
+ from .yolo_split_dfl import postprocess_yolo_split_dfl
20
21
 
21
22
  POSTPROCESSORS = {
22
23
  "yolo": postprocess_yolo,
@@ -33,4 +34,5 @@ POSTPROCESSORS = {
33
34
  "rfdetr": postprocess_rfdetr,
34
35
  "yolonas": postprocess_yolonas,
35
36
  "yunet": postprocess_yunet,
37
+ "yolo-split-dfl": postprocess_yolo_split_dfl,
36
38
  }
@@ -10,6 +10,8 @@ module is now the sole SCRFD decoder in the repo.
10
10
 
11
11
  Output: {"kind": "detections", "detections": [{"class": "face", "score", "bbox": [x1,y1,x2,y2], "landmarks?"}]}
12
12
  """
13
+
14
+ from __future__ import annotations
13
15
  import numpy as np
14
16
 
15
17
 
@@ -1,10 +1,9 @@
1
1
  """Pins the SSD decoder on REAL SSDLite MobileDet output tensors.
2
2
 
3
- `ssdlite-mobiledet-coco-edgetpu` joins the two Coral detectors that already use
4
- `postprocessor: 'ssd'`. Its graph ends in the same `TFLite_Detection_PostProcess`
5
- op, but nothing had checked that the decoder reads MobileDet's outputs the way
6
- the model writes them. Each class below pins one thing that could silently turn
7
- a working model into wrong labels or empty frames:
3
+ The CamStack catalog keeps only `ssd-mobilenet-v2-coco-edgetpu`; Frigate still
4
+ imports MobileDet. Both graphs end in `TFLite_Detection_PostProcess`, and this
5
+ fixture (MobileDet outputs on two COCO val2017 frames) pins what the shared
6
+ `postprocessor: 'ssd'` decoder must read correctly:
8
7
 
9
8
  - the output ORDER (boxes, classes, scores, count on positional keys "0".."3");
10
9
  - the class-index OFFSET (91 score columns, background dropped by the op, so
@@ -16,7 +15,7 @@ a working model into wrong labels or empty frames:
16
15
  confidence thresholds can ever pass.
17
16
 
18
17
  The fixture (`testdata/ssdlite_mobiledet_outputs.json`) holds the model's raw
19
- outputs on two COCO val2017 frames; its `_provenance` says how they were made.
18
+ outputs; its `_provenance` says how they were made.
20
19
 
21
20
  `unittest.TestCase` with no numpy: `scripts/test-python-pool.sh` runs these on
22
21
  CI's bare interpreter, where numpy is absent, and `ssd.py` decodes plain lists
@@ -0,0 +1,135 @@
1
+ """yolo-split-dfl — Scrypted YOLOv9 TFLite 6-tensor DFL split-head decode.
2
+
3
+ Scrypted's TFLite exports emit SIX tensors, not the ONNX ``[1, 7, 2100]`` layout:
4
+
5
+ box × strides: [1, N, 64] (4 × reg_max=16 DFL)
6
+ cls × strides: [1, N, 3] (person/vehicle/animal)
7
+
8
+ Pure-list fixtures so CI's bare interpreter covers the math. A fixture of real
9
+ Scrypted tensors can join later; fakes alone are not proof on Coral hardware.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import unittest
14
+
15
+ from yolo_split_dfl import postprocess_yolo_split_dfl
16
+
17
+
18
+ def _one_hot_dfl(bin_index: int, reg_max: int = 16) -> list[float]:
19
+ logits = [-20.0] * reg_max
20
+ logits[bin_index] = 20.0
21
+ return logits
22
+
23
+
24
+ def _empty_box(n: int) -> list:
25
+ return [[[-20.0] * 64 for _ in range(n)]]
26
+
27
+
28
+ def _empty_cls(n: int, num_classes: int = 3) -> list:
29
+ return [[[-20.0] * num_classes for _ in range(n)]]
30
+
31
+
32
+ def _synthetic_predictions(
33
+ *,
34
+ grid: int,
35
+ cell_x: int,
36
+ cell_y: int,
37
+ ltrb: tuple[int, int, int, int],
38
+ class_id: int,
39
+ num_classes: int = 3,
40
+ ) -> dict:
41
+ n = grid * grid
42
+ box = _empty_box(n)
43
+ cls = _empty_cls(n, num_classes)
44
+ idx = cell_y * grid + cell_x
45
+ l, t, r, b = ltrb
46
+ box[0][idx] = _one_hot_dfl(l) + _one_hot_dfl(t) + _one_hot_dfl(r) + _one_hot_dfl(b)
47
+ cls[0][idx] = [-20.0] * num_classes
48
+ cls[0][idx][class_id] = 20.0
49
+
50
+ # Sibling empty strides + shuffled keys — decoder must pair by shared N.
51
+ return {
52
+ "0": _empty_box(400),
53
+ "1": box,
54
+ "2": _empty_box(100),
55
+ "3": _empty_cls(400),
56
+ "4": cls,
57
+ "5": _empty_cls(100),
58
+ }
59
+
60
+
61
+ class YoloSplitDfl(unittest.TestCase):
62
+ def test_registered_in_dispatch_table_source(self) -> None:
63
+ # Loading ``postprocessors`` as a package needs the parent on sys.path
64
+ # and numpy for sibling imports (yolo.py). On the bare CI path we pin
65
+ # registration by reading the dispatch table source instead.
66
+ from pathlib import Path
67
+
68
+ init_src = (Path(__file__).resolve().parent / "__init__.py").read_text(encoding="utf-8")
69
+ self.assertIn('"yolo-split-dfl": postprocess_yolo_split_dfl', init_src)
70
+ self.assertIn("from .yolo_split_dfl import postprocess_yolo_split_dfl", init_src)
71
+
72
+ def test_decodes_dfl_box_on_resize_preprocess(self) -> None:
73
+ # Stride = 320/40 = 8. Cell (10,10) → centre 84,84.
74
+ # ltrb bins (2,2,2,2) → ±16 px → box (68,68)-(100,100) input space.
75
+ # resize on 640×480 → ×2 / ×1.5.
76
+ preds = _synthetic_predictions(
77
+ grid=40, cell_x=10, cell_y=10, ltrb=(2, 2, 2, 2), class_id=0
78
+ )
79
+ cfg = {
80
+ "labels": ["person", "vehicle", "animal"],
81
+ "confidence": 0.25,
82
+ "preprocessMode": "resize",
83
+ "inputSize": 320,
84
+ "inputWidth": 320,
85
+ "inputHeight": 320,
86
+ "nmsIouThreshold": 0.45,
87
+ }
88
+ out = postprocess_yolo_split_dfl(preds, cfg, 640, 480, 1.0, (0, 0))
89
+ self.assertEqual(out["kind"], "detections")
90
+ self.assertEqual(len(out["detections"]), 1)
91
+ det = out["detections"][0]
92
+ self.assertEqual(det["class"], "person")
93
+ self.assertGreater(det["score"], 0.9)
94
+ x1, y1, x2, y2 = det["bbox"]
95
+ self.assertAlmostEqual(x1, 68.0 * 640 / 320, places=0)
96
+ self.assertAlmostEqual(y1, 68.0 * 480 / 320, places=0)
97
+ self.assertAlmostEqual(x2, 100.0 * 640 / 320, places=0)
98
+ self.assertAlmostEqual(y2, 100.0 * 480 / 320, places=0)
99
+
100
+ def test_respects_confidence_floor(self) -> None:
101
+ preds = _synthetic_predictions(
102
+ grid=40, cell_x=5, cell_y=5, ltrb=(1, 1, 1, 1), class_id=1
103
+ )
104
+ cfg = {
105
+ "labels": ["person", "vehicle", "animal"],
106
+ "confidence": 0.99,
107
+ "preprocessMode": "resize",
108
+ "inputSize": 320,
109
+ }
110
+ preds["4"][0][5 * 40 + 5][1] = -5.0
111
+ out = postprocess_yolo_split_dfl(preds, cfg, 320, 320, 1.0, (0, 0))
112
+ self.assertEqual(out["detections"], [])
113
+
114
+ def test_letterbox_undo_when_scale_pad_provided(self) -> None:
115
+ preds = _synthetic_predictions(
116
+ grid=40, cell_x=10, cell_y=10, ltrb=(2, 2, 2, 2), class_id=2
117
+ )
118
+ cfg = {
119
+ "labels": ["person", "vehicle", "animal"],
120
+ "confidence": 0.25,
121
+ "preprocessMode": "letterbox",
122
+ "inputSize": 320,
123
+ }
124
+ out = postprocess_yolo_split_dfl(preds, cfg, 640, 480, 0.5, (0, 40))
125
+ det = out["detections"][0]
126
+ self.assertEqual(det["class"], "animal")
127
+ x1, y1, x2, y2 = det["bbox"]
128
+ self.assertAlmostEqual(x1, (68.0 - 0) / 0.5, places=0)
129
+ self.assertAlmostEqual(y1, (68.0 - 40) / 0.5, places=0)
130
+ self.assertAlmostEqual(x2, (100.0 - 0) / 0.5, places=0)
131
+ self.assertAlmostEqual(y2, (100.0 - 40) / 0.5, places=0)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
@@ -0,0 +1,255 @@
1
+ """YOLO split-head DFL postprocessor (Scrypted YOLOv9 TFLite / EdgeTPU).
2
+
3
+ Scrypted's TFLite builds emit SIX tensors (3 strides × box+cls), not the
4
+ ONNX ``[1, 7, 2100]`` packed layout:
5
+
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)
8
+
9
+ Pairs are matched by shared spatial length ``N`` (1600/400/100 for 320²), not
10
+ by positional key order — TFLite output order has drifted between exports.
11
+ Coordinates are decoded in INPUT pixel space, then mapped back to the original
12
+ frame: stretch when ``preprocessMode == 'resize'`` (Scrypted default), else
13
+ letterbox undo via the pool's ``scale`` + ``pad``.
14
+
15
+ Pure nested lists so CI's bare interpreter (no numpy) still pins the math —
16
+ same discipline as ``ssd.py``. A live ndarray from the pool is accepted via
17
+ ``.tolist()``.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from typing import Any
23
+
24
+ REG_MAX = 16
25
+
26
+
27
+ def _as_list(value: Any) -> Any:
28
+ return value.tolist() if hasattr(value, "tolist") else value
29
+
30
+
31
+ def _shape(value: Any) -> tuple[int, ...]:
32
+ dims: list[int] = []
33
+ cur = value
34
+ while isinstance(cur, (list, tuple)):
35
+ dims.append(len(cur))
36
+ if not cur:
37
+ break
38
+ cur = cur[0]
39
+ return tuple(dims)
40
+
41
+
42
+ def _sigmoid(x: float) -> float:
43
+ if x >= 0:
44
+ z = math.exp(-x)
45
+ return 1.0 / (1.0 + z)
46
+ z = math.exp(x)
47
+ return z / (1.0 + z)
48
+
49
+
50
+ def _softmax(xs: list[float]) -> list[float]:
51
+ m = max(xs) if xs else 0.0
52
+ exps = [math.exp(x - m) for x in xs]
53
+ s = sum(exps) or 1.0
54
+ return [e / s for e in exps]
55
+
56
+
57
+ def _dfl_distance(logits16: list[float]) -> float:
58
+ """Softmax over ``reg_max`` bins · project ``[0..reg_max)`` → distance in cells."""
59
+ probs = _softmax(logits16)
60
+ return sum(i * p for i, p in enumerate(probs))
61
+
62
+
63
+ def _iou(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
64
+ ax1, ay1, ax2, ay2 = a
65
+ bx1, by1, bx2, by2 = b
66
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
67
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
68
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
69
+ if inter <= 0:
70
+ return 0.0
71
+ area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
72
+ area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
73
+ union = area_a + area_b - inter
74
+ return inter / union if union > 0 else 0.0
75
+
76
+
77
+ def _nms(boxes: list[dict], iou_threshold: float) -> list[dict]:
78
+ ordered = sorted(boxes, key=lambda b: b["score"], reverse=True)
79
+ kept: list[dict] = []
80
+ suppressed: set[int] = set()
81
+ for i, box in enumerate(ordered):
82
+ if i in suppressed:
83
+ continue
84
+ kept.append(box)
85
+ for j in range(i + 1, len(ordered)):
86
+ if j in suppressed:
87
+ continue
88
+ if _iou(box["_xyxy"], ordered[j]["_xyxy"]) > iou_threshold:
89
+ suppressed.add(j)
90
+ return kept
91
+
92
+
93
+ def _squeeze_batch(tensor: Any) -> Any:
94
+ """Drop a leading batch dim of 1: [1, N, C] → [N, C]."""
95
+ shape = _shape(tensor)
96
+ if len(shape) == 3 and shape[0] == 1:
97
+ return tensor[0]
98
+ return tensor
99
+
100
+
101
+ def _pair_box_cls(predictions: dict) -> list[tuple[Any, Any]]:
102
+ """Group outputs into (box, cls) pairs by shared N (= H·W)."""
103
+ tensors = {str(k): _squeeze_batch(_as_list(v)) for k, v in predictions.items()}
104
+ boxes: dict[int, Any] = {}
105
+ classes: dict[int, Any] = {}
106
+ for tensor in tensors.values():
107
+ shape = _shape(tensor)
108
+ if len(shape) != 2:
109
+ continue
110
+ n, width = shape
111
+ if width == 4 * REG_MAX:
112
+ boxes[n] = tensor
113
+ elif width > 0 and width != 4 * REG_MAX:
114
+ classes[n] = tensor
115
+ pairs: list[tuple[Any, Any]] = []
116
+ for n, box in boxes.items():
117
+ cls = classes.get(n)
118
+ if cls is None:
119
+ continue
120
+ pairs.append((box, cls))
121
+ if not pairs:
122
+ raise ValueError(
123
+ "yolo-split-dfl: expected paired box[N,64] + cls[N,C] tensors; "
124
+ f"got shapes {[(_shape(_squeeze_batch(_as_list(v)))) for v in predictions.values()]}"
125
+ )
126
+ # Largest N first (finest stride) — stable, matches Scrypted 1600→400→100.
127
+ pairs.sort(key=lambda p: _shape(p[0])[0], reverse=True)
128
+ return pairs
129
+
130
+
131
+ def _infer_stride(n: int, input_size: int) -> int:
132
+ """N = (input_size / stride)² → stride = input_size / sqrt(N)."""
133
+ side = int(round(math.sqrt(n)))
134
+ if side * side != n or side <= 0:
135
+ # Fall back to the canonical 320² strides by matching known Ns.
136
+ known = {1600: 8, 400: 16, 100: 32, (640 // 8) ** 2: 8}
137
+ if n in known:
138
+ return known[n]
139
+ raise ValueError(f"yolo-split-dfl: cannot infer grid side from N={n}")
140
+ stride = input_size // side
141
+ if stride * side != input_size:
142
+ # Non-divisible — still use the rounded stride from side.
143
+ stride = max(1, int(round(input_size / side)))
144
+ return stride
145
+
146
+
147
+ def _to_original(
148
+ x1: float,
149
+ y1: float,
150
+ x2: float,
151
+ y2: float,
152
+ *,
153
+ orig_w: int,
154
+ orig_h: int,
155
+ scale: float,
156
+ pad: tuple[int, int],
157
+ config: dict,
158
+ ) -> tuple[float, float, float, float]:
159
+ mode = config.get("preprocessMode", "letterbox")
160
+ if mode == "resize":
161
+ in_w = float(config.get("inputWidth") or config.get("inputSize") or 320)
162
+ in_h = float(config.get("inputHeight") or config.get("inputSize") or 320)
163
+ if in_w <= 0 or in_h <= 0:
164
+ in_w = in_h = 320.0
165
+ ox1 = x1 * orig_w / in_w
166
+ oy1 = y1 * orig_h / in_h
167
+ ox2 = x2 * orig_w / in_w
168
+ oy2 = y2 * orig_h / in_h
169
+ else:
170
+ ox1 = (x1 - pad[0]) / scale
171
+ oy1 = (y1 - pad[1]) / scale
172
+ ox2 = (x2 - pad[0]) / scale
173
+ oy2 = (y2 - pad[1]) / scale
174
+ return (
175
+ max(0.0, min(float(orig_w), ox1)),
176
+ max(0.0, min(float(orig_h), oy1)),
177
+ max(0.0, min(float(orig_w), ox2)),
178
+ max(0.0, min(float(orig_h), oy2)),
179
+ )
180
+
181
+
182
+ def postprocess_yolo_split_dfl(
183
+ predictions: dict,
184
+ config: dict,
185
+ orig_w: int,
186
+ orig_h: int,
187
+ scale: float,
188
+ pad: tuple[int, int],
189
+ ) -> dict:
190
+ conf_threshold = float(config.get("confidence", 0))
191
+ labels = config.get("labels") or ["person", "vehicle", "animal"]
192
+ nms_iou = float(config.get("nmsIouThreshold", 0.45))
193
+ input_size = int(config.get("inputSize") or config.get("inputWidth") or 320)
194
+
195
+ candidates: list[dict] = []
196
+ for box_tensor, cls_tensor in _pair_box_cls(predictions):
197
+ n = _shape(box_tensor)[0]
198
+ stride = _infer_stride(n, input_size)
199
+ grid = int(round(math.sqrt(n)))
200
+ num_classes = _shape(cls_tensor)[1]
201
+
202
+ for i in range(n):
203
+ # Class score first — skip cold cells before DFL.
204
+ logits = cls_tensor[i]
205
+ best_c = 0
206
+ best_logit = float(logits[0])
207
+ for c in range(1, num_classes):
208
+ v = float(logits[c])
209
+ if v > best_logit:
210
+ best_logit = v
211
+ best_c = c
212
+ score = _sigmoid(best_logit)
213
+ if score < conf_threshold:
214
+ continue
215
+
216
+ dist = box_tensor[i]
217
+ if len(dist) != 4 * REG_MAX:
218
+ continue
219
+ l = _dfl_distance([float(x) for x in dist[0:16]])
220
+ t = _dfl_distance([float(x) for x in dist[16:32]])
221
+ r = _dfl_distance([float(x) for x in dist[32:48]])
222
+ b = _dfl_distance([float(x) for x in dist[48:64]])
223
+
224
+ gy, gx = i // grid, i % grid
225
+ # Ultralytics-style anchor centre at (gx + 0.5, gy + 0.5) * stride;
226
+ # distances are in cell units → multiply by stride for pixels.
227
+ cx = (gx + 0.5) * stride
228
+ cy = (gy + 0.5) * stride
229
+ x1 = cx - l * stride
230
+ y1 = cy - t * stride
231
+ x2 = cx + r * stride
232
+ y2 = cy + b * stride
233
+
234
+ ox1, oy1, ox2, oy2 = _to_original(
235
+ x1, y1, x2, y2,
236
+ orig_w=orig_w, orig_h=orig_h, scale=scale, pad=pad, config=config,
237
+ )
238
+ if ox2 <= ox1 or oy2 <= oy1:
239
+ continue
240
+
241
+ label = labels[best_c] if best_c < len(labels) else str(best_c)
242
+ candidates.append(
243
+ {
244
+ "class": label,
245
+ "score": round(score, 4),
246
+ "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)],
247
+ "_xyxy": (ox1, oy1, ox2, oy2),
248
+ }
249
+ )
250
+
251
+ kept = _nms(candidates, nms_iou)
252
+ detections = [
253
+ {"class": d["class"], "score": d["score"], "bbox": d["bbox"]} for d in kept
254
+ ]
255
+ return {"kind": "detections", "detections": detections}