@camstack/addon-pipeline 1.2.29 → 1.2.36
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 +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +1229 -1147
- package/dist/detection-pipeline/index.mjs +1229 -1147
- package/dist/{dist-DUHlZcWd.js → dist-BvFLZyhW.js} +105 -3
- package/dist/{dist-CsKaXmP8.mjs → dist-Du7_SNiR.mjs} +105 -3
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +156 -9
- package/dist/pipeline-runner/index.mjs +155 -9
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/{step-definitions-1GIwU6R8.mjs → step-definitions-C8tk9fPJ.mjs} +12 -3
- package/dist/{step-definitions-Drb9cWbB.js → step-definitions-i8O-hxyI.js} +17 -2
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-tHahN-lK.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C5m4hC2K.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ByHi7ZAe.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-CscpvIRR.mjs +26 -0
- package/dist/stream-broker/{hostInit-CIwilJUZ.mjs → hostInit-WXFrgppm.mjs} +3 -3
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-B7VdNJlO.js → MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-YyB4uLdH.js → MotionZonesSettings-NcxxQN8r-9D-BWwQP.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DfqixEad.js → PrivacyMaskSettings-APgPLF7p-HvAetfA4.js} +1 -1
- package/embed-dist/assets/{index-zVLLVdIS.js → index-9Ob2YrIf.js} +14 -14
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D1zJmtUe.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-CSWUWZ8c.mjs +0 -26
|
@@ -3,10 +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_step_definitions = require("../step-definitions-
|
|
6
|
+
const require_dist = require("../dist-BvFLZyhW.js");
|
|
7
|
+
const require_step_definitions = require("../step-definitions-i8O-hxyI.js");
|
|
8
8
|
const require_node_topology_platform = require("../node-topology-platform-CFZ7F4xW.js");
|
|
9
9
|
const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
|
|
10
|
+
let node_os = require("node:os");
|
|
11
|
+
node_os = require_chunk.__toESM(node_os);
|
|
10
12
|
let node_child_process = require("node:child_process");
|
|
11
13
|
let sharp = require("sharp");
|
|
12
14
|
sharp = require_chunk.__toESM(sharp);
|
|
@@ -14,8 +16,6 @@ let node_fs = require("node:fs");
|
|
|
14
16
|
node_fs = require_chunk.__toESM(node_fs);
|
|
15
17
|
let node_path = require("node:path");
|
|
16
18
|
node_path = require_chunk.__toESM(node_path);
|
|
17
|
-
let node_os = require("node:os");
|
|
18
|
-
node_os = require_chunk.__toESM(node_os);
|
|
19
19
|
//#region src/detection-pipeline/runtimes.ts
|
|
20
20
|
var KNOWN_PLATFORMS = [
|
|
21
21
|
"darwin",
|
|
@@ -81,14 +81,28 @@ function probedToHardwareInfo(hw) {
|
|
|
81
81
|
coral: coralInfoFrom(hw)
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* THE per-runtime pool provisioning registry — the single source for every
|
|
86
|
+
* pool's concurrency / worker count / batch tuning, keyed by the runtime THAT
|
|
87
|
+
* POOL runs. `engine-factory` used to carry a second, parallel copy of these
|
|
88
|
+
* defaults; it no longer does.
|
|
89
|
+
*
|
|
90
|
+
* `windowMs` / `maxBatchSize` are inert unless `batchMode !== 'none'`, and
|
|
91
|
+
* `numStreams` / `intraOpThreads` are inert at 0 (the runtime picks) — they are
|
|
92
|
+
* still spelled out per row so a reader never has to guess which default a
|
|
93
|
+
* missing key would have hit.
|
|
94
|
+
*/
|
|
84
95
|
var RUNTIME_DETAIL = {
|
|
85
96
|
onnx: {
|
|
86
97
|
label: "ONNX Runtime",
|
|
87
98
|
pythonRequirements: ["requirements.txt", "requirements-onnxruntime.txt"],
|
|
88
99
|
tuning: {
|
|
89
100
|
concurrency: 4,
|
|
101
|
+
numWorkers: 1,
|
|
90
102
|
batchMode: "list",
|
|
103
|
+
windowMs: 2,
|
|
91
104
|
maxBatchSize: 8,
|
|
105
|
+
numStreams: 0,
|
|
92
106
|
intraOpThreads: 0
|
|
93
107
|
}
|
|
94
108
|
},
|
|
@@ -97,8 +111,12 @@ var RUNTIME_DETAIL = {
|
|
|
97
111
|
pythonRequirements: ["requirements.txt", "requirements-openvino.txt"],
|
|
98
112
|
tuning: {
|
|
99
113
|
concurrency: 1,
|
|
114
|
+
numWorkers: 1,
|
|
100
115
|
batchMode: "none",
|
|
101
|
-
|
|
116
|
+
windowMs: 2,
|
|
117
|
+
maxBatchSize: 8,
|
|
118
|
+
numStreams: 0,
|
|
119
|
+
intraOpThreads: 0
|
|
102
120
|
}
|
|
103
121
|
},
|
|
104
122
|
coreml: {
|
|
@@ -106,10 +124,12 @@ var RUNTIME_DETAIL = {
|
|
|
106
124
|
pythonRequirements: ["requirements.txt", "requirements-coreml.txt"],
|
|
107
125
|
tuning: {
|
|
108
126
|
concurrency: 1,
|
|
127
|
+
numWorkers: 1,
|
|
109
128
|
batchMode: "none",
|
|
110
129
|
windowMs: 8,
|
|
111
130
|
maxBatchSize: 8,
|
|
112
|
-
|
|
131
|
+
numStreams: 0,
|
|
132
|
+
intraOpThreads: 0
|
|
113
133
|
}
|
|
114
134
|
},
|
|
115
135
|
edgetpu: {
|
|
@@ -117,8 +137,12 @@ var RUNTIME_DETAIL = {
|
|
|
117
137
|
pythonRequirements: ["requirements.txt", "requirements-edgetpu.txt"],
|
|
118
138
|
tuning: {
|
|
119
139
|
concurrency: 1,
|
|
140
|
+
numWorkers: 1,
|
|
120
141
|
batchMode: "none",
|
|
121
|
-
|
|
142
|
+
windowMs: 2,
|
|
143
|
+
maxBatchSize: 8,
|
|
144
|
+
numStreams: 0,
|
|
145
|
+
intraOpThreads: 0
|
|
122
146
|
}
|
|
123
147
|
}
|
|
124
148
|
};
|
|
@@ -161,6 +185,12 @@ function modelFormatFor(id) {
|
|
|
161
185
|
function pythonRequirementsFor(id) {
|
|
162
186
|
return RUNTIME_DETAIL[id].pythonRequirements;
|
|
163
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* The pool provisioning for ONE runtime. Callers must pass the runtime of the
|
|
190
|
+
* pool they are provisioning — never the node's default runtime. See
|
|
191
|
+
* `engine/pool-provisioning.ts` for the resolver that layers an operator
|
|
192
|
+
* override on top of this.
|
|
193
|
+
*/
|
|
164
194
|
function tuningFor(id) {
|
|
165
195
|
return RUNTIME_DETAIL[id].tuning;
|
|
166
196
|
}
|
|
@@ -221,43 +251,6 @@ function normalizeEngineNodeId(rawNodeId) {
|
|
|
221
251
|
return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
|
|
222
252
|
}
|
|
223
253
|
//#endregion
|
|
224
|
-
//#region src/detection-pipeline/pipeline/native-crop-compose.ts
|
|
225
|
-
/** Clamp `v` into `[lo, hi]`. */
|
|
226
|
-
function clamp(v, lo, hi) {
|
|
227
|
-
if (v < lo) return lo;
|
|
228
|
-
if (v > hi) return hi;
|
|
229
|
-
return v;
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* Compose a child `roi` given in the parent CROP's normalized space into the
|
|
233
|
-
* FRAME's normalized space, given the crop's frame-space pixel rectangle and the
|
|
234
|
-
* frame dimensions. The result is clamped to the unit square (origin in
|
|
235
|
-
* `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
|
|
236
|
-
* degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
|
|
237
|
-
* clamps to zero area) so the caller falls back to the downscaled tile crop.
|
|
238
|
-
*
|
|
239
|
-
* Pure — never mutates its arguments.
|
|
240
|
-
*/
|
|
241
|
-
function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
|
|
242
|
-
if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
|
|
243
|
-
if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
|
|
244
|
-
const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
|
|
245
|
-
const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
|
|
246
|
-
const fw = roi.w * cropFrameSpace.w / frameWidth;
|
|
247
|
-
const fh = roi.h * cropFrameSpace.h / frameHeight;
|
|
248
|
-
const x = clamp(fx, 0, 1);
|
|
249
|
-
const y = clamp(fy, 0, 1);
|
|
250
|
-
const w = clamp(fw, 0, 1 - x);
|
|
251
|
-
const h = clamp(fh, 0, 1 - y);
|
|
252
|
-
if (!(w > 0) || !(h > 0)) return null;
|
|
253
|
-
return {
|
|
254
|
-
x,
|
|
255
|
-
y,
|
|
256
|
-
w,
|
|
257
|
-
h
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
//#endregion
|
|
261
254
|
//#region src/detection-pipeline/default-detection-model.ts
|
|
262
255
|
/** The object-detection step id — the only slot this resolver applies to. */
|
|
263
256
|
var OBJECT_DETECTION_STEP_ID$1 = "object-detection";
|
|
@@ -308,10 +301,12 @@ function classifyAccelerator(backend, device) {
|
|
|
308
301
|
*
|
|
309
302
|
* Guarantees:
|
|
310
303
|
* - never throws (hot default-tree path);
|
|
311
|
-
* - never returns a model id
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
304
|
+
* - never returns a model id without a build for `format` (unless the step
|
|
305
|
+
* has ZERO builds for `format`, which is unloadable regardless of pick and
|
|
306
|
+
* flagged by `collectZeroBuildIssues`) — an unmapped backend, a `'cpu'`
|
|
307
|
+
* class, a mapped id missing from the catalog, or a mapped id without a
|
|
308
|
+
* `format` build all fall back to the step's PER-FORMAT default
|
|
309
|
+
* (`getDefaultModelForFormatFromDef`), NOT the bare `defaultModelId`.
|
|
315
310
|
*
|
|
316
311
|
* `getStepDef` is injectable (defaults to the real catalog lookup) so the
|
|
317
312
|
* missing-build fallback branch is unit-testable without a live catalog —
|
|
@@ -324,12 +319,12 @@ function resolveDefaultDetectionModel(backend, device, format, getStepDef = requ
|
|
|
324
319
|
} catch {
|
|
325
320
|
return "yolo26n";
|
|
326
321
|
}
|
|
327
|
-
const fallback = def.defaultModelId;
|
|
328
322
|
const candidate = MODEL_BY_CLASS[classifyAccelerator(backend, device)];
|
|
329
|
-
if (candidate
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
323
|
+
if (candidate !== null) {
|
|
324
|
+
const entry = def.models.find((m) => m.id === candidate);
|
|
325
|
+
if (entry && entry.formats[format] !== void 0) return candidate;
|
|
326
|
+
}
|
|
327
|
+
return require_step_definitions.getDefaultModelForFormatFromDef(def, format);
|
|
333
328
|
}
|
|
334
329
|
//#endregion
|
|
335
330
|
//#region src/detection-pipeline/engine/shared-inference-pool.ts
|
|
@@ -379,6 +374,62 @@ var RAW_FMT_CODE = {
|
|
|
379
374
|
* large frame) never trips; override via env for constrained hardware.
|
|
380
375
|
*/
|
|
381
376
|
var POOL_INFER_TIMEOUT_MS = Math.max(1e3, Number(process.env["CAMSTACK_POOL_INFER_TIMEOUT_MS"]) || 6e4);
|
|
377
|
+
/**
|
|
378
|
+
* Max inference requests outstanding to ONE worker before new ones are SHED.
|
|
379
|
+
*
|
|
380
|
+
* Without this the camera dies permanently the first time the worker gets
|
|
381
|
+
* slow, and it did: device 615 produced nothing for 52 minutes on 2026-07-31.
|
|
382
|
+
* The worker was neither dead nor deadlocked — it was replying at full rate,
|
|
383
|
+
* about a minute behind, and every reply landed after {@link
|
|
384
|
+
* POOL_INFER_TIMEOUT_MS} had already abandoned its request. The proof is in the
|
|
385
|
+
* logs: a steady drip of `Response for unknown request id`, request ids
|
|
386
|
+
* climbing ~13/s, for as long as the outage lasted.
|
|
387
|
+
*
|
|
388
|
+
* That state is self-sustaining and cannot drain, because frames keep arriving
|
|
389
|
+
* while every one of them is written straight to the pipe. `dispatch` had NO
|
|
390
|
+
* bound: a transient slowdown (inference went 40ms → 2500ms across several
|
|
391
|
+
* cameras at 16:19 that day) buys a backlog that is never paid off, and once
|
|
392
|
+
* the backlog exceeds the deadline the loss is total.
|
|
393
|
+
*
|
|
394
|
+
* The queue is therefore capped in TIME, not just in count: at the normal ~40ms
|
|
395
|
+
* per inference this is ~0.3s of work, and even at a 20× slowdown it stays well
|
|
396
|
+
* inside the 60s deadline — so a worker that falls behind sheds, catches up,
|
|
397
|
+
* and recovers on its own. Python already sheds under its own per-model bound
|
|
398
|
+
* and answers `{dropped: true}`; this is the same contract applied one layer
|
|
399
|
+
* earlier, where the unbounded queue actually is.
|
|
400
|
+
*/
|
|
401
|
+
var POOL_MAX_IN_FLIGHT = Math.max(1, Number(process.env["CAMSTACK_POOL_MAX_IN_FLIGHT"]) || 8);
|
|
402
|
+
/**
|
|
403
|
+
* Reply deadline for a LIVE inference request (ms), as opposed to the generous
|
|
404
|
+
* {@link POOL_INFER_TIMEOUT_MS} that model loads and commands keep.
|
|
405
|
+
*
|
|
406
|
+
* 60 seconds was never a deadline for live detection — it is three orders of
|
|
407
|
+
* magnitude past the point where the answer is worth having. Frames arrive at
|
|
408
|
+
* ~13/s; a detection that lands 60s late describes a scene that no longer
|
|
409
|
+
* exists, and the only thing the wait accomplished was holding a queue slot
|
|
410
|
+
* while the backlog grew. Operator directive 2026-07-31: "a few seconds are
|
|
411
|
+
* already too many for this scenario".
|
|
412
|
+
*
|
|
413
|
+
* Three seconds is deliberately still loose — normal inference on this fleet is
|
|
414
|
+
* 35-47ms, so this trips only at a ~70× slowdown — but it bounds the damage at
|
|
415
|
+
* seconds instead of a minute, and combined with {@link POOL_MAX_IN_FLIGHT} it
|
|
416
|
+
* makes the backlog mathematically unable to outrun the deadline: the queue can
|
|
417
|
+
* never hold more than `cap × deadline` of work.
|
|
418
|
+
*
|
|
419
|
+
* Commands and model loads keep the long timeout on purpose: a cold model load
|
|
420
|
+
* legitimately takes tens of seconds, and shedding or timing one out
|
|
421
|
+
* desynchronizes the worker's model slots.
|
|
422
|
+
*/
|
|
423
|
+
var POOL_LIVE_INFER_TIMEOUT_MS = Math.max(250, Number(process.env["CAMSTACK_POOL_LIVE_INFER_TIMEOUT_MS"]) || 3e3);
|
|
424
|
+
/** Opcodes the in-flight cap may shed. Commands and frame-cache writes are
|
|
425
|
+
* NEVER shed: a dropped command silently desynchronizes model state, and a
|
|
426
|
+
* dropped `cacheFrame` strands the `inferCached` that follows it. */
|
|
427
|
+
var SHEDDABLE_MSG_TYPES = new Set([
|
|
428
|
+
MSG_INFER_JPEG,
|
|
429
|
+
MSG_INFER_RAW,
|
|
430
|
+
MSG_INFER_BATCH,
|
|
431
|
+
MSG_INFER_CACHED
|
|
432
|
+
]);
|
|
382
433
|
var PoolHandle = class {
|
|
383
434
|
pool;
|
|
384
435
|
modelIndex;
|
|
@@ -390,10 +441,7 @@ var PoolHandle = class {
|
|
|
390
441
|
}
|
|
391
442
|
async infer(input) {
|
|
392
443
|
const start = performance.now();
|
|
393
|
-
return
|
|
394
|
-
structured: input.kind === "raw" ? await this.pool.inferRaw(this.modelIndex, input.data, input.width, input.height, input.format) : await this.pool.infer(this.modelIndex, input.data),
|
|
395
|
-
inferenceMs: performance.now() - start
|
|
396
|
-
};
|
|
444
|
+
return poolResultToEngineOutput(input.kind === "raw" ? await this.pool.inferRaw(this.modelIndex, input.data, input.width, input.height, input.format) : await this.pool.infer(this.modelIndex, input.data), performance.now() - start);
|
|
397
445
|
}
|
|
398
446
|
/**
|
|
399
447
|
* Inference on a frame previously cached in the Python pool via
|
|
@@ -402,20 +450,50 @@ var PoolHandle = class {
|
|
|
402
450
|
*/
|
|
403
451
|
async inferFromCache(frameId) {
|
|
404
452
|
const start = performance.now();
|
|
405
|
-
return
|
|
406
|
-
structured: await this.pool.inferCached(this.modelIndex, frameId),
|
|
407
|
-
inferenceMs: performance.now() - start
|
|
408
|
-
};
|
|
453
|
+
return poolResultToEngineOutput(await this.pool.inferCached(this.modelIndex, frameId), performance.now() - start);
|
|
409
454
|
}
|
|
410
455
|
async dispose() {}
|
|
411
456
|
};
|
|
412
457
|
/**
|
|
458
|
+
* Map a raw pool response record to an {@link EngineOutput}.
|
|
459
|
+
*
|
|
460
|
+
* An overload shed (`{dropped: true}` — from the Python pool's per-model bound
|
|
461
|
+
* OR the TS worker in-flight cap, which deliberately mirrors it) becomes a
|
|
462
|
+
* first-class `EngineOutput.dropped` with NO `structured` payload. Before this
|
|
463
|
+
* mapping the shed record rode through as `structured`, failed the StepOutput
|
|
464
|
+
* kind guard in the postprocess funnel, and every deliberate shed was logged
|
|
465
|
+
* as `runInference failed … unexpected kind: undefined` at error level on
|
|
466
|
+
* every node (~4/s cluster-wide, 2026-08-01). A shed is flow control, not a
|
|
467
|
+
* fault — it must never reach a postprocessor. Exported for tests.
|
|
468
|
+
*/
|
|
469
|
+
function poolResultToEngineOutput(result, inferenceMs) {
|
|
470
|
+
if (result["dropped"] === true) {
|
|
471
|
+
const shedReason = result["shedReason"];
|
|
472
|
+
return {
|
|
473
|
+
dropped: true,
|
|
474
|
+
...typeof shedReason === "string" ? { shedReason } : {},
|
|
475
|
+
inferenceMs
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
structured: result,
|
|
480
|
+
inferenceMs
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
413
484
|
* How long to wait for a worker to exit on SIGTERM before escalating to
|
|
414
485
|
* SIGKILL. A worker idle between frames exits well under this; only a worker
|
|
415
486
|
* stuck in a native inference call (openvino/onnx C++) needs the escalation.
|
|
416
487
|
*/
|
|
417
488
|
var POOL_WORKER_TERM_GRACE_MS = 2e3;
|
|
418
489
|
/**
|
|
490
|
+
* Sampling stride for the "Python pool shed frame under overload" debug
|
|
491
|
+
* line — see `trackDroppedResponse`. At the observed chronic-overload rate
|
|
492
|
+
* (~13 shed/s, 2026-08-01) a stride of 500 yields roughly one line every
|
|
493
|
+
* 40 s while `droppedTotal` in the line keeps the exact count.
|
|
494
|
+
*/
|
|
495
|
+
var SHED_LOG_SAMPLE_EVERY = 500;
|
|
496
|
+
/**
|
|
419
497
|
* Terminate a spawned child GRACEFULLY THEN FORCEFULLY: end stdin, send
|
|
420
498
|
* SIGTERM, and if the process hasn't exited within `graceMs`, send SIGKILL.
|
|
421
499
|
* Resolves once the process has exited (or was already dead).
|
|
@@ -458,6 +536,13 @@ var PoolWorker = class {
|
|
|
458
536
|
process = null;
|
|
459
537
|
receiveBuffer = Buffer.alloc(0);
|
|
460
538
|
pending = /* @__PURE__ */ new Map();
|
|
539
|
+
/** True while the in-flight cap is shedding — so the warning fires on the
|
|
540
|
+
* EDGE rather than once per dropped frame. */
|
|
541
|
+
shedding = false;
|
|
542
|
+
/** Inference requests shed by the in-flight cap, for this worker's lifetime.
|
|
543
|
+
* Surfaced so "the camera is quiet" and "the camera is saturated" can be
|
|
544
|
+
* told apart without reading the logs. */
|
|
545
|
+
shedCount = 0;
|
|
461
546
|
nextRequestId = 1;
|
|
462
547
|
ready = false;
|
|
463
548
|
log;
|
|
@@ -469,6 +554,15 @@ var PoolWorker = class {
|
|
|
469
554
|
getPid() {
|
|
470
555
|
return this.process?.pid ?? null;
|
|
471
556
|
}
|
|
557
|
+
/** Requests written and not yet answered. This is the BACKLOG — the number
|
|
558
|
+
* nothing measured while a camera sat at zero frames for 52 minutes. */
|
|
559
|
+
getInFlight() {
|
|
560
|
+
return this.pending.size;
|
|
561
|
+
}
|
|
562
|
+
/** Inference shed by the in-flight cap over this worker's lifetime. */
|
|
563
|
+
getShedCount() {
|
|
564
|
+
return this.shedCount;
|
|
565
|
+
}
|
|
472
566
|
isReady() {
|
|
473
567
|
return this.ready;
|
|
474
568
|
}
|
|
@@ -617,10 +711,51 @@ var PoolWorker = class {
|
|
|
617
711
|
this.ready = false;
|
|
618
712
|
await terminateChild(proc, POOL_WORKER_TERM_GRACE_MS);
|
|
619
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* Shed an inference request when the worker already has {@link
|
|
716
|
+
* POOL_MAX_IN_FLIGHT} outstanding — the backlog is what turns a slow worker
|
|
717
|
+
* into a dead camera. Returns the shed response, or `null` to proceed.
|
|
718
|
+
*
|
|
719
|
+
* A shed is LOUD the first time and then rate-limited: at frame rate this
|
|
720
|
+
* fires ~13×/s, so logging every one would bury the signal it is meant to
|
|
721
|
+
* raise — but logging none is how a camera went dark for 52 minutes looking
|
|
722
|
+
* like a quiet afternoon.
|
|
723
|
+
*/
|
|
724
|
+
shedIfSaturated(msgType) {
|
|
725
|
+
if (!SHEDDABLE_MSG_TYPES.has(msgType)) return null;
|
|
726
|
+
if (this.pending.size < POOL_MAX_IN_FLIGHT) {
|
|
727
|
+
this.shedding = false;
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
if (!this.shedding) {
|
|
731
|
+
this.shedding = true;
|
|
732
|
+
this.log.warn("worker saturated — shedding inference until it catches up", { meta: {
|
|
733
|
+
worker: this.opts.workerLabel,
|
|
734
|
+
pid: this.getPid(),
|
|
735
|
+
runtime: this.opts.poolRuntime,
|
|
736
|
+
device: this.opts.device ?? "default",
|
|
737
|
+
inFlight: this.pending.size,
|
|
738
|
+
cap: POOL_MAX_IN_FLIGHT,
|
|
739
|
+
deadlineMs: POOL_LIVE_INFER_TIMEOUT_MS
|
|
740
|
+
} });
|
|
741
|
+
}
|
|
742
|
+
this.shedCount++;
|
|
743
|
+
return {
|
|
744
|
+
dropped: true,
|
|
745
|
+
shedReason: "worker-in-flight-cap"
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/** Live inference gets seconds; commands and model loads keep the long
|
|
749
|
+
* timeout — see {@link POOL_LIVE_INFER_TIMEOUT_MS}. */
|
|
750
|
+
deadlineFor(msgType) {
|
|
751
|
+
return SHEDDABLE_MSG_TYPES.has(msgType) ? POOL_LIVE_INFER_TIMEOUT_MS : POOL_INFER_TIMEOUT_MS;
|
|
752
|
+
}
|
|
620
753
|
dispatch(msgType, payload) {
|
|
754
|
+
const shed = this.shedIfSaturated(msgType);
|
|
755
|
+
if (shed) return Promise.resolve(shed);
|
|
621
756
|
const reqId = this.allocRequestId();
|
|
622
757
|
return new Promise((resolve, reject) => {
|
|
623
|
-
const timer = this.armRequestTimeout(reqId, reject);
|
|
758
|
+
const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
|
|
624
759
|
this.pending.set(reqId, {
|
|
625
760
|
resolve,
|
|
626
761
|
reject,
|
|
@@ -640,24 +775,30 @@ var PoolWorker = class {
|
|
|
640
775
|
* `POOL_INFER_TIMEOUT_MS`. `unref` so it never keeps the event loop alive.
|
|
641
776
|
* The normal response + `rejectAll` paths clear it via `PendingRequest.timer`.
|
|
642
777
|
*/
|
|
643
|
-
armRequestTimeout(reqId, reject) {
|
|
778
|
+
armRequestTimeout(reqId, reject, timeoutMs) {
|
|
644
779
|
const timer = setTimeout(() => {
|
|
645
780
|
if (this.pending.delete(reqId)) {
|
|
646
781
|
this.log.error("inference request timed out", { meta: {
|
|
647
782
|
worker: this.opts.workerLabel,
|
|
783
|
+
pid: this.getPid(),
|
|
784
|
+
runtime: this.opts.poolRuntime,
|
|
785
|
+
device: this.opts.device ?? "default",
|
|
786
|
+
inFlight: this.pending.size,
|
|
648
787
|
reqId,
|
|
649
788
|
timeoutMs: POOL_INFER_TIMEOUT_MS
|
|
650
789
|
} });
|
|
651
|
-
reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${
|
|
790
|
+
reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
|
|
652
791
|
}
|
|
653
|
-
},
|
|
792
|
+
}, timeoutMs);
|
|
654
793
|
timer.unref?.();
|
|
655
794
|
return timer;
|
|
656
795
|
}
|
|
657
796
|
dispatchMulti(msgType, payloadLen, parts) {
|
|
797
|
+
const shed = this.shedIfSaturated(msgType);
|
|
798
|
+
if (shed) return Promise.resolve(shed);
|
|
658
799
|
const reqId = this.allocRequestId();
|
|
659
800
|
return new Promise((resolve, reject) => {
|
|
660
|
-
const timer = this.armRequestTimeout(reqId, reject);
|
|
801
|
+
const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
|
|
661
802
|
this.pending.set(reqId, {
|
|
662
803
|
resolve,
|
|
663
804
|
reject,
|
|
@@ -763,6 +904,22 @@ var SharedInferencePool = class {
|
|
|
763
904
|
}
|
|
764
905
|
device;
|
|
765
906
|
/** Pid of the first worker (for legacy callers). Use `getPids()` for all. */
|
|
907
|
+
/** Summed backlog and shed count across the pool's workers. A rising
|
|
908
|
+
* `inFlight` with a rising `shed` is a worker falling behind; a rising
|
|
909
|
+
* `inFlight` with zero `shed` and no results is the state that used to be
|
|
910
|
+
* invisible. */
|
|
911
|
+
getBacklog() {
|
|
912
|
+
let inFlight = 0;
|
|
913
|
+
let shed = 0;
|
|
914
|
+
for (const w of this.workers) {
|
|
915
|
+
inFlight += w.getInFlight();
|
|
916
|
+
shed += w.getShedCount();
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
inFlight,
|
|
920
|
+
shed
|
|
921
|
+
};
|
|
922
|
+
}
|
|
766
923
|
getPid() {
|
|
767
924
|
return this.workers[0]?.getPid() ?? null;
|
|
768
925
|
}
|
|
@@ -796,6 +953,7 @@ var SharedInferencePool = class {
|
|
|
796
953
|
loadedCount,
|
|
797
954
|
startupMs,
|
|
798
955
|
runtime: this.poolRuntime,
|
|
956
|
+
device: this.device ?? "default",
|
|
799
957
|
workers: this.numWorkers,
|
|
800
958
|
pythonWorkers: innerWorkers,
|
|
801
959
|
pids: this.getPids()
|
|
@@ -887,20 +1045,35 @@ var SharedInferencePool = class {
|
|
|
887
1045
|
async dispose() {
|
|
888
1046
|
await Promise.all(this.workers.map((w) => w.dispose()));
|
|
889
1047
|
this.workers.length = 0;
|
|
890
|
-
this.log.info("Pool process(es) terminated"
|
|
1048
|
+
this.log.info("Pool process(es) terminated", { meta: {
|
|
1049
|
+
runtime: this.poolRuntime,
|
|
1050
|
+
device: this.device ?? "default"
|
|
1051
|
+
} });
|
|
891
1052
|
}
|
|
892
1053
|
/**
|
|
893
1054
|
* Recognise a Python overload-shed response (`"dropped": true`) on the
|
|
894
1055
|
* single-frame inference paths. Previously indistinguishable from a
|
|
895
1056
|
* genuine empty detection result — now counted + debug-logged so
|
|
896
1057
|
* overload is measurable. The response passes through unchanged.
|
|
1058
|
+
*
|
|
1059
|
+
* SAMPLED log: one line per shed frame turned into 45 741 rows/hour on
|
|
1060
|
+
* the hub during the 2026-08-01 chronic overload (~13 shed/s) — 2/3 of
|
|
1061
|
+
* the hub's entire log volume, burying every other signal. The counter
|
|
1062
|
+
* keeps exact totals (`droppedTotal` is authoritative, surfaced in
|
|
1063
|
+
* `getStatus` consumers via `getDroppedResponseCount`); the log line
|
|
1064
|
+
* now fires on the FIRST shed and every `SHED_LOG_SAMPLE_EVERY`th,
|
|
1065
|
+
* carrying the running total so nothing is lost — only repeated.
|
|
897
1066
|
*/
|
|
898
1067
|
trackDroppedResponse(result, modelIndex) {
|
|
899
1068
|
if (result["dropped"] === true) {
|
|
900
1069
|
this.droppedResponseCount++;
|
|
901
|
-
|
|
1070
|
+
const total = this.droppedResponseCount;
|
|
1071
|
+
if (total === 1 || total % SHED_LOG_SAMPLE_EVERY === 0) this.log.debug("Python pool shed frame under overload", { meta: {
|
|
902
1072
|
modelIndex,
|
|
903
|
-
droppedTotal:
|
|
1073
|
+
droppedTotal: total,
|
|
1074
|
+
sampledEvery: SHED_LOG_SAMPLE_EVERY,
|
|
1075
|
+
runtime: this.poolRuntime,
|
|
1076
|
+
device: this.device ?? "default"
|
|
904
1077
|
} });
|
|
905
1078
|
}
|
|
906
1079
|
return result;
|
|
@@ -975,6 +1148,66 @@ function flattenEnabledVideoSteps(steps) {
|
|
|
975
1148
|
walk(steps);
|
|
976
1149
|
return result;
|
|
977
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* The steps that EXECUTE in a `plane: 'frame'` dispatch (two-plane design),
|
|
1153
|
+
* mirroring the executor's frame-plane skip in `executeChildren`: every
|
|
1154
|
+
* enabled top-level step runs at the root; a CHILD runs only when its catalog
|
|
1155
|
+
* definition is itself root-plane (`isDetailStep(addonId)` false). The walk
|
|
1156
|
+
* continues only through steps that execute — a skipped detail child's
|
|
1157
|
+
* descendants ride ITS per-track detail-subtree call, never this one.
|
|
1158
|
+
* Disabled and audio-classifier steps execute on neither plane.
|
|
1159
|
+
*/
|
|
1160
|
+
function collectFramePlaneSteps(steps, isDetailStep) {
|
|
1161
|
+
const result = [];
|
|
1162
|
+
const walk = (nodes, topLevel) => {
|
|
1163
|
+
for (const step of nodes) {
|
|
1164
|
+
if (!step.enabled) continue;
|
|
1165
|
+
if (step.slot === "audio-classifier") continue;
|
|
1166
|
+
if (!topLevel && isDetailStep(step.addonId)) continue;
|
|
1167
|
+
result.push(step);
|
|
1168
|
+
if (step.children?.length) walk(step.children, false);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
walk(steps, true);
|
|
1172
|
+
return result;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Immutably remove from a frame-plane dispatch tree every enabled DETAIL
|
|
1176
|
+
* subtree (at any depth below the top level) whose top step `isUnrunnable`.
|
|
1177
|
+
* Those steps can never load into the dispatch device's pool — leaving them
|
|
1178
|
+
* in the tree makes `ensureModelsForSteps` fail the whole call on a format
|
|
1179
|
+
* build that does not exist. They are NOT dropped work: each runs later as
|
|
1180
|
+
* its own detail-subtree dispatch, where the device-jump resolver
|
|
1181
|
+
* (`resolveStepDevice`) places it on a same-node device that can run it, or
|
|
1182
|
+
* warns no-candidate loudly. Runnable detail children are kept so their
|
|
1183
|
+
* models pre-warm the device pool as before. Disabled subtrees are kept
|
|
1184
|
+
* verbatim (never walked — `ensureModelsForSteps` skips them anyway).
|
|
1185
|
+
*/
|
|
1186
|
+
function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
|
|
1187
|
+
const prunedAddonIds = [];
|
|
1188
|
+
const walk = (nodes, topLevel) => {
|
|
1189
|
+
const kept = [];
|
|
1190
|
+
for (const step of nodes) {
|
|
1191
|
+
if (!step.enabled) {
|
|
1192
|
+
kept.push(step);
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step.addonId)) {
|
|
1196
|
+
prunedAddonIds.push(step.addonId);
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
kept.push(step.children?.length ? {
|
|
1200
|
+
...step,
|
|
1201
|
+
children: walk(step.children, false)
|
|
1202
|
+
} : step);
|
|
1203
|
+
}
|
|
1204
|
+
return kept;
|
|
1205
|
+
};
|
|
1206
|
+
return {
|
|
1207
|
+
steps: walk(steps, true),
|
|
1208
|
+
prunedAddonIds
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
978
1211
|
//#endregion
|
|
979
1212
|
//#region src/detection-pipeline/engine/pipeline-model-manager.ts
|
|
980
1213
|
var PipelineModelManager = class {
|
|
@@ -1236,6 +1469,126 @@ var PipelineModelManager = class {
|
|
|
1236
1469
|
}
|
|
1237
1470
|
};
|
|
1238
1471
|
//#endregion
|
|
1472
|
+
//#region src/detection-pipeline/engine/resolve-device-engine.ts
|
|
1473
|
+
/**
|
|
1474
|
+
* Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
|
|
1475
|
+
* {@link PipelineEngineChoice} used to build that device's inference pool.
|
|
1476
|
+
* Throws on an unknown backend token so a bad `deviceKey` fails loud rather
|
|
1477
|
+
* than silently building a wrong pool. Format comes from the shared
|
|
1478
|
+
* {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
|
|
1479
|
+
*/
|
|
1480
|
+
function resolveDeviceEngine(deviceKey) {
|
|
1481
|
+
const colon = deviceKey.indexOf(":");
|
|
1482
|
+
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
1483
|
+
const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
|
|
1484
|
+
const format = require_dist.DEVICE_BACKEND_TO_FORMAT[backend];
|
|
1485
|
+
if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
|
|
1486
|
+
return {
|
|
1487
|
+
runtime: "python",
|
|
1488
|
+
backend,
|
|
1489
|
+
format,
|
|
1490
|
+
device
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* The inverse of {@link resolveDeviceEngine}: the `<backend>:<device>` key that
|
|
1495
|
+
* names the pool an engine belongs to. Every pool-level log line carries this —
|
|
1496
|
+
* without it a default pool logs anonymously and six hours of logs cannot tell
|
|
1497
|
+
* "the NPU is unused" from "the NPU IS the anonymous default pool".
|
|
1498
|
+
* Falls back to the bare backend when the engine carries no device.
|
|
1499
|
+
*/
|
|
1500
|
+
function deviceKeyOf(engine) {
|
|
1501
|
+
return engine.device ? `${engine.backend}:${engine.device}` : engine.backend;
|
|
1502
|
+
}
|
|
1503
|
+
//#endregion
|
|
1504
|
+
//#region src/detection-pipeline/engine/pool-provisioning.ts
|
|
1505
|
+
/** The fields a {@link PoolProvisioningOverride} may carry, in table order. */
|
|
1506
|
+
var OVERRIDABLE_KEYS = [
|
|
1507
|
+
"concurrency",
|
|
1508
|
+
"numWorkers",
|
|
1509
|
+
"batchMode",
|
|
1510
|
+
"windowMs",
|
|
1511
|
+
"maxBatchSize",
|
|
1512
|
+
"numStreams",
|
|
1513
|
+
"intraOpThreads"
|
|
1514
|
+
];
|
|
1515
|
+
/** Backends that are not a runtime of their own and run on onnxruntime. */
|
|
1516
|
+
var BACKEND_TO_RUNTIME = {
|
|
1517
|
+
onnx: "onnx",
|
|
1518
|
+
cpu: "onnx",
|
|
1519
|
+
cuda: "onnx",
|
|
1520
|
+
openvino: "openvino",
|
|
1521
|
+
coreml: "coreml",
|
|
1522
|
+
edgetpu: "edgetpu"
|
|
1523
|
+
};
|
|
1524
|
+
/**
|
|
1525
|
+
* The registry runtime a device-pool backend provisions from. Unknown backends
|
|
1526
|
+
* floor to `onnx` — the same floor `resolveDeviceEngine` applies to the model
|
|
1527
|
+
* format, so a pool never comes up unprovisioned.
|
|
1528
|
+
*/
|
|
1529
|
+
function runtimeIdForBackend(backend) {
|
|
1530
|
+
return BACKEND_TO_RUNTIME[backend] ?? "onnx";
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Resolve the provisioning for a pool running `engine`, applying the precedence
|
|
1534
|
+
* documented at the top of this module. `engine.device` is not consulted today
|
|
1535
|
+
* (see the module doc) but is part of the signature so a bench-backed
|
|
1536
|
+
* per-device row lands here and nowhere else.
|
|
1537
|
+
*/
|
|
1538
|
+
function resolvePoolProvisioning(engine, override = {}) {
|
|
1539
|
+
const runtime = runtimeIdForBackend(engine.backend);
|
|
1540
|
+
const profile = tuningFor(runtime);
|
|
1541
|
+
return {
|
|
1542
|
+
runtime,
|
|
1543
|
+
overriddenFields: OVERRIDABLE_KEYS.filter((k) => override[k] !== void 0),
|
|
1544
|
+
concurrency: Math.max(1, override.concurrency ?? profile.concurrency),
|
|
1545
|
+
numWorkers: Math.max(1, override.numWorkers ?? profile.numWorkers),
|
|
1546
|
+
batchMode: override.batchMode ?? profile.batchMode,
|
|
1547
|
+
windowMs: override.windowMs ?? profile.windowMs,
|
|
1548
|
+
maxBatchSize: override.maxBatchSize ?? profile.maxBatchSize,
|
|
1549
|
+
numStreams: override.numStreams ?? profile.numStreams,
|
|
1550
|
+
intraOpThreads: override.intraOpThreads ?? profile.intraOpThreads
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
function positiveNumber(value) {
|
|
1554
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
|
|
1555
|
+
}
|
|
1556
|
+
function batchMode(value) {
|
|
1557
|
+
return value === "none" || value === "list" || value === "window" ? value : void 0;
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Read the operator override out of the detection addon's settings. Only a
|
|
1561
|
+
* NON-AUTO value counts: the sliders persist `0` and the select persists `''`
|
|
1562
|
+
* for "Auto (backend default)", and those must resolve to the runtime's
|
|
1563
|
+
* registry value, not to a literal 0 worker threads.
|
|
1564
|
+
*
|
|
1565
|
+
* The result is deliberately sparse — an empty object means "nothing
|
|
1566
|
+
* overridden", which is what lets a per-device pool provision from its own
|
|
1567
|
+
* runtime instead of inheriting the node's.
|
|
1568
|
+
*/
|
|
1569
|
+
function readOperatorOverride(settings) {
|
|
1570
|
+
const override = {};
|
|
1571
|
+
const concurrency = positiveNumber(settings.concurrency);
|
|
1572
|
+
if (concurrency !== void 0) override.concurrency = concurrency;
|
|
1573
|
+
const numWorkers = positiveNumber(settings.numWorkers);
|
|
1574
|
+
if (numWorkers !== void 0) override.numWorkers = numWorkers;
|
|
1575
|
+
const mode = batchMode(settings.batchMode);
|
|
1576
|
+
if (mode !== void 0) override.batchMode = mode;
|
|
1577
|
+
const windowMs = positiveNumber(settings.windowMs);
|
|
1578
|
+
if (windowMs !== void 0) override.windowMs = windowMs;
|
|
1579
|
+
const maxBatchSize = positiveNumber(settings.maxBatchSize);
|
|
1580
|
+
if (maxBatchSize !== void 0) override.maxBatchSize = maxBatchSize;
|
|
1581
|
+
const numStreams = positiveNumber(settings.numStreams);
|
|
1582
|
+
if (numStreams !== void 0) override.numStreams = numStreams;
|
|
1583
|
+
const intraOpThreads = positiveNumber(settings.intraOpThreads);
|
|
1584
|
+
if (intraOpThreads !== void 0) override.intraOpThreads = intraOpThreads;
|
|
1585
|
+
return override;
|
|
1586
|
+
}
|
|
1587
|
+
/** Two overrides carry the same fields with the same values. */
|
|
1588
|
+
function overridesEqual(a, b) {
|
|
1589
|
+
return OVERRIDABLE_KEYS.every((k) => a[k] === b[k]);
|
|
1590
|
+
}
|
|
1591
|
+
//#endregion
|
|
1239
1592
|
//#region src/detection-pipeline/engine/engine-factory.ts
|
|
1240
1593
|
var BACKEND_TO_POOL_RUNTIME = {
|
|
1241
1594
|
coreml: "coreml",
|
|
@@ -1252,7 +1605,7 @@ var RUNTIME_TO_FORMAT = {
|
|
|
1252
1605
|
edgetpu: "tflite"
|
|
1253
1606
|
};
|
|
1254
1607
|
/** The step whose detections are re-filtered downstream by the executor's
|
|
1255
|
-
* per-macro `minConfidence*` sliders (`
|
|
1608
|
+
* per-macro `minConfidence*` sliders (`macroFilterVerdict`). Its pool-side
|
|
1256
1609
|
* floor must stay BELOW those sliders or it silently pre-empts them. */
|
|
1257
1610
|
var OBJECT_DETECTION_STEP_ID = "object-detection";
|
|
1258
1611
|
/**
|
|
@@ -1305,9 +1658,31 @@ var EngineFactory = class {
|
|
|
1305
1658
|
poolManager = null;
|
|
1306
1659
|
log;
|
|
1307
1660
|
opts;
|
|
1661
|
+
/**
|
|
1662
|
+
* `<backend>:<device>` — on EVERY log line this factory writes. A pool that
|
|
1663
|
+
* logs anonymously cannot be told apart from another pool of the same runtime
|
|
1664
|
+
* on a different accelerator.
|
|
1665
|
+
*/
|
|
1666
|
+
deviceKey;
|
|
1667
|
+
/**
|
|
1668
|
+
* Resolved at CONSTRUCTION, not at pool spawn, so it is inspectable without
|
|
1669
|
+
* starting Python and so a pool that fails to spawn still logs what it was
|
|
1670
|
+
* trying to be.
|
|
1671
|
+
*/
|
|
1672
|
+
resolvedProvisioning;
|
|
1308
1673
|
constructor(opts) {
|
|
1309
1674
|
this.opts = opts;
|
|
1310
1675
|
this.log = opts.logger;
|
|
1676
|
+
this.deviceKey = deviceKeyOf(opts.engine);
|
|
1677
|
+
this.resolvedProvisioning = resolvePoolProvisioning(opts.engine, opts.provisioning);
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* The provisioning this factory's pool runs with — resolved from the pool's
|
|
1681
|
+
* OWN runtime plus any operator override. Read by the effective-tuning
|
|
1682
|
+
* readout and by the tests that pin the per-runtime behaviour.
|
|
1683
|
+
*/
|
|
1684
|
+
get provisioning() {
|
|
1685
|
+
return this.resolvedProvisioning;
|
|
1311
1686
|
}
|
|
1312
1687
|
/** Detection always uses the Python pool. */
|
|
1313
1688
|
get usesPythonPool() {
|
|
@@ -1409,40 +1784,51 @@ var EngineFactory = class {
|
|
|
1409
1784
|
this.poolManager = null;
|
|
1410
1785
|
}
|
|
1411
1786
|
}
|
|
1787
|
+
/**
|
|
1788
|
+
* The one pool-init log line. `deviceKey` / `device` are the reason it
|
|
1789
|
+
* exists: the default pool used to log neither, so an operator reading a
|
|
1790
|
+
* day of logs could not tell whether the NPU was idle or was simply the
|
|
1791
|
+
* anonymous default pool.
|
|
1792
|
+
*
|
|
1793
|
+
* `overriddenFields` replaces the old `source` field. `source` claimed
|
|
1794
|
+
* `'auto-default' | 'user-override'` about the CONCURRENCY TUNING but read as
|
|
1795
|
+
* the provenance of the device/engine choice, and was `'user-override'` on
|
|
1796
|
+
* every pool because the addon always passed explicit tuning. This lists the
|
|
1797
|
+
* fields the operator actually overrode — empty means pure registry.
|
|
1798
|
+
*/
|
|
1799
|
+
logProvisioning() {
|
|
1800
|
+
const p = this.resolvedProvisioning;
|
|
1801
|
+
this.log.info("Python inference pool", { meta: {
|
|
1802
|
+
deviceKey: this.deviceKey,
|
|
1803
|
+
backend: this.opts.engine.backend,
|
|
1804
|
+
device: this.opts.engine.device ?? null,
|
|
1805
|
+
runtime: p.runtime,
|
|
1806
|
+
concurrency: p.concurrency,
|
|
1807
|
+
numWorkers: p.numWorkers,
|
|
1808
|
+
overriddenFields: p.overriddenFields,
|
|
1809
|
+
tuning: {
|
|
1810
|
+
batchMode: p.batchMode,
|
|
1811
|
+
windowMs: p.windowMs,
|
|
1812
|
+
maxBatchSize: p.maxBatchSize,
|
|
1813
|
+
numStreams: p.numStreams,
|
|
1814
|
+
intraOpThreads: p.intraOpThreads
|
|
1815
|
+
}
|
|
1816
|
+
} });
|
|
1817
|
+
}
|
|
1412
1818
|
async initPythonPool(steps) {
|
|
1819
|
+
this.logProvisioning();
|
|
1413
1820
|
const pythonPath = this.opts.pythonPath;
|
|
1414
1821
|
if (!pythonPath) throw new Error("EngineFactory: pythonPath is required — the addon must call ctx.deps.ensurePython() and pass the result. The embedded portable Python download likely failed; check the addon logs for the download error.");
|
|
1415
1822
|
const poolRuntime = BACKEND_TO_POOL_RUNTIME[this.opts.engine.backend];
|
|
1416
1823
|
if (!poolRuntime) throw new Error(`No pool runtime mapping for backend "${this.opts.engine.backend}"`);
|
|
1417
|
-
const concurrency = this.
|
|
1418
|
-
coreml: 1,
|
|
1419
|
-
openvino: 1,
|
|
1420
|
-
onnxruntime: 4,
|
|
1421
|
-
edgetpu: 1
|
|
1422
|
-
}[poolRuntime];
|
|
1423
|
-
const isAutoDefault = this.opts.concurrency === void 0;
|
|
1424
|
-
const defaultBatchMode = {
|
|
1425
|
-
coreml: "window",
|
|
1426
|
-
openvino: "none",
|
|
1427
|
-
onnxruntime: "list",
|
|
1428
|
-
edgetpu: "none"
|
|
1429
|
-
};
|
|
1430
|
-
const tuning = this.opts.tuning ?? {};
|
|
1824
|
+
const { concurrency, numWorkers } = this.resolvedProvisioning;
|
|
1431
1825
|
const resolvedTuning = {
|
|
1432
|
-
batchMode:
|
|
1433
|
-
windowMs:
|
|
1434
|
-
maxBatchSize:
|
|
1435
|
-
numStreams:
|
|
1436
|
-
intraOpThreads:
|
|
1826
|
+
batchMode: this.resolvedProvisioning.batchMode,
|
|
1827
|
+
windowMs: this.resolvedProvisioning.windowMs,
|
|
1828
|
+
maxBatchSize: this.resolvedProvisioning.maxBatchSize,
|
|
1829
|
+
numStreams: this.resolvedProvisioning.numStreams,
|
|
1830
|
+
intraOpThreads: this.resolvedProvisioning.intraOpThreads
|
|
1437
1831
|
};
|
|
1438
|
-
const numWorkers = Math.max(1, this.opts.numWorkers ?? 1);
|
|
1439
|
-
this.log.info("Python inference pool", { meta: {
|
|
1440
|
-
runtime: poolRuntime,
|
|
1441
|
-
concurrency,
|
|
1442
|
-
numWorkers,
|
|
1443
|
-
source: isAutoDefault ? "auto-default" : "user-override",
|
|
1444
|
-
tuning: resolvedTuning
|
|
1445
|
-
} });
|
|
1446
1832
|
this.pool = new SharedInferencePool(pythonPath, poolRuntime, this.log.child("pool"), {
|
|
1447
1833
|
concurrency,
|
|
1448
1834
|
tuning: resolvedTuning,
|
|
@@ -1498,28 +1884,6 @@ var EngineFactory = class {
|
|
|
1498
1884
|
}
|
|
1499
1885
|
};
|
|
1500
1886
|
//#endregion
|
|
1501
|
-
//#region src/detection-pipeline/engine/resolve-device-engine.ts
|
|
1502
|
-
/**
|
|
1503
|
-
* Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
|
|
1504
|
-
* {@link PipelineEngineChoice} used to build that device's inference pool.
|
|
1505
|
-
* Throws on an unknown backend token so a bad `deviceKey` fails loud rather
|
|
1506
|
-
* than silently building a wrong pool. Format comes from the shared
|
|
1507
|
-
* {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
|
|
1508
|
-
*/
|
|
1509
|
-
function resolveDeviceEngine(deviceKey) {
|
|
1510
|
-
const colon = deviceKey.indexOf(":");
|
|
1511
|
-
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
1512
|
-
const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
|
|
1513
|
-
const format = require_dist.DEVICE_BACKEND_TO_FORMAT[backend];
|
|
1514
|
-
if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
|
|
1515
|
-
return {
|
|
1516
|
-
runtime: "python",
|
|
1517
|
-
backend,
|
|
1518
|
-
format,
|
|
1519
|
-
device
|
|
1520
|
-
};
|
|
1521
|
-
}
|
|
1522
|
-
//#endregion
|
|
1523
1887
|
//#region src/detection-pipeline/engine/idle-pool-reaper.ts
|
|
1524
1888
|
var IdlePoolReaper = class {
|
|
1525
1889
|
lastUsed = /* @__PURE__ */ new Map();
|
|
@@ -1686,563 +2050,153 @@ var EngineProvisioner = class {
|
|
|
1686
2050
|
}
|
|
1687
2051
|
};
|
|
1688
2052
|
//#endregion
|
|
1689
|
-
//#region src/detection-pipeline/
|
|
1690
|
-
/** Detector NMS IoU fallback — the historical hardcoded literal, used when the
|
|
1691
|
-
* step carries no `nmsIouThreshold` setting (unset == today). */
|
|
1692
|
-
var DEFAULT_NMS_IOU = .45;
|
|
2053
|
+
//#region src/detection-pipeline/pipeline/crop-utils.ts
|
|
1693
2054
|
/**
|
|
1694
|
-
*
|
|
1695
|
-
* VALIDATION-ONLY: this mirror annotates `formatValid` but does NOT rescore
|
|
1696
|
-
* confusables — the confusable repair needs the aligned per-timestep softmax
|
|
1697
|
-
* that only the Python path retains (see `python/postprocessors/ctc.py`). Keep
|
|
1698
|
-
* the pattern set in sync with `PLATE_GRAMMARS` there. `'off'`/unset disables it.
|
|
2055
|
+
* Crop utilities — ROI extraction and bbox coordinate transforms.
|
|
1699
2056
|
*/
|
|
1700
|
-
var TS_PLATE_GRAMMARS = { DE: /^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$/ };
|
|
1701
|
-
var VALID_KINDS = new Set([
|
|
1702
|
-
"detections",
|
|
1703
|
-
"classifications",
|
|
1704
|
-
"embedding",
|
|
1705
|
-
"text",
|
|
1706
|
-
"mask"
|
|
1707
|
-
]);
|
|
1708
2057
|
/**
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
* The Python inference_pool.py always sets `output.structured` with a `kind`
|
|
1713
|
-
* field. This guard narrows `Record<string, unknown>` to `StepOutput` without
|
|
1714
|
-
* resorting to double-cast.
|
|
2058
|
+
* Extract a JPEG crop from a full-frame JPEG buffer.
|
|
2059
|
+
* Clamps to image boundaries and returns the cropped JPEG + dimensions.
|
|
1715
2060
|
*/
|
|
1716
|
-
function
|
|
1717
|
-
|
|
2061
|
+
async function cropJpeg(jpeg, bbox, imageWidth, imageHeight) {
|
|
2062
|
+
const x1 = Math.max(0, Math.round(bbox[0]));
|
|
2063
|
+
const y1 = Math.max(0, Math.round(bbox[1]));
|
|
2064
|
+
const x2 = Math.min(imageWidth, Math.round(bbox[2]));
|
|
2065
|
+
const y2 = Math.min(imageHeight, Math.round(bbox[3]));
|
|
2066
|
+
const w = x2 - x1;
|
|
2067
|
+
const h = y2 - y1;
|
|
2068
|
+
if (w < 1 || h < 1) throw new Error(`Degenerate crop: [${x1},${y1},${x2},${y2}] on ${imageWidth}x${imageHeight}`);
|
|
2069
|
+
return {
|
|
2070
|
+
jpeg: await (0, sharp.default)(jpeg).extract({
|
|
2071
|
+
left: x1,
|
|
2072
|
+
top: y1,
|
|
2073
|
+
width: w,
|
|
2074
|
+
height: h
|
|
2075
|
+
}).jpeg({ quality: 90 }).toBuffer(),
|
|
2076
|
+
width: w,
|
|
2077
|
+
height: h
|
|
2078
|
+
};
|
|
1718
2079
|
}
|
|
1719
2080
|
/**
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
2081
|
+
* Transform a child detection's bbox from ROI-relative to image-absolute coordinates.
|
|
2082
|
+
*
|
|
2083
|
+
* When a child step (e.g., face-detection) runs on a crop of the parent (e.g., person),
|
|
2084
|
+
* its bbox is relative to the crop. This function maps it back to the full image space.
|
|
1722
2085
|
*/
|
|
1723
|
-
function
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
if (structured) return structured;
|
|
1731
|
-
const tensor = output.tensor;
|
|
1732
|
-
if (!tensor) throw new Error("YOLO postprocessor: no tensor in engine output");
|
|
1733
|
-
const labels = stepDef.labels ?? [];
|
|
1734
|
-
const numClasses = labels.length || 80;
|
|
1735
|
-
const numBoxes = tensor.length / (4 + numClasses);
|
|
1736
|
-
const letterbox = output.letterbox;
|
|
1737
|
-
const dets = [];
|
|
1738
|
-
for (let i = 0; i < numBoxes; i++) {
|
|
1739
|
-
const cx = tensor[i];
|
|
1740
|
-
const cy = tensor[1 * numBoxes + i];
|
|
1741
|
-
const w = tensor[2 * numBoxes + i];
|
|
1742
|
-
const h = tensor[3 * numBoxes + i];
|
|
1743
|
-
let bestScore = -Infinity;
|
|
1744
|
-
let bestClass = 0;
|
|
1745
|
-
for (let j = 0; j < numClasses; j++) {
|
|
1746
|
-
const score = tensor[(4 + j) * numBoxes + i];
|
|
1747
|
-
if (score > bestScore) {
|
|
1748
|
-
bestScore = score;
|
|
1749
|
-
bestClass = j;
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
if (bestScore <= 0) continue;
|
|
1753
|
-
let x1 = cx - w / 2;
|
|
1754
|
-
let y1 = cy - h / 2;
|
|
1755
|
-
let x2 = cx + w / 2;
|
|
1756
|
-
let y2 = cy + h / 2;
|
|
1757
|
-
if (letterbox) {
|
|
1758
|
-
x1 = (x1 - letterbox.padX) / letterbox.scale;
|
|
1759
|
-
y1 = (y1 - letterbox.padY) / letterbox.scale;
|
|
1760
|
-
x2 = (x2 - letterbox.padX) / letterbox.scale;
|
|
1761
|
-
y2 = (y2 - letterbox.padY) / letterbox.scale;
|
|
1762
|
-
}
|
|
1763
|
-
const label = labels[bestClass] ?? String(bestClass);
|
|
1764
|
-
dets.push({
|
|
1765
|
-
class: label,
|
|
1766
|
-
score: bestScore,
|
|
1767
|
-
bbox: [
|
|
1768
|
-
x1,
|
|
1769
|
-
y1,
|
|
1770
|
-
x2,
|
|
1771
|
-
y2
|
|
1772
|
-
]
|
|
1773
|
-
});
|
|
1774
|
-
}
|
|
1775
|
-
return {
|
|
1776
|
-
kind: "detections",
|
|
1777
|
-
detections: simpleNms(dets, nmsIouThreshold)
|
|
1778
|
-
};
|
|
1779
|
-
}
|
|
1780
|
-
function postprocessSsd(output, _stepDef) {
|
|
1781
|
-
const structured = tryStructured(output);
|
|
1782
|
-
if (structured) return structured;
|
|
1783
|
-
throw new Error("SSD postprocessing runs in the Python inference pool (ssd.py); no TypeScript raw-tensor path.");
|
|
1784
|
-
}
|
|
1785
|
-
function postprocessScrfd(output, stepDef, nmsIouThreshold) {
|
|
1786
|
-
const structured = tryStructured(output);
|
|
1787
|
-
if (structured) return structured;
|
|
1788
|
-
if (!output.tensors) throw new Error("SCRFD postprocessor: no tensors in engine output");
|
|
1789
|
-
const strides = [
|
|
1790
|
-
8,
|
|
1791
|
-
16,
|
|
1792
|
-
32
|
|
2086
|
+
function transformBboxToImageSpace(childBbox, parentBbox) {
|
|
2087
|
+
const [px1, py1] = parentBbox;
|
|
2088
|
+
return [
|
|
2089
|
+
px1 + childBbox[0],
|
|
2090
|
+
py1 + childBbox[1],
|
|
2091
|
+
px1 + childBbox[2],
|
|
2092
|
+
py1 + childBbox[3]
|
|
1793
2093
|
];
|
|
1794
|
-
const anchorsPerStride = 2;
|
|
1795
|
-
const inputSize = Math.max(stepDef.models[0]?.inputSize.width ?? 640, stepDef.models[0]?.inputSize.height ?? 640);
|
|
1796
|
-
const strideData = matchTensorsToStrides(output.tensors, strides, inputSize, anchorsPerStride);
|
|
1797
|
-
const letterbox = output.letterbox;
|
|
1798
|
-
const scale = letterbox?.scale ?? 1;
|
|
1799
|
-
const padX = letterbox?.padX ?? 0;
|
|
1800
|
-
const padY = letterbox?.padY ?? 0;
|
|
1801
|
-
const origW = letterbox?.originalWidth ?? inputSize;
|
|
1802
|
-
const origH = letterbox?.originalHeight ?? inputSize;
|
|
1803
|
-
const candidates = [];
|
|
1804
|
-
for (const stride of strides) {
|
|
1805
|
-
const data = strideData.get(stride);
|
|
1806
|
-
if (!data) continue;
|
|
1807
|
-
const { scores, bboxes, landmarks } = data;
|
|
1808
|
-
const featSize = Math.ceil(inputSize / stride);
|
|
1809
|
-
const numAnchors = featSize * featSize * anchorsPerStride;
|
|
1810
|
-
for (let i = 0; i < numAnchors; i++) {
|
|
1811
|
-
if (i >= scores.length) break;
|
|
1812
|
-
const score = scores[i];
|
|
1813
|
-
if (score <= 0) continue;
|
|
1814
|
-
const gridIdx = Math.floor(i / anchorsPerStride);
|
|
1815
|
-
const cx = (gridIdx % featSize + .5) * stride;
|
|
1816
|
-
const cy = (Math.floor(gridIdx / featSize) + .5) * stride;
|
|
1817
|
-
const x1 = cx - bboxes[i * 4] * stride;
|
|
1818
|
-
const y1 = cy - bboxes[i * 4 + 1] * stride;
|
|
1819
|
-
const x2 = cx + bboxes[i * 4 + 2] * stride;
|
|
1820
|
-
const y2 = cy + bboxes[i * 4 + 3] * stride;
|
|
1821
|
-
const ox1 = Math.max(0, Math.min(origW, (x1 - padX) / scale));
|
|
1822
|
-
const oy1 = Math.max(0, Math.min(origH, (y1 - padY) / scale));
|
|
1823
|
-
const ox2 = Math.max(0, Math.min(origW, (x2 - padX) / scale));
|
|
1824
|
-
const oy2 = Math.max(0, Math.min(origH, (y2 - padY) / scale));
|
|
1825
|
-
if (ox2 - ox1 < 1 || oy2 - oy1 < 1) continue;
|
|
1826
|
-
const det = {
|
|
1827
|
-
class: "face",
|
|
1828
|
-
score: Math.round(score * 1e4) / 1e4,
|
|
1829
|
-
bbox: [
|
|
1830
|
-
Math.round(ox1 * 10) / 10,
|
|
1831
|
-
Math.round(oy1 * 10) / 10,
|
|
1832
|
-
Math.round(ox2 * 10) / 10,
|
|
1833
|
-
Math.round(oy2 * 10) / 10
|
|
1834
|
-
],
|
|
1835
|
-
_xyxy: [
|
|
1836
|
-
ox1,
|
|
1837
|
-
oy1,
|
|
1838
|
-
ox2,
|
|
1839
|
-
oy2
|
|
1840
|
-
]
|
|
1841
|
-
};
|
|
1842
|
-
if (landmarks && i * 10 + 9 < landmarks.length) {
|
|
1843
|
-
det.landmarks = [];
|
|
1844
|
-
for (let p = 0; p < 5; p++) {
|
|
1845
|
-
const lx = (cx + landmarks[i * 10 + p * 2] * stride - padX) / scale;
|
|
1846
|
-
const ly = (cy + landmarks[i * 10 + p * 2 + 1] * stride - padY) / scale;
|
|
1847
|
-
det.landmarks.push({
|
|
1848
|
-
x: Math.round(lx * 10) / 10,
|
|
1849
|
-
y: Math.round(ly * 10) / 10
|
|
1850
|
-
});
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
candidates.push(det);
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
return {
|
|
1857
|
-
kind: "detections",
|
|
1858
|
-
detections: simpleNms(candidates.map((c) => ({
|
|
1859
|
-
class: c.class,
|
|
1860
|
-
score: c.score,
|
|
1861
|
-
bbox: c.bbox,
|
|
1862
|
-
...c.landmarks ? { landmarks: c.landmarks } : {}
|
|
1863
|
-
})), nmsIouThreshold)
|
|
1864
|
-
};
|
|
1865
|
-
}
|
|
1866
|
-
/** Match ONNX output tensors to SCRFD strides by expected anchor count. */
|
|
1867
|
-
function matchTensorsToStrides(tensors, strides, inputSize, anchorsPerStride) {
|
|
1868
|
-
const scoresByN = /* @__PURE__ */ new Map();
|
|
1869
|
-
const bboxesByN = /* @__PURE__ */ new Map();
|
|
1870
|
-
const landmarksByN = /* @__PURE__ */ new Map();
|
|
1871
|
-
for (const [, tensor] of Object.entries(tensors)) for (const stride of strides) {
|
|
1872
|
-
const featSize = Math.ceil(inputSize / stride);
|
|
1873
|
-
const expectedN = featSize * featSize * anchorsPerStride;
|
|
1874
|
-
if (tensor.length === expectedN) scoresByN.set(expectedN, tensor);
|
|
1875
|
-
else if (tensor.length === expectedN * 4) bboxesByN.set(expectedN, tensor);
|
|
1876
|
-
else if (tensor.length === expectedN * 10) landmarksByN.set(expectedN, tensor);
|
|
1877
|
-
}
|
|
1878
|
-
const result = /* @__PURE__ */ new Map();
|
|
1879
|
-
for (const stride of strides) {
|
|
1880
|
-
const featSize = Math.ceil(inputSize / stride);
|
|
1881
|
-
const n = featSize * featSize * anchorsPerStride;
|
|
1882
|
-
const scores = scoresByN.get(n);
|
|
1883
|
-
const bboxes = bboxesByN.get(n);
|
|
1884
|
-
if (scores && bboxes) result.set(stride, {
|
|
1885
|
-
scores,
|
|
1886
|
-
bboxes,
|
|
1887
|
-
landmarks: landmarksByN.get(n)
|
|
1888
|
-
});
|
|
1889
|
-
}
|
|
1890
|
-
return result;
|
|
1891
|
-
}
|
|
1892
|
-
function postprocessArcface(output, _stepDef) {
|
|
1893
|
-
const structured = tryStructured(output);
|
|
1894
|
-
if (structured) return structured;
|
|
1895
|
-
const tensor = output.tensor;
|
|
1896
|
-
if (!tensor) throw new Error("ArcFace postprocessor: no tensor in engine output");
|
|
1897
|
-
let sumSq = 0;
|
|
1898
|
-
for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
|
|
1899
|
-
const norm = Math.sqrt(sumSq);
|
|
1900
|
-
const normalized = Array.from({ length: tensor.length });
|
|
1901
|
-
for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
|
|
1902
|
-
return {
|
|
1903
|
-
kind: "embedding",
|
|
1904
|
-
embedding: Array.from(tensor),
|
|
1905
|
-
embeddingNorm: normalized
|
|
1906
|
-
};
|
|
1907
|
-
}
|
|
1908
|
-
function postprocessClip(output, _stepDef) {
|
|
1909
|
-
const structured = tryStructured(output);
|
|
1910
|
-
if (structured) return structured;
|
|
1911
|
-
const tensor = output.tensor;
|
|
1912
|
-
if (!tensor) throw new Error("CLIP postprocessor: no tensor in engine output");
|
|
1913
|
-
let sumSq = 0;
|
|
1914
|
-
for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
|
|
1915
|
-
const norm = Math.sqrt(sumSq);
|
|
1916
|
-
const normalized = Array.from({ length: tensor.length });
|
|
1917
|
-
for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
|
|
1918
|
-
return {
|
|
1919
|
-
kind: "embedding",
|
|
1920
|
-
embedding: Array.from(tensor),
|
|
1921
|
-
embeddingNorm: normalized
|
|
1922
|
-
};
|
|
1923
2094
|
}
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
const labels = stepDef.labels ?? [];
|
|
1930
|
-
let max = -Infinity;
|
|
1931
|
-
for (let i = 0; i < tensor.length; i++) if (tensor[i] > max) max = tensor[i];
|
|
1932
|
-
const exps = new Float32Array(tensor.length);
|
|
1933
|
-
let sum = 0;
|
|
1934
|
-
for (let i = 0; i < tensor.length; i++) {
|
|
1935
|
-
exps[i] = Math.exp(tensor[i] - max);
|
|
1936
|
-
sum += exps[i];
|
|
1937
|
-
}
|
|
1938
|
-
const classifications = [];
|
|
1939
|
-
for (let i = 0; i < exps.length; i++) {
|
|
1940
|
-
const score = exps[i] / sum;
|
|
1941
|
-
if (score >= .01) classifications.push({
|
|
1942
|
-
class: labels[i] ?? String(i),
|
|
1943
|
-
score
|
|
1944
|
-
});
|
|
1945
|
-
}
|
|
1946
|
-
classifications.sort((a, b) => b.score - a.score);
|
|
1947
|
-
return {
|
|
1948
|
-
kind: "classifications",
|
|
1949
|
-
classifications: classifications.slice(0, 5)
|
|
1950
|
-
};
|
|
1951
|
-
}
|
|
1952
|
-
function postprocessCtc(output, stepDef) {
|
|
1953
|
-
const structured = tryStructured(output);
|
|
1954
|
-
if (structured) return structured;
|
|
1955
|
-
const tensor = output.tensor;
|
|
1956
|
-
if (!tensor) throw new Error("CTC postprocessor: no tensor in engine output");
|
|
1957
|
-
const charset = stepDef.charset ?? [];
|
|
1958
|
-
const numChars = charset.length || 97;
|
|
1959
|
-
const seqLen = Math.floor(tensor.length / numChars);
|
|
1960
|
-
const chars = [];
|
|
1961
|
-
let totalScore = 0;
|
|
1962
|
-
let prev = -1;
|
|
1963
|
-
for (let t = 0; t < seqLen; t++) {
|
|
1964
|
-
let bestIdx = 0;
|
|
1965
|
-
let bestVal = tensor[t * numChars];
|
|
1966
|
-
for (let c = 1; c < numChars; c++) {
|
|
1967
|
-
const val = tensor[t * numChars + c];
|
|
1968
|
-
if (val > bestVal) {
|
|
1969
|
-
bestVal = val;
|
|
1970
|
-
bestIdx = c;
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
totalScore += bestVal;
|
|
1974
|
-
if (bestIdx !== 0 && bestIdx !== prev) chars.push(charset[bestIdx] ?? "");
|
|
1975
|
-
prev = bestIdx;
|
|
1976
|
-
}
|
|
1977
|
-
const text = chars.join("");
|
|
1978
|
-
const region = stepDef.plateRegion;
|
|
1979
|
-
if (region !== void 0 && region !== "off") {
|
|
1980
|
-
const grammar = TS_PLATE_GRAMMARS[region];
|
|
1981
|
-
if (grammar) return {
|
|
1982
|
-
kind: "text",
|
|
1983
|
-
text,
|
|
1984
|
-
confidence: seqLen > 0 ? totalScore / seqLen : 0,
|
|
1985
|
-
formatValid: grammar.test(text)
|
|
1986
|
-
};
|
|
1987
|
-
}
|
|
1988
|
-
return {
|
|
1989
|
-
kind: "text",
|
|
1990
|
-
text,
|
|
1991
|
-
confidence: seqLen > 0 ? totalScore / seqLen : 0
|
|
1992
|
-
};
|
|
2095
|
+
/**
|
|
2096
|
+
* Check if a bbox is degenerate (zero or negative area).
|
|
2097
|
+
*/
|
|
2098
|
+
function isBboxDegenerate(bbox) {
|
|
2099
|
+
return bbox[2] - bbox[0] < 1 || bbox[3] - bbox[1] < 1;
|
|
1993
2100
|
}
|
|
2101
|
+
//#endregion
|
|
2102
|
+
//#region src/detection-pipeline/pipeline/crop-zone.ts
|
|
1994
2103
|
/**
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
* `
|
|
2104
|
+
* Resolve the absolute-pixel crop bbox for a `crop-zone` root detector: the
|
|
2105
|
+
* bounding box of the UNION of every polygon referenced by the device's
|
|
2106
|
+
* ENABLED `package`-stage zone rules. Zone polygons are normalized (0..1); the
|
|
2107
|
+
* union is clamped to [0,1] then scaled to the frame's pixel dimensions.
|
|
1998
2108
|
*
|
|
1999
|
-
* Returns null when there
|
|
2109
|
+
* Returns `null` when there is nothing usable to crop to — no enabled rules, no
|
|
2110
|
+
* referenced geometry, or a degenerate result — in which case the caller falls
|
|
2111
|
+
* back to full-frame inference (behaviour identical to before the crop-zone
|
|
2112
|
+
* plumbing).
|
|
2000
2113
|
*/
|
|
2001
|
-
function
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
if (
|
|
2005
|
-
const
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
if (
|
|
2021
|
-
if (
|
|
2022
|
-
if (
|
|
2023
|
-
if (cy > maxY) maxY = cy;
|
|
2024
|
-
const neighbours = [
|
|
2025
|
-
[cy - 1, cx],
|
|
2026
|
-
[cy + 1, cx],
|
|
2027
|
-
[cy, cx - 1],
|
|
2028
|
-
[cy, cx + 1]
|
|
2029
|
-
];
|
|
2030
|
-
for (const [ny, nx] of neighbours) {
|
|
2031
|
-
if (ny < 0 || ny >= h || nx < 0 || nx >= w) continue;
|
|
2032
|
-
const nIdx = ny * w + nx;
|
|
2033
|
-
if ((binary[nIdx] ?? 0) > 0 && visited[nIdx] !== 1) {
|
|
2034
|
-
visited[nIdx] = 1;
|
|
2035
|
-
stack.push([ny, nx]);
|
|
2036
|
-
}
|
|
2037
|
-
}
|
|
2114
|
+
function resolvePackageCropBbox(zones, packageRules, frameWidth, frameHeight) {
|
|
2115
|
+
if (frameWidth <= 0 || frameHeight <= 0) return null;
|
|
2116
|
+
const enabledRules = packageRules.filter((r) => r.enabled !== false);
|
|
2117
|
+
if (enabledRules.length === 0) return null;
|
|
2118
|
+
const zoneIds = /* @__PURE__ */ new Set();
|
|
2119
|
+
for (const rule of enabledRules) for (const id of rule.zoneIds) zoneIds.add(id);
|
|
2120
|
+
if (zoneIds.size === 0) return null;
|
|
2121
|
+
const zoneById = new Map(zones.map((z) => [z.id, z]));
|
|
2122
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
2123
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
2124
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
2125
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
2126
|
+
let sawPoint = false;
|
|
2127
|
+
for (const id of zoneIds) {
|
|
2128
|
+
const zone = zoneById.get(id);
|
|
2129
|
+
if (!zone || zone.polygon.length === 0) continue;
|
|
2130
|
+
for (const point of zone.polygon) {
|
|
2131
|
+
sawPoint = true;
|
|
2132
|
+
if (point.x < minX) minX = point.x;
|
|
2133
|
+
if (point.y < minY) minY = point.y;
|
|
2134
|
+
if (point.x > maxX) maxX = point.x;
|
|
2135
|
+
if (point.y > maxY) maxY = point.y;
|
|
2038
2136
|
}
|
|
2039
|
-
if (best === null || area > best.area) best = {
|
|
2040
|
-
area,
|
|
2041
|
-
minX,
|
|
2042
|
-
minY,
|
|
2043
|
-
maxX,
|
|
2044
|
-
maxY
|
|
2045
|
-
};
|
|
2046
2137
|
}
|
|
2047
|
-
if (
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2138
|
+
if (!sawPoint) return null;
|
|
2139
|
+
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
2140
|
+
const bbox = [
|
|
2141
|
+
clamp01(minX) * frameWidth,
|
|
2142
|
+
clamp01(minY) * frameHeight,
|
|
2143
|
+
clamp01(maxX) * frameWidth,
|
|
2144
|
+
clamp01(maxY) * frameHeight
|
|
2053
2145
|
];
|
|
2146
|
+
if (isBboxDegenerate(bbox)) return null;
|
|
2147
|
+
return bbox;
|
|
2054
2148
|
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
function postprocessYoloSeg(output, stepDef, nmsIouThreshold) {
|
|
2080
|
-
const structured = tryStructured(output);
|
|
2081
|
-
if (structured) return structured;
|
|
2082
|
-
const tensors = output.tensors;
|
|
2083
|
-
if (!tensors) throw new Error("YOLO-seg postprocessor: no tensors in engine output");
|
|
2084
|
-
const labels = stepDef.labels ?? [];
|
|
2085
|
-
const numClasses = labels.length || 80;
|
|
2086
|
-
const numMaskCoeffs = 32;
|
|
2087
|
-
const protoSize = 160;
|
|
2088
|
-
const letterbox = output.letterbox;
|
|
2089
|
-
let detTensor;
|
|
2090
|
-
let protoTensor;
|
|
2091
|
-
let detRows = 300;
|
|
2092
|
-
let detCols = 0;
|
|
2093
|
-
for (const [, tensor] of Object.entries(tensors)) {
|
|
2094
|
-
if (tensor.length === numMaskCoeffs * protoSize * protoSize) {
|
|
2095
|
-
protoTensor = tensor;
|
|
2096
|
-
continue;
|
|
2097
|
-
}
|
|
2098
|
-
const cols = 38;
|
|
2099
|
-
if (tensor.length % cols === 0) {
|
|
2100
|
-
detTensor = tensor;
|
|
2101
|
-
detRows = tensor.length / cols;
|
|
2102
|
-
detCols = cols;
|
|
2103
|
-
continue;
|
|
2104
|
-
}
|
|
2105
|
-
const rawCols = 4 + numClasses + numMaskCoeffs;
|
|
2106
|
-
if (tensor.length % rawCols === 0 && !detTensor) {
|
|
2107
|
-
detTensor = tensor;
|
|
2108
|
-
detRows = tensor.length / rawCols;
|
|
2109
|
-
detCols = rawCols;
|
|
2110
|
-
}
|
|
2111
|
-
}
|
|
2112
|
-
if (!detTensor) throw new Error("YOLO-seg postprocessor: could not find detection tensor");
|
|
2113
|
-
if (!protoTensor) throw new Error("YOLO-seg postprocessor: could not find prototype tensor");
|
|
2114
|
-
const isNmsFormat = detCols === 38;
|
|
2115
|
-
const dets = [];
|
|
2116
|
-
for (let i = 0; i < detRows; i++) {
|
|
2117
|
-
const offset = i * detCols;
|
|
2118
|
-
let x1, y1, x2, y2;
|
|
2119
|
-
let bestScore;
|
|
2120
|
-
let bestClass;
|
|
2121
|
-
let coeffStart;
|
|
2122
|
-
if (isNmsFormat) {
|
|
2123
|
-
x1 = detTensor[offset];
|
|
2124
|
-
y1 = detTensor[offset + 1];
|
|
2125
|
-
x2 = detTensor[offset + 2];
|
|
2126
|
-
y2 = detTensor[offset + 3];
|
|
2127
|
-
bestScore = detTensor[offset + 4];
|
|
2128
|
-
bestClass = detTensor[offset + 5];
|
|
2129
|
-
coeffStart = offset + 6;
|
|
2130
|
-
if (bestScore <= 0) continue;
|
|
2131
|
-
if (bestClass < 0) continue;
|
|
2132
|
-
} else {
|
|
2133
|
-
const cx = detTensor[offset];
|
|
2134
|
-
const cy = detTensor[offset + 1];
|
|
2135
|
-
const w = detTensor[offset + 2];
|
|
2136
|
-
const h = detTensor[offset + 3];
|
|
2137
|
-
bestScore = -Infinity;
|
|
2138
|
-
bestClass = 0;
|
|
2139
|
-
for (let j = 0; j < numClasses; j++) {
|
|
2140
|
-
const score = detTensor[offset + 4 + j];
|
|
2141
|
-
if (score > bestScore) {
|
|
2142
|
-
bestScore = score;
|
|
2143
|
-
bestClass = j;
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
if (bestScore <= 0) continue;
|
|
2147
|
-
x1 = cx - w / 2;
|
|
2148
|
-
y1 = cy - h / 2;
|
|
2149
|
-
x2 = cx + w / 2;
|
|
2150
|
-
y2 = cy + h / 2;
|
|
2151
|
-
coeffStart = offset + 4 + numClasses;
|
|
2152
|
-
}
|
|
2153
|
-
const coeffs = new Float32Array(numMaskCoeffs);
|
|
2154
|
-
for (let j = 0; j < numMaskCoeffs; j++) coeffs[j] = detTensor[coeffStart + j];
|
|
2155
|
-
const maskPixels = protoSize * protoSize;
|
|
2156
|
-
const maskRaw = new Float32Array(maskPixels);
|
|
2157
|
-
for (let p = 0; p < maskPixels; p++) {
|
|
2158
|
-
let sum = 0;
|
|
2159
|
-
for (let c = 0; c < numMaskCoeffs; c++) sum += coeffs[c] * protoTensor[c * maskPixels + p];
|
|
2160
|
-
maskRaw[p] = 1 / (1 + Math.exp(-sum));
|
|
2161
|
-
}
|
|
2162
|
-
const protoScale = protoSize / (stepDef.models[0]?.inputSize.width ?? 640);
|
|
2163
|
-
const px1 = Math.max(0, Math.floor(x1 * protoScale));
|
|
2164
|
-
const py1 = Math.max(0, Math.floor(y1 * protoScale));
|
|
2165
|
-
const px2 = Math.min(protoSize, Math.ceil(x2 * protoScale));
|
|
2166
|
-
const py2 = Math.min(protoSize, Math.ceil(y2 * protoScale));
|
|
2167
|
-
const cropW = Math.max(1, px2 - px1);
|
|
2168
|
-
const cropH = Math.max(1, py2 - py1);
|
|
2169
|
-
const cropped = new Uint8Array(cropW * cropH);
|
|
2170
|
-
for (let row = 0; row < cropH; row++) for (let col = 0; col < cropW; col++) {
|
|
2171
|
-
const srcIdx = (py1 + row) * protoSize + (px1 + col);
|
|
2172
|
-
cropped[row * cropW + col] = maskRaw[srcIdx] > .5 ? 255 : 0;
|
|
2173
|
-
}
|
|
2174
|
-
let ox1 = x1;
|
|
2175
|
-
let oy1 = y1;
|
|
2176
|
-
let ox2 = x2;
|
|
2177
|
-
let oy2 = y2;
|
|
2178
|
-
if (letterbox) {
|
|
2179
|
-
ox1 = (x1 - letterbox.padX) / letterbox.scale;
|
|
2180
|
-
oy1 = (y1 - letterbox.padY) / letterbox.scale;
|
|
2181
|
-
ox2 = (x2 - letterbox.padX) / letterbox.scale;
|
|
2182
|
-
oy2 = (y2 - letterbox.padY) / letterbox.scale;
|
|
2183
|
-
}
|
|
2184
|
-
const label = labels[bestClass] ?? String(bestClass);
|
|
2185
|
-
const maskB64 = Buffer.from(cropped).toString("base64");
|
|
2186
|
-
dets.push({
|
|
2187
|
-
class: label,
|
|
2188
|
-
score: Math.round(bestScore * 1e4) / 1e4,
|
|
2189
|
-
bbox: [
|
|
2190
|
-
Math.round(ox1 * 10) / 10,
|
|
2191
|
-
Math.round(oy1 * 10) / 10,
|
|
2192
|
-
Math.round(ox2 * 10) / 10,
|
|
2193
|
-
Math.round(oy2 * 10) / 10
|
|
2194
|
-
],
|
|
2195
|
-
mask: maskB64,
|
|
2196
|
-
maskWidth: cropW,
|
|
2197
|
-
maskHeight: cropH
|
|
2198
|
-
});
|
|
2199
|
-
}
|
|
2200
|
-
const keptBoxes = simpleNms(dets.map((d) => ({
|
|
2201
|
-
class: d.class,
|
|
2202
|
-
score: d.score,
|
|
2203
|
-
bbox: d.bbox
|
|
2204
|
-
})), nmsIouThreshold);
|
|
2205
|
-
const keptSet = new Set(keptBoxes.map((d) => `${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`));
|
|
2206
|
-
return {
|
|
2207
|
-
kind: "detections",
|
|
2208
|
-
detections: dets.filter((d) => keptSet.has(`${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`))
|
|
2209
|
-
};
|
|
2210
|
-
}
|
|
2211
|
-
function postprocessYamnet(output, _stepDef) {
|
|
2212
|
-
const structured = tryStructured(output);
|
|
2213
|
-
if (structured) return structured;
|
|
2214
|
-
throw new Error("YAMNet TypeScript postprocessing not implemented. Use Python backend or Apple SoundAnalysis.");
|
|
2215
|
-
}
|
|
2216
|
-
var DISPATCH = {
|
|
2217
|
-
yolo: postprocessYolo,
|
|
2218
|
-
ssd: postprocessSsd,
|
|
2219
|
-
"yolo-seg": postprocessYoloSeg,
|
|
2220
|
-
scrfd: postprocessScrfd,
|
|
2221
|
-
arcface: postprocessArcface,
|
|
2222
|
-
clip: postprocessClip,
|
|
2223
|
-
softmax: postprocessSoftmax,
|
|
2224
|
-
ctc: postprocessCtc,
|
|
2225
|
-
saliency: postprocessSaliency,
|
|
2226
|
-
yamnet: postprocessYamnet
|
|
2227
|
-
};
|
|
2149
|
+
//#endregion
|
|
2150
|
+
//#region src/detection-pipeline/postprocess/dispatch.ts
|
|
2151
|
+
var VALID_KINDS = new Set([
|
|
2152
|
+
"detections",
|
|
2153
|
+
"classifications",
|
|
2154
|
+
"embedding",
|
|
2155
|
+
"text",
|
|
2156
|
+
"mask"
|
|
2157
|
+
]);
|
|
2158
|
+
/** Every postprocessor the Python pool implements — the dispatch table's keys
|
|
2159
|
+
* survived the raw-tensor removal as a config-drift guard: an unknown
|
|
2160
|
+
* postprocessor in a step definition still fails fast here. */
|
|
2161
|
+
var KNOWN_POSTPROCESSORS = new Set([
|
|
2162
|
+
"yolo",
|
|
2163
|
+
"ssd",
|
|
2164
|
+
"yolo-seg",
|
|
2165
|
+
"scrfd",
|
|
2166
|
+
"arcface",
|
|
2167
|
+
"clip",
|
|
2168
|
+
"softmax",
|
|
2169
|
+
"ctc",
|
|
2170
|
+
"saliency",
|
|
2171
|
+
"yamnet"
|
|
2172
|
+
]);
|
|
2228
2173
|
/**
|
|
2229
|
-
*
|
|
2230
|
-
*
|
|
2231
|
-
* For Python backends: output.structured is already populated → passthrough.
|
|
2232
|
-
* For Node.js ONNX: raw tensors → postprocess in TypeScript.
|
|
2174
|
+
* Type guard: validates that a structured payload from the Python pool is a
|
|
2175
|
+
* well-formed StepOutput discriminated union.
|
|
2233
2176
|
*
|
|
2234
|
-
*
|
|
2235
|
-
*
|
|
2177
|
+
* The Python inference_pool.py always sets `output.structured` with a `kind`
|
|
2178
|
+
* field. This guard narrows `Record<string, unknown>` to `StepOutput` without
|
|
2179
|
+
* resorting to double-cast.
|
|
2236
2180
|
*/
|
|
2237
|
-
function
|
|
2238
|
-
|
|
2239
|
-
if (!fn) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
|
|
2240
|
-
const raw = settings?.["nmsIouThreshold"];
|
|
2241
|
-
return normalizeOutput(fn(output, stepDef, typeof raw === "number" && raw > 0 ? raw : DEFAULT_NMS_IOU));
|
|
2181
|
+
function isStepOutput(value) {
|
|
2182
|
+
return typeof value === "object" && value !== null && typeof value["kind"] === "string" && VALID_KINDS.has(value["kind"]);
|
|
2242
2183
|
}
|
|
2243
2184
|
/**
|
|
2244
|
-
*
|
|
2245
|
-
|
|
2185
|
+
* Validate + normalize a Python-pool engine output into a StepOutput.
|
|
2186
|
+
*/
|
|
2187
|
+
function dispatchPostprocess(output, stepDef) {
|
|
2188
|
+
if (output.dropped === true) return {
|
|
2189
|
+
kind: "detections",
|
|
2190
|
+
detections: []
|
|
2191
|
+
};
|
|
2192
|
+
if (!KNOWN_POSTPROCESSORS.has(stepDef.postprocessor)) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
|
|
2193
|
+
if (!output.structured) throw new Error(`Postprocessing for step "${stepDef.id}" (${stepDef.postprocessor}) runs in the Python inference pool — the engine output carried no structured payload. The Node.js raw-tensor path was removed; there is nothing to fall back to.`);
|
|
2194
|
+
if (!isStepOutput(output.structured)) throw new Error(`Python pool returned structured output with unexpected kind: ${JSON.stringify(output.structured["kind"])}`);
|
|
2195
|
+
return normalizeOutput(output.structured);
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Normalize any StepOutput to a consistent format — the single funnel every
|
|
2199
|
+
* pool payload passes through.
|
|
2246
2200
|
*
|
|
2247
2201
|
* Classifications: keep only top-1 in classifications, move full list to alternates.
|
|
2248
2202
|
*/
|
|
@@ -2256,78 +2210,103 @@ function normalizeOutput(output) {
|
|
|
2256
2210
|
alternates: all
|
|
2257
2211
|
};
|
|
2258
2212
|
}
|
|
2259
|
-
function simpleNms(dets, iouThreshold) {
|
|
2260
|
-
if (dets.length === 0) return [];
|
|
2261
|
-
const sorted = [...dets].toSorted((a, b) => b.score - a.score);
|
|
2262
|
-
const kept = [];
|
|
2263
|
-
const suppressed = /* @__PURE__ */ new Set();
|
|
2264
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
2265
|
-
if (suppressed.has(i)) continue;
|
|
2266
|
-
kept.push(sorted[i]);
|
|
2267
|
-
for (let j = i + 1; j < sorted.length; j++) {
|
|
2268
|
-
if (suppressed.has(j)) continue;
|
|
2269
|
-
if (iou(sorted[i].bbox, sorted[j].bbox) > iouThreshold) suppressed.add(j);
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
return kept;
|
|
2273
|
-
}
|
|
2274
|
-
function iou(a, b) {
|
|
2275
|
-
const ix1 = Math.max(a[0], b[0]);
|
|
2276
|
-
const iy1 = Math.max(a[1], b[1]);
|
|
2277
|
-
const ix2 = Math.min(a[2], b[2]);
|
|
2278
|
-
const iy2 = Math.min(a[3], b[3]);
|
|
2279
|
-
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
|
|
2280
|
-
if (inter === 0) return 0;
|
|
2281
|
-
return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter);
|
|
2282
|
-
}
|
|
2283
2213
|
//#endregion
|
|
2284
|
-
//#region src/detection-pipeline/pipeline/
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2214
|
+
//#region src/detection-pipeline/pipeline/execution-trace.ts
|
|
2215
|
+
var traceCounter = 0;
|
|
2216
|
+
var ExecutionTraceBuilder = class {
|
|
2217
|
+
verbosity;
|
|
2218
|
+
deviceId;
|
|
2219
|
+
frameWidth;
|
|
2220
|
+
frameHeight;
|
|
2221
|
+
engineRuntime;
|
|
2222
|
+
steps = [];
|
|
2223
|
+
startTime;
|
|
2224
|
+
constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
|
|
2225
|
+
this.verbosity = verbosity;
|
|
2226
|
+
this.deviceId = deviceId;
|
|
2227
|
+
this.frameWidth = frameWidth;
|
|
2228
|
+
this.frameHeight = frameHeight;
|
|
2229
|
+
this.engineRuntime = engineRuntime;
|
|
2230
|
+
this.startTime = Date.now();
|
|
2231
|
+
}
|
|
2232
|
+
/** Whether trace collection is active. */
|
|
2233
|
+
get isActive() {
|
|
2234
|
+
return this.verbosity !== "off";
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Record a completed step execution.
|
|
2238
|
+
* No-op if verbosity is 'off'.
|
|
2239
|
+
*/
|
|
2240
|
+
addStep(params) {
|
|
2241
|
+
if (this.verbosity === "off") return;
|
|
2242
|
+
const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
|
|
2243
|
+
const trace = {
|
|
2244
|
+
stepId: params.stepId,
|
|
2245
|
+
modelId: params.modelId,
|
|
2246
|
+
slot: params.slot,
|
|
2247
|
+
postprocessor: params.postprocessor,
|
|
2248
|
+
preprocessMs: Math.round(params.preprocessMs * 100) / 100,
|
|
2249
|
+
inferenceMs: Math.round(params.inferenceMs * 100) / 100,
|
|
2250
|
+
postprocessMs: Math.round(params.postprocessMs * 100) / 100,
|
|
2251
|
+
totalMs: Math.round(totalMs * 100) / 100,
|
|
2252
|
+
inputType: params.inputType,
|
|
2253
|
+
inputSize: {
|
|
2254
|
+
width: params.inputWidth,
|
|
2255
|
+
height: params.inputHeight
|
|
2256
|
+
},
|
|
2257
|
+
parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
|
|
2258
|
+
outputKind: params.output.kind,
|
|
2259
|
+
outputCount: countOutput(params.output),
|
|
2260
|
+
topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
|
|
2261
|
+
error: params.error
|
|
2262
|
+
};
|
|
2263
|
+
this.steps.push(trace);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Finalize and return the complete execution trace.
|
|
2267
|
+
* Returns null if verbosity is 'off'.
|
|
2268
|
+
*/
|
|
2269
|
+
build(detectionCount) {
|
|
2270
|
+
if (this.verbosity === "off") return null;
|
|
2271
|
+
const totalMs = Date.now() - this.startTime;
|
|
2272
|
+
return {
|
|
2273
|
+
traceId: `trace-${Date.now()}-${++traceCounter}`,
|
|
2274
|
+
deviceId: this.deviceId,
|
|
2275
|
+
timestamp: this.startTime,
|
|
2276
|
+
frameSize: {
|
|
2277
|
+
width: this.frameWidth,
|
|
2278
|
+
height: this.frameHeight
|
|
2279
|
+
},
|
|
2280
|
+
engineRuntime: this.engineRuntime,
|
|
2281
|
+
steps: this.steps,
|
|
2282
|
+
totalMs,
|
|
2283
|
+
detectionCount
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
function countOutput(output) {
|
|
2288
|
+
switch (output.kind) {
|
|
2289
|
+
case "detections": return output.detections.length;
|
|
2290
|
+
case "classifications": return output.classifications.length;
|
|
2291
|
+
case "embedding": return 1;
|
|
2292
|
+
case "text": return output.text.length > 0 ? 1 : 0;
|
|
2293
|
+
case "mask": return 1;
|
|
2294
|
+
}
|
|
2325
2295
|
}
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2296
|
+
function formatTopResult(output) {
|
|
2297
|
+
switch (output.kind) {
|
|
2298
|
+
case "detections": {
|
|
2299
|
+
const top = output.detections[0];
|
|
2300
|
+
return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
|
|
2301
|
+
}
|
|
2302
|
+
case "classifications": {
|
|
2303
|
+
const top = output.classifications[0];
|
|
2304
|
+
return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
|
|
2305
|
+
}
|
|
2306
|
+
case "embedding": return `embedding[${output.embedding.length}]`;
|
|
2307
|
+
case "text": return output.text || void 0;
|
|
2308
|
+
case "mask": return `mask ${output.maskWidth}×${output.maskHeight}`;
|
|
2309
|
+
}
|
|
2331
2310
|
}
|
|
2332
2311
|
//#endregion
|
|
2333
2312
|
//#region src/detection-pipeline/pipeline/face-align.ts
|
|
@@ -2635,63 +2614,6 @@ async function alignFaceCrop(fullFrameJpeg, faceBbox, landmarksImageSpace, image
|
|
|
2635
2614
|
height: aligned.height
|
|
2636
2615
|
};
|
|
2637
2616
|
}
|
|
2638
|
-
//#endregion
|
|
2639
|
-
//#region src/detection-pipeline/pipeline/native-child-crop.ts
|
|
2640
|
-
/**
|
|
2641
|
-
* NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
|
|
2642
|
-
* — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
|
|
2643
|
-
*
|
|
2644
|
-
* On the detail plane the executor runs a child model against a parent crop
|
|
2645
|
-
* (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
|
|
2646
|
-
* in the parent crop's PIXEL space) is what the child model reads. Cutting it
|
|
2647
|
-
* from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
|
|
2648
|
-
* to the tile's resolution — fatal for OCR on a small, distant plate. When a
|
|
2649
|
-
* native-crop provider is bound to the frame, we instead resolve the SAME ROI
|
|
2650
|
-
* straight from the frame's retained NATIVE surface (the provider re-composes
|
|
2651
|
-
* the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
|
|
2652
|
-
* giving the model native pixels. A miss/degenerate returns `null` so the caller
|
|
2653
|
-
* falls back to the existing tile crop (never an upscale — native or the tile).
|
|
2654
|
-
*/
|
|
2655
|
-
/**
|
|
2656
|
-
* Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
|
|
2657
|
-
* native pixels wide, so this never binds for plates (effectively uncapped —
|
|
2658
|
-
* the quality path the task requires); it only bounds a pathologically large
|
|
2659
|
-
* whole-object classifier ROI so the in-process native fetch/encode stays cheap.
|
|
2660
|
-
*/
|
|
2661
|
-
var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
|
|
2662
|
-
/**
|
|
2663
|
-
* Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
|
|
2664
|
-
* resolution via `provider`, returning a JPEG crop + its native dimensions.
|
|
2665
|
-
* Returns `null` on a degenerate ROI or any provider miss (so the caller uses
|
|
2666
|
-
* the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
|
|
2667
|
-
* the sharp encode of the returned pixels.
|
|
2668
|
-
*/
|
|
2669
|
-
async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
|
|
2670
|
-
if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
|
|
2671
|
-
const x1 = Math.max(0, bbox[0]);
|
|
2672
|
-
const y1 = Math.max(0, bbox[1]);
|
|
2673
|
-
const x2 = Math.min(imageWidth, bbox[2]);
|
|
2674
|
-
const y2 = Math.min(imageHeight, bbox[3]);
|
|
2675
|
-
const w = x2 - x1;
|
|
2676
|
-
const h = y2 - y1;
|
|
2677
|
-
if (w < 1 || h < 1) return null;
|
|
2678
|
-
const native = await provider({
|
|
2679
|
-
x: x1 / imageWidth,
|
|
2680
|
-
y: y1 / imageHeight,
|
|
2681
|
-
w: w / imageWidth,
|
|
2682
|
-
h: h / imageHeight
|
|
2683
|
-
}, maxWidth);
|
|
2684
|
-
if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
|
|
2685
|
-
return {
|
|
2686
|
-
jpeg: await (0, sharp.default)(Buffer.from(native.bytes), { raw: {
|
|
2687
|
-
width: native.width,
|
|
2688
|
-
height: native.height,
|
|
2689
|
-
channels: 3
|
|
2690
|
-
} }).jpeg({ quality: 90 }).toBuffer(),
|
|
2691
|
-
width: native.width,
|
|
2692
|
-
height: native.height
|
|
2693
|
-
};
|
|
2694
|
-
}
|
|
2695
2617
|
/**
|
|
2696
2618
|
* Detection score at/above which a near-full-frame box is trusted as a REAL
|
|
2697
2619
|
* close subject rather than a phantom. Below this bar a full-frame box is
|
|
@@ -2773,6 +2695,63 @@ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxCo
|
|
|
2773
2695
|
return false;
|
|
2774
2696
|
}
|
|
2775
2697
|
//#endregion
|
|
2698
|
+
//#region src/detection-pipeline/pipeline/native-child-crop.ts
|
|
2699
|
+
/**
|
|
2700
|
+
* NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
|
|
2701
|
+
* — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
|
|
2702
|
+
*
|
|
2703
|
+
* On the detail plane the executor runs a child model against a parent crop
|
|
2704
|
+
* (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
|
|
2705
|
+
* in the parent crop's PIXEL space) is what the child model reads. Cutting it
|
|
2706
|
+
* from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
|
|
2707
|
+
* to the tile's resolution — fatal for OCR on a small, distant plate. When a
|
|
2708
|
+
* native-crop provider is bound to the frame, we instead resolve the SAME ROI
|
|
2709
|
+
* straight from the frame's retained NATIVE surface (the provider re-composes
|
|
2710
|
+
* the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
|
|
2711
|
+
* giving the model native pixels. A miss/degenerate returns `null` so the caller
|
|
2712
|
+
* falls back to the existing tile crop (never an upscale — native or the tile).
|
|
2713
|
+
*/
|
|
2714
|
+
/**
|
|
2715
|
+
* Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
|
|
2716
|
+
* native pixels wide, so this never binds for plates (effectively uncapped —
|
|
2717
|
+
* the quality path the task requires); it only bounds a pathologically large
|
|
2718
|
+
* whole-object classifier ROI so the in-process native fetch/encode stays cheap.
|
|
2719
|
+
*/
|
|
2720
|
+
var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
|
|
2721
|
+
/**
|
|
2722
|
+
* Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
|
|
2723
|
+
* resolution via `provider`, returning a JPEG crop + its native dimensions.
|
|
2724
|
+
* Returns `null` on a degenerate ROI or any provider miss (so the caller uses
|
|
2725
|
+
* the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
|
|
2726
|
+
* the sharp encode of the returned pixels.
|
|
2727
|
+
*/
|
|
2728
|
+
async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
|
|
2729
|
+
if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
|
|
2730
|
+
const x1 = Math.max(0, bbox[0]);
|
|
2731
|
+
const y1 = Math.max(0, bbox[1]);
|
|
2732
|
+
const x2 = Math.min(imageWidth, bbox[2]);
|
|
2733
|
+
const y2 = Math.min(imageHeight, bbox[3]);
|
|
2734
|
+
const w = x2 - x1;
|
|
2735
|
+
const h = y2 - y1;
|
|
2736
|
+
if (w < 1 || h < 1) return null;
|
|
2737
|
+
const native = await provider({
|
|
2738
|
+
x: x1 / imageWidth,
|
|
2739
|
+
y: y1 / imageHeight,
|
|
2740
|
+
w: w / imageWidth,
|
|
2741
|
+
h: h / imageHeight
|
|
2742
|
+
}, maxWidth);
|
|
2743
|
+
if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
|
|
2744
|
+
return {
|
|
2745
|
+
jpeg: await (0, sharp.default)(Buffer.from(native.bytes), { raw: {
|
|
2746
|
+
width: native.width,
|
|
2747
|
+
height: native.height,
|
|
2748
|
+
channels: 3
|
|
2749
|
+
} }).jpeg({ quality: 90 }).toBuffer(),
|
|
2750
|
+
width: native.width,
|
|
2751
|
+
height: native.height
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
//#endregion
|
|
2776
2755
|
//#region src/detection-pipeline/pipeline/plate-deskew.ts
|
|
2777
2756
|
/**
|
|
2778
2757
|
* Plate-crop DESKEW — rotation rectification for oblique licence-plate crops.
|
|
@@ -3287,6 +3266,7 @@ function buildFrameResult(input) {
|
|
|
3287
3266
|
width: input.width,
|
|
3288
3267
|
height: input.height,
|
|
3289
3268
|
detections,
|
|
3269
|
+
...input.discarded !== void 0 && input.discarded.length > 0 ? { discarded: input.discarded } : {},
|
|
3290
3270
|
debug: frameDebug
|
|
3291
3271
|
};
|
|
3292
3272
|
}
|
|
@@ -3296,136 +3276,38 @@ function bboxTupleToRect(bbox) {
|
|
|
3296
3276
|
x: x1,
|
|
3297
3277
|
y: y1,
|
|
3298
3278
|
width: x2 - x1,
|
|
3299
|
-
height: y2 - y1
|
|
3300
|
-
};
|
|
3301
|
-
}
|
|
3302
|
-
function pruneUndefined(obj) {
|
|
3303
|
-
const out = {};
|
|
3304
|
-
for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
|
|
3305
|
-
return out;
|
|
3306
|
-
}
|
|
3307
|
-
function generateFrameId() {
|
|
3308
|
-
return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3309
|
-
}
|
|
3310
|
-
/** Seed a MutableObjectDetection from a root-level detector output. */
|
|
3311
|
-
function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
|
|
3312
|
-
return {
|
|
3313
|
-
id: idGen.next(),
|
|
3314
|
-
kind: "first-level",
|
|
3315
|
-
macroClass: det.class,
|
|
3316
|
-
originalClass: det.class,
|
|
3317
|
-
score: det.score,
|
|
3318
|
-
bbox: [...det.bbox],
|
|
3319
|
-
ownLabels: [],
|
|
3320
|
-
alternateLabels: {},
|
|
3321
|
-
stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
|
|
3322
|
-
modelIds: { [rootStep.stepId]: rootStep.modelId },
|
|
3323
|
-
extractMode: "full-frame",
|
|
3324
|
-
...det.mask !== void 0 ? {
|
|
3325
|
-
mask: det.mask,
|
|
3326
|
-
maskWidth: det.maskWidth,
|
|
3327
|
-
maskHeight: det.maskHeight
|
|
3328
|
-
} : {},
|
|
3329
|
-
...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
|
|
3330
|
-
};
|
|
3331
|
-
}
|
|
3332
|
-
//#endregion
|
|
3333
|
-
//#region src/detection-pipeline/pipeline/execution-trace.ts
|
|
3334
|
-
var traceCounter = 0;
|
|
3335
|
-
var ExecutionTraceBuilder = class {
|
|
3336
|
-
verbosity;
|
|
3337
|
-
deviceId;
|
|
3338
|
-
frameWidth;
|
|
3339
|
-
frameHeight;
|
|
3340
|
-
engineRuntime;
|
|
3341
|
-
steps = [];
|
|
3342
|
-
startTime;
|
|
3343
|
-
constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
|
|
3344
|
-
this.verbosity = verbosity;
|
|
3345
|
-
this.deviceId = deviceId;
|
|
3346
|
-
this.frameWidth = frameWidth;
|
|
3347
|
-
this.frameHeight = frameHeight;
|
|
3348
|
-
this.engineRuntime = engineRuntime;
|
|
3349
|
-
this.startTime = Date.now();
|
|
3350
|
-
}
|
|
3351
|
-
/** Whether trace collection is active. */
|
|
3352
|
-
get isActive() {
|
|
3353
|
-
return this.verbosity !== "off";
|
|
3354
|
-
}
|
|
3355
|
-
/**
|
|
3356
|
-
* Record a completed step execution.
|
|
3357
|
-
* No-op if verbosity is 'off'.
|
|
3358
|
-
*/
|
|
3359
|
-
addStep(params) {
|
|
3360
|
-
if (this.verbosity === "off") return;
|
|
3361
|
-
const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
|
|
3362
|
-
const trace = {
|
|
3363
|
-
stepId: params.stepId,
|
|
3364
|
-
modelId: params.modelId,
|
|
3365
|
-
slot: params.slot,
|
|
3366
|
-
postprocessor: params.postprocessor,
|
|
3367
|
-
preprocessMs: Math.round(params.preprocessMs * 100) / 100,
|
|
3368
|
-
inferenceMs: Math.round(params.inferenceMs * 100) / 100,
|
|
3369
|
-
postprocessMs: Math.round(params.postprocessMs * 100) / 100,
|
|
3370
|
-
totalMs: Math.round(totalMs * 100) / 100,
|
|
3371
|
-
inputType: params.inputType,
|
|
3372
|
-
inputSize: {
|
|
3373
|
-
width: params.inputWidth,
|
|
3374
|
-
height: params.inputHeight
|
|
3375
|
-
},
|
|
3376
|
-
parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
|
|
3377
|
-
outputKind: params.output.kind,
|
|
3378
|
-
outputCount: countOutput(params.output),
|
|
3379
|
-
topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
|
|
3380
|
-
error: params.error
|
|
3381
|
-
};
|
|
3382
|
-
this.steps.push(trace);
|
|
3383
|
-
}
|
|
3384
|
-
/**
|
|
3385
|
-
* Finalize and return the complete execution trace.
|
|
3386
|
-
* Returns null if verbosity is 'off'.
|
|
3387
|
-
*/
|
|
3388
|
-
build(detectionCount) {
|
|
3389
|
-
if (this.verbosity === "off") return null;
|
|
3390
|
-
const totalMs = Date.now() - this.startTime;
|
|
3391
|
-
return {
|
|
3392
|
-
traceId: `trace-${Date.now()}-${++traceCounter}`,
|
|
3393
|
-
deviceId: this.deviceId,
|
|
3394
|
-
timestamp: this.startTime,
|
|
3395
|
-
frameSize: {
|
|
3396
|
-
width: this.frameWidth,
|
|
3397
|
-
height: this.frameHeight
|
|
3398
|
-
},
|
|
3399
|
-
engineRuntime: this.engineRuntime,
|
|
3400
|
-
steps: this.steps,
|
|
3401
|
-
totalMs,
|
|
3402
|
-
detectionCount
|
|
3403
|
-
};
|
|
3404
|
-
}
|
|
3405
|
-
};
|
|
3406
|
-
function countOutput(output) {
|
|
3407
|
-
switch (output.kind) {
|
|
3408
|
-
case "detections": return output.detections.length;
|
|
3409
|
-
case "classifications": return output.classifications.length;
|
|
3410
|
-
case "embedding": return 1;
|
|
3411
|
-
case "text": return output.text.length > 0 ? 1 : 0;
|
|
3412
|
-
case "mask": return 1;
|
|
3413
|
-
}
|
|
3279
|
+
height: y2 - y1
|
|
3280
|
+
};
|
|
3414
3281
|
}
|
|
3415
|
-
function
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3282
|
+
function pruneUndefined(obj) {
|
|
3283
|
+
const out = {};
|
|
3284
|
+
for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
|
|
3285
|
+
return out;
|
|
3286
|
+
}
|
|
3287
|
+
function generateFrameId() {
|
|
3288
|
+
return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3289
|
+
}
|
|
3290
|
+
/** Seed a MutableObjectDetection from a root-level detector output. */
|
|
3291
|
+
function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
|
|
3292
|
+
return {
|
|
3293
|
+
id: idGen.next(),
|
|
3294
|
+
kind: "first-level",
|
|
3295
|
+
macroClass: det.class,
|
|
3296
|
+
originalClass: det.class,
|
|
3297
|
+
score: det.score,
|
|
3298
|
+
bbox: [...det.bbox],
|
|
3299
|
+
ownLabels: [],
|
|
3300
|
+
alternateLabels: {},
|
|
3301
|
+
stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
|
|
3302
|
+
modelIds: { [rootStep.stepId]: rootStep.modelId },
|
|
3303
|
+
extractMode: "full-frame",
|
|
3304
|
+
...det.mask !== void 0 ? {
|
|
3305
|
+
mask: det.mask,
|
|
3306
|
+
maskWidth: det.maskWidth,
|
|
3307
|
+
maskHeight: det.maskHeight
|
|
3308
|
+
} : {},
|
|
3309
|
+
...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
|
|
3310
|
+
};
|
|
3429
3311
|
}
|
|
3430
3312
|
//#endregion
|
|
3431
3313
|
//#region src/detection-pipeline/pipeline/executor.ts
|
|
@@ -3577,12 +3459,14 @@ var PipelineExecutor = class {
|
|
|
3577
3459
|
idGen,
|
|
3578
3460
|
details
|
|
3579
3461
|
};
|
|
3462
|
+
let discarded;
|
|
3580
3463
|
for (const rootStep of tree.roots) {
|
|
3581
3464
|
if (debug) console.log(`[executor] rootStep=${rootStep.stepId} settings=${JSON.stringify(rootStep.settings ?? {})}`);
|
|
3582
3465
|
const cropPrep = rootStep.definition.extractMode === "crop-zone" && cropZoneBbox !== void 0 ? await this.prepareCropZoneInput(cropZoneBbox, fullFrameJpegProvider, imageWidth, imageHeight, deviceId) : null;
|
|
3583
3466
|
const rootStart = Date.now();
|
|
3584
3467
|
const rawRootOutput = await this.executeStep(rootStep, cropPrep?.input ?? rootInput, cropPrep?.width ?? imageWidth, cropPrep?.height ?? imageHeight, "full-frame", void 0, void 0, traceBuilder, stepTimings, poolAgg);
|
|
3585
3468
|
const rootMs = Date.now() - rootStart;
|
|
3469
|
+
if (rawRootOutput === null) continue;
|
|
3586
3470
|
const rootOutput = cropPrep && rawRootOutput.kind === "detections" ? reprojectCropZoneDetections(rawRootOutput, cropPrep.origin) : rawRootOutput;
|
|
3587
3471
|
if (rootOutput.kind !== "detections") {
|
|
3588
3472
|
if (isEnrichmentOutput(rootOutput)) {
|
|
@@ -3612,10 +3496,35 @@ var PipelineExecutor = class {
|
|
|
3612
3496
|
if (mapped) {
|
|
3613
3497
|
mutable.originalClass = det.class;
|
|
3614
3498
|
mutable.macroClass = mapped;
|
|
3615
|
-
} else if (!rootStep.definition.classMap.preserveOriginal)
|
|
3499
|
+
} else if (!rootStep.definition.classMap.preserveOriginal) {
|
|
3500
|
+
(discarded ??= []).push({
|
|
3501
|
+
bbox: [
|
|
3502
|
+
mutable.bbox[0],
|
|
3503
|
+
mutable.bbox[1],
|
|
3504
|
+
mutable.bbox[2],
|
|
3505
|
+
mutable.bbox[3]
|
|
3506
|
+
],
|
|
3507
|
+
macroClass: mutable.macroClass,
|
|
3508
|
+
score: mutable.score,
|
|
3509
|
+
reason: "class-filter"
|
|
3510
|
+
});
|
|
3511
|
+
continue;
|
|
3512
|
+
}
|
|
3616
3513
|
}
|
|
3617
|
-
|
|
3618
|
-
|
|
3514
|
+
const filterVerdict = this.macroFilterVerdict(mutable.macroClass, mutable.score, rootStep.settings);
|
|
3515
|
+
if (filterVerdict !== "pass") {
|
|
3516
|
+
(discarded ??= []).push({
|
|
3517
|
+
bbox: [
|
|
3518
|
+
mutable.bbox[0],
|
|
3519
|
+
mutable.bbox[1],
|
|
3520
|
+
mutable.bbox[2],
|
|
3521
|
+
mutable.bbox[3]
|
|
3522
|
+
],
|
|
3523
|
+
macroClass: mutable.macroClass,
|
|
3524
|
+
score: mutable.score,
|
|
3525
|
+
reason: filterVerdict
|
|
3526
|
+
});
|
|
3527
|
+
if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} (${filterVerdict})`);
|
|
3619
3528
|
continue;
|
|
3620
3529
|
}
|
|
3621
3530
|
const fullFrameBar = rootStep.settings?.["fullFrameGuardMaxConfidence"];
|
|
@@ -3634,9 +3543,20 @@ var PipelineExecutor = class {
|
|
|
3634
3543
|
hardAreaRatio: typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0
|
|
3635
3544
|
}
|
|
3636
3545
|
});
|
|
3546
|
+
(discarded ??= []).push({
|
|
3547
|
+
bbox: [
|
|
3548
|
+
mutable.bbox[0],
|
|
3549
|
+
mutable.bbox[1],
|
|
3550
|
+
mutable.bbox[2],
|
|
3551
|
+
mutable.bbox[3]
|
|
3552
|
+
],
|
|
3553
|
+
macroClass: mutable.macroClass,
|
|
3554
|
+
score: mutable.score,
|
|
3555
|
+
reason: "full-frame-guard"
|
|
3556
|
+
});
|
|
3637
3557
|
continue;
|
|
3638
3558
|
}
|
|
3639
|
-
if (isFullFrameSurvivor(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) this.opts.logger?.
|
|
3559
|
+
if (isFullFrameSurvivor(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) this.opts.logger?.info("full-frame box SURVIVED the guard on score", {
|
|
3640
3560
|
tags: { deviceId },
|
|
3641
3561
|
meta: {
|
|
3642
3562
|
macroClass: mutable.macroClass,
|
|
@@ -3663,6 +3583,7 @@ var PipelineExecutor = class {
|
|
|
3663
3583
|
firstLevel.push(mutable);
|
|
3664
3584
|
}
|
|
3665
3585
|
}
|
|
3586
|
+
const totalMs = Date.now() - startMs;
|
|
3666
3587
|
return {
|
|
3667
3588
|
result: buildFrameResult({
|
|
3668
3589
|
deviceId,
|
|
@@ -3670,8 +3591,9 @@ var PipelineExecutor = class {
|
|
|
3670
3591
|
height: imageHeight,
|
|
3671
3592
|
firstLevel,
|
|
3672
3593
|
details,
|
|
3594
|
+
discarded,
|
|
3673
3595
|
stepTimings,
|
|
3674
|
-
totalMs
|
|
3596
|
+
totalMs,
|
|
3675
3597
|
engine: this.opts.engineRuntime,
|
|
3676
3598
|
debug,
|
|
3677
3599
|
timestamp: Date.now(),
|
|
@@ -3776,13 +3698,40 @@ var PipelineExecutor = class {
|
|
|
3776
3698
|
});
|
|
3777
3699
|
throw new Error(`Inference failed for step "${step.stepId}": ${errorMsg}`, { cause: err });
|
|
3778
3700
|
}
|
|
3701
|
+
if (engineOutput.dropped === true) {
|
|
3702
|
+
stepTimings.push({
|
|
3703
|
+
source: step.stepId,
|
|
3704
|
+
modelId: step.modelId,
|
|
3705
|
+
ms: Date.now() - preprocessStart,
|
|
3706
|
+
detectionCount: 0
|
|
3707
|
+
});
|
|
3708
|
+
if (traceBuilder.isActive) traceBuilder.addStep({
|
|
3709
|
+
stepId: step.stepId,
|
|
3710
|
+
modelId: step.modelId,
|
|
3711
|
+
slot: step.definition.slot,
|
|
3712
|
+
postprocessor: step.definition.postprocessor,
|
|
3713
|
+
preprocessMs,
|
|
3714
|
+
inferenceMs: Date.now() - inferenceStart,
|
|
3715
|
+
postprocessMs: 0,
|
|
3716
|
+
inputType,
|
|
3717
|
+
inputWidth,
|
|
3718
|
+
inputHeight,
|
|
3719
|
+
parentDetection: parentClass,
|
|
3720
|
+
output: {
|
|
3721
|
+
kind: "detections",
|
|
3722
|
+
detections: []
|
|
3723
|
+
},
|
|
3724
|
+
error: `shed:${engineOutput.shedReason ?? "pool-overload"}`
|
|
3725
|
+
});
|
|
3726
|
+
return null;
|
|
3727
|
+
}
|
|
3779
3728
|
const structured = engineOutput.structured ?? {};
|
|
3780
3729
|
const inferenceMs = typeof structured.inferenceMs === "number" ? structured.inferenceMs : Date.now() - inferenceStart;
|
|
3781
3730
|
if (typeof structured.preprocessMs === "number") poolAgg.preprocessMs += structured.preprocessMs;
|
|
3782
3731
|
if (typeof structured.predictMs === "number") poolAgg.predictMs += structured.predictMs;
|
|
3783
3732
|
if (typeof structured.batchSize === "number" && structured.batchSize > poolAgg.batchSize) poolAgg.batchSize = structured.batchSize;
|
|
3784
3733
|
const postprocessStart = Date.now();
|
|
3785
|
-
const output = dispatchPostprocess(engineOutput, step.definition
|
|
3734
|
+
const output = dispatchPostprocess(engineOutput, step.definition);
|
|
3786
3735
|
const postprocessMs = Date.now() - postprocessStart;
|
|
3787
3736
|
stepTimings.push({
|
|
3788
3737
|
source: step.stepId,
|
|
@@ -3925,6 +3874,7 @@ var PipelineExecutor = class {
|
|
|
3925
3874
|
data: cropJpegBuf
|
|
3926
3875
|
}, cropW, cropH, "crop-roi", parentDetection.bbox, parentDetection.macroClass, traceBuilder, stepTimings, poolAgg);
|
|
3927
3876
|
const childMs = Date.now() - childStart;
|
|
3877
|
+
if (childOutput === null) continue;
|
|
3928
3878
|
const detailsBefore = ctx.details.length;
|
|
3929
3879
|
applyChildOutput(parentDetection, child, childOutput, childMs, ctx);
|
|
3930
3880
|
if (childOutput.kind === "detections" && child.children.length > 0) {
|
|
@@ -3958,20 +3908,25 @@ var PipelineExecutor = class {
|
|
|
3958
3908
|
* Apply the object-detection step's macro filter + per-macro
|
|
3959
3909
|
* minConfidence sliders introduced in the Phase 6 step rework.
|
|
3960
3910
|
*
|
|
3911
|
+
* Returns WHICH gate failed rather than a bare boolean so the
|
|
3912
|
+
* discard trail can label the dropped box with the honest reason
|
|
3913
|
+
* (`class-filter` vs `below-threshold`) — the two are remediated
|
|
3914
|
+
* differently (enable the class vs lower a slider).
|
|
3915
|
+
*
|
|
3961
3916
|
* Expected `settings` shape:
|
|
3962
3917
|
* - `enabledMacroClasses`: readonly string[] (e.g. ['person','vehicle','animal']).
|
|
3963
3918
|
* Empty array = all allowed (legacy behaviour).
|
|
3964
3919
|
* - `minConfidence<Macro>`: number (e.g. `minConfidencePerson: 0.5`)
|
|
3965
3920
|
*/
|
|
3966
|
-
|
|
3967
|
-
if (!settings) return
|
|
3921
|
+
macroFilterVerdict(macroClass, score, settings) {
|
|
3922
|
+
if (!settings) return "pass";
|
|
3968
3923
|
const enabled = settings["enabledMacroClasses"];
|
|
3969
3924
|
if (Array.isArray(enabled) && enabled.length > 0) {
|
|
3970
|
-
if (!enabled.includes(macroClass)) return
|
|
3925
|
+
if (!enabled.includes(macroClass)) return "class-filter";
|
|
3971
3926
|
}
|
|
3972
3927
|
const perMacroThreshold = settings[`minConfidence${capitalize(macroClass)}`];
|
|
3973
|
-
if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return
|
|
3974
|
-
return
|
|
3928
|
+
if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return "below-threshold";
|
|
3929
|
+
return "pass";
|
|
3975
3930
|
}
|
|
3976
3931
|
};
|
|
3977
3932
|
function capitalize(s) {
|
|
@@ -3979,52 +3934,41 @@ function capitalize(s) {
|
|
|
3979
3934
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
3980
3935
|
}
|
|
3981
3936
|
//#endregion
|
|
3982
|
-
//#region src/detection-pipeline/pipeline/crop-
|
|
3937
|
+
//#region src/detection-pipeline/pipeline/native-crop-compose.ts
|
|
3938
|
+
/** Clamp `v` into `[lo, hi]`. */
|
|
3939
|
+
function clamp(v, lo, hi) {
|
|
3940
|
+
if (v < lo) return lo;
|
|
3941
|
+
if (v > hi) return hi;
|
|
3942
|
+
return v;
|
|
3943
|
+
}
|
|
3983
3944
|
/**
|
|
3984
|
-
*
|
|
3985
|
-
*
|
|
3986
|
-
*
|
|
3987
|
-
*
|
|
3945
|
+
* Compose a child `roi` given in the parent CROP's normalized space into the
|
|
3946
|
+
* FRAME's normalized space, given the crop's frame-space pixel rectangle and the
|
|
3947
|
+
* frame dimensions. The result is clamped to the unit square (origin in
|
|
3948
|
+
* `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
|
|
3949
|
+
* degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
|
|
3950
|
+
* clamps to zero area) so the caller falls back to the downscaled tile crop.
|
|
3988
3951
|
*
|
|
3989
|
-
*
|
|
3990
|
-
* referenced geometry, or a degenerate result — in which case the caller falls
|
|
3991
|
-
* back to full-frame inference (behaviour identical to before the crop-zone
|
|
3992
|
-
* plumbing).
|
|
3952
|
+
* Pure — never mutates its arguments.
|
|
3993
3953
|
*/
|
|
3994
|
-
function
|
|
3995
|
-
if (frameWidth
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
const
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
const
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
if (point.x < minX) minX = point.x;
|
|
4013
|
-
if (point.y < minY) minY = point.y;
|
|
4014
|
-
if (point.x > maxX) maxX = point.x;
|
|
4015
|
-
if (point.y > maxY) maxY = point.y;
|
|
4016
|
-
}
|
|
4017
|
-
}
|
|
4018
|
-
if (!sawPoint) return null;
|
|
4019
|
-
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
4020
|
-
const bbox = [
|
|
4021
|
-
clamp01(minX) * frameWidth,
|
|
4022
|
-
clamp01(minY) * frameHeight,
|
|
4023
|
-
clamp01(maxX) * frameWidth,
|
|
4024
|
-
clamp01(maxY) * frameHeight
|
|
4025
|
-
];
|
|
4026
|
-
if (isBboxDegenerate(bbox)) return null;
|
|
4027
|
-
return bbox;
|
|
3954
|
+
function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
|
|
3955
|
+
if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
|
|
3956
|
+
if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
|
|
3957
|
+
const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
|
|
3958
|
+
const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
|
|
3959
|
+
const fw = roi.w * cropFrameSpace.w / frameWidth;
|
|
3960
|
+
const fh = roi.h * cropFrameSpace.h / frameHeight;
|
|
3961
|
+
const x = clamp(fx, 0, 1);
|
|
3962
|
+
const y = clamp(fy, 0, 1);
|
|
3963
|
+
const w = clamp(fw, 0, 1 - x);
|
|
3964
|
+
const h = clamp(fh, 0, 1 - y);
|
|
3965
|
+
if (!(w > 0) || !(h > 0)) return null;
|
|
3966
|
+
return {
|
|
3967
|
+
x,
|
|
3968
|
+
y,
|
|
3969
|
+
w,
|
|
3970
|
+
h
|
|
3971
|
+
};
|
|
4028
3972
|
}
|
|
4029
3973
|
//#endregion
|
|
4030
3974
|
//#region src/detection-pipeline/pipeline/tree-builder.ts
|
|
@@ -4260,6 +4204,44 @@ function resolveInputSteps(steps, format, engine) {
|
|
|
4260
4204
|
};
|
|
4261
4205
|
}
|
|
4262
4206
|
//#endregion
|
|
4207
|
+
//#region src/detection-pipeline/zone-gate.ts
|
|
4208
|
+
function applyZoneRuleGate(result, zones, rules) {
|
|
4209
|
+
if (result.detections.length === 0) return result;
|
|
4210
|
+
if (zones.length === 0 || rules.length === 0) return result;
|
|
4211
|
+
const frameW = result.width;
|
|
4212
|
+
const frameH = result.height;
|
|
4213
|
+
if (frameW === 0 || frameH === 0) return result;
|
|
4214
|
+
const firstLevel = result.detections.filter((d) => d.kind === "first-level");
|
|
4215
|
+
const details = result.detections.filter((d) => d.kind === "detail");
|
|
4216
|
+
const { passed, excluded } = require_dist.evaluateZoneRules(firstLevel, zones, rules, (det) => ({
|
|
4217
|
+
x: (det.bbox.x + det.bbox.width / 2) / frameW,
|
|
4218
|
+
y: (det.bbox.y + det.bbox.height / 2) / frameH
|
|
4219
|
+
}), (det) => det.macroClass);
|
|
4220
|
+
if (passed.length === firstLevel.length) return result;
|
|
4221
|
+
const passedIds = new Set(passed.map((d) => d.id));
|
|
4222
|
+
const filteredDetails = details.filter((d) => {
|
|
4223
|
+
const parentId = d.parentId;
|
|
4224
|
+
return parentId === void 0 || passedIds.has(parentId);
|
|
4225
|
+
});
|
|
4226
|
+
const zoneDiscards = excluded.map((d) => ({
|
|
4227
|
+
bbox: [
|
|
4228
|
+
d.bbox.x,
|
|
4229
|
+
d.bbox.y,
|
|
4230
|
+
d.bbox.x + d.bbox.width,
|
|
4231
|
+
d.bbox.y + d.bbox.height
|
|
4232
|
+
],
|
|
4233
|
+
macroClass: d.macroClass,
|
|
4234
|
+
score: d.score,
|
|
4235
|
+
reason: "zone-gate"
|
|
4236
|
+
}));
|
|
4237
|
+
const mergedDiscarded = [...result.discarded ?? [], ...zoneDiscards];
|
|
4238
|
+
return {
|
|
4239
|
+
...result,
|
|
4240
|
+
detections: [...passed, ...filteredDetails],
|
|
4241
|
+
...mergedDiscarded.length > 0 ? { discarded: mergedDiscarded } : {}
|
|
4242
|
+
};
|
|
4243
|
+
}
|
|
4244
|
+
//#endregion
|
|
4263
4245
|
//#region src/detection-pipeline/provider.ts
|
|
4264
4246
|
/**
|
|
4265
4247
|
* DetectionPipelineProvider — implements IPipelineExecutorProvider.
|
|
@@ -4961,8 +4943,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
4961
4943
|
modelsDir: this.modelsDir,
|
|
4962
4944
|
logger: this.log.child("engine-verify"),
|
|
4963
4945
|
pythonPath: this.executorOptions.pythonPath,
|
|
4964
|
-
|
|
4965
|
-
|
|
4946
|
+
provisioning: {
|
|
4947
|
+
concurrency: 1,
|
|
4948
|
+
numWorkers: 1
|
|
4949
|
+
}
|
|
4966
4950
|
});
|
|
4967
4951
|
try {
|
|
4968
4952
|
await probe.initialize([]);
|
|
@@ -5656,19 +5640,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
5656
5640
|
emit(`Image: ${imageWidth}×${imageHeight}`);
|
|
5657
5641
|
}
|
|
5658
5642
|
decodeMs = performance.now() - decodeT0;
|
|
5659
|
-
const
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
backend: this.currentEngine.backend,
|
|
5669
|
-
device: this.currentEngine.device ?? null
|
|
5670
|
-
};
|
|
5671
|
-
const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
|
|
5643
|
+
const dispatchResolution = this.resolveStepsForDispatch({
|
|
5644
|
+
steps: input.steps,
|
|
5645
|
+
deviceKey: input.deviceKey,
|
|
5646
|
+
engineOverride: input.engine,
|
|
5647
|
+
deviceId: input.deviceId,
|
|
5648
|
+
plane: input.plane
|
|
5649
|
+
});
|
|
5650
|
+
const dispatchDeviceKey = dispatchResolution.deviceKey;
|
|
5651
|
+
const benchmarkSteps = dispatchResolution.steps;
|
|
5672
5652
|
const stepsSignature = JSON.stringify(benchmarkSteps.map((s) => ({
|
|
5673
5653
|
id: s.addonId,
|
|
5674
5654
|
settings: s.settings ?? {}
|
|
@@ -5718,9 +5698,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
5718
5698
|
modelsDir: this.modelsDir,
|
|
5719
5699
|
logger: this.log.child("engine-override"),
|
|
5720
5700
|
pythonPath: this.executorOptions.pythonPath ?? "",
|
|
5721
|
-
|
|
5722
|
-
tuning: this.executorOptions.tuning,
|
|
5723
|
-
numWorkers: this.executorOptions.numWorkers
|
|
5701
|
+
provisioning: this.executorOptions.provisioning
|
|
5724
5702
|
});
|
|
5725
5703
|
const initPromise = newFactory.initialize([]);
|
|
5726
5704
|
this.overrideCache = {
|
|
@@ -5762,8 +5740,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
5762
5740
|
logger: this.log
|
|
5763
5741
|
});
|
|
5764
5742
|
this.executor = executor;
|
|
5765
|
-
const dispatchEngine =
|
|
5766
|
-
const dispatchFactory =
|
|
5743
|
+
const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
|
|
5744
|
+
const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
|
|
5767
5745
|
const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
|
|
5768
5746
|
if (needed.length > 0) {
|
|
5769
5747
|
this.log.info("Benchmark: models to load", { meta: {
|
|
@@ -5900,9 +5878,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
5900
5878
|
modelsDir: this.modelsDir,
|
|
5901
5879
|
logger: this.log.child("engine-override"),
|
|
5902
5880
|
pythonPath: this.executorOptions.pythonPath ?? "",
|
|
5903
|
-
|
|
5904
|
-
tuning: this.executorOptions.tuning,
|
|
5905
|
-
numWorkers: this.executorOptions.numWorkers
|
|
5881
|
+
provisioning: this.executorOptions.provisioning
|
|
5906
5882
|
});
|
|
5907
5883
|
const initPromise = newFactory.initialize([]);
|
|
5908
5884
|
this.overrideCache = {
|
|
@@ -6014,6 +5990,142 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6014
5990
|
fn();
|
|
6015
5991
|
}
|
|
6016
5992
|
/**
|
|
5993
|
+
* Resolve a dispatch's step tree against its target engine, with the
|
|
5994
|
+
* CAPABILITY GATE + node-local engine fallback (cam-615, 2026-08-01;
|
|
5995
|
+
* per-step split 2026-08-01 evening).
|
|
5996
|
+
*
|
|
5997
|
+
* A per-call `deviceKey` (orchestrator device routing / detail-plane step
|
|
5998
|
+
* jump) selects an engine whose format every step EXECUTING IN THIS CALL
|
|
5999
|
+
* must actually ship a model build for. When such a step's catalog has
|
|
6000
|
+
* ZERO builds for that format (so not even `resolveModelForFormat`'s
|
|
6001
|
+
* substitution can rescue it), dispatching anyway means every frame
|
|
6002
|
+
* fails — before this gate it failed as a doomed 3-retry model download,
|
|
6003
|
+
* ~5.5s per frame, forever.
|
|
6004
|
+
*
|
|
6005
|
+
* The gated set is per-plane — the ROOT decides the dispatch device, a
|
|
6006
|
+
* child never bounces it (operator requirement: devices compensate for
|
|
6007
|
+
* each other's missing steps; the original whole-tree gate let ONE
|
|
6008
|
+
* tflite-less child knock the root off an otherwise-capable Coral):
|
|
6009
|
+
*
|
|
6010
|
+
* - `'frame'` (the live per-frame path) executes only root-plane steps,
|
|
6011
|
+
* so only those gate the device. Detail children that can NEVER run on
|
|
6012
|
+
* this device are pruned from THIS dispatch (so `ensureModelsForSteps`
|
|
6013
|
+
* doesn't fail the call on a format build that does not exist) and
|
|
6014
|
+
* logged once — NOT dropped work: each runs later as its own per-track
|
|
6015
|
+
* detail dispatch, where the runner's device-jump resolver
|
|
6016
|
+
* (`resolveStepDevice`) places it on a same-node device that can run
|
|
6017
|
+
* it, or warns no-candidate loudly.
|
|
6018
|
+
* - any other plane (detail subtrees, benchmark, batch) executes the
|
|
6019
|
+
* whole passed tree inline on ONE pool — a single call cannot split
|
|
6020
|
+
* devices — so the whole-tree gate stays.
|
|
6021
|
+
*
|
|
6022
|
+
* Fallback is strictly NODE-LOCAL and ordered: the node's own selected
|
|
6023
|
+
* engine (or the benchmark's explicit `engineOverride`) is the one and only
|
|
6024
|
+
* fallback — frames never migrate to another node from here (that is the
|
|
6025
|
+
* orchestrator's tier). The fallback is logged ONCE per distinct
|
|
6026
|
+
* (deviceKey, steps, format) signature, with `tags: { deviceId }` when the
|
|
6027
|
+
* dispatch is camera-scoped.
|
|
6028
|
+
*
|
|
6029
|
+
* When the node's own engine ALSO cannot run a step, this throws — fast,
|
|
6030
|
+
* before any download attempt — and logs the full picture once (model,
|
|
6031
|
+
* its available formats, both engines tried). A genuinely impossible
|
|
6032
|
+
* configuration must stay loud, not vanish into a silent fallback chain.
|
|
6033
|
+
*/
|
|
6034
|
+
resolveStepsForDispatch(args) {
|
|
6035
|
+
const { deviceKey, engineOverride, deviceId } = args;
|
|
6036
|
+
const nodeFormat = engineOverride?.format ?? this.currentEngine.format;
|
|
6037
|
+
const nodeEngine = engineOverride ? {
|
|
6038
|
+
backend: engineOverride.backend,
|
|
6039
|
+
device: engineOverride.device ?? null
|
|
6040
|
+
} : {
|
|
6041
|
+
backend: this.currentEngine.backend,
|
|
6042
|
+
device: this.currentEngine.device ?? null
|
|
6043
|
+
};
|
|
6044
|
+
const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
|
|
6045
|
+
const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
|
|
6046
|
+
if (deviceKey === void 0 || deviceEngine === void 0) {
|
|
6047
|
+
const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
|
|
6048
|
+
this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
|
|
6049
|
+
return {
|
|
6050
|
+
deviceKey: void 0,
|
|
6051
|
+
steps
|
|
6052
|
+
};
|
|
6053
|
+
}
|
|
6054
|
+
const deviceFormat = deviceEngine.format;
|
|
6055
|
+
const deviceStepEngine = {
|
|
6056
|
+
backend: deviceEngine.backend,
|
|
6057
|
+
device: deviceEngine.device ?? null
|
|
6058
|
+
};
|
|
6059
|
+
const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
|
|
6060
|
+
const framePlane = args.plane === "frame";
|
|
6061
|
+
const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
|
|
6062
|
+
if (zeroBuild.length === 0) {
|
|
6063
|
+
if (!framePlane) return {
|
|
6064
|
+
deviceKey,
|
|
6065
|
+
steps
|
|
6066
|
+
};
|
|
6067
|
+
const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
|
|
6068
|
+
if (pruneResult.prunedAddonIds.length > 0) {
|
|
6069
|
+
const prunedList = pruneResult.prunedAddonIds.join(",");
|
|
6070
|
+
this.logLiveDispatchIssueOnce(`detail-defer:${deviceKey}:${deviceFormat}:${prunedList}`, () => this.log.info("Detail steps have no model build for the dispatch device format — deferred to the detail-plane device jump", {
|
|
6071
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
6072
|
+
meta: {
|
|
6073
|
+
deviceKey,
|
|
6074
|
+
deviceFormat,
|
|
6075
|
+
steps: [...pruneResult.prunedAddonIds]
|
|
6076
|
+
}
|
|
6077
|
+
}));
|
|
6078
|
+
}
|
|
6079
|
+
return {
|
|
6080
|
+
deviceKey,
|
|
6081
|
+
steps: pruneResult.steps
|
|
6082
|
+
};
|
|
6083
|
+
}
|
|
6084
|
+
const blockedSteps = zeroBuild.map((issue) => issue.addonId);
|
|
6085
|
+
this.logLiveDispatchIssueOnce(`capability-fallback:${deviceKey}:${deviceFormat}:${blockedSteps.join(",")}`, () => this.log.warn("Device pool cannot run step (no model build for its format) — falling back to the node-local default engine", {
|
|
6086
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
6087
|
+
meta: {
|
|
6088
|
+
deviceKey,
|
|
6089
|
+
deviceFormat,
|
|
6090
|
+
steps: blockedSteps,
|
|
6091
|
+
fallbackEngine: nodeEngineLabel
|
|
6092
|
+
}
|
|
6093
|
+
}));
|
|
6094
|
+
const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
|
|
6095
|
+
this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
|
|
6096
|
+
return {
|
|
6097
|
+
deviceKey: void 0,
|
|
6098
|
+
steps: fallbackSteps
|
|
6099
|
+
};
|
|
6100
|
+
}
|
|
6101
|
+
/**
|
|
6102
|
+
* The loud, fast half of the capability gate: throw when any ENABLED step
|
|
6103
|
+
* has ZERO model builds for `format` — BEFORE any model download runs. The
|
|
6104
|
+
* error names the step, its chosen model, the formats the catalog actually
|
|
6105
|
+
* ships, and every engine that was tried, and is logged ONCE per distinct
|
|
6106
|
+
* signature (the throw itself still surfaces per dispatch, but costs
|
|
6107
|
+
* microseconds instead of a 3-retry download backoff).
|
|
6108
|
+
*/
|
|
6109
|
+
assertStepsRunnable(steps, format, deviceId, enginesTried) {
|
|
6110
|
+
const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format);
|
|
6111
|
+
if (zeroBuild.length === 0) return;
|
|
6112
|
+
const detail = zeroBuild.map((issue) => {
|
|
6113
|
+
const step = flattenSteps(steps).find((s) => s.addonId === issue.addonId);
|
|
6114
|
+
const formats = availableFormatsForStep(issue.addonId);
|
|
6115
|
+
return `step "${issue.addonId}" (model "${step?.modelId ?? "unknown"}") has no ${format} build (available: ${formats.join(", ") || "none"})`;
|
|
6116
|
+
}).join("; ");
|
|
6117
|
+
const message = `Pipeline dispatch impossible on this node: ${detail}. Engines tried: ${enginesTried.join(" → ")}.`;
|
|
6118
|
+
this.logLiveDispatchIssueOnce(`unrunnable:${format}:${zeroBuild.map((i) => i.addonId).join(",")}`, () => this.log.error("Pipeline step cannot run on any node-local engine", {
|
|
6119
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
6120
|
+
meta: {
|
|
6121
|
+
format,
|
|
6122
|
+
enginesTried: [...enginesTried],
|
|
6123
|
+
detail
|
|
6124
|
+
}
|
|
6125
|
+
}));
|
|
6126
|
+
throw new Error(message);
|
|
6127
|
+
}
|
|
6128
|
+
/**
|
|
6017
6129
|
* Single-flight gate around `ensureModelsForSteps`. Concurrent
|
|
6018
6130
|
* callers (every camera that fires motion in the same window calls
|
|
6019
6131
|
* `runPipeline` → `ensureModelsForSteps`) share the same in-flight
|
|
@@ -6040,6 +6152,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6040
6152
|
for (const step of needed) {
|
|
6041
6153
|
const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
|
|
6042
6154
|
if (modelEntry && !require_model_download_service_Cp9f4dk6.isModelDownloaded(this.modelsDir, modelEntry, format)) {
|
|
6155
|
+
if (modelEntry.formats[format] === void 0) throw new Error(`Model "${modelEntry.id}" has no ${format} format build (available: ${Object.keys(modelEntry.formats).join(", ") || "none"}) — not retrying a permanent format mismatch`);
|
|
6043
6156
|
this.log.info("Downloading model for step", { meta: {
|
|
6044
6157
|
modelId: step.modelId,
|
|
6045
6158
|
format,
|
|
@@ -6157,19 +6270,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6157
6270
|
}
|
|
6158
6271
|
async runPipelineBatchImpl(input) {
|
|
6159
6272
|
if (input.frames.length === 0) return { results: [] };
|
|
6160
|
-
const
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
device: this.currentEngine.device ?? null
|
|
6171
|
-
};
|
|
6172
|
-
const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
|
|
6273
|
+
const dispatchResolution = this.resolveStepsForDispatch({
|
|
6274
|
+
steps: input.steps,
|
|
6275
|
+
deviceKey: input.deviceKey,
|
|
6276
|
+
engineOverride: input.engine,
|
|
6277
|
+
deviceId: input.deviceId,
|
|
6278
|
+
plane: void 0
|
|
6279
|
+
});
|
|
6280
|
+
const dispatchDeviceKey = dispatchResolution.deviceKey;
|
|
6281
|
+
const resolveFormat = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey).format : input.engine?.format ?? this.currentEngine.format;
|
|
6282
|
+
const benchmarkSteps = dispatchResolution.steps;
|
|
6173
6283
|
const enabledSteps = flattenEnabledVideoSteps(benchmarkSteps);
|
|
6174
6284
|
if (enabledSteps.length === 0) throw new Error("runPipelineBatch: no enabled steps");
|
|
6175
6285
|
const rootStep = enabledSteps[0];
|
|
@@ -6191,7 +6301,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6191
6301
|
await this.ensureEngineFactory();
|
|
6192
6302
|
const restoreEngine = await this.applyEngineOverride(input.engine);
|
|
6193
6303
|
try {
|
|
6194
|
-
const factory =
|
|
6304
|
+
const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
|
|
6195
6305
|
if (!factory) throw new Error("runPipelineBatch: factory not initialised");
|
|
6196
6306
|
if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
|
|
6197
6307
|
const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
|
|
@@ -6208,7 +6318,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6208
6318
|
frame,
|
|
6209
6319
|
deviceId: input.deviceId,
|
|
6210
6320
|
sessionId: input.sessionId,
|
|
6211
|
-
deviceKey:
|
|
6321
|
+
deviceKey: dispatchDeviceKey
|
|
6212
6322
|
}))) };
|
|
6213
6323
|
const items = input.frames.map((frame) => ({
|
|
6214
6324
|
raw: Buffer.from(frame.data),
|
|
@@ -6393,31 +6503,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6393
6503
|
* detections).
|
|
6394
6504
|
*/
|
|
6395
6505
|
gateDetectionsByZoneRules(deviceId, result) {
|
|
6396
|
-
if (deviceId <= 0
|
|
6506
|
+
if (deviceId <= 0) return result;
|
|
6397
6507
|
const proxy = this.deviceProxies.get(deviceId);
|
|
6398
6508
|
if (!proxy) return result;
|
|
6399
|
-
|
|
6400
|
-
const rules = proxy.state.zoneRules.value?.detection ?? [];
|
|
6401
|
-
if (zones.length === 0 || rules.length === 0) return result;
|
|
6402
|
-
const frameW = result.width;
|
|
6403
|
-
const frameH = result.height;
|
|
6404
|
-
if (frameW === 0 || frameH === 0) return result;
|
|
6405
|
-
const firstLevel = result.detections.filter((d) => d.kind === "first-level");
|
|
6406
|
-
const details = result.detections.filter((d) => d.kind === "detail");
|
|
6407
|
-
const { passed } = require_dist.evaluateZoneRules(firstLevel, zones, rules, (det) => ({
|
|
6408
|
-
x: (det.bbox.x + det.bbox.width / 2) / frameW,
|
|
6409
|
-
y: (det.bbox.y + det.bbox.height / 2) / frameH
|
|
6410
|
-
}), (det) => det.macroClass);
|
|
6411
|
-
if (passed.length === firstLevel.length) return result;
|
|
6412
|
-
const passedIds = new Set(passed.map((d) => d.id));
|
|
6413
|
-
const filteredDetails = details.filter((d) => {
|
|
6414
|
-
const parentId = d.parentId;
|
|
6415
|
-
return parentId === void 0 || passedIds.has(parentId);
|
|
6416
|
-
});
|
|
6417
|
-
return {
|
|
6418
|
-
...result,
|
|
6419
|
-
detections: [...passed, ...filteredDetails]
|
|
6420
|
-
};
|
|
6509
|
+
return applyZoneRuleGate(result, proxy.state.zones.value?.zones ?? [], proxy.state.zoneRules.value?.detection ?? []);
|
|
6421
6510
|
}
|
|
6422
6511
|
async cacheFrameInPool(input) {
|
|
6423
6512
|
await this.ensureEngineFactory();
|
|
@@ -6434,13 +6523,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6434
6523
|
if (!this.engineFactory) return;
|
|
6435
6524
|
await this.engineFactory.uncacheFrame(input.frameId);
|
|
6436
6525
|
}
|
|
6526
|
+
/**
|
|
6527
|
+
* The tuning of the node's DEFAULT pool. Per-device pools each resolve their
|
|
6528
|
+
* own runtime's row, so this is not "the node's tuning" — the per-pool truth
|
|
6529
|
+
* is on each pool's `Python inference pool` log line and in `listLoadedEngines`.
|
|
6530
|
+
*/
|
|
6437
6531
|
async getEffectiveTuning() {
|
|
6438
|
-
const
|
|
6532
|
+
const p = resolvePoolProvisioning(this.currentEngine, this.executorOptions.provisioning);
|
|
6439
6533
|
return {
|
|
6440
|
-
batchMode:
|
|
6441
|
-
windowMs:
|
|
6442
|
-
maxBatchSize:
|
|
6443
|
-
concurrency:
|
|
6534
|
+
batchMode: p.batchMode,
|
|
6535
|
+
windowMs: p.windowMs,
|
|
6536
|
+
maxBatchSize: p.maxBatchSize,
|
|
6537
|
+
concurrency: p.concurrency
|
|
6444
6538
|
};
|
|
6445
6539
|
}
|
|
6446
6540
|
/**
|
|
@@ -6458,9 +6552,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6458
6552
|
modelsDir: this.modelsDir,
|
|
6459
6553
|
logger: this.log.child("engine"),
|
|
6460
6554
|
pythonPath: this.executorOptions.pythonPath ?? "",
|
|
6461
|
-
|
|
6462
|
-
tuning: this.executorOptions.tuning,
|
|
6463
|
-
numWorkers: this.executorOptions.numWorkers
|
|
6555
|
+
provisioning: this.executorOptions.provisioning
|
|
6464
6556
|
});
|
|
6465
6557
|
await this.engineFactory.initialize([]);
|
|
6466
6558
|
}
|
|
@@ -6499,13 +6591,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6499
6591
|
modelsDir: this.modelsDir,
|
|
6500
6592
|
logger: this.log.child(`engine:${deviceKey}`),
|
|
6501
6593
|
pythonPath: this.executorOptions.pythonPath ?? "",
|
|
6502
|
-
|
|
6503
|
-
tuning: this.executorOptions.tuning,
|
|
6504
|
-
numWorkers: this.executorOptions.numWorkers
|
|
6594
|
+
provisioning: this.executorOptions.provisioning
|
|
6505
6595
|
});
|
|
6506
6596
|
try {
|
|
6507
6597
|
await factory.initialize([]);
|
|
6508
6598
|
} catch (err) {
|
|
6599
|
+
this.log.error("Device pool failed to initialize", { meta: {
|
|
6600
|
+
deviceKey,
|
|
6601
|
+
backend: engine.backend,
|
|
6602
|
+
device: engine.device ?? null,
|
|
6603
|
+
provisioning: factory.provisioning,
|
|
6604
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6605
|
+
} });
|
|
6509
6606
|
await factory.dispose().catch(() => void 0);
|
|
6510
6607
|
throw err;
|
|
6511
6608
|
}
|
|
@@ -6999,12 +7096,49 @@ function stepsToPipelineConfig(steps, engine) {
|
|
|
6999
7096
|
} : {}
|
|
7000
7097
|
};
|
|
7001
7098
|
}
|
|
7002
|
-
/** Flatten a step tree into a flat list (DFS). */
|
|
7003
7099
|
/** Flatten a step tree into a flat list (DFS) — delegates to shared utility. */
|
|
7004
7100
|
function flattenSteps(steps) {
|
|
7005
7101
|
return flattenEnabledVideoSteps(steps);
|
|
7006
7102
|
}
|
|
7007
7103
|
/**
|
|
7104
|
+
* Catalog root-vs-detail plane of a step: a non-null `inputClasses` on the
|
|
7105
|
+
* DEFINITION marks a crop child served per-track on the detail plane —
|
|
7106
|
+
* mirrors the executor's frame-plane skip (`executeChildren`) and the
|
|
7107
|
+
* runner's `deriveDetailSteps`. Unknown addonIds (already dropped from
|
|
7108
|
+
* resolved trees) default to root-plane.
|
|
7109
|
+
*/
|
|
7110
|
+
function isDetailPlaneStep(addonId) {
|
|
7111
|
+
try {
|
|
7112
|
+
return require_step_definitions.getStepDefinition(addonId).inputClasses !== null;
|
|
7113
|
+
} catch {
|
|
7114
|
+
return false;
|
|
7115
|
+
}
|
|
7116
|
+
}
|
|
7117
|
+
/**
|
|
7118
|
+
* True when the step's FULL catalog ships no model with a build for
|
|
7119
|
+
* `format` — the single-step form of `collectZeroBuildIssues`. Unknown/custom
|
|
7120
|
+
* addonIds (no catalog to check) are never zero-build, mirroring the
|
|
7121
|
+
* collector's skip.
|
|
7122
|
+
*/
|
|
7123
|
+
function stepHasZeroBuildsFor(addonId, format) {
|
|
7124
|
+
return collectZeroBuildIssues([{ addonId }], format).length > 0;
|
|
7125
|
+
}
|
|
7126
|
+
/**
|
|
7127
|
+
* The union of model formats a step's catalog ships ANY build for — the
|
|
7128
|
+
* "Available: …" half of the capability-gate error. Unknown/custom addonIds
|
|
7129
|
+
* yield an empty list (no catalog to enumerate), never a throw.
|
|
7130
|
+
*/
|
|
7131
|
+
function availableFormatsForStep(addonId) {
|
|
7132
|
+
try {
|
|
7133
|
+
const def = require_step_definitions.getStepDefinition(addonId);
|
|
7134
|
+
const formats = /* @__PURE__ */ new Set();
|
|
7135
|
+
for (const model of def.models) for (const format of Object.keys(model.formats)) formats.add(format);
|
|
7136
|
+
return Array.from(formats).toSorted();
|
|
7137
|
+
} catch {
|
|
7138
|
+
return [];
|
|
7139
|
+
}
|
|
7140
|
+
}
|
|
7141
|
+
/**
|
|
7008
7142
|
* Return a shallow-cloned executable tree with `rootStepId`'s `settings`
|
|
7009
7143
|
* merged with `overrides` (per-device values win). Non-matching root steps
|
|
7010
7144
|
* pass through unchanged. Used by `runFrame` to apply per-device detection
|
|
@@ -7116,21 +7250,22 @@ function toNodePlatform(value) {
|
|
|
7116
7250
|
return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
|
|
7117
7251
|
}
|
|
7118
7252
|
/**
|
|
7119
|
-
*
|
|
7120
|
-
*
|
|
7121
|
-
*
|
|
7122
|
-
* these and rebuilds the provider when they flip.
|
|
7253
|
+
* Slider keys on the "Inference executor" section that map 1:1 onto a
|
|
7254
|
+
* {@link RuntimePoolProfile} field. Used to inject the node-default runtime's
|
|
7255
|
+
* registry value as the field's suggested default in `getGlobalSettings`.
|
|
7123
7256
|
*/
|
|
7124
|
-
var
|
|
7257
|
+
var PROFILE_SLIDER_KEYS = [
|
|
7125
7258
|
"concurrency",
|
|
7126
|
-
"
|
|
7259
|
+
"numWorkers",
|
|
7127
7260
|
"windowMs",
|
|
7128
7261
|
"maxBatchSize",
|
|
7129
7262
|
"numStreams",
|
|
7130
|
-
"intraOpThreads"
|
|
7131
|
-
"numWorkers"
|
|
7263
|
+
"intraOpThreads"
|
|
7132
7264
|
];
|
|
7133
|
-
|
|
7265
|
+
function isProfileSliderKey(key) {
|
|
7266
|
+
return PROFILE_SLIDER_KEYS.some((k) => k === key);
|
|
7267
|
+
}
|
|
7268
|
+
var DetectionPipelineAddon = class extends require_dist.BaseAddon {
|
|
7134
7269
|
provider = null;
|
|
7135
7270
|
/** Last non-null probed hardware PER NODE — reused when the probe transiently
|
|
7136
7271
|
* returns null so offered backends / device lists don't collapse. Keyed by
|
|
@@ -7145,14 +7280,17 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
|
|
|
7145
7280
|
* ENGINE_METRICS_HEARTBEAT_MS for liveness. */
|
|
7146
7281
|
lastEmittedEngineSnapshot = null;
|
|
7147
7282
|
/**
|
|
7148
|
-
* Last-applied
|
|
7149
|
-
*
|
|
7150
|
-
*
|
|
7151
|
-
*
|
|
7152
|
-
*
|
|
7153
|
-
*
|
|
7283
|
+
* Last-applied OPERATOR OVERRIDE, taken at the end of `onInitialize` and
|
|
7284
|
+
* refreshed at the end of `onConfigChanged`. Lets the change handler diff the
|
|
7285
|
+
* current config against the snapshot and decide whether a pool respawn is
|
|
7286
|
+
* necessary — most config edits (engine cascade, audio settings) don't touch
|
|
7287
|
+
* the tuning fields and don't deserve a multi-second model reload.
|
|
7288
|
+
*
|
|
7289
|
+
* The per-runtime registry values are compile-time constants resolved inside
|
|
7290
|
+
* each pool, so they can never be the reason for a respawn; only an operator
|
|
7291
|
+
* edit can.
|
|
7154
7292
|
*/
|
|
7155
|
-
|
|
7293
|
+
lastAppliedOverride = {};
|
|
7156
7294
|
/**
|
|
7157
7295
|
* Embedded Python path resolved once at boot via
|
|
7158
7296
|
* `ctx.deps.ensurePython()`. Empty string means the download failed
|
|
@@ -7320,17 +7458,16 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
|
|
|
7320
7458
|
...section,
|
|
7321
7459
|
fields: section.fields.map((field) => {
|
|
7322
7460
|
if (field.type === "select") {
|
|
7323
|
-
if (field.key === "batchMode"
|
|
7461
|
+
if (field.key === "batchMode") return {
|
|
7324
7462
|
...field,
|
|
7325
|
-
default: tuning
|
|
7463
|
+
default: tuning.batchMode
|
|
7326
7464
|
};
|
|
7327
7465
|
}
|
|
7328
7466
|
if (field.type === "slider" && "key" in field) {
|
|
7329
|
-
const tuned = tuning[field.key];
|
|
7330
7467
|
const sliderField = field;
|
|
7331
|
-
let patchedField =
|
|
7468
|
+
let patchedField = isProfileSliderKey(sliderField.key) ? {
|
|
7332
7469
|
...sliderField,
|
|
7333
|
-
default:
|
|
7470
|
+
default: tuning[sliderField.key]
|
|
7334
7471
|
} : sliderField;
|
|
7335
7472
|
if (sliderField.key === "concurrency" && backend === "coreml") patchedField = {
|
|
7336
7473
|
...patchedField,
|
|
@@ -7439,60 +7576,39 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
|
|
|
7439
7576
|
return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
|
|
7440
7577
|
}
|
|
7441
7578
|
/**
|
|
7442
|
-
*
|
|
7579
|
+
* The operator's explicit deviations from the per-runtime registry — and
|
|
7580
|
+
* NOTHING else.
|
|
7443
7581
|
*
|
|
7444
|
-
*
|
|
7445
|
-
*
|
|
7446
|
-
*
|
|
7447
|
-
*
|
|
7448
|
-
*
|
|
7582
|
+
* This used to be `resolveBackendTuning()`: it read `tuningFor(<the node's
|
|
7583
|
+
* default backend>)` and handed the whole resolved row to the provider, which
|
|
7584
|
+
* passed it verbatim to every pool it built. That is how an `openvino:npu`
|
|
7585
|
+
* pool on an onnx-default node came up with onnx's `concurrency: 4`. The
|
|
7586
|
+
* per-runtime values now resolve inside each pool
|
|
7587
|
+
* (`engine/pool-provisioning.ts`), keyed by that pool's own runtime, so
|
|
7588
|
+
* nothing backend-shaped may be computed here.
|
|
7449
7589
|
*
|
|
7450
|
-
*
|
|
7451
|
-
*
|
|
7452
|
-
*
|
|
7590
|
+
* Precedence, in full: an operator override below wins over the pool's
|
|
7591
|
+
* runtime registry row; an "Auto" field (slider `0`, select `''`) is not an
|
|
7592
|
+
* override. An override is node-wide by design — the operator set it against
|
|
7593
|
+
* a node, not against a runtime.
|
|
7453
7594
|
*/
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
|
|
7457
|
-
const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
|
|
7458
|
-
return {
|
|
7459
|
-
concurrency: num(t["concurrency"], 1),
|
|
7460
|
-
batchMode: batch(t["batchMode"], "window"),
|
|
7461
|
-
windowMs: num(t["windowMs"], 2),
|
|
7462
|
-
maxBatchSize: num(t["maxBatchSize"], 8),
|
|
7463
|
-
numStreams: num(t["numStreams"], 0),
|
|
7464
|
-
intraOpThreads: num(t["intraOpThreads"], 0),
|
|
7465
|
-
numWorkers: num(t["numWorkers"], 1)
|
|
7466
|
-
};
|
|
7595
|
+
resolveOperatorOverride() {
|
|
7596
|
+
return readOperatorOverride(this.config);
|
|
7467
7597
|
}
|
|
7468
7598
|
/**
|
|
7469
|
-
* Construct a fresh provider
|
|
7470
|
-
* truth for the provider wiring — shared by `onInitialize
|
|
7471
|
-
*
|
|
7599
|
+
* Construct a fresh provider carrying the operator override. Single source of
|
|
7600
|
+
* truth for the provider wiring — shared by `onInitialize` and the
|
|
7601
|
+
* `onConfigChanged` pool respawn. NOTHING backend-derived is passed: each
|
|
7602
|
+
* pool resolves its own runtime's registry row.
|
|
7472
7603
|
*/
|
|
7473
|
-
buildProvider(modelsDir,
|
|
7604
|
+
buildProvider(modelsDir, override) {
|
|
7474
7605
|
if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
|
|
7475
7606
|
return new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
|
|
7476
|
-
|
|
7477
|
-
tuning: {
|
|
7478
|
-
batchMode: tuning.batchMode,
|
|
7479
|
-
windowMs: tuning.windowMs,
|
|
7480
|
-
maxBatchSize: tuning.maxBatchSize,
|
|
7481
|
-
numStreams: tuning.numStreams,
|
|
7482
|
-
intraOpThreads: tuning.intraOpThreads
|
|
7483
|
-
},
|
|
7484
|
-
numWorkers: tuning.numWorkers,
|
|
7607
|
+
provisioning: override,
|
|
7485
7608
|
pythonPath: this.pythonPath,
|
|
7486
7609
|
pythonAddonDir: this.pythonAddonDir
|
|
7487
7610
|
});
|
|
7488
7611
|
}
|
|
7489
|
-
/**
|
|
7490
|
-
* Pool-bound tuning keys of `a` and `b` are equal (used to decide whether a
|
|
7491
|
-
* post-resolve / config-change respawn is actually needed).
|
|
7492
|
-
*/
|
|
7493
|
-
static poolTuningEqual(a, b) {
|
|
7494
|
-
return a.concurrency === b.concurrency && a.batchMode === b.batchMode && a.windowMs === b.windowMs && a.maxBatchSize === b.maxBatchSize && a.numStreams === b.numStreams && a.intraOpThreads === b.intraOpThreads && a.numWorkers === b.numWorkers;
|
|
7495
|
-
}
|
|
7496
7612
|
async onInitialize() {
|
|
7497
7613
|
const modelsDir = await this.resolveModelsDir();
|
|
7498
7614
|
if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
|
|
@@ -7501,23 +7617,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
|
|
|
7501
7617
|
if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
|
|
7502
7618
|
else this.ctx.logger.warn("Detection engine boot continues without Python — selection will provision on demand", {});
|
|
7503
7619
|
await this.proactivelyInstallOpenvino();
|
|
7504
|
-
|
|
7505
|
-
this.provider = this.buildProvider(modelsDir, initialTuning);
|
|
7620
|
+
this.provider = this.buildProvider(modelsDir, this.resolveOperatorOverride());
|
|
7506
7621
|
await this.provider.init();
|
|
7507
7622
|
await this.provider.setApi(this.ctx);
|
|
7508
|
-
const resolvedTuning = this.resolveBackendTuning();
|
|
7509
|
-
if (!DetectionPipelineAddon.poolTuningEqual(initialTuning, resolvedTuning)) {
|
|
7510
|
-
this.ctx.logger.info("detection-pipeline tuning reconciled to resolved engine", { meta: {
|
|
7511
|
-
from: initialTuning,
|
|
7512
|
-
to: resolvedTuning
|
|
7513
|
-
} });
|
|
7514
|
-
this.provider = this.buildProvider(modelsDir, resolvedTuning);
|
|
7515
|
-
await this.provider.init();
|
|
7516
|
-
await this.provider.setApi(this.ctx);
|
|
7517
|
-
}
|
|
7518
7623
|
await this.provider.warmPool();
|
|
7519
7624
|
this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
|
|
7520
|
-
this.
|
|
7625
|
+
this.lastAppliedOverride = this.resolveOperatorOverride();
|
|
7521
7626
|
return [{
|
|
7522
7627
|
capability: require_dist.pipelineExecutorCapability,
|
|
7523
7628
|
provider: this.provider
|
|
@@ -7603,36 +7708,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
|
|
|
7603
7708
|
}
|
|
7604
7709
|
}
|
|
7605
7710
|
/**
|
|
7606
|
-
* Snapshot the pool-bound subset of the EFFECTIVE tuning (post-
|
|
7607
|
-
* `resolveBackendTuning`). Stored config values for these fields are
|
|
7608
|
-
* ignored, so this snapshot only changes when `engineBackend` flips
|
|
7609
|
-
* onto a different `tuningFor` row.
|
|
7610
|
-
*/
|
|
7611
|
-
snapshotPoolConfig() {
|
|
7612
|
-
const t = this.resolveBackendTuning();
|
|
7613
|
-
return {
|
|
7614
|
-
concurrency: t.concurrency ?? 0,
|
|
7615
|
-
batchMode: t.batchMode,
|
|
7616
|
-
windowMs: t.windowMs,
|
|
7617
|
-
maxBatchSize: t.maxBatchSize,
|
|
7618
|
-
numStreams: t.numStreams,
|
|
7619
|
-
intraOpThreads: t.intraOpThreads,
|
|
7620
|
-
numWorkers: t.numWorkers
|
|
7621
|
-
};
|
|
7622
|
-
}
|
|
7623
|
-
poolConfigChanged() {
|
|
7624
|
-
const next = this.snapshotPoolConfig();
|
|
7625
|
-
const prev = this.lastAppliedPoolConfig;
|
|
7626
|
-
for (const k of POOL_BOUND_KEYS) if (next[k] !== prev[k]) return true;
|
|
7627
|
-
return false;
|
|
7628
|
-
}
|
|
7629
|
-
/**
|
|
7630
7711
|
* BaseAddon calls `onConfigChanged` after every settings write. This is the
|
|
7631
7712
|
* SOLE apply path for tuning changes — none of those fields declare
|
|
7632
7713
|
* `requiresRestart` anymore (an addon restart re-probes hardware + reloads
|
|
7633
|
-
* every model on every set, which is exactly what we want to avoid):
|
|
7634
|
-
*
|
|
7635
|
-
*
|
|
7714
|
+
* every model on every set, which is exactly what we want to avoid): a changed
|
|
7715
|
+
* OPERATOR OVERRIDE drives an in-place pool respawn. The per-runtime registry
|
|
7716
|
+
* values cannot change at runtime, so they can never trigger one. The engine
|
|
7636
7717
|
* SELECTION is no longer operator-facing — the node-default backend is derived
|
|
7637
7718
|
* once at boot (`setApi` → `resolveAutoEngine`) from static hardware, so there
|
|
7638
7719
|
* is no per-write engine re-select. The respawn applies optimistically — the
|
|
@@ -7640,14 +7721,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
|
|
|
7640
7721
|
* crashed), no addon bounce.
|
|
7641
7722
|
*/
|
|
7642
7723
|
async onConfigChanged() {
|
|
7643
|
-
|
|
7724
|
+
const next = this.resolveOperatorOverride();
|
|
7725
|
+
if (overridesEqual(next, this.lastAppliedOverride)) return;
|
|
7644
7726
|
if (!this.provider) {
|
|
7645
|
-
this.
|
|
7727
|
+
this.lastAppliedOverride = next;
|
|
7646
7728
|
return;
|
|
7647
7729
|
}
|
|
7648
|
-
this.ctx.logger.info("detection-pipeline tuning changed — respawning pool", { meta: {
|
|
7649
|
-
prev: this.
|
|
7650
|
-
next
|
|
7730
|
+
this.ctx.logger.info("detection-pipeline tuning override changed — respawning pool", { meta: {
|
|
7731
|
+
prev: this.lastAppliedOverride,
|
|
7732
|
+
next
|
|
7651
7733
|
} });
|
|
7652
7734
|
try {
|
|
7653
7735
|
await this.provider.shutdown();
|
|
@@ -7655,11 +7737,11 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
|
|
|
7655
7737
|
this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
7656
7738
|
}
|
|
7657
7739
|
const modelsDir = await this.resolveModelsDir();
|
|
7658
|
-
this.provider = this.buildProvider(modelsDir,
|
|
7740
|
+
this.provider = this.buildProvider(modelsDir, next);
|
|
7659
7741
|
await this.provider.init();
|
|
7660
7742
|
await this.provider.setApi(this.ctx);
|
|
7661
7743
|
await this.provider.warmPool();
|
|
7662
|
-
this.
|
|
7744
|
+
this.lastAppliedOverride = next;
|
|
7663
7745
|
this.ctx.logger.info("detection-pipeline pool respawn complete");
|
|
7664
7746
|
}
|
|
7665
7747
|
};
|