@camstack/addon-pipeline 1.2.333 → 1.2.335

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 (37) hide show
  1. package/THIRD_PARTY_MODELS.md +3 -8
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/{default-detection-model-CDffuH2m.mjs → custom-model-catalog-Buehc666.mjs} +518 -179
  5. package/dist/{default-detection-model-Biwf5-ML.js → custom-model-catalog-CL-xJa8w.js} +529 -178
  6. package/dist/detection-pipeline/index.js +186 -120
  7. package/dist/detection-pipeline/index.mjs +135 -69
  8. package/dist/{dist-DJV7Z6Bb.js → dist-C5DlS_lW.js} +214 -22
  9. package/dist/{dist-BsT15i7i.mjs → dist-CPhDwlSN.mjs} +203 -23
  10. package/dist/motion-wasm/index.js +1 -1
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-CkmucLYG.js → node-BUafKArW.js} +1 -1
  13. package/dist/{node-Crsi_8ea.mjs → node-DYfEOwjW.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +248 -44
  15. package/dist/pipeline-runner/index.mjs +226 -22
  16. package/dist/{process-memory-BJb7Ihdp.js → process-memory-DWOQCbg5.js} +1 -1
  17. package/dist/{process-memory-1mAUV8S1.mjs → process-memory-WPRYnN8c.mjs} +1 -1
  18. package/dist/recorder/index.js +2 -2
  19. package/dist/recorder/index.mjs +2 -2
  20. package/dist/{segment-demux-js-mK9IBGLf.js → segment-demux-js-DWIgUPW1.js} +1 -1
  21. package/dist/{segment-demux-js-B_tboBcJ.mjs → segment-demux-js-Kz4EnmOr.mjs} +1 -1
  22. package/dist/stream-broker/_stub.js +1 -1
  23. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-a3HMFtd5.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-RPk9F8CY.mjs} +2 -2
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-1Y-WK-Zy.mjs +26 -0
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-DChPnZ5C.mjs → hostInit-DXXdQdLd.mjs} +2 -2
  28. package/dist/stream-broker/index.js +3 -3
  29. package/dist/stream-broker/index.mjs +3 -3
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/python/inference_pool.py +52 -2
  33. package/python/postprocessors/test_yolo_seg_root.py +109 -0
  34. package/python/postprocessors/yolo.py +52 -13
  35. package/python/postprocessors/yolo_seg.py +79 -156
  36. package/python/test_inference_pool_ov_precision.py +98 -0
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DPbj3ML4.mjs +0 -26
@@ -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-DJV7Z6Bb.js");
7
- const require_node = require("../node-CkmucLYG.js");
6
+ const require_dist = require("../dist-C5DlS_lW.js");
7
+ const require_node = require("../node-BUafKArW.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-mK9IBGLf.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-DWIgUPW1.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");
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $ as cameraStreamsCapability, $n as union, $t as resolveEgressDecodeHwAccel, At as logChannelsCapability, C as EncodeProfileSchema, Cn as errMsg, Ct as gifFromMp4Args, Dn as CamProfileSchema, E as G711_SCALE_CORRECTION_DB, En as CAM_PROFILE_ORDER, F as RATE_CONTROL_TIGHT, Fn as makeProfileBrokerId, Ft as memoizePinnedFfmpeg, Gn as discriminatedUnion, H as RingBuffer, Hn as _enum, In as makeSourceBrokerId, Jn as number, K as addonWidgetsSourceCapability, Kt as rankStreams, Ln as nodePin, Mn as expandAudioChunkToF32le, Mt as maskUrlCredentials, P as RATE_CONTROL_RELAXED, Pn as isEvent, Qn as string, Sn as DeviceType, St as gifDeriveSpeed, Tn as BaseAddon, Un as array, Wn as boolean, Yn as object, Z as batteryCapability, Zn as record, _n as buildFfmpegArgs$1, at as declareLogChannel, bn as isSoftwareDecode, bt as ffmpegRate, c as CLIP_STILL_REASON_CODE_HEADER, er as EventCategory, ft as egressTranscodeSharingKey, it as createLogChannelsProvider, kn as createEvent, kt as loadContributionCapability, ln as streamBrokerCapability, mn as AUDIO_PRESETS, o as BatteryStatusSchema, pn as webrtcSessionCapability, pt as egressTransportFromRequest, qn as literal, rt as createHwAccelCache, s as CLIP_BROKER_PACED_RATES, st as deriveBatteryPresence, tt as clipExportFailureOf, wn as BOOT_RECOVERY_BACKOFF_MS, xn as DeviceFeature, yn as invocationFromEncodeProfile, zn as parseProfileBrokerId } from "../dist-BsT15i7i.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Crsi_8ea.mjs";
2
+ import { $n as record, At as loadContributionCapability, C as EncodeProfileSchema, Cn as DeviceFeature, Ct as gifDeriveSpeed, Dn as BaseAddon, E as G711_SCALE_CORRECTION_DB, En as BOOT_RECOVERY_BACKOFF_MS, F as RATE_CONTROL_TIGHT, Gn as array, H as RingBuffer, In as isEvent, It as memoizePinnedFfmpeg, K as addonWidgetsSourceCapability, Kn as boolean, Ln as makeProfileBrokerId, Nt as maskUrlCredentials, On as CAM_PROFILE_ORDER, P as RATE_CONTROL_RELAXED, Pn as expandAudioChunkToF32le, Q as batteryCapability, Rn as makeSourceBrokerId, Sn as isSoftwareDecode, Tn as errMsg, Vn as parseProfileBrokerId, Wn as _enum, Xn as number, Yn as literal, Zn as object, at as createLogChannelsProvider, c as CLIP_STILL_REASON_CODE_HEADER, ct as deriveBatteryPresence, dn as streamBrokerCapability, en as resolveEgressDecodeHwAccel, er as string, et as cameraStreamsCapability, gn as AUDIO_PRESETS, hn as webrtcSessionCapability, it as createHwAccelCache, jn as createEvent, jt as logChannelsCapability, kn as CamProfileSchema, mt as egressTransportFromRequest, nr as EventCategory, nt as clipExportFailureOf, o as BatteryStatusSchema, ot as declareLogChannel, pt as egressTranscodeSharingKey, qn as discriminatedUnion, qt as rankStreams, s as CLIP_BROKER_PACED_RATES, tr as union, wn as DeviceType, wt as gifFromMp4Args, xn as invocationFromEncodeProfile, xt as ffmpegRate, yn as buildFfmpegArgs$1, zn as nodePin } from "../dist-CPhDwlSN.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-DYfEOwjW.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-B_tboBcJ.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-Kz4EnmOr.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -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-a3HMFtd5.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-RPk9F8CY.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.333",
3
+ "version": "1.2.335",
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",
@@ -389,6 +389,34 @@ def _ov_compilation_properties(supported: Any, threads: int) -> dict:
389
389
  return {"COMPILATION_NUM_THREADS": threads}
390
390
 
391
391
 
392
+ # Inference precisions a catalog entry may require on an OpenVINO GPU.
393
+ _OV_GPU_PRECISIONS = ("f32", "f16")
394
+
395
+
396
+ def _ov_precision_properties(config: dict, device: str, supported: Any) -> dict:
397
+ """The INFERENCE_PRECISION_HINT a model's catalog entry requires on a GPU.
398
+
399
+ An Intel GPU runs an FP32 IR at f16 by default. Some IRs do not survive
400
+ that: on 2026-10-07 the YOLO26 base IRs `yolo26s`, `yolo26m` and
401
+ `yolo26m-320` returned no valid detection on the Core Ultra iGPU and the
402
+ N100 (0 true / 146 false positives, or NaN scores) and matched the CPU at
403
+ f32. The entry says so (`gpuInferencePrecision`, threaded as config key
404
+ `gpuInferencePrecision`) — never a model-name match.
405
+
406
+ GPU only: the NPU and CPU already compute these IRs correctly. And only for
407
+ a device that lists the key — a rejected property would make the failover
408
+ loop push the model onto the next device.
409
+ """
410
+ precision = config.get("gpuInferencePrecision")
411
+ if precision not in _OV_GPU_PRECISIONS:
412
+ return {}
413
+ if not str(device).upper().startswith("GPU"):
414
+ return {}
415
+ if "INFERENCE_PRECISION_HINT" not in set(supported):
416
+ return {}
417
+ return {"INFERENCE_PRECISION_HINT": precision}
418
+
419
+
392
420
  def _resolve_ov_devices(available: list[str], full_names: dict[str, str]) -> list[str]:
393
421
  """Ordered EXPLICIT device candidates for compile-time failover (NPU>GPU>CPU).
394
422
 
@@ -1127,6 +1155,7 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1127
1155
  # `nproc` threads for the life of the process and the operator must be
1128
1156
  # able to SEE that rather than assume the bound applied.
1129
1157
  ov_compile_threads = 0
1158
+ ov_precision = "device-default"
1130
1159
  last_exc: Optional[BaseException] = None
1131
1160
  for candidate in candidates:
1132
1161
  try:
@@ -1143,12 +1172,16 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1143
1172
  # OV_COMPILATION_NUM_THREADS). Gated on the device's own
1144
1173
  # SUPPORTED_PROPERTIES so a plugin that does not know the key
1145
1174
  # compiles unchanged instead of failing over to the CPU.
1175
+ candidate_supported = _ov_supported_properties(core, candidate)
1146
1176
  candidate_config = {
1147
1177
  **ov_config,
1148
1178
  **_ov_compilation_properties(
1149
- _ov_supported_properties(core, candidate),
1179
+ candidate_supported,
1150
1180
  OV_COMPILATION_NUM_THREADS,
1151
1181
  ),
1182
+ # A model the GPU must not run at its default f16
1183
+ # (catalog `gpuInferencePrecision`).
1184
+ **_ov_precision_properties(config, candidate, candidate_supported),
1152
1185
  }
1153
1186
  compiled = core.compile_model(
1154
1187
  candidate_source, device_name=candidate, config=candidate_config,
@@ -1157,6 +1190,9 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1157
1190
  ov_compile_threads = int(
1158
1191
  candidate_config.get("COMPILATION_NUM_THREADS", 0)
1159
1192
  )
1193
+ ov_precision = str(
1194
+ candidate_config.get("INFERENCE_PRECISION_HINT", "device-default")
1195
+ )
1160
1196
  break
1161
1197
  except Exception as exc:
1162
1198
  last_exc = exc
@@ -1178,8 +1214,22 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1178
1214
  sys.stderr.write(
1179
1215
  f"OpenVINO: compiled {os.path.basename(path)} mode={ov_device} "
1180
1216
  f"EXECUTION_DEVICES={_exec_devices} compilationThreads="
1181
- f"{ov_compile_threads if ov_compile_threads > 0 else 'unbounded(device rejects the key)'}\n"
1217
+ f"{ov_compile_threads if ov_compile_threads > 0 else 'unbounded(device rejects the key)'}"
1218
+ f" inferencePrecision={ov_precision}\n"
1182
1219
  )
1220
+ # A model that needs a GPU precision the device would not take runs at
1221
+ # f16 and may return garbage — say so rather than look healthy.
1222
+ if (
1223
+ config.get("gpuInferencePrecision") in _OV_GPU_PRECISIONS
1224
+ and str(ov_device).upper().startswith("GPU")
1225
+ and ov_precision == "device-default"
1226
+ ):
1227
+ sys.stderr.write(
1228
+ f"OpenVINO: WARNING {os.path.basename(path)} requires "
1229
+ f"inferencePrecision={config.get('gpuInferencePrecision')} on {ov_device}, "
1230
+ "which does not list INFERENCE_PRECISION_HINT — it runs at the device default "
1231
+ "and its detections may be invalid\n"
1232
+ )
1183
1233
  sys.stderr.flush()
1184
1234
  # An OpenVINO IR converted from ONNX can carry an UNNAMED output tensor
1185
1235
  # (e.g. `yolov8n-package`): `get_any_name()` then raises
@@ -0,0 +1,109 @@
1
+ """D737 — a YOLO26-seg ROOT decodes its boxes exactly like the plain detector
2
+ and keeps one mask per kept detection.
3
+
4
+ Run like the other postprocessor tests:
5
+ ``PYTHONPATH=postprocessors:. python -m pytest postprocessors/`` from
6
+ ``packages/addon-pipeline/python``.
7
+ """
8
+ import base64
9
+
10
+ import numpy as np
11
+
12
+ from postprocessors.yolo import postprocess_yolo
13
+ from postprocessors.yolo_seg import postprocess_yolo_seg
14
+
15
+ LABELS = ["person", "car", "dog"]
16
+ CONFIG = {"labels": LABELS, "numClasses": 3, "confidence": 0.3, "inputSize": 64}
17
+ # A 1280x720 frame letterboxed into 64: scale 0.05, vertical pad 14.
18
+ ORIG_W, ORIG_H, SCALE, PAD = 1280, 720, 0.05, (0, 14)
19
+ PROTO = 16 # P x P prototype grid at the 64 input (4 px per cell)
20
+
21
+
22
+ def _protos(rng: np.random.Generator) -> np.ndarray:
23
+ return rng.normal(size=(1, 32, PROTO, PROTO)).astype(np.float32)
24
+
25
+
26
+ def _post_nms_rows(rng: np.random.Generator) -> np.ndarray:
27
+ """[1, 6, 38]: two overlapping persons (the end-to-end head keeps both),
28
+ a car, one row under the floor and two padding rows (class -1)."""
29
+ boxes = [
30
+ [8, 20, 24, 44, 0.91, 0],
31
+ [10, 22, 26, 46, 0.80, 0],
32
+ [30, 30, 56, 48, 0.66, 1],
33
+ [40, 18, 50, 28, 0.10, 2],
34
+ [0, 0, 0, 0, 0.0, -1],
35
+ [0, 0, 0, 0, 0.0, -1],
36
+ ]
37
+ coeffs = rng.normal(size=(len(boxes), 32))
38
+ return np.concatenate([np.array(boxes, dtype=np.float32), coeffs], axis=1)[None].astype(np.float32)
39
+
40
+
41
+ def _raw_head(rng: np.random.Generator) -> np.ndarray:
42
+ """[1, 4 + 3 + 32, 5] with LOGIT scores (YOLO26 raw), two overlapping."""
43
+ cxcywh = np.array(
44
+ [[16, 32, 16, 24], [18, 34, 16, 24], [43, 39, 26, 18], [45, 23, 10, 10], [5, 5, 4, 4]],
45
+ dtype=np.float32,
46
+ ).T
47
+ logits = np.array(
48
+ [[3.0, 2.0, -4, -4, -6], [-4, -4, 1.5, -4, -6], [-4, -4, -4, -2.0, -6]], dtype=np.float32
49
+ )
50
+ coeffs = rng.normal(size=(32, 5)).astype(np.float32)
51
+ return np.concatenate([cxcywh, logits, coeffs], axis=0)[None]
52
+
53
+
54
+ def _boxes(out: dict) -> list[tuple]:
55
+ return [(d["class"], d["score"], tuple(d["bbox"])) for d in out["detections"]]
56
+
57
+
58
+ def test_post_nms_head_boxes_equal_the_plain_detectors():
59
+ rng = np.random.default_rng(1)
60
+ rows = _post_nms_rows(rng)
61
+ plain = postprocess_yolo({"output0": rows[..., :6].copy()}, dict(CONFIG), ORIG_W, ORIG_H, SCALE, PAD)
62
+ seg = postprocess_yolo_seg(
63
+ {"output0": rows, "output1": _protos(rng)}, dict(CONFIG), ORIG_W, ORIG_H, SCALE, PAD
64
+ )
65
+ assert _boxes(seg) == _boxes(plain)
66
+ # The plain detector does not NMS an end-to-end head; neither does the seg root.
67
+ assert len(seg["detections"]) == 3
68
+
69
+
70
+ def test_raw_head_boxes_equal_the_plain_detectors():
71
+ rng = np.random.default_rng(2)
72
+ head = _raw_head(rng)
73
+ plain = postprocess_yolo({"output0": head[:, :7, :].copy()}, dict(CONFIG), ORIG_W, ORIG_H, SCALE, PAD)
74
+ seg = postprocess_yolo_seg(
75
+ {"output0": head.copy(), "output1": _protos(rng)}, dict(CONFIG), ORIG_W, ORIG_H, SCALE, PAD
76
+ )
77
+ assert _boxes(seg) == _boxes(plain)
78
+ assert len(seg["detections"]) == 2 # the overlap is suppressed, the floor drops two
79
+
80
+
81
+ def test_same_nms_iou_knob_as_the_plain_detector():
82
+ rng = np.random.default_rng(3)
83
+ head = _raw_head(rng)
84
+ loose = dict(CONFIG, nmsIouThreshold=0.95)
85
+ plain = postprocess_yolo({"output0": head[:, :7, :].copy()}, loose, ORIG_W, ORIG_H, SCALE, PAD)
86
+ seg = postprocess_yolo_seg(
87
+ {"output0": head.copy(), "output1": _protos(rng)}, loose, ORIG_W, ORIG_H, SCALE, PAD
88
+ )
89
+ assert _boxes(seg) == _boxes(plain)
90
+ assert len(seg["detections"]) == 3
91
+
92
+
93
+ def test_every_kept_detection_carries_its_own_mask_over_its_box():
94
+ rng = np.random.default_rng(4)
95
+ rows = _post_nms_rows(rng)
96
+ protos = _protos(rng)
97
+ out = postprocess_yolo_seg({"output0": rows, "output1": protos}, dict(CONFIG), ORIG_W, ORIG_H, SCALE, PAD)
98
+ first = out["detections"][0]
99
+ # Box [8,20,24,44] in the 64 input → proto cells [2,5,6,11] → 4 x 6.
100
+ assert (first["maskWidth"], first["maskHeight"]) == (4, 6)
101
+ raw = np.frombuffer(base64.b64decode(first["mask"]), dtype=np.uint8)
102
+ assert raw.size == 4 * 6
103
+ assert set(np.unique(raw)) <= {0, 255}
104
+ # The mask IS sigmoid(coeffs @ protos) > 0.5 over that window, for this row.
105
+ logits = rows[0, 0, 6:] @ protos[0][:, 5:11, 2:6].reshape(32, -1)
106
+ expected = ((1 / (1 + np.exp(-logits))) > 0.5).astype(np.uint8) * 255
107
+ assert np.array_equal(raw, expected)
108
+ # Different rows → different coefficient vectors → masks are per instance.
109
+ assert out["detections"][1]["mask"] != first["mask"]
@@ -180,12 +180,28 @@ def _postprocess_yolo11_nms(
180
180
 
181
181
  Coordinates are in input pixel space (0..inputSize). Already post-NMS.
182
182
  """
183
+ rows = decode_yolo11_nms_rows(output, config, orig_w, orig_h, scale, pad)
184
+ return {"kind": "detections", "detections": [d for _, d in rows]}
185
+
186
+
187
+ def decode_yolo11_nms_rows(
188
+ output: np.ndarray,
189
+ config: dict,
190
+ orig_w: int,
191
+ orig_h: int,
192
+ scale: float,
193
+ pad: tuple[int, int],
194
+ ) -> list[tuple[int, dict]]:
195
+ """The post-NMS box decode, each kept detection paired with its ROW in
196
+ ``output``. Only the first 6 columns are read, so a seg head's
197
+ ``[N, 6 + 32]`` rows decode exactly like the plain detector's ``[N, 6]``
198
+ (D737) and the row index finds the detection's mask coefficients."""
183
199
  conf_threshold = float(config.get("confidence", 0))
184
200
  labels = config.get("labels", COCO_80)
185
201
 
186
- detections = []
202
+ detections: list[tuple[int, dict]] = []
187
203
  for i in range(output.shape[0]):
188
- x1, y1, x2, y2, score, class_id = output[i]
204
+ x1, y1, x2, y2, score, class_id = output[i][:6]
189
205
  score = float(score)
190
206
  if score < conf_threshold:
191
207
  continue
@@ -200,13 +216,13 @@ def _postprocess_yolo11_nms(
200
216
  oy2 = max(0, min(orig_h, (float(y2) - pad[1]) / scale))
201
217
 
202
218
  label = labels[class_id] if class_id < len(labels) else str(class_id)
203
- detections.append({
219
+ detections.append((i, {
204
220
  "class": label,
205
221
  "score": round(score, 4),
206
222
  "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)],
207
- })
223
+ }))
208
224
 
209
- return {"kind": "detections", "detections": detections}
225
+ return detections
210
226
 
211
227
 
212
228
  def postprocess_yolo(
@@ -223,12 +239,8 @@ def postprocess_yolo(
223
239
  1. Raw tensor (ONNX/OpenVINO): [4+numClasses, numBoxes] — needs NMS
224
240
  2. CoreML built-in NMS: 'confidence' + 'coordinates' tensors — already post-NMS
225
241
  """
226
- conf_threshold = float(config.get("confidence", 0))
227
242
  labels = config.get("labels", COCO_80)
228
243
  num_classes = config.get("numClasses", len(labels))
229
- # NMS IoU from the step config (operator slider); the 0.45 literal stays as
230
- # the fallback so an unset value is byte-identical to today (#4).
231
- nms_iou = float(config.get("nmsIouThreshold", 0.45))
232
244
 
233
245
  yolox = yolox_head_to_v8(predictions, int(config.get("inputSize", 640)))
234
246
  if yolox is not None:
@@ -272,6 +284,31 @@ def postprocess_yolo(
272
284
  else:
273
285
  output = output.T
274
286
 
287
+ rows = decode_raw_yolo_columns(output, config, orig_w, orig_h, scale, pad)
288
+ return {"kind": "detections", "detections": [d for _, d in rows]}
289
+
290
+
291
+ def decode_raw_yolo_columns(
292
+ output: np.ndarray,
293
+ config: dict,
294
+ orig_w: int,
295
+ orig_h: int,
296
+ scale: float,
297
+ pad: tuple[int, int],
298
+ ) -> list[tuple[int, dict]]:
299
+ """The raw-head box decode (``[4 + numClasses (+ extra), numBoxes]``, one
300
+ COLUMN per candidate): sigmoid when the scores are logits, confidence
301
+ floor, NMS. Each kept detection is paired with its column in ``output``.
302
+ Rows past ``4 + numClasses`` are never read, so a seg head's mask
303
+ coefficients ride along untouched and decode exactly like the plain
304
+ detector (D737). May apply the sigmoid IN PLACE on the class rows."""
305
+ conf_threshold = float(config.get("confidence", 0))
306
+ labels = config.get("labels", COCO_80)
307
+ num_classes = config.get("numClasses", len(labels))
308
+ # NMS IoU from the step config (operator slider); the 0.45 literal stays as
309
+ # the fallback so an unset value is byte-identical to today (#4).
310
+ nms_iou = float(config.get("nmsIouThreshold", 0.45))
311
+
275
312
  num_boxes = output.shape[1]
276
313
 
277
314
  # Detect if scores are raw logits (YOLO11/v26) vs sigmoid-activated (YOLOv8/v9).
@@ -290,7 +327,7 @@ def postprocess_yolo(
290
327
  best_scores = class_scores[best_classes, np.arange(num_boxes)] # (N,)
291
328
  mask = best_scores >= conf_threshold
292
329
  if not np.any(mask):
293
- return {"kind": "detections", "detections": []}
330
+ return []
294
331
 
295
332
  # Gather only above-threshold boxes
296
333
  idxs = np.nonzero(mask)[0]
@@ -315,11 +352,13 @@ def postprocess_yolo(
315
352
  "score": round(float(sc[k]), 4),
316
353
  "bbox": [round(float(x1[k]), 1), round(float(y1[k]), 1), round(float(x2[k]), 1), round(float(y2[k]), 1)],
317
354
  "_xyxy": (float(x1[k]), float(y1[k]), float(x2[k]), float(y2[k])),
355
+ "_col": int(idxs[k]),
318
356
  })
319
357
 
320
358
  kept = _nms(candidates, nms_iou)
321
- # Remove internal _xyxy field
359
+ # Remove the internal fields; the column travels beside the detection.
360
+ out: list[tuple[int, dict]] = []
322
361
  for d in kept:
323
362
  del d["_xyxy"]
324
-
325
- return {"kind": "detections", "detections": kept}
363
+ out.append((d.pop("_col"), d))
364
+ return out