@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.
- package/THIRD_PARTY_MODELS.md +29 -55
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-4nJVRIT6.js → default-detection-model-CBIYdAJ5.js} +97 -483
- package/dist/{default-detection-model-lWMQATmQ.mjs → default-detection-model-hql6BX9-.mjs} +98 -478
- package/dist/detection-pipeline/index.js +10 -5
- package/dist/detection-pipeline/index.mjs +10 -5
- package/dist/{dist-CbRb0Zyj.mjs → dist-B9LnBPz9.mjs} +122 -76
- package/dist/{dist-DlgXwnqU.js → dist-BBZwYfSN.js} +122 -76
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-CZ5DXcvq.mjs → node-Ci0E-0VP.mjs} +1 -1
- package/dist/{node-s3wZnnJg.js → node-mJQBUiEc.js} +1 -1
- package/dist/pipeline-runner/index.js +41 -14
- package/dist/pipeline-runner/index.mjs +41 -15
- package/dist/{process-memory-CJsA4yRc.mjs → process-memory-B63Jlpev.mjs} +1 -1
- package/dist/{process-memory-C-mZ1LNi.js → process-memory-DgEfKdtC.js} +1 -1
- package/dist/recorder/index.js +10 -10
- package/dist/recorder/index.mjs +10 -10
- package/dist/{segment-demux-js-BbM0_Fmz.js → segment-demux-js-C2ufKMu0.js} +1 -1
- package/dist/{segment-demux-js-CuwXgwpj.mjs → segment-demux-js-DzwHW4Ey.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- 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
- 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
- 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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-C_x1pkx-.mjs → hostInit-Cg4IdtE5.mjs} +3 -3
- package/dist/stream-broker/index.js +5 -5
- package/dist/stream-broker/index.mjs +5 -5
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/python/edgetpu_dequant.py +55 -0
- package/python/inference_pool.py +19 -3
- package/python/postprocessors/__init__.py +2 -0
- package/python/postprocessors/scrfd.py +2 -0
- package/python/postprocessors/test_ssd.py +5 -6
- package/python/postprocessors/test_yolo_split_dfl.py +135 -0
- package/python/postprocessors/yolo_split_dfl.py +255 -0
- package/python/test_dequant_edgetpu.py +65 -0
- 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
- 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.
|
|
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.
|
|
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.
|
|
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-
|
|
7
|
-
const require_node = require("../node-
|
|
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-
|
|
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.
|
|
9794
|
+
* - Absolute: "rtsp://192.168.10.100/trackID=1"
|
|
9795
9795
|
* - Relative: "trackID=1" or "track1"
|
|
9796
|
-
* - Session control: "rtsp://192.168.
|
|
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-
|
|
3
|
-
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-
|
|
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-
|
|
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.
|
|
9790
|
+
* - Absolute: "rtsp://192.168.10.100/trackID=1"
|
|
9791
9791
|
* - Relative: "trackID=1" or "track1"
|
|
9792
|
-
* - Session control: "rtsp://192.168.
|
|
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-
|
|
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.
|
|
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
|
package/python/inference_pool.py
CHANGED
|
@@ -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 —
|
|
1423
|
-
|
|
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
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"""Pins the SSD decoder on REAL SSDLite MobileDet output tensors.
|
|
2
2
|
|
|
3
|
-
`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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}
|