@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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { n as __require } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import { B as errMsg, E as evaluateZoneRules, F as runtimeDevices$1, G as createEvent, K as hydrateSchema, R as supportedRuntimes$1, T as enumerateInferenceDevices, V as BaseAddon, X as nodePin, Z as parseJsonUnknown, ct as object, dt as union, et as sleep, ft as EventCategory, h as YAMNET_TO_MACRO, j as pipelineExecutorCapability, nt as array, s as DEVICE_BACKEND_TO_FORMAT, t as APPLE_SA_TO_MACRO, ut as string, w as detectionPipelineCapability, x as defaultDeviceFor$1 } from "../dist-
|
|
3
|
-
import { a as
|
|
2
|
+
import { B as errMsg, E as evaluateZoneRules, F as runtimeDevices$1, G as createEvent, K as hydrateSchema, R as supportedRuntimes$1, T as enumerateInferenceDevices, V as BaseAddon, X as nodePin, Z as parseJsonUnknown, ct as object, dt as union, et as sleep, ft as EventCategory, h as YAMNET_TO_MACRO, j as pipelineExecutorCapability, nt as array, s as DEVICE_BACKEND_TO_FORMAT, t as APPLE_SA_TO_MACRO, ut as string, w as detectionPipelineCapability, x as defaultDeviceFor$1 } from "../dist-Du7_SNiR.mjs";
|
|
3
|
+
import { a as getStep, i as getDefaultModelForFormatFromDef, n as ALL_STEPS, o as getStepDefinition, r as getDefaultModelForFormat, s as resolveModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-C8tk9fPJ.mjs";
|
|
4
4
|
import { t as pickNodePlatformArch } from "../node-topology-platform-BkR_k6WT.mjs";
|
|
5
5
|
import { a as ensureModel, o as isModelDownloaded, r as deleteModelFromDisk } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
|
|
6
|
+
import * as os from "node:os";
|
|
6
7
|
import { spawn } from "node:child_process";
|
|
7
8
|
import sharp from "sharp";
|
|
8
9
|
import * as fs from "node:fs";
|
|
9
10
|
import * as path$1 from "node:path";
|
|
10
|
-
import * as os from "node:os";
|
|
11
11
|
//#region src/detection-pipeline/runtimes.ts
|
|
12
12
|
var KNOWN_PLATFORMS = [
|
|
13
13
|
"darwin",
|
|
@@ -73,14 +73,28 @@ function probedToHardwareInfo(hw) {
|
|
|
73
73
|
coral: coralInfoFrom(hw)
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* THE per-runtime pool provisioning registry — the single source for every
|
|
78
|
+
* pool's concurrency / worker count / batch tuning, keyed by the runtime THAT
|
|
79
|
+
* POOL runs. `engine-factory` used to carry a second, parallel copy of these
|
|
80
|
+
* defaults; it no longer does.
|
|
81
|
+
*
|
|
82
|
+
* `windowMs` / `maxBatchSize` are inert unless `batchMode !== 'none'`, and
|
|
83
|
+
* `numStreams` / `intraOpThreads` are inert at 0 (the runtime picks) — they are
|
|
84
|
+
* still spelled out per row so a reader never has to guess which default a
|
|
85
|
+
* missing key would have hit.
|
|
86
|
+
*/
|
|
76
87
|
var RUNTIME_DETAIL = {
|
|
77
88
|
onnx: {
|
|
78
89
|
label: "ONNX Runtime",
|
|
79
90
|
pythonRequirements: ["requirements.txt", "requirements-onnxruntime.txt"],
|
|
80
91
|
tuning: {
|
|
81
92
|
concurrency: 4,
|
|
93
|
+
numWorkers: 1,
|
|
82
94
|
batchMode: "list",
|
|
95
|
+
windowMs: 2,
|
|
83
96
|
maxBatchSize: 8,
|
|
97
|
+
numStreams: 0,
|
|
84
98
|
intraOpThreads: 0
|
|
85
99
|
}
|
|
86
100
|
},
|
|
@@ -89,8 +103,12 @@ var RUNTIME_DETAIL = {
|
|
|
89
103
|
pythonRequirements: ["requirements.txt", "requirements-openvino.txt"],
|
|
90
104
|
tuning: {
|
|
91
105
|
concurrency: 1,
|
|
106
|
+
numWorkers: 1,
|
|
92
107
|
batchMode: "none",
|
|
93
|
-
|
|
108
|
+
windowMs: 2,
|
|
109
|
+
maxBatchSize: 8,
|
|
110
|
+
numStreams: 0,
|
|
111
|
+
intraOpThreads: 0
|
|
94
112
|
}
|
|
95
113
|
},
|
|
96
114
|
coreml: {
|
|
@@ -98,10 +116,12 @@ var RUNTIME_DETAIL = {
|
|
|
98
116
|
pythonRequirements: ["requirements.txt", "requirements-coreml.txt"],
|
|
99
117
|
tuning: {
|
|
100
118
|
concurrency: 1,
|
|
119
|
+
numWorkers: 1,
|
|
101
120
|
batchMode: "none",
|
|
102
121
|
windowMs: 8,
|
|
103
122
|
maxBatchSize: 8,
|
|
104
|
-
|
|
123
|
+
numStreams: 0,
|
|
124
|
+
intraOpThreads: 0
|
|
105
125
|
}
|
|
106
126
|
},
|
|
107
127
|
edgetpu: {
|
|
@@ -109,8 +129,12 @@ var RUNTIME_DETAIL = {
|
|
|
109
129
|
pythonRequirements: ["requirements.txt", "requirements-edgetpu.txt"],
|
|
110
130
|
tuning: {
|
|
111
131
|
concurrency: 1,
|
|
132
|
+
numWorkers: 1,
|
|
112
133
|
batchMode: "none",
|
|
113
|
-
|
|
134
|
+
windowMs: 2,
|
|
135
|
+
maxBatchSize: 8,
|
|
136
|
+
numStreams: 0,
|
|
137
|
+
intraOpThreads: 0
|
|
114
138
|
}
|
|
115
139
|
}
|
|
116
140
|
};
|
|
@@ -153,6 +177,12 @@ function modelFormatFor(id) {
|
|
|
153
177
|
function pythonRequirementsFor(id) {
|
|
154
178
|
return RUNTIME_DETAIL[id].pythonRequirements;
|
|
155
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The pool provisioning for ONE runtime. Callers must pass the runtime of the
|
|
182
|
+
* pool they are provisioning — never the node's default runtime. See
|
|
183
|
+
* `engine/pool-provisioning.ts` for the resolver that layers an operator
|
|
184
|
+
* override on top of this.
|
|
185
|
+
*/
|
|
156
186
|
function tuningFor(id) {
|
|
157
187
|
return RUNTIME_DETAIL[id].tuning;
|
|
158
188
|
}
|
|
@@ -213,43 +243,6 @@ function normalizeEngineNodeId(rawNodeId) {
|
|
|
213
243
|
return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
|
|
214
244
|
}
|
|
215
245
|
//#endregion
|
|
216
|
-
//#region src/detection-pipeline/pipeline/native-crop-compose.ts
|
|
217
|
-
/** Clamp `v` into `[lo, hi]`. */
|
|
218
|
-
function clamp(v, lo, hi) {
|
|
219
|
-
if (v < lo) return lo;
|
|
220
|
-
if (v > hi) return hi;
|
|
221
|
-
return v;
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Compose a child `roi` given in the parent CROP's normalized space into the
|
|
225
|
-
* FRAME's normalized space, given the crop's frame-space pixel rectangle and the
|
|
226
|
-
* frame dimensions. The result is clamped to the unit square (origin in
|
|
227
|
-
* `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
|
|
228
|
-
* degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
|
|
229
|
-
* clamps to zero area) so the caller falls back to the downscaled tile crop.
|
|
230
|
-
*
|
|
231
|
-
* Pure — never mutates its arguments.
|
|
232
|
-
*/
|
|
233
|
-
function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
|
|
234
|
-
if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
|
|
235
|
-
if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
|
|
236
|
-
const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
|
|
237
|
-
const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
|
|
238
|
-
const fw = roi.w * cropFrameSpace.w / frameWidth;
|
|
239
|
-
const fh = roi.h * cropFrameSpace.h / frameHeight;
|
|
240
|
-
const x = clamp(fx, 0, 1);
|
|
241
|
-
const y = clamp(fy, 0, 1);
|
|
242
|
-
const w = clamp(fw, 0, 1 - x);
|
|
243
|
-
const h = clamp(fh, 0, 1 - y);
|
|
244
|
-
if (!(w > 0) || !(h > 0)) return null;
|
|
245
|
-
return {
|
|
246
|
-
x,
|
|
247
|
-
y,
|
|
248
|
-
w,
|
|
249
|
-
h
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
//#endregion
|
|
253
246
|
//#region src/detection-pipeline/default-detection-model.ts
|
|
254
247
|
/** The object-detection step id — the only slot this resolver applies to. */
|
|
255
248
|
var OBJECT_DETECTION_STEP_ID$1 = "object-detection";
|
|
@@ -300,10 +293,12 @@ function classifyAccelerator(backend, device) {
|
|
|
300
293
|
*
|
|
301
294
|
* Guarantees:
|
|
302
295
|
* - never throws (hot default-tree path);
|
|
303
|
-
* - never returns a model id
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
296
|
+
* - never returns a model id without a build for `format` (unless the step
|
|
297
|
+
* has ZERO builds for `format`, which is unloadable regardless of pick and
|
|
298
|
+
* flagged by `collectZeroBuildIssues`) — an unmapped backend, a `'cpu'`
|
|
299
|
+
* class, a mapped id missing from the catalog, or a mapped id without a
|
|
300
|
+
* `format` build all fall back to the step's PER-FORMAT default
|
|
301
|
+
* (`getDefaultModelForFormatFromDef`), NOT the bare `defaultModelId`.
|
|
307
302
|
*
|
|
308
303
|
* `getStepDef` is injectable (defaults to the real catalog lookup) so the
|
|
309
304
|
* missing-build fallback branch is unit-testable without a live catalog —
|
|
@@ -316,12 +311,12 @@ function resolveDefaultDetectionModel(backend, device, format, getStepDef = getS
|
|
|
316
311
|
} catch {
|
|
317
312
|
return "yolo26n";
|
|
318
313
|
}
|
|
319
|
-
const fallback = def.defaultModelId;
|
|
320
314
|
const candidate = MODEL_BY_CLASS[classifyAccelerator(backend, device)];
|
|
321
|
-
if (candidate
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
315
|
+
if (candidate !== null) {
|
|
316
|
+
const entry = def.models.find((m) => m.id === candidate);
|
|
317
|
+
if (entry && entry.formats[format] !== void 0) return candidate;
|
|
318
|
+
}
|
|
319
|
+
return getDefaultModelForFormatFromDef(def, format);
|
|
325
320
|
}
|
|
326
321
|
//#endregion
|
|
327
322
|
//#region src/detection-pipeline/engine/shared-inference-pool.ts
|
|
@@ -371,6 +366,62 @@ var RAW_FMT_CODE = {
|
|
|
371
366
|
* large frame) never trips; override via env for constrained hardware.
|
|
372
367
|
*/
|
|
373
368
|
var POOL_INFER_TIMEOUT_MS = Math.max(1e3, Number(process.env["CAMSTACK_POOL_INFER_TIMEOUT_MS"]) || 6e4);
|
|
369
|
+
/**
|
|
370
|
+
* Max inference requests outstanding to ONE worker before new ones are SHED.
|
|
371
|
+
*
|
|
372
|
+
* Without this the camera dies permanently the first time the worker gets
|
|
373
|
+
* slow, and it did: device 615 produced nothing for 52 minutes on 2026-07-31.
|
|
374
|
+
* The worker was neither dead nor deadlocked — it was replying at full rate,
|
|
375
|
+
* about a minute behind, and every reply landed after {@link
|
|
376
|
+
* POOL_INFER_TIMEOUT_MS} had already abandoned its request. The proof is in the
|
|
377
|
+
* logs: a steady drip of `Response for unknown request id`, request ids
|
|
378
|
+
* climbing ~13/s, for as long as the outage lasted.
|
|
379
|
+
*
|
|
380
|
+
* That state is self-sustaining and cannot drain, because frames keep arriving
|
|
381
|
+
* while every one of them is written straight to the pipe. `dispatch` had NO
|
|
382
|
+
* bound: a transient slowdown (inference went 40ms → 2500ms across several
|
|
383
|
+
* cameras at 16:19 that day) buys a backlog that is never paid off, and once
|
|
384
|
+
* the backlog exceeds the deadline the loss is total.
|
|
385
|
+
*
|
|
386
|
+
* The queue is therefore capped in TIME, not just in count: at the normal ~40ms
|
|
387
|
+
* per inference this is ~0.3s of work, and even at a 20× slowdown it stays well
|
|
388
|
+
* inside the 60s deadline — so a worker that falls behind sheds, catches up,
|
|
389
|
+
* and recovers on its own. Python already sheds under its own per-model bound
|
|
390
|
+
* and answers `{dropped: true}`; this is the same contract applied one layer
|
|
391
|
+
* earlier, where the unbounded queue actually is.
|
|
392
|
+
*/
|
|
393
|
+
var POOL_MAX_IN_FLIGHT = Math.max(1, Number(process.env["CAMSTACK_POOL_MAX_IN_FLIGHT"]) || 8);
|
|
394
|
+
/**
|
|
395
|
+
* Reply deadline for a LIVE inference request (ms), as opposed to the generous
|
|
396
|
+
* {@link POOL_INFER_TIMEOUT_MS} that model loads and commands keep.
|
|
397
|
+
*
|
|
398
|
+
* 60 seconds was never a deadline for live detection — it is three orders of
|
|
399
|
+
* magnitude past the point where the answer is worth having. Frames arrive at
|
|
400
|
+
* ~13/s; a detection that lands 60s late describes a scene that no longer
|
|
401
|
+
* exists, and the only thing the wait accomplished was holding a queue slot
|
|
402
|
+
* while the backlog grew. Operator directive 2026-07-31: "a few seconds are
|
|
403
|
+
* already too many for this scenario".
|
|
404
|
+
*
|
|
405
|
+
* Three seconds is deliberately still loose — normal inference on this fleet is
|
|
406
|
+
* 35-47ms, so this trips only at a ~70× slowdown — but it bounds the damage at
|
|
407
|
+
* seconds instead of a minute, and combined with {@link POOL_MAX_IN_FLIGHT} it
|
|
408
|
+
* makes the backlog mathematically unable to outrun the deadline: the queue can
|
|
409
|
+
* never hold more than `cap × deadline` of work.
|
|
410
|
+
*
|
|
411
|
+
* Commands and model loads keep the long timeout on purpose: a cold model load
|
|
412
|
+
* legitimately takes tens of seconds, and shedding or timing one out
|
|
413
|
+
* desynchronizes the worker's model slots.
|
|
414
|
+
*/
|
|
415
|
+
var POOL_LIVE_INFER_TIMEOUT_MS = Math.max(250, Number(process.env["CAMSTACK_POOL_LIVE_INFER_TIMEOUT_MS"]) || 3e3);
|
|
416
|
+
/** Opcodes the in-flight cap may shed. Commands and frame-cache writes are
|
|
417
|
+
* NEVER shed: a dropped command silently desynchronizes model state, and a
|
|
418
|
+
* dropped `cacheFrame` strands the `inferCached` that follows it. */
|
|
419
|
+
var SHEDDABLE_MSG_TYPES = new Set([
|
|
420
|
+
MSG_INFER_JPEG,
|
|
421
|
+
MSG_INFER_RAW,
|
|
422
|
+
MSG_INFER_BATCH,
|
|
423
|
+
MSG_INFER_CACHED
|
|
424
|
+
]);
|
|
374
425
|
var PoolHandle = class {
|
|
375
426
|
pool;
|
|
376
427
|
modelIndex;
|
|
@@ -382,10 +433,7 @@ var PoolHandle = class {
|
|
|
382
433
|
}
|
|
383
434
|
async infer(input) {
|
|
384
435
|
const start = performance.now();
|
|
385
|
-
return
|
|
386
|
-
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),
|
|
387
|
-
inferenceMs: performance.now() - start
|
|
388
|
-
};
|
|
436
|
+
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);
|
|
389
437
|
}
|
|
390
438
|
/**
|
|
391
439
|
* Inference on a frame previously cached in the Python pool via
|
|
@@ -394,20 +442,50 @@ var PoolHandle = class {
|
|
|
394
442
|
*/
|
|
395
443
|
async inferFromCache(frameId) {
|
|
396
444
|
const start = performance.now();
|
|
397
|
-
return
|
|
398
|
-
structured: await this.pool.inferCached(this.modelIndex, frameId),
|
|
399
|
-
inferenceMs: performance.now() - start
|
|
400
|
-
};
|
|
445
|
+
return poolResultToEngineOutput(await this.pool.inferCached(this.modelIndex, frameId), performance.now() - start);
|
|
401
446
|
}
|
|
402
447
|
async dispose() {}
|
|
403
448
|
};
|
|
404
449
|
/**
|
|
450
|
+
* Map a raw pool response record to an {@link EngineOutput}.
|
|
451
|
+
*
|
|
452
|
+
* An overload shed (`{dropped: true}` — from the Python pool's per-model bound
|
|
453
|
+
* OR the TS worker in-flight cap, which deliberately mirrors it) becomes a
|
|
454
|
+
* first-class `EngineOutput.dropped` with NO `structured` payload. Before this
|
|
455
|
+
* mapping the shed record rode through as `structured`, failed the StepOutput
|
|
456
|
+
* kind guard in the postprocess funnel, and every deliberate shed was logged
|
|
457
|
+
* as `runInference failed … unexpected kind: undefined` at error level on
|
|
458
|
+
* every node (~4/s cluster-wide, 2026-08-01). A shed is flow control, not a
|
|
459
|
+
* fault — it must never reach a postprocessor. Exported for tests.
|
|
460
|
+
*/
|
|
461
|
+
function poolResultToEngineOutput(result, inferenceMs) {
|
|
462
|
+
if (result["dropped"] === true) {
|
|
463
|
+
const shedReason = result["shedReason"];
|
|
464
|
+
return {
|
|
465
|
+
dropped: true,
|
|
466
|
+
...typeof shedReason === "string" ? { shedReason } : {},
|
|
467
|
+
inferenceMs
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
structured: result,
|
|
472
|
+
inferenceMs
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
405
476
|
* How long to wait for a worker to exit on SIGTERM before escalating to
|
|
406
477
|
* SIGKILL. A worker idle between frames exits well under this; only a worker
|
|
407
478
|
* stuck in a native inference call (openvino/onnx C++) needs the escalation.
|
|
408
479
|
*/
|
|
409
480
|
var POOL_WORKER_TERM_GRACE_MS = 2e3;
|
|
410
481
|
/**
|
|
482
|
+
* Sampling stride for the "Python pool shed frame under overload" debug
|
|
483
|
+
* line — see `trackDroppedResponse`. At the observed chronic-overload rate
|
|
484
|
+
* (~13 shed/s, 2026-08-01) a stride of 500 yields roughly one line every
|
|
485
|
+
* 40 s while `droppedTotal` in the line keeps the exact count.
|
|
486
|
+
*/
|
|
487
|
+
var SHED_LOG_SAMPLE_EVERY = 500;
|
|
488
|
+
/**
|
|
411
489
|
* Terminate a spawned child GRACEFULLY THEN FORCEFULLY: end stdin, send
|
|
412
490
|
* SIGTERM, and if the process hasn't exited within `graceMs`, send SIGKILL.
|
|
413
491
|
* Resolves once the process has exited (or was already dead).
|
|
@@ -450,6 +528,13 @@ var PoolWorker = class {
|
|
|
450
528
|
process = null;
|
|
451
529
|
receiveBuffer = Buffer.alloc(0);
|
|
452
530
|
pending = /* @__PURE__ */ new Map();
|
|
531
|
+
/** True while the in-flight cap is shedding — so the warning fires on the
|
|
532
|
+
* EDGE rather than once per dropped frame. */
|
|
533
|
+
shedding = false;
|
|
534
|
+
/** Inference requests shed by the in-flight cap, for this worker's lifetime.
|
|
535
|
+
* Surfaced so "the camera is quiet" and "the camera is saturated" can be
|
|
536
|
+
* told apart without reading the logs. */
|
|
537
|
+
shedCount = 0;
|
|
453
538
|
nextRequestId = 1;
|
|
454
539
|
ready = false;
|
|
455
540
|
log;
|
|
@@ -461,6 +546,15 @@ var PoolWorker = class {
|
|
|
461
546
|
getPid() {
|
|
462
547
|
return this.process?.pid ?? null;
|
|
463
548
|
}
|
|
549
|
+
/** Requests written and not yet answered. This is the BACKLOG — the number
|
|
550
|
+
* nothing measured while a camera sat at zero frames for 52 minutes. */
|
|
551
|
+
getInFlight() {
|
|
552
|
+
return this.pending.size;
|
|
553
|
+
}
|
|
554
|
+
/** Inference shed by the in-flight cap over this worker's lifetime. */
|
|
555
|
+
getShedCount() {
|
|
556
|
+
return this.shedCount;
|
|
557
|
+
}
|
|
464
558
|
isReady() {
|
|
465
559
|
return this.ready;
|
|
466
560
|
}
|
|
@@ -609,10 +703,51 @@ var PoolWorker = class {
|
|
|
609
703
|
this.ready = false;
|
|
610
704
|
await terminateChild(proc, POOL_WORKER_TERM_GRACE_MS);
|
|
611
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* Shed an inference request when the worker already has {@link
|
|
708
|
+
* POOL_MAX_IN_FLIGHT} outstanding — the backlog is what turns a slow worker
|
|
709
|
+
* into a dead camera. Returns the shed response, or `null` to proceed.
|
|
710
|
+
*
|
|
711
|
+
* A shed is LOUD the first time and then rate-limited: at frame rate this
|
|
712
|
+
* fires ~13×/s, so logging every one would bury the signal it is meant to
|
|
713
|
+
* raise — but logging none is how a camera went dark for 52 minutes looking
|
|
714
|
+
* like a quiet afternoon.
|
|
715
|
+
*/
|
|
716
|
+
shedIfSaturated(msgType) {
|
|
717
|
+
if (!SHEDDABLE_MSG_TYPES.has(msgType)) return null;
|
|
718
|
+
if (this.pending.size < POOL_MAX_IN_FLIGHT) {
|
|
719
|
+
this.shedding = false;
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
if (!this.shedding) {
|
|
723
|
+
this.shedding = true;
|
|
724
|
+
this.log.warn("worker saturated — shedding inference until it catches up", { meta: {
|
|
725
|
+
worker: this.opts.workerLabel,
|
|
726
|
+
pid: this.getPid(),
|
|
727
|
+
runtime: this.opts.poolRuntime,
|
|
728
|
+
device: this.opts.device ?? "default",
|
|
729
|
+
inFlight: this.pending.size,
|
|
730
|
+
cap: POOL_MAX_IN_FLIGHT,
|
|
731
|
+
deadlineMs: POOL_LIVE_INFER_TIMEOUT_MS
|
|
732
|
+
} });
|
|
733
|
+
}
|
|
734
|
+
this.shedCount++;
|
|
735
|
+
return {
|
|
736
|
+
dropped: true,
|
|
737
|
+
shedReason: "worker-in-flight-cap"
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
/** Live inference gets seconds; commands and model loads keep the long
|
|
741
|
+
* timeout — see {@link POOL_LIVE_INFER_TIMEOUT_MS}. */
|
|
742
|
+
deadlineFor(msgType) {
|
|
743
|
+
return SHEDDABLE_MSG_TYPES.has(msgType) ? POOL_LIVE_INFER_TIMEOUT_MS : POOL_INFER_TIMEOUT_MS;
|
|
744
|
+
}
|
|
612
745
|
dispatch(msgType, payload) {
|
|
746
|
+
const shed = this.shedIfSaturated(msgType);
|
|
747
|
+
if (shed) return Promise.resolve(shed);
|
|
613
748
|
const reqId = this.allocRequestId();
|
|
614
749
|
return new Promise((resolve, reject) => {
|
|
615
|
-
const timer = this.armRequestTimeout(reqId, reject);
|
|
750
|
+
const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
|
|
616
751
|
this.pending.set(reqId, {
|
|
617
752
|
resolve,
|
|
618
753
|
reject,
|
|
@@ -632,24 +767,30 @@ var PoolWorker = class {
|
|
|
632
767
|
* `POOL_INFER_TIMEOUT_MS`. `unref` so it never keeps the event loop alive.
|
|
633
768
|
* The normal response + `rejectAll` paths clear it via `PendingRequest.timer`.
|
|
634
769
|
*/
|
|
635
|
-
armRequestTimeout(reqId, reject) {
|
|
770
|
+
armRequestTimeout(reqId, reject, timeoutMs) {
|
|
636
771
|
const timer = setTimeout(() => {
|
|
637
772
|
if (this.pending.delete(reqId)) {
|
|
638
773
|
this.log.error("inference request timed out", { meta: {
|
|
639
774
|
worker: this.opts.workerLabel,
|
|
775
|
+
pid: this.getPid(),
|
|
776
|
+
runtime: this.opts.poolRuntime,
|
|
777
|
+
device: this.opts.device ?? "default",
|
|
778
|
+
inFlight: this.pending.size,
|
|
640
779
|
reqId,
|
|
641
780
|
timeoutMs: POOL_INFER_TIMEOUT_MS
|
|
642
781
|
} });
|
|
643
|
-
reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${
|
|
782
|
+
reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
|
|
644
783
|
}
|
|
645
|
-
},
|
|
784
|
+
}, timeoutMs);
|
|
646
785
|
timer.unref?.();
|
|
647
786
|
return timer;
|
|
648
787
|
}
|
|
649
788
|
dispatchMulti(msgType, payloadLen, parts) {
|
|
789
|
+
const shed = this.shedIfSaturated(msgType);
|
|
790
|
+
if (shed) return Promise.resolve(shed);
|
|
650
791
|
const reqId = this.allocRequestId();
|
|
651
792
|
return new Promise((resolve, reject) => {
|
|
652
|
-
const timer = this.armRequestTimeout(reqId, reject);
|
|
793
|
+
const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
|
|
653
794
|
this.pending.set(reqId, {
|
|
654
795
|
resolve,
|
|
655
796
|
reject,
|
|
@@ -755,6 +896,22 @@ var SharedInferencePool = class {
|
|
|
755
896
|
}
|
|
756
897
|
device;
|
|
757
898
|
/** Pid of the first worker (for legacy callers). Use `getPids()` for all. */
|
|
899
|
+
/** Summed backlog and shed count across the pool's workers. A rising
|
|
900
|
+
* `inFlight` with a rising `shed` is a worker falling behind; a rising
|
|
901
|
+
* `inFlight` with zero `shed` and no results is the state that used to be
|
|
902
|
+
* invisible. */
|
|
903
|
+
getBacklog() {
|
|
904
|
+
let inFlight = 0;
|
|
905
|
+
let shed = 0;
|
|
906
|
+
for (const w of this.workers) {
|
|
907
|
+
inFlight += w.getInFlight();
|
|
908
|
+
shed += w.getShedCount();
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
inFlight,
|
|
912
|
+
shed
|
|
913
|
+
};
|
|
914
|
+
}
|
|
758
915
|
getPid() {
|
|
759
916
|
return this.workers[0]?.getPid() ?? null;
|
|
760
917
|
}
|
|
@@ -788,6 +945,7 @@ var SharedInferencePool = class {
|
|
|
788
945
|
loadedCount,
|
|
789
946
|
startupMs,
|
|
790
947
|
runtime: this.poolRuntime,
|
|
948
|
+
device: this.device ?? "default",
|
|
791
949
|
workers: this.numWorkers,
|
|
792
950
|
pythonWorkers: innerWorkers,
|
|
793
951
|
pids: this.getPids()
|
|
@@ -879,20 +1037,35 @@ var SharedInferencePool = class {
|
|
|
879
1037
|
async dispose() {
|
|
880
1038
|
await Promise.all(this.workers.map((w) => w.dispose()));
|
|
881
1039
|
this.workers.length = 0;
|
|
882
|
-
this.log.info("Pool process(es) terminated"
|
|
1040
|
+
this.log.info("Pool process(es) terminated", { meta: {
|
|
1041
|
+
runtime: this.poolRuntime,
|
|
1042
|
+
device: this.device ?? "default"
|
|
1043
|
+
} });
|
|
883
1044
|
}
|
|
884
1045
|
/**
|
|
885
1046
|
* Recognise a Python overload-shed response (`"dropped": true`) on the
|
|
886
1047
|
* single-frame inference paths. Previously indistinguishable from a
|
|
887
1048
|
* genuine empty detection result — now counted + debug-logged so
|
|
888
1049
|
* overload is measurable. The response passes through unchanged.
|
|
1050
|
+
*
|
|
1051
|
+
* SAMPLED log: one line per shed frame turned into 45 741 rows/hour on
|
|
1052
|
+
* the hub during the 2026-08-01 chronic overload (~13 shed/s) — 2/3 of
|
|
1053
|
+
* the hub's entire log volume, burying every other signal. The counter
|
|
1054
|
+
* keeps exact totals (`droppedTotal` is authoritative, surfaced in
|
|
1055
|
+
* `getStatus` consumers via `getDroppedResponseCount`); the log line
|
|
1056
|
+
* now fires on the FIRST shed and every `SHED_LOG_SAMPLE_EVERY`th,
|
|
1057
|
+
* carrying the running total so nothing is lost — only repeated.
|
|
889
1058
|
*/
|
|
890
1059
|
trackDroppedResponse(result, modelIndex) {
|
|
891
1060
|
if (result["dropped"] === true) {
|
|
892
1061
|
this.droppedResponseCount++;
|
|
893
|
-
|
|
1062
|
+
const total = this.droppedResponseCount;
|
|
1063
|
+
if (total === 1 || total % SHED_LOG_SAMPLE_EVERY === 0) this.log.debug("Python pool shed frame under overload", { meta: {
|
|
894
1064
|
modelIndex,
|
|
895
|
-
droppedTotal:
|
|
1065
|
+
droppedTotal: total,
|
|
1066
|
+
sampledEvery: SHED_LOG_SAMPLE_EVERY,
|
|
1067
|
+
runtime: this.poolRuntime,
|
|
1068
|
+
device: this.device ?? "default"
|
|
896
1069
|
} });
|
|
897
1070
|
}
|
|
898
1071
|
return result;
|
|
@@ -967,6 +1140,66 @@ function flattenEnabledVideoSteps(steps) {
|
|
|
967
1140
|
walk(steps);
|
|
968
1141
|
return result;
|
|
969
1142
|
}
|
|
1143
|
+
/**
|
|
1144
|
+
* The steps that EXECUTE in a `plane: 'frame'` dispatch (two-plane design),
|
|
1145
|
+
* mirroring the executor's frame-plane skip in `executeChildren`: every
|
|
1146
|
+
* enabled top-level step runs at the root; a CHILD runs only when its catalog
|
|
1147
|
+
* definition is itself root-plane (`isDetailStep(addonId)` false). The walk
|
|
1148
|
+
* continues only through steps that execute — a skipped detail child's
|
|
1149
|
+
* descendants ride ITS per-track detail-subtree call, never this one.
|
|
1150
|
+
* Disabled and audio-classifier steps execute on neither plane.
|
|
1151
|
+
*/
|
|
1152
|
+
function collectFramePlaneSteps(steps, isDetailStep) {
|
|
1153
|
+
const result = [];
|
|
1154
|
+
const walk = (nodes, topLevel) => {
|
|
1155
|
+
for (const step of nodes) {
|
|
1156
|
+
if (!step.enabled) continue;
|
|
1157
|
+
if (step.slot === "audio-classifier") continue;
|
|
1158
|
+
if (!topLevel && isDetailStep(step.addonId)) continue;
|
|
1159
|
+
result.push(step);
|
|
1160
|
+
if (step.children?.length) walk(step.children, false);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
walk(steps, true);
|
|
1164
|
+
return result;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Immutably remove from a frame-plane dispatch tree every enabled DETAIL
|
|
1168
|
+
* subtree (at any depth below the top level) whose top step `isUnrunnable`.
|
|
1169
|
+
* Those steps can never load into the dispatch device's pool — leaving them
|
|
1170
|
+
* in the tree makes `ensureModelsForSteps` fail the whole call on a format
|
|
1171
|
+
* build that does not exist. They are NOT dropped work: each runs later as
|
|
1172
|
+
* its own detail-subtree dispatch, where the device-jump resolver
|
|
1173
|
+
* (`resolveStepDevice`) places it on a same-node device that can run it, or
|
|
1174
|
+
* warns no-candidate loudly. Runnable detail children are kept so their
|
|
1175
|
+
* models pre-warm the device pool as before. Disabled subtrees are kept
|
|
1176
|
+
* verbatim (never walked — `ensureModelsForSteps` skips them anyway).
|
|
1177
|
+
*/
|
|
1178
|
+
function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
|
|
1179
|
+
const prunedAddonIds = [];
|
|
1180
|
+
const walk = (nodes, topLevel) => {
|
|
1181
|
+
const kept = [];
|
|
1182
|
+
for (const step of nodes) {
|
|
1183
|
+
if (!step.enabled) {
|
|
1184
|
+
kept.push(step);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step.addonId)) {
|
|
1188
|
+
prunedAddonIds.push(step.addonId);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
kept.push(step.children?.length ? {
|
|
1192
|
+
...step,
|
|
1193
|
+
children: walk(step.children, false)
|
|
1194
|
+
} : step);
|
|
1195
|
+
}
|
|
1196
|
+
return kept;
|
|
1197
|
+
};
|
|
1198
|
+
return {
|
|
1199
|
+
steps: walk(steps, true),
|
|
1200
|
+
prunedAddonIds
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
970
1203
|
//#endregion
|
|
971
1204
|
//#region src/detection-pipeline/engine/pipeline-model-manager.ts
|
|
972
1205
|
var PipelineModelManager = class {
|
|
@@ -1228,6 +1461,126 @@ var PipelineModelManager = class {
|
|
|
1228
1461
|
}
|
|
1229
1462
|
};
|
|
1230
1463
|
//#endregion
|
|
1464
|
+
//#region src/detection-pipeline/engine/resolve-device-engine.ts
|
|
1465
|
+
/**
|
|
1466
|
+
* Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
|
|
1467
|
+
* {@link PipelineEngineChoice} used to build that device's inference pool.
|
|
1468
|
+
* Throws on an unknown backend token so a bad `deviceKey` fails loud rather
|
|
1469
|
+
* than silently building a wrong pool. Format comes from the shared
|
|
1470
|
+
* {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
|
|
1471
|
+
*/
|
|
1472
|
+
function resolveDeviceEngine(deviceKey) {
|
|
1473
|
+
const colon = deviceKey.indexOf(":");
|
|
1474
|
+
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
1475
|
+
const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
|
|
1476
|
+
const format = DEVICE_BACKEND_TO_FORMAT[backend];
|
|
1477
|
+
if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
|
|
1478
|
+
return {
|
|
1479
|
+
runtime: "python",
|
|
1480
|
+
backend,
|
|
1481
|
+
format,
|
|
1482
|
+
device
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* The inverse of {@link resolveDeviceEngine}: the `<backend>:<device>` key that
|
|
1487
|
+
* names the pool an engine belongs to. Every pool-level log line carries this —
|
|
1488
|
+
* without it a default pool logs anonymously and six hours of logs cannot tell
|
|
1489
|
+
* "the NPU is unused" from "the NPU IS the anonymous default pool".
|
|
1490
|
+
* Falls back to the bare backend when the engine carries no device.
|
|
1491
|
+
*/
|
|
1492
|
+
function deviceKeyOf(engine) {
|
|
1493
|
+
return engine.device ? `${engine.backend}:${engine.device}` : engine.backend;
|
|
1494
|
+
}
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/detection-pipeline/engine/pool-provisioning.ts
|
|
1497
|
+
/** The fields a {@link PoolProvisioningOverride} may carry, in table order. */
|
|
1498
|
+
var OVERRIDABLE_KEYS = [
|
|
1499
|
+
"concurrency",
|
|
1500
|
+
"numWorkers",
|
|
1501
|
+
"batchMode",
|
|
1502
|
+
"windowMs",
|
|
1503
|
+
"maxBatchSize",
|
|
1504
|
+
"numStreams",
|
|
1505
|
+
"intraOpThreads"
|
|
1506
|
+
];
|
|
1507
|
+
/** Backends that are not a runtime of their own and run on onnxruntime. */
|
|
1508
|
+
var BACKEND_TO_RUNTIME = {
|
|
1509
|
+
onnx: "onnx",
|
|
1510
|
+
cpu: "onnx",
|
|
1511
|
+
cuda: "onnx",
|
|
1512
|
+
openvino: "openvino",
|
|
1513
|
+
coreml: "coreml",
|
|
1514
|
+
edgetpu: "edgetpu"
|
|
1515
|
+
};
|
|
1516
|
+
/**
|
|
1517
|
+
* The registry runtime a device-pool backend provisions from. Unknown backends
|
|
1518
|
+
* floor to `onnx` — the same floor `resolveDeviceEngine` applies to the model
|
|
1519
|
+
* format, so a pool never comes up unprovisioned.
|
|
1520
|
+
*/
|
|
1521
|
+
function runtimeIdForBackend(backend) {
|
|
1522
|
+
return BACKEND_TO_RUNTIME[backend] ?? "onnx";
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Resolve the provisioning for a pool running `engine`, applying the precedence
|
|
1526
|
+
* documented at the top of this module. `engine.device` is not consulted today
|
|
1527
|
+
* (see the module doc) but is part of the signature so a bench-backed
|
|
1528
|
+
* per-device row lands here and nowhere else.
|
|
1529
|
+
*/
|
|
1530
|
+
function resolvePoolProvisioning(engine, override = {}) {
|
|
1531
|
+
const runtime = runtimeIdForBackend(engine.backend);
|
|
1532
|
+
const profile = tuningFor(runtime);
|
|
1533
|
+
return {
|
|
1534
|
+
runtime,
|
|
1535
|
+
overriddenFields: OVERRIDABLE_KEYS.filter((k) => override[k] !== void 0),
|
|
1536
|
+
concurrency: Math.max(1, override.concurrency ?? profile.concurrency),
|
|
1537
|
+
numWorkers: Math.max(1, override.numWorkers ?? profile.numWorkers),
|
|
1538
|
+
batchMode: override.batchMode ?? profile.batchMode,
|
|
1539
|
+
windowMs: override.windowMs ?? profile.windowMs,
|
|
1540
|
+
maxBatchSize: override.maxBatchSize ?? profile.maxBatchSize,
|
|
1541
|
+
numStreams: override.numStreams ?? profile.numStreams,
|
|
1542
|
+
intraOpThreads: override.intraOpThreads ?? profile.intraOpThreads
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
function positiveNumber(value) {
|
|
1546
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
|
|
1547
|
+
}
|
|
1548
|
+
function batchMode(value) {
|
|
1549
|
+
return value === "none" || value === "list" || value === "window" ? value : void 0;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Read the operator override out of the detection addon's settings. Only a
|
|
1553
|
+
* NON-AUTO value counts: the sliders persist `0` and the select persists `''`
|
|
1554
|
+
* for "Auto (backend default)", and those must resolve to the runtime's
|
|
1555
|
+
* registry value, not to a literal 0 worker threads.
|
|
1556
|
+
*
|
|
1557
|
+
* The result is deliberately sparse — an empty object means "nothing
|
|
1558
|
+
* overridden", which is what lets a per-device pool provision from its own
|
|
1559
|
+
* runtime instead of inheriting the node's.
|
|
1560
|
+
*/
|
|
1561
|
+
function readOperatorOverride(settings) {
|
|
1562
|
+
const override = {};
|
|
1563
|
+
const concurrency = positiveNumber(settings.concurrency);
|
|
1564
|
+
if (concurrency !== void 0) override.concurrency = concurrency;
|
|
1565
|
+
const numWorkers = positiveNumber(settings.numWorkers);
|
|
1566
|
+
if (numWorkers !== void 0) override.numWorkers = numWorkers;
|
|
1567
|
+
const mode = batchMode(settings.batchMode);
|
|
1568
|
+
if (mode !== void 0) override.batchMode = mode;
|
|
1569
|
+
const windowMs = positiveNumber(settings.windowMs);
|
|
1570
|
+
if (windowMs !== void 0) override.windowMs = windowMs;
|
|
1571
|
+
const maxBatchSize = positiveNumber(settings.maxBatchSize);
|
|
1572
|
+
if (maxBatchSize !== void 0) override.maxBatchSize = maxBatchSize;
|
|
1573
|
+
const numStreams = positiveNumber(settings.numStreams);
|
|
1574
|
+
if (numStreams !== void 0) override.numStreams = numStreams;
|
|
1575
|
+
const intraOpThreads = positiveNumber(settings.intraOpThreads);
|
|
1576
|
+
if (intraOpThreads !== void 0) override.intraOpThreads = intraOpThreads;
|
|
1577
|
+
return override;
|
|
1578
|
+
}
|
|
1579
|
+
/** Two overrides carry the same fields with the same values. */
|
|
1580
|
+
function overridesEqual(a, b) {
|
|
1581
|
+
return OVERRIDABLE_KEYS.every((k) => a[k] === b[k]);
|
|
1582
|
+
}
|
|
1583
|
+
//#endregion
|
|
1231
1584
|
//#region src/detection-pipeline/engine/engine-factory.ts
|
|
1232
1585
|
var BACKEND_TO_POOL_RUNTIME = {
|
|
1233
1586
|
coreml: "coreml",
|
|
@@ -1244,7 +1597,7 @@ var RUNTIME_TO_FORMAT = {
|
|
|
1244
1597
|
edgetpu: "tflite"
|
|
1245
1598
|
};
|
|
1246
1599
|
/** The step whose detections are re-filtered downstream by the executor's
|
|
1247
|
-
* per-macro `minConfidence*` sliders (`
|
|
1600
|
+
* per-macro `minConfidence*` sliders (`macroFilterVerdict`). Its pool-side
|
|
1248
1601
|
* floor must stay BELOW those sliders or it silently pre-empts them. */
|
|
1249
1602
|
var OBJECT_DETECTION_STEP_ID = "object-detection";
|
|
1250
1603
|
/**
|
|
@@ -1297,9 +1650,31 @@ var EngineFactory = class {
|
|
|
1297
1650
|
poolManager = null;
|
|
1298
1651
|
log;
|
|
1299
1652
|
opts;
|
|
1653
|
+
/**
|
|
1654
|
+
* `<backend>:<device>` — on EVERY log line this factory writes. A pool that
|
|
1655
|
+
* logs anonymously cannot be told apart from another pool of the same runtime
|
|
1656
|
+
* on a different accelerator.
|
|
1657
|
+
*/
|
|
1658
|
+
deviceKey;
|
|
1659
|
+
/**
|
|
1660
|
+
* Resolved at CONSTRUCTION, not at pool spawn, so it is inspectable without
|
|
1661
|
+
* starting Python and so a pool that fails to spawn still logs what it was
|
|
1662
|
+
* trying to be.
|
|
1663
|
+
*/
|
|
1664
|
+
resolvedProvisioning;
|
|
1300
1665
|
constructor(opts) {
|
|
1301
1666
|
this.opts = opts;
|
|
1302
1667
|
this.log = opts.logger;
|
|
1668
|
+
this.deviceKey = deviceKeyOf(opts.engine);
|
|
1669
|
+
this.resolvedProvisioning = resolvePoolProvisioning(opts.engine, opts.provisioning);
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* The provisioning this factory's pool runs with — resolved from the pool's
|
|
1673
|
+
* OWN runtime plus any operator override. Read by the effective-tuning
|
|
1674
|
+
* readout and by the tests that pin the per-runtime behaviour.
|
|
1675
|
+
*/
|
|
1676
|
+
get provisioning() {
|
|
1677
|
+
return this.resolvedProvisioning;
|
|
1303
1678
|
}
|
|
1304
1679
|
/** Detection always uses the Python pool. */
|
|
1305
1680
|
get usesPythonPool() {
|
|
@@ -1401,40 +1776,51 @@ var EngineFactory = class {
|
|
|
1401
1776
|
this.poolManager = null;
|
|
1402
1777
|
}
|
|
1403
1778
|
}
|
|
1779
|
+
/**
|
|
1780
|
+
* The one pool-init log line. `deviceKey` / `device` are the reason it
|
|
1781
|
+
* exists: the default pool used to log neither, so an operator reading a
|
|
1782
|
+
* day of logs could not tell whether the NPU was idle or was simply the
|
|
1783
|
+
* anonymous default pool.
|
|
1784
|
+
*
|
|
1785
|
+
* `overriddenFields` replaces the old `source` field. `source` claimed
|
|
1786
|
+
* `'auto-default' | 'user-override'` about the CONCURRENCY TUNING but read as
|
|
1787
|
+
* the provenance of the device/engine choice, and was `'user-override'` on
|
|
1788
|
+
* every pool because the addon always passed explicit tuning. This lists the
|
|
1789
|
+
* fields the operator actually overrode — empty means pure registry.
|
|
1790
|
+
*/
|
|
1791
|
+
logProvisioning() {
|
|
1792
|
+
const p = this.resolvedProvisioning;
|
|
1793
|
+
this.log.info("Python inference pool", { meta: {
|
|
1794
|
+
deviceKey: this.deviceKey,
|
|
1795
|
+
backend: this.opts.engine.backend,
|
|
1796
|
+
device: this.opts.engine.device ?? null,
|
|
1797
|
+
runtime: p.runtime,
|
|
1798
|
+
concurrency: p.concurrency,
|
|
1799
|
+
numWorkers: p.numWorkers,
|
|
1800
|
+
overriddenFields: p.overriddenFields,
|
|
1801
|
+
tuning: {
|
|
1802
|
+
batchMode: p.batchMode,
|
|
1803
|
+
windowMs: p.windowMs,
|
|
1804
|
+
maxBatchSize: p.maxBatchSize,
|
|
1805
|
+
numStreams: p.numStreams,
|
|
1806
|
+
intraOpThreads: p.intraOpThreads
|
|
1807
|
+
}
|
|
1808
|
+
} });
|
|
1809
|
+
}
|
|
1404
1810
|
async initPythonPool(steps) {
|
|
1811
|
+
this.logProvisioning();
|
|
1405
1812
|
const pythonPath = this.opts.pythonPath;
|
|
1406
1813
|
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.");
|
|
1407
1814
|
const poolRuntime = BACKEND_TO_POOL_RUNTIME[this.opts.engine.backend];
|
|
1408
1815
|
if (!poolRuntime) throw new Error(`No pool runtime mapping for backend "${this.opts.engine.backend}"`);
|
|
1409
|
-
const concurrency = this.
|
|
1410
|
-
coreml: 1,
|
|
1411
|
-
openvino: 1,
|
|
1412
|
-
onnxruntime: 4,
|
|
1413
|
-
edgetpu: 1
|
|
1414
|
-
}[poolRuntime];
|
|
1415
|
-
const isAutoDefault = this.opts.concurrency === void 0;
|
|
1416
|
-
const defaultBatchMode = {
|
|
1417
|
-
coreml: "window",
|
|
1418
|
-
openvino: "none",
|
|
1419
|
-
onnxruntime: "list",
|
|
1420
|
-
edgetpu: "none"
|
|
1421
|
-
};
|
|
1422
|
-
const tuning = this.opts.tuning ?? {};
|
|
1816
|
+
const { concurrency, numWorkers } = this.resolvedProvisioning;
|
|
1423
1817
|
const resolvedTuning = {
|
|
1424
|
-
batchMode:
|
|
1425
|
-
windowMs:
|
|
1426
|
-
maxBatchSize:
|
|
1427
|
-
numStreams:
|
|
1428
|
-
intraOpThreads:
|
|
1818
|
+
batchMode: this.resolvedProvisioning.batchMode,
|
|
1819
|
+
windowMs: this.resolvedProvisioning.windowMs,
|
|
1820
|
+
maxBatchSize: this.resolvedProvisioning.maxBatchSize,
|
|
1821
|
+
numStreams: this.resolvedProvisioning.numStreams,
|
|
1822
|
+
intraOpThreads: this.resolvedProvisioning.intraOpThreads
|
|
1429
1823
|
};
|
|
1430
|
-
const numWorkers = Math.max(1, this.opts.numWorkers ?? 1);
|
|
1431
|
-
this.log.info("Python inference pool", { meta: {
|
|
1432
|
-
runtime: poolRuntime,
|
|
1433
|
-
concurrency,
|
|
1434
|
-
numWorkers,
|
|
1435
|
-
source: isAutoDefault ? "auto-default" : "user-override",
|
|
1436
|
-
tuning: resolvedTuning
|
|
1437
|
-
} });
|
|
1438
1824
|
this.pool = new SharedInferencePool(pythonPath, poolRuntime, this.log.child("pool"), {
|
|
1439
1825
|
concurrency,
|
|
1440
1826
|
tuning: resolvedTuning,
|
|
@@ -1490,28 +1876,6 @@ var EngineFactory = class {
|
|
|
1490
1876
|
}
|
|
1491
1877
|
};
|
|
1492
1878
|
//#endregion
|
|
1493
|
-
//#region src/detection-pipeline/engine/resolve-device-engine.ts
|
|
1494
|
-
/**
|
|
1495
|
-
* Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
|
|
1496
|
-
* {@link PipelineEngineChoice} used to build that device's inference pool.
|
|
1497
|
-
* Throws on an unknown backend token so a bad `deviceKey` fails loud rather
|
|
1498
|
-
* than silently building a wrong pool. Format comes from the shared
|
|
1499
|
-
* {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
|
|
1500
|
-
*/
|
|
1501
|
-
function resolveDeviceEngine(deviceKey) {
|
|
1502
|
-
const colon = deviceKey.indexOf(":");
|
|
1503
|
-
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
1504
|
-
const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
|
|
1505
|
-
const format = DEVICE_BACKEND_TO_FORMAT[backend];
|
|
1506
|
-
if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
|
|
1507
|
-
return {
|
|
1508
|
-
runtime: "python",
|
|
1509
|
-
backend,
|
|
1510
|
-
format,
|
|
1511
|
-
device
|
|
1512
|
-
};
|
|
1513
|
-
}
|
|
1514
|
-
//#endregion
|
|
1515
1879
|
//#region src/detection-pipeline/engine/idle-pool-reaper.ts
|
|
1516
1880
|
var IdlePoolReaper = class {
|
|
1517
1881
|
lastUsed = /* @__PURE__ */ new Map();
|
|
@@ -1678,563 +2042,153 @@ var EngineProvisioner = class {
|
|
|
1678
2042
|
}
|
|
1679
2043
|
};
|
|
1680
2044
|
//#endregion
|
|
1681
|
-
//#region src/detection-pipeline/
|
|
1682
|
-
/** Detector NMS IoU fallback — the historical hardcoded literal, used when the
|
|
1683
|
-
* step carries no `nmsIouThreshold` setting (unset == today). */
|
|
1684
|
-
var DEFAULT_NMS_IOU = .45;
|
|
2045
|
+
//#region src/detection-pipeline/pipeline/crop-utils.ts
|
|
1685
2046
|
/**
|
|
1686
|
-
*
|
|
1687
|
-
* VALIDATION-ONLY: this mirror annotates `formatValid` but does NOT rescore
|
|
1688
|
-
* confusables — the confusable repair needs the aligned per-timestep softmax
|
|
1689
|
-
* that only the Python path retains (see `python/postprocessors/ctc.py`). Keep
|
|
1690
|
-
* the pattern set in sync with `PLATE_GRAMMARS` there. `'off'`/unset disables it.
|
|
2047
|
+
* Crop utilities — ROI extraction and bbox coordinate transforms.
|
|
1691
2048
|
*/
|
|
1692
|
-
var TS_PLATE_GRAMMARS = { DE: /^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$/ };
|
|
1693
|
-
var VALID_KINDS = new Set([
|
|
1694
|
-
"detections",
|
|
1695
|
-
"classifications",
|
|
1696
|
-
"embedding",
|
|
1697
|
-
"text",
|
|
1698
|
-
"mask"
|
|
1699
|
-
]);
|
|
1700
2049
|
/**
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
1703
|
-
*
|
|
1704
|
-
* The Python inference_pool.py always sets `output.structured` with a `kind`
|
|
1705
|
-
* field. This guard narrows `Record<string, unknown>` to `StepOutput` without
|
|
1706
|
-
* resorting to double-cast.
|
|
2050
|
+
* Extract a JPEG crop from a full-frame JPEG buffer.
|
|
2051
|
+
* Clamps to image boundaries and returns the cropped JPEG + dimensions.
|
|
1707
2052
|
*/
|
|
1708
|
-
function
|
|
1709
|
-
|
|
2053
|
+
async function cropJpeg(jpeg, bbox, imageWidth, imageHeight) {
|
|
2054
|
+
const x1 = Math.max(0, Math.round(bbox[0]));
|
|
2055
|
+
const y1 = Math.max(0, Math.round(bbox[1]));
|
|
2056
|
+
const x2 = Math.min(imageWidth, Math.round(bbox[2]));
|
|
2057
|
+
const y2 = Math.min(imageHeight, Math.round(bbox[3]));
|
|
2058
|
+
const w = x2 - x1;
|
|
2059
|
+
const h = y2 - y1;
|
|
2060
|
+
if (w < 1 || h < 1) throw new Error(`Degenerate crop: [${x1},${y1},${x2},${y2}] on ${imageWidth}x${imageHeight}`);
|
|
2061
|
+
return {
|
|
2062
|
+
jpeg: await sharp(jpeg).extract({
|
|
2063
|
+
left: x1,
|
|
2064
|
+
top: y1,
|
|
2065
|
+
width: w,
|
|
2066
|
+
height: h
|
|
2067
|
+
}).jpeg({ quality: 90 }).toBuffer(),
|
|
2068
|
+
width: w,
|
|
2069
|
+
height: h
|
|
2070
|
+
};
|
|
1710
2071
|
}
|
|
1711
2072
|
/**
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
|
|
1715
|
-
function
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
if (!tensor) throw new Error("YOLO postprocessor: no tensor in engine output");
|
|
1725
|
-
const labels = stepDef.labels ?? [];
|
|
1726
|
-
const numClasses = labels.length || 80;
|
|
1727
|
-
const numBoxes = tensor.length / (4 + numClasses);
|
|
1728
|
-
const letterbox = output.letterbox;
|
|
1729
|
-
const dets = [];
|
|
1730
|
-
for (let i = 0; i < numBoxes; i++) {
|
|
1731
|
-
const cx = tensor[i];
|
|
1732
|
-
const cy = tensor[1 * numBoxes + i];
|
|
1733
|
-
const w = tensor[2 * numBoxes + i];
|
|
1734
|
-
const h = tensor[3 * numBoxes + i];
|
|
1735
|
-
let bestScore = -Infinity;
|
|
1736
|
-
let bestClass = 0;
|
|
1737
|
-
for (let j = 0; j < numClasses; j++) {
|
|
1738
|
-
const score = tensor[(4 + j) * numBoxes + i];
|
|
1739
|
-
if (score > bestScore) {
|
|
1740
|
-
bestScore = score;
|
|
1741
|
-
bestClass = j;
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
|
-
if (bestScore <= 0) continue;
|
|
1745
|
-
let x1 = cx - w / 2;
|
|
1746
|
-
let y1 = cy - h / 2;
|
|
1747
|
-
let x2 = cx + w / 2;
|
|
1748
|
-
let y2 = cy + h / 2;
|
|
1749
|
-
if (letterbox) {
|
|
1750
|
-
x1 = (x1 - letterbox.padX) / letterbox.scale;
|
|
1751
|
-
y1 = (y1 - letterbox.padY) / letterbox.scale;
|
|
1752
|
-
x2 = (x2 - letterbox.padX) / letterbox.scale;
|
|
1753
|
-
y2 = (y2 - letterbox.padY) / letterbox.scale;
|
|
1754
|
-
}
|
|
1755
|
-
const label = labels[bestClass] ?? String(bestClass);
|
|
1756
|
-
dets.push({
|
|
1757
|
-
class: label,
|
|
1758
|
-
score: bestScore,
|
|
1759
|
-
bbox: [
|
|
1760
|
-
x1,
|
|
1761
|
-
y1,
|
|
1762
|
-
x2,
|
|
1763
|
-
y2
|
|
1764
|
-
]
|
|
1765
|
-
});
|
|
1766
|
-
}
|
|
1767
|
-
return {
|
|
1768
|
-
kind: "detections",
|
|
1769
|
-
detections: simpleNms(dets, nmsIouThreshold)
|
|
1770
|
-
};
|
|
1771
|
-
}
|
|
1772
|
-
function postprocessSsd(output, _stepDef) {
|
|
1773
|
-
const structured = tryStructured(output);
|
|
1774
|
-
if (structured) return structured;
|
|
1775
|
-
throw new Error("SSD postprocessing runs in the Python inference pool (ssd.py); no TypeScript raw-tensor path.");
|
|
1776
|
-
}
|
|
1777
|
-
function postprocessScrfd(output, stepDef, nmsIouThreshold) {
|
|
1778
|
-
const structured = tryStructured(output);
|
|
1779
|
-
if (structured) return structured;
|
|
1780
|
-
if (!output.tensors) throw new Error("SCRFD postprocessor: no tensors in engine output");
|
|
1781
|
-
const strides = [
|
|
1782
|
-
8,
|
|
1783
|
-
16,
|
|
1784
|
-
32
|
|
2073
|
+
* Transform a child detection's bbox from ROI-relative to image-absolute coordinates.
|
|
2074
|
+
*
|
|
2075
|
+
* When a child step (e.g., face-detection) runs on a crop of the parent (e.g., person),
|
|
2076
|
+
* its bbox is relative to the crop. This function maps it back to the full image space.
|
|
2077
|
+
*/
|
|
2078
|
+
function transformBboxToImageSpace(childBbox, parentBbox) {
|
|
2079
|
+
const [px1, py1] = parentBbox;
|
|
2080
|
+
return [
|
|
2081
|
+
px1 + childBbox[0],
|
|
2082
|
+
py1 + childBbox[1],
|
|
2083
|
+
px1 + childBbox[2],
|
|
2084
|
+
py1 + childBbox[3]
|
|
1785
2085
|
];
|
|
1786
|
-
const anchorsPerStride = 2;
|
|
1787
|
-
const inputSize = Math.max(stepDef.models[0]?.inputSize.width ?? 640, stepDef.models[0]?.inputSize.height ?? 640);
|
|
1788
|
-
const strideData = matchTensorsToStrides(output.tensors, strides, inputSize, anchorsPerStride);
|
|
1789
|
-
const letterbox = output.letterbox;
|
|
1790
|
-
const scale = letterbox?.scale ?? 1;
|
|
1791
|
-
const padX = letterbox?.padX ?? 0;
|
|
1792
|
-
const padY = letterbox?.padY ?? 0;
|
|
1793
|
-
const origW = letterbox?.originalWidth ?? inputSize;
|
|
1794
|
-
const origH = letterbox?.originalHeight ?? inputSize;
|
|
1795
|
-
const candidates = [];
|
|
1796
|
-
for (const stride of strides) {
|
|
1797
|
-
const data = strideData.get(stride);
|
|
1798
|
-
if (!data) continue;
|
|
1799
|
-
const { scores, bboxes, landmarks } = data;
|
|
1800
|
-
const featSize = Math.ceil(inputSize / stride);
|
|
1801
|
-
const numAnchors = featSize * featSize * anchorsPerStride;
|
|
1802
|
-
for (let i = 0; i < numAnchors; i++) {
|
|
1803
|
-
if (i >= scores.length) break;
|
|
1804
|
-
const score = scores[i];
|
|
1805
|
-
if (score <= 0) continue;
|
|
1806
|
-
const gridIdx = Math.floor(i / anchorsPerStride);
|
|
1807
|
-
const cx = (gridIdx % featSize + .5) * stride;
|
|
1808
|
-
const cy = (Math.floor(gridIdx / featSize) + .5) * stride;
|
|
1809
|
-
const x1 = cx - bboxes[i * 4] * stride;
|
|
1810
|
-
const y1 = cy - bboxes[i * 4 + 1] * stride;
|
|
1811
|
-
const x2 = cx + bboxes[i * 4 + 2] * stride;
|
|
1812
|
-
const y2 = cy + bboxes[i * 4 + 3] * stride;
|
|
1813
|
-
const ox1 = Math.max(0, Math.min(origW, (x1 - padX) / scale));
|
|
1814
|
-
const oy1 = Math.max(0, Math.min(origH, (y1 - padY) / scale));
|
|
1815
|
-
const ox2 = Math.max(0, Math.min(origW, (x2 - padX) / scale));
|
|
1816
|
-
const oy2 = Math.max(0, Math.min(origH, (y2 - padY) / scale));
|
|
1817
|
-
if (ox2 - ox1 < 1 || oy2 - oy1 < 1) continue;
|
|
1818
|
-
const det = {
|
|
1819
|
-
class: "face",
|
|
1820
|
-
score: Math.round(score * 1e4) / 1e4,
|
|
1821
|
-
bbox: [
|
|
1822
|
-
Math.round(ox1 * 10) / 10,
|
|
1823
|
-
Math.round(oy1 * 10) / 10,
|
|
1824
|
-
Math.round(ox2 * 10) / 10,
|
|
1825
|
-
Math.round(oy2 * 10) / 10
|
|
1826
|
-
],
|
|
1827
|
-
_xyxy: [
|
|
1828
|
-
ox1,
|
|
1829
|
-
oy1,
|
|
1830
|
-
ox2,
|
|
1831
|
-
oy2
|
|
1832
|
-
]
|
|
1833
|
-
};
|
|
1834
|
-
if (landmarks && i * 10 + 9 < landmarks.length) {
|
|
1835
|
-
det.landmarks = [];
|
|
1836
|
-
for (let p = 0; p < 5; p++) {
|
|
1837
|
-
const lx = (cx + landmarks[i * 10 + p * 2] * stride - padX) / scale;
|
|
1838
|
-
const ly = (cy + landmarks[i * 10 + p * 2 + 1] * stride - padY) / scale;
|
|
1839
|
-
det.landmarks.push({
|
|
1840
|
-
x: Math.round(lx * 10) / 10,
|
|
1841
|
-
y: Math.round(ly * 10) / 10
|
|
1842
|
-
});
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
candidates.push(det);
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
return {
|
|
1849
|
-
kind: "detections",
|
|
1850
|
-
detections: simpleNms(candidates.map((c) => ({
|
|
1851
|
-
class: c.class,
|
|
1852
|
-
score: c.score,
|
|
1853
|
-
bbox: c.bbox,
|
|
1854
|
-
...c.landmarks ? { landmarks: c.landmarks } : {}
|
|
1855
|
-
})), nmsIouThreshold)
|
|
1856
|
-
};
|
|
1857
|
-
}
|
|
1858
|
-
/** Match ONNX output tensors to SCRFD strides by expected anchor count. */
|
|
1859
|
-
function matchTensorsToStrides(tensors, strides, inputSize, anchorsPerStride) {
|
|
1860
|
-
const scoresByN = /* @__PURE__ */ new Map();
|
|
1861
|
-
const bboxesByN = /* @__PURE__ */ new Map();
|
|
1862
|
-
const landmarksByN = /* @__PURE__ */ new Map();
|
|
1863
|
-
for (const [, tensor] of Object.entries(tensors)) for (const stride of strides) {
|
|
1864
|
-
const featSize = Math.ceil(inputSize / stride);
|
|
1865
|
-
const expectedN = featSize * featSize * anchorsPerStride;
|
|
1866
|
-
if (tensor.length === expectedN) scoresByN.set(expectedN, tensor);
|
|
1867
|
-
else if (tensor.length === expectedN * 4) bboxesByN.set(expectedN, tensor);
|
|
1868
|
-
else if (tensor.length === expectedN * 10) landmarksByN.set(expectedN, tensor);
|
|
1869
|
-
}
|
|
1870
|
-
const result = /* @__PURE__ */ new Map();
|
|
1871
|
-
for (const stride of strides) {
|
|
1872
|
-
const featSize = Math.ceil(inputSize / stride);
|
|
1873
|
-
const n = featSize * featSize * anchorsPerStride;
|
|
1874
|
-
const scores = scoresByN.get(n);
|
|
1875
|
-
const bboxes = bboxesByN.get(n);
|
|
1876
|
-
if (scores && bboxes) result.set(stride, {
|
|
1877
|
-
scores,
|
|
1878
|
-
bboxes,
|
|
1879
|
-
landmarks: landmarksByN.get(n)
|
|
1880
|
-
});
|
|
1881
|
-
}
|
|
1882
|
-
return result;
|
|
1883
|
-
}
|
|
1884
|
-
function postprocessArcface(output, _stepDef) {
|
|
1885
|
-
const structured = tryStructured(output);
|
|
1886
|
-
if (structured) return structured;
|
|
1887
|
-
const tensor = output.tensor;
|
|
1888
|
-
if (!tensor) throw new Error("ArcFace postprocessor: no tensor in engine output");
|
|
1889
|
-
let sumSq = 0;
|
|
1890
|
-
for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
|
|
1891
|
-
const norm = Math.sqrt(sumSq);
|
|
1892
|
-
const normalized = Array.from({ length: tensor.length });
|
|
1893
|
-
for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
|
|
1894
|
-
return {
|
|
1895
|
-
kind: "embedding",
|
|
1896
|
-
embedding: Array.from(tensor),
|
|
1897
|
-
embeddingNorm: normalized
|
|
1898
|
-
};
|
|
1899
|
-
}
|
|
1900
|
-
function postprocessClip(output, _stepDef) {
|
|
1901
|
-
const structured = tryStructured(output);
|
|
1902
|
-
if (structured) return structured;
|
|
1903
|
-
const tensor = output.tensor;
|
|
1904
|
-
if (!tensor) throw new Error("CLIP postprocessor: no tensor in engine output");
|
|
1905
|
-
let sumSq = 0;
|
|
1906
|
-
for (let i = 0; i < tensor.length; i++) sumSq += tensor[i] * tensor[i];
|
|
1907
|
-
const norm = Math.sqrt(sumSq);
|
|
1908
|
-
const normalized = Array.from({ length: tensor.length });
|
|
1909
|
-
for (let i = 0; i < tensor.length; i++) normalized[i] = norm === 0 ? 0 : tensor[i] / norm;
|
|
1910
|
-
return {
|
|
1911
|
-
kind: "embedding",
|
|
1912
|
-
embedding: Array.from(tensor),
|
|
1913
|
-
embeddingNorm: normalized
|
|
1914
|
-
};
|
|
1915
2086
|
}
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
const labels = stepDef.labels ?? [];
|
|
1922
|
-
let max = -Infinity;
|
|
1923
|
-
for (let i = 0; i < tensor.length; i++) if (tensor[i] > max) max = tensor[i];
|
|
1924
|
-
const exps = new Float32Array(tensor.length);
|
|
1925
|
-
let sum = 0;
|
|
1926
|
-
for (let i = 0; i < tensor.length; i++) {
|
|
1927
|
-
exps[i] = Math.exp(tensor[i] - max);
|
|
1928
|
-
sum += exps[i];
|
|
1929
|
-
}
|
|
1930
|
-
const classifications = [];
|
|
1931
|
-
for (let i = 0; i < exps.length; i++) {
|
|
1932
|
-
const score = exps[i] / sum;
|
|
1933
|
-
if (score >= .01) classifications.push({
|
|
1934
|
-
class: labels[i] ?? String(i),
|
|
1935
|
-
score
|
|
1936
|
-
});
|
|
1937
|
-
}
|
|
1938
|
-
classifications.sort((a, b) => b.score - a.score);
|
|
1939
|
-
return {
|
|
1940
|
-
kind: "classifications",
|
|
1941
|
-
classifications: classifications.slice(0, 5)
|
|
1942
|
-
};
|
|
1943
|
-
}
|
|
1944
|
-
function postprocessCtc(output, stepDef) {
|
|
1945
|
-
const structured = tryStructured(output);
|
|
1946
|
-
if (structured) return structured;
|
|
1947
|
-
const tensor = output.tensor;
|
|
1948
|
-
if (!tensor) throw new Error("CTC postprocessor: no tensor in engine output");
|
|
1949
|
-
const charset = stepDef.charset ?? [];
|
|
1950
|
-
const numChars = charset.length || 97;
|
|
1951
|
-
const seqLen = Math.floor(tensor.length / numChars);
|
|
1952
|
-
const chars = [];
|
|
1953
|
-
let totalScore = 0;
|
|
1954
|
-
let prev = -1;
|
|
1955
|
-
for (let t = 0; t < seqLen; t++) {
|
|
1956
|
-
let bestIdx = 0;
|
|
1957
|
-
let bestVal = tensor[t * numChars];
|
|
1958
|
-
for (let c = 1; c < numChars; c++) {
|
|
1959
|
-
const val = tensor[t * numChars + c];
|
|
1960
|
-
if (val > bestVal) {
|
|
1961
|
-
bestVal = val;
|
|
1962
|
-
bestIdx = c;
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
totalScore += bestVal;
|
|
1966
|
-
if (bestIdx !== 0 && bestIdx !== prev) chars.push(charset[bestIdx] ?? "");
|
|
1967
|
-
prev = bestIdx;
|
|
1968
|
-
}
|
|
1969
|
-
const text = chars.join("");
|
|
1970
|
-
const region = stepDef.plateRegion;
|
|
1971
|
-
if (region !== void 0 && region !== "off") {
|
|
1972
|
-
const grammar = TS_PLATE_GRAMMARS[region];
|
|
1973
|
-
if (grammar) return {
|
|
1974
|
-
kind: "text",
|
|
1975
|
-
text,
|
|
1976
|
-
confidence: seqLen > 0 ? totalScore / seqLen : 0,
|
|
1977
|
-
formatValid: grammar.test(text)
|
|
1978
|
-
};
|
|
1979
|
-
}
|
|
1980
|
-
return {
|
|
1981
|
-
kind: "text",
|
|
1982
|
-
text,
|
|
1983
|
-
confidence: seqLen > 0 ? totalScore / seqLen : 0
|
|
1984
|
-
};
|
|
2087
|
+
/**
|
|
2088
|
+
* Check if a bbox is degenerate (zero or negative area).
|
|
2089
|
+
*/
|
|
2090
|
+
function isBboxDegenerate(bbox) {
|
|
2091
|
+
return bbox[2] - bbox[0] < 1 || bbox[3] - bbox[1] < 1;
|
|
1985
2092
|
}
|
|
2093
|
+
//#endregion
|
|
2094
|
+
//#region src/detection-pipeline/pipeline/crop-zone.ts
|
|
1986
2095
|
/**
|
|
1987
|
-
*
|
|
1988
|
-
*
|
|
1989
|
-
* `
|
|
2096
|
+
* Resolve the absolute-pixel crop bbox for a `crop-zone` root detector: the
|
|
2097
|
+
* bounding box of the UNION of every polygon referenced by the device's
|
|
2098
|
+
* ENABLED `package`-stage zone rules. Zone polygons are normalized (0..1); the
|
|
2099
|
+
* union is clamped to [0,1] then scaled to the frame's pixel dimensions.
|
|
1990
2100
|
*
|
|
1991
|
-
* Returns null when there
|
|
2101
|
+
* Returns `null` when there is nothing usable to crop to — no enabled rules, no
|
|
2102
|
+
* referenced geometry, or a degenerate result — in which case the caller falls
|
|
2103
|
+
* back to full-frame inference (behaviour identical to before the crop-zone
|
|
2104
|
+
* plumbing).
|
|
1992
2105
|
*/
|
|
1993
|
-
function
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
if (
|
|
1997
|
-
const
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
if (
|
|
2013
|
-
if (
|
|
2014
|
-
if (
|
|
2015
|
-
if (cy > maxY) maxY = cy;
|
|
2016
|
-
const neighbours = [
|
|
2017
|
-
[cy - 1, cx],
|
|
2018
|
-
[cy + 1, cx],
|
|
2019
|
-
[cy, cx - 1],
|
|
2020
|
-
[cy, cx + 1]
|
|
2021
|
-
];
|
|
2022
|
-
for (const [ny, nx] of neighbours) {
|
|
2023
|
-
if (ny < 0 || ny >= h || nx < 0 || nx >= w) continue;
|
|
2024
|
-
const nIdx = ny * w + nx;
|
|
2025
|
-
if ((binary[nIdx] ?? 0) > 0 && visited[nIdx] !== 1) {
|
|
2026
|
-
visited[nIdx] = 1;
|
|
2027
|
-
stack.push([ny, nx]);
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2106
|
+
function resolvePackageCropBbox(zones, packageRules, frameWidth, frameHeight) {
|
|
2107
|
+
if (frameWidth <= 0 || frameHeight <= 0) return null;
|
|
2108
|
+
const enabledRules = packageRules.filter((r) => r.enabled !== false);
|
|
2109
|
+
if (enabledRules.length === 0) return null;
|
|
2110
|
+
const zoneIds = /* @__PURE__ */ new Set();
|
|
2111
|
+
for (const rule of enabledRules) for (const id of rule.zoneIds) zoneIds.add(id);
|
|
2112
|
+
if (zoneIds.size === 0) return null;
|
|
2113
|
+
const zoneById = new Map(zones.map((z) => [z.id, z]));
|
|
2114
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
2115
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
2116
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
2117
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
2118
|
+
let sawPoint = false;
|
|
2119
|
+
for (const id of zoneIds) {
|
|
2120
|
+
const zone = zoneById.get(id);
|
|
2121
|
+
if (!zone || zone.polygon.length === 0) continue;
|
|
2122
|
+
for (const point of zone.polygon) {
|
|
2123
|
+
sawPoint = true;
|
|
2124
|
+
if (point.x < minX) minX = point.x;
|
|
2125
|
+
if (point.y < minY) minY = point.y;
|
|
2126
|
+
if (point.x > maxX) maxX = point.x;
|
|
2127
|
+
if (point.y > maxY) maxY = point.y;
|
|
2030
2128
|
}
|
|
2031
|
-
if (best === null || area > best.area) best = {
|
|
2032
|
-
area,
|
|
2033
|
-
minX,
|
|
2034
|
-
minY,
|
|
2035
|
-
maxX,
|
|
2036
|
-
maxY
|
|
2037
|
-
};
|
|
2038
2129
|
}
|
|
2039
|
-
if (
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2130
|
+
if (!sawPoint) return null;
|
|
2131
|
+
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
2132
|
+
const bbox = [
|
|
2133
|
+
clamp01(minX) * frameWidth,
|
|
2134
|
+
clamp01(minY) * frameHeight,
|
|
2135
|
+
clamp01(maxX) * frameWidth,
|
|
2136
|
+
clamp01(maxY) * frameHeight
|
|
2045
2137
|
];
|
|
2138
|
+
if (isBboxDegenerate(bbox)) return null;
|
|
2139
|
+
return bbox;
|
|
2046
2140
|
}
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
function postprocessYoloSeg(output, stepDef, nmsIouThreshold) {
|
|
2072
|
-
const structured = tryStructured(output);
|
|
2073
|
-
if (structured) return structured;
|
|
2074
|
-
const tensors = output.tensors;
|
|
2075
|
-
if (!tensors) throw new Error("YOLO-seg postprocessor: no tensors in engine output");
|
|
2076
|
-
const labels = stepDef.labels ?? [];
|
|
2077
|
-
const numClasses = labels.length || 80;
|
|
2078
|
-
const numMaskCoeffs = 32;
|
|
2079
|
-
const protoSize = 160;
|
|
2080
|
-
const letterbox = output.letterbox;
|
|
2081
|
-
let detTensor;
|
|
2082
|
-
let protoTensor;
|
|
2083
|
-
let detRows = 300;
|
|
2084
|
-
let detCols = 0;
|
|
2085
|
-
for (const [, tensor] of Object.entries(tensors)) {
|
|
2086
|
-
if (tensor.length === numMaskCoeffs * protoSize * protoSize) {
|
|
2087
|
-
protoTensor = tensor;
|
|
2088
|
-
continue;
|
|
2089
|
-
}
|
|
2090
|
-
const cols = 38;
|
|
2091
|
-
if (tensor.length % cols === 0) {
|
|
2092
|
-
detTensor = tensor;
|
|
2093
|
-
detRows = tensor.length / cols;
|
|
2094
|
-
detCols = cols;
|
|
2095
|
-
continue;
|
|
2096
|
-
}
|
|
2097
|
-
const rawCols = 4 + numClasses + numMaskCoeffs;
|
|
2098
|
-
if (tensor.length % rawCols === 0 && !detTensor) {
|
|
2099
|
-
detTensor = tensor;
|
|
2100
|
-
detRows = tensor.length / rawCols;
|
|
2101
|
-
detCols = rawCols;
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
if (!detTensor) throw new Error("YOLO-seg postprocessor: could not find detection tensor");
|
|
2105
|
-
if (!protoTensor) throw new Error("YOLO-seg postprocessor: could not find prototype tensor");
|
|
2106
|
-
const isNmsFormat = detCols === 38;
|
|
2107
|
-
const dets = [];
|
|
2108
|
-
for (let i = 0; i < detRows; i++) {
|
|
2109
|
-
const offset = i * detCols;
|
|
2110
|
-
let x1, y1, x2, y2;
|
|
2111
|
-
let bestScore;
|
|
2112
|
-
let bestClass;
|
|
2113
|
-
let coeffStart;
|
|
2114
|
-
if (isNmsFormat) {
|
|
2115
|
-
x1 = detTensor[offset];
|
|
2116
|
-
y1 = detTensor[offset + 1];
|
|
2117
|
-
x2 = detTensor[offset + 2];
|
|
2118
|
-
y2 = detTensor[offset + 3];
|
|
2119
|
-
bestScore = detTensor[offset + 4];
|
|
2120
|
-
bestClass = detTensor[offset + 5];
|
|
2121
|
-
coeffStart = offset + 6;
|
|
2122
|
-
if (bestScore <= 0) continue;
|
|
2123
|
-
if (bestClass < 0) continue;
|
|
2124
|
-
} else {
|
|
2125
|
-
const cx = detTensor[offset];
|
|
2126
|
-
const cy = detTensor[offset + 1];
|
|
2127
|
-
const w = detTensor[offset + 2];
|
|
2128
|
-
const h = detTensor[offset + 3];
|
|
2129
|
-
bestScore = -Infinity;
|
|
2130
|
-
bestClass = 0;
|
|
2131
|
-
for (let j = 0; j < numClasses; j++) {
|
|
2132
|
-
const score = detTensor[offset + 4 + j];
|
|
2133
|
-
if (score > bestScore) {
|
|
2134
|
-
bestScore = score;
|
|
2135
|
-
bestClass = j;
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
if (bestScore <= 0) continue;
|
|
2139
|
-
x1 = cx - w / 2;
|
|
2140
|
-
y1 = cy - h / 2;
|
|
2141
|
-
x2 = cx + w / 2;
|
|
2142
|
-
y2 = cy + h / 2;
|
|
2143
|
-
coeffStart = offset + 4 + numClasses;
|
|
2144
|
-
}
|
|
2145
|
-
const coeffs = new Float32Array(numMaskCoeffs);
|
|
2146
|
-
for (let j = 0; j < numMaskCoeffs; j++) coeffs[j] = detTensor[coeffStart + j];
|
|
2147
|
-
const maskPixels = protoSize * protoSize;
|
|
2148
|
-
const maskRaw = new Float32Array(maskPixels);
|
|
2149
|
-
for (let p = 0; p < maskPixels; p++) {
|
|
2150
|
-
let sum = 0;
|
|
2151
|
-
for (let c = 0; c < numMaskCoeffs; c++) sum += coeffs[c] * protoTensor[c * maskPixels + p];
|
|
2152
|
-
maskRaw[p] = 1 / (1 + Math.exp(-sum));
|
|
2153
|
-
}
|
|
2154
|
-
const protoScale = protoSize / (stepDef.models[0]?.inputSize.width ?? 640);
|
|
2155
|
-
const px1 = Math.max(0, Math.floor(x1 * protoScale));
|
|
2156
|
-
const py1 = Math.max(0, Math.floor(y1 * protoScale));
|
|
2157
|
-
const px2 = Math.min(protoSize, Math.ceil(x2 * protoScale));
|
|
2158
|
-
const py2 = Math.min(protoSize, Math.ceil(y2 * protoScale));
|
|
2159
|
-
const cropW = Math.max(1, px2 - px1);
|
|
2160
|
-
const cropH = Math.max(1, py2 - py1);
|
|
2161
|
-
const cropped = new Uint8Array(cropW * cropH);
|
|
2162
|
-
for (let row = 0; row < cropH; row++) for (let col = 0; col < cropW; col++) {
|
|
2163
|
-
const srcIdx = (py1 + row) * protoSize + (px1 + col);
|
|
2164
|
-
cropped[row * cropW + col] = maskRaw[srcIdx] > .5 ? 255 : 0;
|
|
2165
|
-
}
|
|
2166
|
-
let ox1 = x1;
|
|
2167
|
-
let oy1 = y1;
|
|
2168
|
-
let ox2 = x2;
|
|
2169
|
-
let oy2 = y2;
|
|
2170
|
-
if (letterbox) {
|
|
2171
|
-
ox1 = (x1 - letterbox.padX) / letterbox.scale;
|
|
2172
|
-
oy1 = (y1 - letterbox.padY) / letterbox.scale;
|
|
2173
|
-
ox2 = (x2 - letterbox.padX) / letterbox.scale;
|
|
2174
|
-
oy2 = (y2 - letterbox.padY) / letterbox.scale;
|
|
2175
|
-
}
|
|
2176
|
-
const label = labels[bestClass] ?? String(bestClass);
|
|
2177
|
-
const maskB64 = Buffer.from(cropped).toString("base64");
|
|
2178
|
-
dets.push({
|
|
2179
|
-
class: label,
|
|
2180
|
-
score: Math.round(bestScore * 1e4) / 1e4,
|
|
2181
|
-
bbox: [
|
|
2182
|
-
Math.round(ox1 * 10) / 10,
|
|
2183
|
-
Math.round(oy1 * 10) / 10,
|
|
2184
|
-
Math.round(ox2 * 10) / 10,
|
|
2185
|
-
Math.round(oy2 * 10) / 10
|
|
2186
|
-
],
|
|
2187
|
-
mask: maskB64,
|
|
2188
|
-
maskWidth: cropW,
|
|
2189
|
-
maskHeight: cropH
|
|
2190
|
-
});
|
|
2191
|
-
}
|
|
2192
|
-
const keptBoxes = simpleNms(dets.map((d) => ({
|
|
2193
|
-
class: d.class,
|
|
2194
|
-
score: d.score,
|
|
2195
|
-
bbox: d.bbox
|
|
2196
|
-
})), nmsIouThreshold);
|
|
2197
|
-
const keptSet = new Set(keptBoxes.map((d) => `${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`));
|
|
2198
|
-
return {
|
|
2199
|
-
kind: "detections",
|
|
2200
|
-
detections: dets.filter((d) => keptSet.has(`${d.bbox[0]}_${d.bbox[1]}_${d.bbox[2]}_${d.bbox[3]}_${d.score}`))
|
|
2201
|
-
};
|
|
2202
|
-
}
|
|
2203
|
-
function postprocessYamnet(output, _stepDef) {
|
|
2204
|
-
const structured = tryStructured(output);
|
|
2205
|
-
if (structured) return structured;
|
|
2206
|
-
throw new Error("YAMNet TypeScript postprocessing not implemented. Use Python backend or Apple SoundAnalysis.");
|
|
2207
|
-
}
|
|
2208
|
-
var DISPATCH = {
|
|
2209
|
-
yolo: postprocessYolo,
|
|
2210
|
-
ssd: postprocessSsd,
|
|
2211
|
-
"yolo-seg": postprocessYoloSeg,
|
|
2212
|
-
scrfd: postprocessScrfd,
|
|
2213
|
-
arcface: postprocessArcface,
|
|
2214
|
-
clip: postprocessClip,
|
|
2215
|
-
softmax: postprocessSoftmax,
|
|
2216
|
-
ctc: postprocessCtc,
|
|
2217
|
-
saliency: postprocessSaliency,
|
|
2218
|
-
yamnet: postprocessYamnet
|
|
2219
|
-
};
|
|
2141
|
+
//#endregion
|
|
2142
|
+
//#region src/detection-pipeline/postprocess/dispatch.ts
|
|
2143
|
+
var VALID_KINDS = new Set([
|
|
2144
|
+
"detections",
|
|
2145
|
+
"classifications",
|
|
2146
|
+
"embedding",
|
|
2147
|
+
"text",
|
|
2148
|
+
"mask"
|
|
2149
|
+
]);
|
|
2150
|
+
/** Every postprocessor the Python pool implements — the dispatch table's keys
|
|
2151
|
+
* survived the raw-tensor removal as a config-drift guard: an unknown
|
|
2152
|
+
* postprocessor in a step definition still fails fast here. */
|
|
2153
|
+
var KNOWN_POSTPROCESSORS = new Set([
|
|
2154
|
+
"yolo",
|
|
2155
|
+
"ssd",
|
|
2156
|
+
"yolo-seg",
|
|
2157
|
+
"scrfd",
|
|
2158
|
+
"arcface",
|
|
2159
|
+
"clip",
|
|
2160
|
+
"softmax",
|
|
2161
|
+
"ctc",
|
|
2162
|
+
"saliency",
|
|
2163
|
+
"yamnet"
|
|
2164
|
+
]);
|
|
2220
2165
|
/**
|
|
2221
|
-
*
|
|
2222
|
-
*
|
|
2223
|
-
* For Python backends: output.structured is already populated → passthrough.
|
|
2224
|
-
* For Node.js ONNX: raw tensors → postprocess in TypeScript.
|
|
2166
|
+
* Type guard: validates that a structured payload from the Python pool is a
|
|
2167
|
+
* well-formed StepOutput discriminated union.
|
|
2225
2168
|
*
|
|
2226
|
-
*
|
|
2227
|
-
*
|
|
2169
|
+
* The Python inference_pool.py always sets `output.structured` with a `kind`
|
|
2170
|
+
* field. This guard narrows `Record<string, unknown>` to `StepOutput` without
|
|
2171
|
+
* resorting to double-cast.
|
|
2228
2172
|
*/
|
|
2229
|
-
function
|
|
2230
|
-
|
|
2231
|
-
if (!fn) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
|
|
2232
|
-
const raw = settings?.["nmsIouThreshold"];
|
|
2233
|
-
return normalizeOutput(fn(output, stepDef, typeof raw === "number" && raw > 0 ? raw : DEFAULT_NMS_IOU));
|
|
2173
|
+
function isStepOutput(value) {
|
|
2174
|
+
return typeof value === "object" && value !== null && typeof value["kind"] === "string" && VALID_KINDS.has(value["kind"]);
|
|
2234
2175
|
}
|
|
2235
2176
|
/**
|
|
2236
|
-
*
|
|
2237
|
-
|
|
2177
|
+
* Validate + normalize a Python-pool engine output into a StepOutput.
|
|
2178
|
+
*/
|
|
2179
|
+
function dispatchPostprocess(output, stepDef) {
|
|
2180
|
+
if (output.dropped === true) return {
|
|
2181
|
+
kind: "detections",
|
|
2182
|
+
detections: []
|
|
2183
|
+
};
|
|
2184
|
+
if (!KNOWN_POSTPROCESSORS.has(stepDef.postprocessor)) throw new Error(`Unknown postprocessor type: "${stepDef.postprocessor}"`);
|
|
2185
|
+
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.`);
|
|
2186
|
+
if (!isStepOutput(output.structured)) throw new Error(`Python pool returned structured output with unexpected kind: ${JSON.stringify(output.structured["kind"])}`);
|
|
2187
|
+
return normalizeOutput(output.structured);
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Normalize any StepOutput to a consistent format — the single funnel every
|
|
2191
|
+
* pool payload passes through.
|
|
2238
2192
|
*
|
|
2239
2193
|
* Classifications: keep only top-1 in classifications, move full list to alternates.
|
|
2240
2194
|
*/
|
|
@@ -2248,78 +2202,103 @@ function normalizeOutput(output) {
|
|
|
2248
2202
|
alternates: all
|
|
2249
2203
|
};
|
|
2250
2204
|
}
|
|
2251
|
-
function simpleNms(dets, iouThreshold) {
|
|
2252
|
-
if (dets.length === 0) return [];
|
|
2253
|
-
const sorted = [...dets].toSorted((a, b) => b.score - a.score);
|
|
2254
|
-
const kept = [];
|
|
2255
|
-
const suppressed = /* @__PURE__ */ new Set();
|
|
2256
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
2257
|
-
if (suppressed.has(i)) continue;
|
|
2258
|
-
kept.push(sorted[i]);
|
|
2259
|
-
for (let j = i + 1; j < sorted.length; j++) {
|
|
2260
|
-
if (suppressed.has(j)) continue;
|
|
2261
|
-
if (iou(sorted[i].bbox, sorted[j].bbox) > iouThreshold) suppressed.add(j);
|
|
2262
|
-
}
|
|
2263
|
-
}
|
|
2264
|
-
return kept;
|
|
2265
|
-
}
|
|
2266
|
-
function iou(a, b) {
|
|
2267
|
-
const ix1 = Math.max(a[0], b[0]);
|
|
2268
|
-
const iy1 = Math.max(a[1], b[1]);
|
|
2269
|
-
const ix2 = Math.min(a[2], b[2]);
|
|
2270
|
-
const iy2 = Math.min(a[3], b[3]);
|
|
2271
|
-
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
|
|
2272
|
-
if (inter === 0) return 0;
|
|
2273
|
-
return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter);
|
|
2274
|
-
}
|
|
2275
2205
|
//#endregion
|
|
2276
|
-
//#region src/detection-pipeline/pipeline/
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
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
|
-
|
|
2206
|
+
//#region src/detection-pipeline/pipeline/execution-trace.ts
|
|
2207
|
+
var traceCounter = 0;
|
|
2208
|
+
var ExecutionTraceBuilder = class {
|
|
2209
|
+
verbosity;
|
|
2210
|
+
deviceId;
|
|
2211
|
+
frameWidth;
|
|
2212
|
+
frameHeight;
|
|
2213
|
+
engineRuntime;
|
|
2214
|
+
steps = [];
|
|
2215
|
+
startTime;
|
|
2216
|
+
constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
|
|
2217
|
+
this.verbosity = verbosity;
|
|
2218
|
+
this.deviceId = deviceId;
|
|
2219
|
+
this.frameWidth = frameWidth;
|
|
2220
|
+
this.frameHeight = frameHeight;
|
|
2221
|
+
this.engineRuntime = engineRuntime;
|
|
2222
|
+
this.startTime = Date.now();
|
|
2223
|
+
}
|
|
2224
|
+
/** Whether trace collection is active. */
|
|
2225
|
+
get isActive() {
|
|
2226
|
+
return this.verbosity !== "off";
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* Record a completed step execution.
|
|
2230
|
+
* No-op if verbosity is 'off'.
|
|
2231
|
+
*/
|
|
2232
|
+
addStep(params) {
|
|
2233
|
+
if (this.verbosity === "off") return;
|
|
2234
|
+
const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
|
|
2235
|
+
const trace = {
|
|
2236
|
+
stepId: params.stepId,
|
|
2237
|
+
modelId: params.modelId,
|
|
2238
|
+
slot: params.slot,
|
|
2239
|
+
postprocessor: params.postprocessor,
|
|
2240
|
+
preprocessMs: Math.round(params.preprocessMs * 100) / 100,
|
|
2241
|
+
inferenceMs: Math.round(params.inferenceMs * 100) / 100,
|
|
2242
|
+
postprocessMs: Math.round(params.postprocessMs * 100) / 100,
|
|
2243
|
+
totalMs: Math.round(totalMs * 100) / 100,
|
|
2244
|
+
inputType: params.inputType,
|
|
2245
|
+
inputSize: {
|
|
2246
|
+
width: params.inputWidth,
|
|
2247
|
+
height: params.inputHeight
|
|
2248
|
+
},
|
|
2249
|
+
parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
|
|
2250
|
+
outputKind: params.output.kind,
|
|
2251
|
+
outputCount: countOutput(params.output),
|
|
2252
|
+
topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
|
|
2253
|
+
error: params.error
|
|
2254
|
+
};
|
|
2255
|
+
this.steps.push(trace);
|
|
2256
|
+
}
|
|
2257
|
+
/**
|
|
2258
|
+
* Finalize and return the complete execution trace.
|
|
2259
|
+
* Returns null if verbosity is 'off'.
|
|
2260
|
+
*/
|
|
2261
|
+
build(detectionCount) {
|
|
2262
|
+
if (this.verbosity === "off") return null;
|
|
2263
|
+
const totalMs = Date.now() - this.startTime;
|
|
2264
|
+
return {
|
|
2265
|
+
traceId: `trace-${Date.now()}-${++traceCounter}`,
|
|
2266
|
+
deviceId: this.deviceId,
|
|
2267
|
+
timestamp: this.startTime,
|
|
2268
|
+
frameSize: {
|
|
2269
|
+
width: this.frameWidth,
|
|
2270
|
+
height: this.frameHeight
|
|
2271
|
+
},
|
|
2272
|
+
engineRuntime: this.engineRuntime,
|
|
2273
|
+
steps: this.steps,
|
|
2274
|
+
totalMs,
|
|
2275
|
+
detectionCount
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
function countOutput(output) {
|
|
2280
|
+
switch (output.kind) {
|
|
2281
|
+
case "detections": return output.detections.length;
|
|
2282
|
+
case "classifications": return output.classifications.length;
|
|
2283
|
+
case "embedding": return 1;
|
|
2284
|
+
case "text": return output.text.length > 0 ? 1 : 0;
|
|
2285
|
+
case "mask": return 1;
|
|
2286
|
+
}
|
|
2317
2287
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2288
|
+
function formatTopResult(output) {
|
|
2289
|
+
switch (output.kind) {
|
|
2290
|
+
case "detections": {
|
|
2291
|
+
const top = output.detections[0];
|
|
2292
|
+
return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
|
|
2293
|
+
}
|
|
2294
|
+
case "classifications": {
|
|
2295
|
+
const top = output.classifications[0];
|
|
2296
|
+
return top ? `${top.class} ${top.score.toFixed(2)}` : void 0;
|
|
2297
|
+
}
|
|
2298
|
+
case "embedding": return `embedding[${output.embedding.length}]`;
|
|
2299
|
+
case "text": return output.text || void 0;
|
|
2300
|
+
case "mask": return `mask ${output.maskWidth}×${output.maskHeight}`;
|
|
2301
|
+
}
|
|
2323
2302
|
}
|
|
2324
2303
|
//#endregion
|
|
2325
2304
|
//#region src/detection-pipeline/pipeline/face-align.ts
|
|
@@ -2627,63 +2606,6 @@ async function alignFaceCrop(fullFrameJpeg, faceBbox, landmarksImageSpace, image
|
|
|
2627
2606
|
height: aligned.height
|
|
2628
2607
|
};
|
|
2629
2608
|
}
|
|
2630
|
-
//#endregion
|
|
2631
|
-
//#region src/detection-pipeline/pipeline/native-child-crop.ts
|
|
2632
|
-
/**
|
|
2633
|
-
* NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
|
|
2634
|
-
* — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
|
|
2635
|
-
*
|
|
2636
|
-
* On the detail plane the executor runs a child model against a parent crop
|
|
2637
|
-
* (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
|
|
2638
|
-
* in the parent crop's PIXEL space) is what the child model reads. Cutting it
|
|
2639
|
-
* from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
|
|
2640
|
-
* to the tile's resolution — fatal for OCR on a small, distant plate. When a
|
|
2641
|
-
* native-crop provider is bound to the frame, we instead resolve the SAME ROI
|
|
2642
|
-
* straight from the frame's retained NATIVE surface (the provider re-composes
|
|
2643
|
-
* the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
|
|
2644
|
-
* giving the model native pixels. A miss/degenerate returns `null` so the caller
|
|
2645
|
-
* falls back to the existing tile crop (never an upscale — native or the tile).
|
|
2646
|
-
*/
|
|
2647
|
-
/**
|
|
2648
|
-
* Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
|
|
2649
|
-
* native pixels wide, so this never binds for plates (effectively uncapped —
|
|
2650
|
-
* the quality path the task requires); it only bounds a pathologically large
|
|
2651
|
-
* whole-object classifier ROI so the in-process native fetch/encode stays cheap.
|
|
2652
|
-
*/
|
|
2653
|
-
var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
|
|
2654
|
-
/**
|
|
2655
|
-
* Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
|
|
2656
|
-
* resolution via `provider`, returning a JPEG crop + its native dimensions.
|
|
2657
|
-
* Returns `null` on a degenerate ROI or any provider miss (so the caller uses
|
|
2658
|
-
* the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
|
|
2659
|
-
* the sharp encode of the returned pixels.
|
|
2660
|
-
*/
|
|
2661
|
-
async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
|
|
2662
|
-
if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
|
|
2663
|
-
const x1 = Math.max(0, bbox[0]);
|
|
2664
|
-
const y1 = Math.max(0, bbox[1]);
|
|
2665
|
-
const x2 = Math.min(imageWidth, bbox[2]);
|
|
2666
|
-
const y2 = Math.min(imageHeight, bbox[3]);
|
|
2667
|
-
const w = x2 - x1;
|
|
2668
|
-
const h = y2 - y1;
|
|
2669
|
-
if (w < 1 || h < 1) return null;
|
|
2670
|
-
const native = await provider({
|
|
2671
|
-
x: x1 / imageWidth,
|
|
2672
|
-
y: y1 / imageHeight,
|
|
2673
|
-
w: w / imageWidth,
|
|
2674
|
-
h: h / imageHeight
|
|
2675
|
-
}, maxWidth);
|
|
2676
|
-
if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
|
|
2677
|
-
return {
|
|
2678
|
-
jpeg: await sharp(Buffer.from(native.bytes), { raw: {
|
|
2679
|
-
width: native.width,
|
|
2680
|
-
height: native.height,
|
|
2681
|
-
channels: 3
|
|
2682
|
-
} }).jpeg({ quality: 90 }).toBuffer(),
|
|
2683
|
-
width: native.width,
|
|
2684
|
-
height: native.height
|
|
2685
|
-
};
|
|
2686
|
-
}
|
|
2687
2609
|
/**
|
|
2688
2610
|
* Detection score at/above which a near-full-frame box is trusted as a REAL
|
|
2689
2611
|
* close subject rather than a phantom. Below this bar a full-frame box is
|
|
@@ -2765,6 +2687,63 @@ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxCo
|
|
|
2765
2687
|
return false;
|
|
2766
2688
|
}
|
|
2767
2689
|
//#endregion
|
|
2690
|
+
//#region src/detection-pipeline/pipeline/native-child-crop.ts
|
|
2691
|
+
/**
|
|
2692
|
+
* NATIVE-resolution crop of a LEAF crop-child ROI (plate-ocr, leaf classifiers)
|
|
2693
|
+
* — the plate-side mirror of the face path's {@link ./face-align.buildAlignedFaceCrop}.
|
|
2694
|
+
*
|
|
2695
|
+
* On the detail plane the executor runs a child model against a parent crop
|
|
2696
|
+
* (e.g. the native vehicle crop). A leaf child's ROI (`parentDetection.bbox`,
|
|
2697
|
+
* in the parent crop's PIXEL space) is what the child model reads. Cutting it
|
|
2698
|
+
* from the parent tile with {@link ./crop-utils.cropJpeg} bounds the child input
|
|
2699
|
+
* to the tile's resolution — fatal for OCR on a small, distant plate. When a
|
|
2700
|
+
* native-crop provider is bound to the frame, we instead resolve the SAME ROI
|
|
2701
|
+
* straight from the frame's retained NATIVE surface (the provider re-composes
|
|
2702
|
+
* the crop-normalized ROI into frame space — see {@link ./native-crop-compose}),
|
|
2703
|
+
* giving the model native pixels. A miss/degenerate returns `null` so the caller
|
|
2704
|
+
* falls back to the existing tile crop (never an upscale — native or the tile).
|
|
2705
|
+
*/
|
|
2706
|
+
/**
|
|
2707
|
+
* Generous native-width cap for a leaf child crop. A plate ROI is a few hundred
|
|
2708
|
+
* native pixels wide, so this never binds for plates (effectively uncapped —
|
|
2709
|
+
* the quality path the task requires); it only bounds a pathologically large
|
|
2710
|
+
* whole-object classifier ROI so the in-process native fetch/encode stays cheap.
|
|
2711
|
+
*/
|
|
2712
|
+
var NATIVE_CHILD_CROP_MAX_WIDTH = 1920;
|
|
2713
|
+
/**
|
|
2714
|
+
* Resolve `bbox` (a parent-crop PIXEL rectangle `[x1,y1,x2,y2]`) at native
|
|
2715
|
+
* resolution via `provider`, returning a JPEG crop + its native dimensions.
|
|
2716
|
+
* Returns `null` on a degenerate ROI or any provider miss (so the caller uses
|
|
2717
|
+
* the downscaled tile crop instead). Pure w.r.t. its inputs; the only effect is
|
|
2718
|
+
* the sharp encode of the returned pixels.
|
|
2719
|
+
*/
|
|
2720
|
+
async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth) {
|
|
2721
|
+
if (!(imageWidth > 0) || !(imageHeight > 0)) return null;
|
|
2722
|
+
const x1 = Math.max(0, bbox[0]);
|
|
2723
|
+
const y1 = Math.max(0, bbox[1]);
|
|
2724
|
+
const x2 = Math.min(imageWidth, bbox[2]);
|
|
2725
|
+
const y2 = Math.min(imageHeight, bbox[3]);
|
|
2726
|
+
const w = x2 - x1;
|
|
2727
|
+
const h = y2 - y1;
|
|
2728
|
+
if (w < 1 || h < 1) return null;
|
|
2729
|
+
const native = await provider({
|
|
2730
|
+
x: x1 / imageWidth,
|
|
2731
|
+
y: y1 / imageHeight,
|
|
2732
|
+
w: w / imageWidth,
|
|
2733
|
+
h: h / imageHeight
|
|
2734
|
+
}, maxWidth);
|
|
2735
|
+
if (!native || native.width < 2 || native.height < 2 || native.bytes.length < native.width * native.height * 3) return null;
|
|
2736
|
+
return {
|
|
2737
|
+
jpeg: await sharp(Buffer.from(native.bytes), { raw: {
|
|
2738
|
+
width: native.width,
|
|
2739
|
+
height: native.height,
|
|
2740
|
+
channels: 3
|
|
2741
|
+
} }).jpeg({ quality: 90 }).toBuffer(),
|
|
2742
|
+
width: native.width,
|
|
2743
|
+
height: native.height
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
//#endregion
|
|
2768
2747
|
//#region src/detection-pipeline/pipeline/plate-deskew.ts
|
|
2769
2748
|
/**
|
|
2770
2749
|
* Plate-crop DESKEW — rotation rectification for oblique licence-plate crops.
|
|
@@ -3279,6 +3258,7 @@ function buildFrameResult(input) {
|
|
|
3279
3258
|
width: input.width,
|
|
3280
3259
|
height: input.height,
|
|
3281
3260
|
detections,
|
|
3261
|
+
...input.discarded !== void 0 && input.discarded.length > 0 ? { discarded: input.discarded } : {},
|
|
3282
3262
|
debug: frameDebug
|
|
3283
3263
|
};
|
|
3284
3264
|
}
|
|
@@ -3288,136 +3268,38 @@ function bboxTupleToRect(bbox) {
|
|
|
3288
3268
|
x: x1,
|
|
3289
3269
|
y: y1,
|
|
3290
3270
|
width: x2 - x1,
|
|
3291
|
-
height: y2 - y1
|
|
3292
|
-
};
|
|
3293
|
-
}
|
|
3294
|
-
function pruneUndefined(obj) {
|
|
3295
|
-
const out = {};
|
|
3296
|
-
for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
|
|
3297
|
-
return out;
|
|
3298
|
-
}
|
|
3299
|
-
function generateFrameId() {
|
|
3300
|
-
return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3301
|
-
}
|
|
3302
|
-
/** Seed a MutableObjectDetection from a root-level detector output. */
|
|
3303
|
-
function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
|
|
3304
|
-
return {
|
|
3305
|
-
id: idGen.next(),
|
|
3306
|
-
kind: "first-level",
|
|
3307
|
-
macroClass: det.class,
|
|
3308
|
-
originalClass: det.class,
|
|
3309
|
-
score: det.score,
|
|
3310
|
-
bbox: [...det.bbox],
|
|
3311
|
-
ownLabels: [],
|
|
3312
|
-
alternateLabels: {},
|
|
3313
|
-
stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
|
|
3314
|
-
modelIds: { [rootStep.stepId]: rootStep.modelId },
|
|
3315
|
-
extractMode: "full-frame",
|
|
3316
|
-
...det.mask !== void 0 ? {
|
|
3317
|
-
mask: det.mask,
|
|
3318
|
-
maskWidth: det.maskWidth,
|
|
3319
|
-
maskHeight: det.maskHeight
|
|
3320
|
-
} : {},
|
|
3321
|
-
...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
|
|
3322
|
-
};
|
|
3323
|
-
}
|
|
3324
|
-
//#endregion
|
|
3325
|
-
//#region src/detection-pipeline/pipeline/execution-trace.ts
|
|
3326
|
-
var traceCounter = 0;
|
|
3327
|
-
var ExecutionTraceBuilder = class {
|
|
3328
|
-
verbosity;
|
|
3329
|
-
deviceId;
|
|
3330
|
-
frameWidth;
|
|
3331
|
-
frameHeight;
|
|
3332
|
-
engineRuntime;
|
|
3333
|
-
steps = [];
|
|
3334
|
-
startTime;
|
|
3335
|
-
constructor(verbosity, deviceId, frameWidth, frameHeight, engineRuntime) {
|
|
3336
|
-
this.verbosity = verbosity;
|
|
3337
|
-
this.deviceId = deviceId;
|
|
3338
|
-
this.frameWidth = frameWidth;
|
|
3339
|
-
this.frameHeight = frameHeight;
|
|
3340
|
-
this.engineRuntime = engineRuntime;
|
|
3341
|
-
this.startTime = Date.now();
|
|
3342
|
-
}
|
|
3343
|
-
/** Whether trace collection is active. */
|
|
3344
|
-
get isActive() {
|
|
3345
|
-
return this.verbosity !== "off";
|
|
3346
|
-
}
|
|
3347
|
-
/**
|
|
3348
|
-
* Record a completed step execution.
|
|
3349
|
-
* No-op if verbosity is 'off'.
|
|
3350
|
-
*/
|
|
3351
|
-
addStep(params) {
|
|
3352
|
-
if (this.verbosity === "off") return;
|
|
3353
|
-
const totalMs = params.preprocessMs + params.inferenceMs + params.postprocessMs;
|
|
3354
|
-
const trace = {
|
|
3355
|
-
stepId: params.stepId,
|
|
3356
|
-
modelId: params.modelId,
|
|
3357
|
-
slot: params.slot,
|
|
3358
|
-
postprocessor: params.postprocessor,
|
|
3359
|
-
preprocessMs: Math.round(params.preprocessMs * 100) / 100,
|
|
3360
|
-
inferenceMs: Math.round(params.inferenceMs * 100) / 100,
|
|
3361
|
-
postprocessMs: Math.round(params.postprocessMs * 100) / 100,
|
|
3362
|
-
totalMs: Math.round(totalMs * 100) / 100,
|
|
3363
|
-
inputType: params.inputType,
|
|
3364
|
-
inputSize: {
|
|
3365
|
-
width: params.inputWidth,
|
|
3366
|
-
height: params.inputHeight
|
|
3367
|
-
},
|
|
3368
|
-
parentDetection: this.verbosity === "full" ? params.parentDetection : void 0,
|
|
3369
|
-
outputKind: params.output.kind,
|
|
3370
|
-
outputCount: countOutput(params.output),
|
|
3371
|
-
topResult: this.verbosity === "full" ? formatTopResult(params.output) : void 0,
|
|
3372
|
-
error: params.error
|
|
3373
|
-
};
|
|
3374
|
-
this.steps.push(trace);
|
|
3375
|
-
}
|
|
3376
|
-
/**
|
|
3377
|
-
* Finalize and return the complete execution trace.
|
|
3378
|
-
* Returns null if verbosity is 'off'.
|
|
3379
|
-
*/
|
|
3380
|
-
build(detectionCount) {
|
|
3381
|
-
if (this.verbosity === "off") return null;
|
|
3382
|
-
const totalMs = Date.now() - this.startTime;
|
|
3383
|
-
return {
|
|
3384
|
-
traceId: `trace-${Date.now()}-${++traceCounter}`,
|
|
3385
|
-
deviceId: this.deviceId,
|
|
3386
|
-
timestamp: this.startTime,
|
|
3387
|
-
frameSize: {
|
|
3388
|
-
width: this.frameWidth,
|
|
3389
|
-
height: this.frameHeight
|
|
3390
|
-
},
|
|
3391
|
-
engineRuntime: this.engineRuntime,
|
|
3392
|
-
steps: this.steps,
|
|
3393
|
-
totalMs,
|
|
3394
|
-
detectionCount
|
|
3395
|
-
};
|
|
3396
|
-
}
|
|
3397
|
-
};
|
|
3398
|
-
function countOutput(output) {
|
|
3399
|
-
switch (output.kind) {
|
|
3400
|
-
case "detections": return output.detections.length;
|
|
3401
|
-
case "classifications": return output.classifications.length;
|
|
3402
|
-
case "embedding": return 1;
|
|
3403
|
-
case "text": return output.text.length > 0 ? 1 : 0;
|
|
3404
|
-
case "mask": return 1;
|
|
3405
|
-
}
|
|
3271
|
+
height: y2 - y1
|
|
3272
|
+
};
|
|
3406
3273
|
}
|
|
3407
|
-
function
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3274
|
+
function pruneUndefined(obj) {
|
|
3275
|
+
const out = {};
|
|
3276
|
+
for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
|
|
3277
|
+
return out;
|
|
3278
|
+
}
|
|
3279
|
+
function generateFrameId() {
|
|
3280
|
+
return `frame-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3281
|
+
}
|
|
3282
|
+
/** Seed a MutableObjectDetection from a root-level detector output. */
|
|
3283
|
+
function toMutableRootDetection(det, rootStep, idGen, rootStepLatencyMs) {
|
|
3284
|
+
return {
|
|
3285
|
+
id: idGen.next(),
|
|
3286
|
+
kind: "first-level",
|
|
3287
|
+
macroClass: det.class,
|
|
3288
|
+
originalClass: det.class,
|
|
3289
|
+
score: det.score,
|
|
3290
|
+
bbox: [...det.bbox],
|
|
3291
|
+
ownLabels: [],
|
|
3292
|
+
alternateLabels: {},
|
|
3293
|
+
stepLatencyMs: { [rootStep.stepId]: rootStepLatencyMs },
|
|
3294
|
+
modelIds: { [rootStep.stepId]: rootStep.modelId },
|
|
3295
|
+
extractMode: "full-frame",
|
|
3296
|
+
...det.mask !== void 0 ? {
|
|
3297
|
+
mask: det.mask,
|
|
3298
|
+
maskWidth: det.maskWidth,
|
|
3299
|
+
maskHeight: det.maskHeight
|
|
3300
|
+
} : {},
|
|
3301
|
+
...det.landmarks !== void 0 ? { landmarks: det.landmarks } : {}
|
|
3302
|
+
};
|
|
3421
3303
|
}
|
|
3422
3304
|
//#endregion
|
|
3423
3305
|
//#region src/detection-pipeline/pipeline/executor.ts
|
|
@@ -3569,12 +3451,14 @@ var PipelineExecutor = class {
|
|
|
3569
3451
|
idGen,
|
|
3570
3452
|
details
|
|
3571
3453
|
};
|
|
3454
|
+
let discarded;
|
|
3572
3455
|
for (const rootStep of tree.roots) {
|
|
3573
3456
|
if (debug) console.log(`[executor] rootStep=${rootStep.stepId} settings=${JSON.stringify(rootStep.settings ?? {})}`);
|
|
3574
3457
|
const cropPrep = rootStep.definition.extractMode === "crop-zone" && cropZoneBbox !== void 0 ? await this.prepareCropZoneInput(cropZoneBbox, fullFrameJpegProvider, imageWidth, imageHeight, deviceId) : null;
|
|
3575
3458
|
const rootStart = Date.now();
|
|
3576
3459
|
const rawRootOutput = await this.executeStep(rootStep, cropPrep?.input ?? rootInput, cropPrep?.width ?? imageWidth, cropPrep?.height ?? imageHeight, "full-frame", void 0, void 0, traceBuilder, stepTimings, poolAgg);
|
|
3577
3460
|
const rootMs = Date.now() - rootStart;
|
|
3461
|
+
if (rawRootOutput === null) continue;
|
|
3578
3462
|
const rootOutput = cropPrep && rawRootOutput.kind === "detections" ? reprojectCropZoneDetections(rawRootOutput, cropPrep.origin) : rawRootOutput;
|
|
3579
3463
|
if (rootOutput.kind !== "detections") {
|
|
3580
3464
|
if (isEnrichmentOutput(rootOutput)) {
|
|
@@ -3604,10 +3488,35 @@ var PipelineExecutor = class {
|
|
|
3604
3488
|
if (mapped) {
|
|
3605
3489
|
mutable.originalClass = det.class;
|
|
3606
3490
|
mutable.macroClass = mapped;
|
|
3607
|
-
} else if (!rootStep.definition.classMap.preserveOriginal)
|
|
3491
|
+
} else if (!rootStep.definition.classMap.preserveOriginal) {
|
|
3492
|
+
(discarded ??= []).push({
|
|
3493
|
+
bbox: [
|
|
3494
|
+
mutable.bbox[0],
|
|
3495
|
+
mutable.bbox[1],
|
|
3496
|
+
mutable.bbox[2],
|
|
3497
|
+
mutable.bbox[3]
|
|
3498
|
+
],
|
|
3499
|
+
macroClass: mutable.macroClass,
|
|
3500
|
+
score: mutable.score,
|
|
3501
|
+
reason: "class-filter"
|
|
3502
|
+
});
|
|
3503
|
+
continue;
|
|
3504
|
+
}
|
|
3608
3505
|
}
|
|
3609
|
-
|
|
3610
|
-
|
|
3506
|
+
const filterVerdict = this.macroFilterVerdict(mutable.macroClass, mutable.score, rootStep.settings);
|
|
3507
|
+
if (filterVerdict !== "pass") {
|
|
3508
|
+
(discarded ??= []).push({
|
|
3509
|
+
bbox: [
|
|
3510
|
+
mutable.bbox[0],
|
|
3511
|
+
mutable.bbox[1],
|
|
3512
|
+
mutable.bbox[2],
|
|
3513
|
+
mutable.bbox[3]
|
|
3514
|
+
],
|
|
3515
|
+
macroClass: mutable.macroClass,
|
|
3516
|
+
score: mutable.score,
|
|
3517
|
+
reason: filterVerdict
|
|
3518
|
+
});
|
|
3519
|
+
if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} (${filterVerdict})`);
|
|
3611
3520
|
continue;
|
|
3612
3521
|
}
|
|
3613
3522
|
const fullFrameBar = rootStep.settings?.["fullFrameGuardMaxConfidence"];
|
|
@@ -3626,9 +3535,20 @@ var PipelineExecutor = class {
|
|
|
3626
3535
|
hardAreaRatio: typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0
|
|
3627
3536
|
}
|
|
3628
3537
|
});
|
|
3538
|
+
(discarded ??= []).push({
|
|
3539
|
+
bbox: [
|
|
3540
|
+
mutable.bbox[0],
|
|
3541
|
+
mutable.bbox[1],
|
|
3542
|
+
mutable.bbox[2],
|
|
3543
|
+
mutable.bbox[3]
|
|
3544
|
+
],
|
|
3545
|
+
macroClass: mutable.macroClass,
|
|
3546
|
+
score: mutable.score,
|
|
3547
|
+
reason: "full-frame-guard"
|
|
3548
|
+
});
|
|
3629
3549
|
continue;
|
|
3630
3550
|
}
|
|
3631
|
-
if (isFullFrameSurvivor(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) this.opts.logger?.
|
|
3551
|
+
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", {
|
|
3632
3552
|
tags: { deviceId },
|
|
3633
3553
|
meta: {
|
|
3634
3554
|
macroClass: mutable.macroClass,
|
|
@@ -3655,6 +3575,7 @@ var PipelineExecutor = class {
|
|
|
3655
3575
|
firstLevel.push(mutable);
|
|
3656
3576
|
}
|
|
3657
3577
|
}
|
|
3578
|
+
const totalMs = Date.now() - startMs;
|
|
3658
3579
|
return {
|
|
3659
3580
|
result: buildFrameResult({
|
|
3660
3581
|
deviceId,
|
|
@@ -3662,8 +3583,9 @@ var PipelineExecutor = class {
|
|
|
3662
3583
|
height: imageHeight,
|
|
3663
3584
|
firstLevel,
|
|
3664
3585
|
details,
|
|
3586
|
+
discarded,
|
|
3665
3587
|
stepTimings,
|
|
3666
|
-
totalMs
|
|
3588
|
+
totalMs,
|
|
3667
3589
|
engine: this.opts.engineRuntime,
|
|
3668
3590
|
debug,
|
|
3669
3591
|
timestamp: Date.now(),
|
|
@@ -3768,13 +3690,40 @@ var PipelineExecutor = class {
|
|
|
3768
3690
|
});
|
|
3769
3691
|
throw new Error(`Inference failed for step "${step.stepId}": ${errorMsg}`, { cause: err });
|
|
3770
3692
|
}
|
|
3693
|
+
if (engineOutput.dropped === true) {
|
|
3694
|
+
stepTimings.push({
|
|
3695
|
+
source: step.stepId,
|
|
3696
|
+
modelId: step.modelId,
|
|
3697
|
+
ms: Date.now() - preprocessStart,
|
|
3698
|
+
detectionCount: 0
|
|
3699
|
+
});
|
|
3700
|
+
if (traceBuilder.isActive) traceBuilder.addStep({
|
|
3701
|
+
stepId: step.stepId,
|
|
3702
|
+
modelId: step.modelId,
|
|
3703
|
+
slot: step.definition.slot,
|
|
3704
|
+
postprocessor: step.definition.postprocessor,
|
|
3705
|
+
preprocessMs,
|
|
3706
|
+
inferenceMs: Date.now() - inferenceStart,
|
|
3707
|
+
postprocessMs: 0,
|
|
3708
|
+
inputType,
|
|
3709
|
+
inputWidth,
|
|
3710
|
+
inputHeight,
|
|
3711
|
+
parentDetection: parentClass,
|
|
3712
|
+
output: {
|
|
3713
|
+
kind: "detections",
|
|
3714
|
+
detections: []
|
|
3715
|
+
},
|
|
3716
|
+
error: `shed:${engineOutput.shedReason ?? "pool-overload"}`
|
|
3717
|
+
});
|
|
3718
|
+
return null;
|
|
3719
|
+
}
|
|
3771
3720
|
const structured = engineOutput.structured ?? {};
|
|
3772
3721
|
const inferenceMs = typeof structured.inferenceMs === "number" ? structured.inferenceMs : Date.now() - inferenceStart;
|
|
3773
3722
|
if (typeof structured.preprocessMs === "number") poolAgg.preprocessMs += structured.preprocessMs;
|
|
3774
3723
|
if (typeof structured.predictMs === "number") poolAgg.predictMs += structured.predictMs;
|
|
3775
3724
|
if (typeof structured.batchSize === "number" && structured.batchSize > poolAgg.batchSize) poolAgg.batchSize = structured.batchSize;
|
|
3776
3725
|
const postprocessStart = Date.now();
|
|
3777
|
-
const output = dispatchPostprocess(engineOutput, step.definition
|
|
3726
|
+
const output = dispatchPostprocess(engineOutput, step.definition);
|
|
3778
3727
|
const postprocessMs = Date.now() - postprocessStart;
|
|
3779
3728
|
stepTimings.push({
|
|
3780
3729
|
source: step.stepId,
|
|
@@ -3917,6 +3866,7 @@ var PipelineExecutor = class {
|
|
|
3917
3866
|
data: cropJpegBuf
|
|
3918
3867
|
}, cropW, cropH, "crop-roi", parentDetection.bbox, parentDetection.macroClass, traceBuilder, stepTimings, poolAgg);
|
|
3919
3868
|
const childMs = Date.now() - childStart;
|
|
3869
|
+
if (childOutput === null) continue;
|
|
3920
3870
|
const detailsBefore = ctx.details.length;
|
|
3921
3871
|
applyChildOutput(parentDetection, child, childOutput, childMs, ctx);
|
|
3922
3872
|
if (childOutput.kind === "detections" && child.children.length > 0) {
|
|
@@ -3950,20 +3900,25 @@ var PipelineExecutor = class {
|
|
|
3950
3900
|
* Apply the object-detection step's macro filter + per-macro
|
|
3951
3901
|
* minConfidence sliders introduced in the Phase 6 step rework.
|
|
3952
3902
|
*
|
|
3903
|
+
* Returns WHICH gate failed rather than a bare boolean so the
|
|
3904
|
+
* discard trail can label the dropped box with the honest reason
|
|
3905
|
+
* (`class-filter` vs `below-threshold`) — the two are remediated
|
|
3906
|
+
* differently (enable the class vs lower a slider).
|
|
3907
|
+
*
|
|
3953
3908
|
* Expected `settings` shape:
|
|
3954
3909
|
* - `enabledMacroClasses`: readonly string[] (e.g. ['person','vehicle','animal']).
|
|
3955
3910
|
* Empty array = all allowed (legacy behaviour).
|
|
3956
3911
|
* - `minConfidence<Macro>`: number (e.g. `minConfidencePerson: 0.5`)
|
|
3957
3912
|
*/
|
|
3958
|
-
|
|
3959
|
-
if (!settings) return
|
|
3913
|
+
macroFilterVerdict(macroClass, score, settings) {
|
|
3914
|
+
if (!settings) return "pass";
|
|
3960
3915
|
const enabled = settings["enabledMacroClasses"];
|
|
3961
3916
|
if (Array.isArray(enabled) && enabled.length > 0) {
|
|
3962
|
-
if (!enabled.includes(macroClass)) return
|
|
3917
|
+
if (!enabled.includes(macroClass)) return "class-filter";
|
|
3963
3918
|
}
|
|
3964
3919
|
const perMacroThreshold = settings[`minConfidence${capitalize(macroClass)}`];
|
|
3965
|
-
if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return
|
|
3966
|
-
return
|
|
3920
|
+
if (typeof perMacroThreshold === "number" && score < perMacroThreshold) return "below-threshold";
|
|
3921
|
+
return "pass";
|
|
3967
3922
|
}
|
|
3968
3923
|
};
|
|
3969
3924
|
function capitalize(s) {
|
|
@@ -3971,52 +3926,41 @@ function capitalize(s) {
|
|
|
3971
3926
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
3972
3927
|
}
|
|
3973
3928
|
//#endregion
|
|
3974
|
-
//#region src/detection-pipeline/pipeline/crop-
|
|
3929
|
+
//#region src/detection-pipeline/pipeline/native-crop-compose.ts
|
|
3930
|
+
/** Clamp `v` into `[lo, hi]`. */
|
|
3931
|
+
function clamp(v, lo, hi) {
|
|
3932
|
+
if (v < lo) return lo;
|
|
3933
|
+
if (v > hi) return hi;
|
|
3934
|
+
return v;
|
|
3935
|
+
}
|
|
3975
3936
|
/**
|
|
3976
|
-
*
|
|
3977
|
-
*
|
|
3978
|
-
*
|
|
3979
|
-
*
|
|
3937
|
+
* Compose a child `roi` given in the parent CROP's normalized space into the
|
|
3938
|
+
* FRAME's normalized space, given the crop's frame-space pixel rectangle and the
|
|
3939
|
+
* frame dimensions. The result is clamped to the unit square (origin in
|
|
3940
|
+
* `[0,1]`, extent bounded so `x + w ≤ 1` and `y + h ≤ 1`). Returns `null` for
|
|
3941
|
+
* degenerate inputs (non-finite frame dims, empty crop rect, or an ROI that
|
|
3942
|
+
* clamps to zero area) so the caller falls back to the downscaled tile crop.
|
|
3980
3943
|
*
|
|
3981
|
-
*
|
|
3982
|
-
* referenced geometry, or a degenerate result — in which case the caller falls
|
|
3983
|
-
* back to full-frame inference (behaviour identical to before the crop-zone
|
|
3984
|
-
* plumbing).
|
|
3944
|
+
* Pure — never mutates its arguments.
|
|
3985
3945
|
*/
|
|
3986
|
-
function
|
|
3987
|
-
if (frameWidth
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
const
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
const
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
if (point.x < minX) minX = point.x;
|
|
4005
|
-
if (point.y < minY) minY = point.y;
|
|
4006
|
-
if (point.x > maxX) maxX = point.x;
|
|
4007
|
-
if (point.y > maxY) maxY = point.y;
|
|
4008
|
-
}
|
|
4009
|
-
}
|
|
4010
|
-
if (!sawPoint) return null;
|
|
4011
|
-
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
4012
|
-
const bbox = [
|
|
4013
|
-
clamp01(minX) * frameWidth,
|
|
4014
|
-
clamp01(minY) * frameHeight,
|
|
4015
|
-
clamp01(maxX) * frameWidth,
|
|
4016
|
-
clamp01(maxY) * frameHeight
|
|
4017
|
-
];
|
|
4018
|
-
if (isBboxDegenerate(bbox)) return null;
|
|
4019
|
-
return bbox;
|
|
3946
|
+
function composeCropRoiToFrameNorm(roi, cropFrameSpace, frameWidth, frameHeight) {
|
|
3947
|
+
if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
|
|
3948
|
+
if (!(cropFrameSpace.w > 0) || !(cropFrameSpace.h > 0)) return null;
|
|
3949
|
+
const fx = (cropFrameSpace.x + roi.x * cropFrameSpace.w) / frameWidth;
|
|
3950
|
+
const fy = (cropFrameSpace.y + roi.y * cropFrameSpace.h) / frameHeight;
|
|
3951
|
+
const fw = roi.w * cropFrameSpace.w / frameWidth;
|
|
3952
|
+
const fh = roi.h * cropFrameSpace.h / frameHeight;
|
|
3953
|
+
const x = clamp(fx, 0, 1);
|
|
3954
|
+
const y = clamp(fy, 0, 1);
|
|
3955
|
+
const w = clamp(fw, 0, 1 - x);
|
|
3956
|
+
const h = clamp(fh, 0, 1 - y);
|
|
3957
|
+
if (!(w > 0) || !(h > 0)) return null;
|
|
3958
|
+
return {
|
|
3959
|
+
x,
|
|
3960
|
+
y,
|
|
3961
|
+
w,
|
|
3962
|
+
h
|
|
3963
|
+
};
|
|
4020
3964
|
}
|
|
4021
3965
|
//#endregion
|
|
4022
3966
|
//#region src/detection-pipeline/pipeline/tree-builder.ts
|
|
@@ -4252,6 +4196,44 @@ function resolveInputSteps(steps, format, engine) {
|
|
|
4252
4196
|
};
|
|
4253
4197
|
}
|
|
4254
4198
|
//#endregion
|
|
4199
|
+
//#region src/detection-pipeline/zone-gate.ts
|
|
4200
|
+
function applyZoneRuleGate(result, zones, rules) {
|
|
4201
|
+
if (result.detections.length === 0) return result;
|
|
4202
|
+
if (zones.length === 0 || rules.length === 0) return result;
|
|
4203
|
+
const frameW = result.width;
|
|
4204
|
+
const frameH = result.height;
|
|
4205
|
+
if (frameW === 0 || frameH === 0) return result;
|
|
4206
|
+
const firstLevel = result.detections.filter((d) => d.kind === "first-level");
|
|
4207
|
+
const details = result.detections.filter((d) => d.kind === "detail");
|
|
4208
|
+
const { passed, excluded } = evaluateZoneRules(firstLevel, zones, rules, (det) => ({
|
|
4209
|
+
x: (det.bbox.x + det.bbox.width / 2) / frameW,
|
|
4210
|
+
y: (det.bbox.y + det.bbox.height / 2) / frameH
|
|
4211
|
+
}), (det) => det.macroClass);
|
|
4212
|
+
if (passed.length === firstLevel.length) return result;
|
|
4213
|
+
const passedIds = new Set(passed.map((d) => d.id));
|
|
4214
|
+
const filteredDetails = details.filter((d) => {
|
|
4215
|
+
const parentId = d.parentId;
|
|
4216
|
+
return parentId === void 0 || passedIds.has(parentId);
|
|
4217
|
+
});
|
|
4218
|
+
const zoneDiscards = excluded.map((d) => ({
|
|
4219
|
+
bbox: [
|
|
4220
|
+
d.bbox.x,
|
|
4221
|
+
d.bbox.y,
|
|
4222
|
+
d.bbox.x + d.bbox.width,
|
|
4223
|
+
d.bbox.y + d.bbox.height
|
|
4224
|
+
],
|
|
4225
|
+
macroClass: d.macroClass,
|
|
4226
|
+
score: d.score,
|
|
4227
|
+
reason: "zone-gate"
|
|
4228
|
+
}));
|
|
4229
|
+
const mergedDiscarded = [...result.discarded ?? [], ...zoneDiscards];
|
|
4230
|
+
return {
|
|
4231
|
+
...result,
|
|
4232
|
+
detections: [...passed, ...filteredDetails],
|
|
4233
|
+
...mergedDiscarded.length > 0 ? { discarded: mergedDiscarded } : {}
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
//#endregion
|
|
4255
4237
|
//#region src/detection-pipeline/provider.ts
|
|
4256
4238
|
/**
|
|
4257
4239
|
* DetectionPipelineProvider — implements IPipelineExecutorProvider.
|
|
@@ -4953,8 +4935,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
4953
4935
|
modelsDir: this.modelsDir,
|
|
4954
4936
|
logger: this.log.child("engine-verify"),
|
|
4955
4937
|
pythonPath: this.executorOptions.pythonPath,
|
|
4956
|
-
|
|
4957
|
-
|
|
4938
|
+
provisioning: {
|
|
4939
|
+
concurrency: 1,
|
|
4940
|
+
numWorkers: 1
|
|
4941
|
+
}
|
|
4958
4942
|
});
|
|
4959
4943
|
try {
|
|
4960
4944
|
await probe.initialize([]);
|
|
@@ -5648,19 +5632,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
5648
5632
|
emit(`Image: ${imageWidth}×${imageHeight}`);
|
|
5649
5633
|
}
|
|
5650
5634
|
decodeMs = performance.now() - decodeT0;
|
|
5651
|
-
const
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
backend: this.currentEngine.backend,
|
|
5661
|
-
device: this.currentEngine.device ?? null
|
|
5662
|
-
};
|
|
5663
|
-
const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
|
|
5635
|
+
const dispatchResolution = this.resolveStepsForDispatch({
|
|
5636
|
+
steps: input.steps,
|
|
5637
|
+
deviceKey: input.deviceKey,
|
|
5638
|
+
engineOverride: input.engine,
|
|
5639
|
+
deviceId: input.deviceId,
|
|
5640
|
+
plane: input.plane
|
|
5641
|
+
});
|
|
5642
|
+
const dispatchDeviceKey = dispatchResolution.deviceKey;
|
|
5643
|
+
const benchmarkSteps = dispatchResolution.steps;
|
|
5664
5644
|
const stepsSignature = JSON.stringify(benchmarkSteps.map((s) => ({
|
|
5665
5645
|
id: s.addonId,
|
|
5666
5646
|
settings: s.settings ?? {}
|
|
@@ -5710,9 +5690,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
5710
5690
|
modelsDir: this.modelsDir,
|
|
5711
5691
|
logger: this.log.child("engine-override"),
|
|
5712
5692
|
pythonPath: this.executorOptions.pythonPath ?? "",
|
|
5713
|
-
|
|
5714
|
-
tuning: this.executorOptions.tuning,
|
|
5715
|
-
numWorkers: this.executorOptions.numWorkers
|
|
5693
|
+
provisioning: this.executorOptions.provisioning
|
|
5716
5694
|
});
|
|
5717
5695
|
const initPromise = newFactory.initialize([]);
|
|
5718
5696
|
this.overrideCache = {
|
|
@@ -5754,8 +5732,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
5754
5732
|
logger: this.log
|
|
5755
5733
|
});
|
|
5756
5734
|
this.executor = executor;
|
|
5757
|
-
const dispatchEngine =
|
|
5758
|
-
const dispatchFactory =
|
|
5735
|
+
const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
|
|
5736
|
+
const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
|
|
5759
5737
|
const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
|
|
5760
5738
|
if (needed.length > 0) {
|
|
5761
5739
|
this.log.info("Benchmark: models to load", { meta: {
|
|
@@ -5892,9 +5870,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
5892
5870
|
modelsDir: this.modelsDir,
|
|
5893
5871
|
logger: this.log.child("engine-override"),
|
|
5894
5872
|
pythonPath: this.executorOptions.pythonPath ?? "",
|
|
5895
|
-
|
|
5896
|
-
tuning: this.executorOptions.tuning,
|
|
5897
|
-
numWorkers: this.executorOptions.numWorkers
|
|
5873
|
+
provisioning: this.executorOptions.provisioning
|
|
5898
5874
|
});
|
|
5899
5875
|
const initPromise = newFactory.initialize([]);
|
|
5900
5876
|
this.overrideCache = {
|
|
@@ -6006,6 +5982,142 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6006
5982
|
fn();
|
|
6007
5983
|
}
|
|
6008
5984
|
/**
|
|
5985
|
+
* Resolve a dispatch's step tree against its target engine, with the
|
|
5986
|
+
* CAPABILITY GATE + node-local engine fallback (cam-615, 2026-08-01;
|
|
5987
|
+
* per-step split 2026-08-01 evening).
|
|
5988
|
+
*
|
|
5989
|
+
* A per-call `deviceKey` (orchestrator device routing / detail-plane step
|
|
5990
|
+
* jump) selects an engine whose format every step EXECUTING IN THIS CALL
|
|
5991
|
+
* must actually ship a model build for. When such a step's catalog has
|
|
5992
|
+
* ZERO builds for that format (so not even `resolveModelForFormat`'s
|
|
5993
|
+
* substitution can rescue it), dispatching anyway means every frame
|
|
5994
|
+
* fails — before this gate it failed as a doomed 3-retry model download,
|
|
5995
|
+
* ~5.5s per frame, forever.
|
|
5996
|
+
*
|
|
5997
|
+
* The gated set is per-plane — the ROOT decides the dispatch device, a
|
|
5998
|
+
* child never bounces it (operator requirement: devices compensate for
|
|
5999
|
+
* each other's missing steps; the original whole-tree gate let ONE
|
|
6000
|
+
* tflite-less child knock the root off an otherwise-capable Coral):
|
|
6001
|
+
*
|
|
6002
|
+
* - `'frame'` (the live per-frame path) executes only root-plane steps,
|
|
6003
|
+
* so only those gate the device. Detail children that can NEVER run on
|
|
6004
|
+
* this device are pruned from THIS dispatch (so `ensureModelsForSteps`
|
|
6005
|
+
* doesn't fail the call on a format build that does not exist) and
|
|
6006
|
+
* logged once — NOT dropped work: each runs later as its own per-track
|
|
6007
|
+
* detail dispatch, where the runner's device-jump resolver
|
|
6008
|
+
* (`resolveStepDevice`) places it on a same-node device that can run
|
|
6009
|
+
* it, or warns no-candidate loudly.
|
|
6010
|
+
* - any other plane (detail subtrees, benchmark, batch) executes the
|
|
6011
|
+
* whole passed tree inline on ONE pool — a single call cannot split
|
|
6012
|
+
* devices — so the whole-tree gate stays.
|
|
6013
|
+
*
|
|
6014
|
+
* Fallback is strictly NODE-LOCAL and ordered: the node's own selected
|
|
6015
|
+
* engine (or the benchmark's explicit `engineOverride`) is the one and only
|
|
6016
|
+
* fallback — frames never migrate to another node from here (that is the
|
|
6017
|
+
* orchestrator's tier). The fallback is logged ONCE per distinct
|
|
6018
|
+
* (deviceKey, steps, format) signature, with `tags: { deviceId }` when the
|
|
6019
|
+
* dispatch is camera-scoped.
|
|
6020
|
+
*
|
|
6021
|
+
* When the node's own engine ALSO cannot run a step, this throws — fast,
|
|
6022
|
+
* before any download attempt — and logs the full picture once (model,
|
|
6023
|
+
* its available formats, both engines tried). A genuinely impossible
|
|
6024
|
+
* configuration must stay loud, not vanish into a silent fallback chain.
|
|
6025
|
+
*/
|
|
6026
|
+
resolveStepsForDispatch(args) {
|
|
6027
|
+
const { deviceKey, engineOverride, deviceId } = args;
|
|
6028
|
+
const nodeFormat = engineOverride?.format ?? this.currentEngine.format;
|
|
6029
|
+
const nodeEngine = engineOverride ? {
|
|
6030
|
+
backend: engineOverride.backend,
|
|
6031
|
+
device: engineOverride.device ?? null
|
|
6032
|
+
} : {
|
|
6033
|
+
backend: this.currentEngine.backend,
|
|
6034
|
+
device: this.currentEngine.device ?? null
|
|
6035
|
+
};
|
|
6036
|
+
const nodeEngineLabel = `${nodeEngine.backend}/${nodeEngine.device ?? "default"} (${nodeFormat})`;
|
|
6037
|
+
const deviceEngine = deviceKey ? resolveDeviceEngine(deviceKey) : void 0;
|
|
6038
|
+
if (deviceKey === void 0 || deviceEngine === void 0) {
|
|
6039
|
+
const steps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
|
|
6040
|
+
this.assertStepsRunnable(steps, nodeFormat, deviceId, [nodeEngineLabel]);
|
|
6041
|
+
return {
|
|
6042
|
+
deviceKey: void 0,
|
|
6043
|
+
steps
|
|
6044
|
+
};
|
|
6045
|
+
}
|
|
6046
|
+
const deviceFormat = deviceEngine.format;
|
|
6047
|
+
const deviceStepEngine = {
|
|
6048
|
+
backend: deviceEngine.backend,
|
|
6049
|
+
device: deviceEngine.device ?? null
|
|
6050
|
+
};
|
|
6051
|
+
const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine);
|
|
6052
|
+
const framePlane = args.plane === "frame";
|
|
6053
|
+
const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
|
|
6054
|
+
if (zeroBuild.length === 0) {
|
|
6055
|
+
if (!framePlane) return {
|
|
6056
|
+
deviceKey,
|
|
6057
|
+
steps
|
|
6058
|
+
};
|
|
6059
|
+
const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
|
|
6060
|
+
if (pruneResult.prunedAddonIds.length > 0) {
|
|
6061
|
+
const prunedList = pruneResult.prunedAddonIds.join(",");
|
|
6062
|
+
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", {
|
|
6063
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
6064
|
+
meta: {
|
|
6065
|
+
deviceKey,
|
|
6066
|
+
deviceFormat,
|
|
6067
|
+
steps: [...pruneResult.prunedAddonIds]
|
|
6068
|
+
}
|
|
6069
|
+
}));
|
|
6070
|
+
}
|
|
6071
|
+
return {
|
|
6072
|
+
deviceKey,
|
|
6073
|
+
steps: pruneResult.steps
|
|
6074
|
+
};
|
|
6075
|
+
}
|
|
6076
|
+
const blockedSteps = zeroBuild.map((issue) => issue.addonId);
|
|
6077
|
+
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", {
|
|
6078
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
6079
|
+
meta: {
|
|
6080
|
+
deviceKey,
|
|
6081
|
+
deviceFormat,
|
|
6082
|
+
steps: blockedSteps,
|
|
6083
|
+
fallbackEngine: nodeEngineLabel
|
|
6084
|
+
}
|
|
6085
|
+
}));
|
|
6086
|
+
const fallbackSteps = this.inputStepsToPipelineSteps(args.steps, nodeFormat, nodeEngine);
|
|
6087
|
+
this.assertStepsRunnable(fallbackSteps, nodeFormat, deviceId, [`${deviceKey} (${deviceFormat})`, nodeEngineLabel]);
|
|
6088
|
+
return {
|
|
6089
|
+
deviceKey: void 0,
|
|
6090
|
+
steps: fallbackSteps
|
|
6091
|
+
};
|
|
6092
|
+
}
|
|
6093
|
+
/**
|
|
6094
|
+
* The loud, fast half of the capability gate: throw when any ENABLED step
|
|
6095
|
+
* has ZERO model builds for `format` — BEFORE any model download runs. The
|
|
6096
|
+
* error names the step, its chosen model, the formats the catalog actually
|
|
6097
|
+
* ships, and every engine that was tried, and is logged ONCE per distinct
|
|
6098
|
+
* signature (the throw itself still surfaces per dispatch, but costs
|
|
6099
|
+
* microseconds instead of a 3-retry download backoff).
|
|
6100
|
+
*/
|
|
6101
|
+
assertStepsRunnable(steps, format, deviceId, enginesTried) {
|
|
6102
|
+
const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format);
|
|
6103
|
+
if (zeroBuild.length === 0) return;
|
|
6104
|
+
const detail = zeroBuild.map((issue) => {
|
|
6105
|
+
const step = flattenSteps(steps).find((s) => s.addonId === issue.addonId);
|
|
6106
|
+
const formats = availableFormatsForStep(issue.addonId);
|
|
6107
|
+
return `step "${issue.addonId}" (model "${step?.modelId ?? "unknown"}") has no ${format} build (available: ${formats.join(", ") || "none"})`;
|
|
6108
|
+
}).join("; ");
|
|
6109
|
+
const message = `Pipeline dispatch impossible on this node: ${detail}. Engines tried: ${enginesTried.join(" → ")}.`;
|
|
6110
|
+
this.logLiveDispatchIssueOnce(`unrunnable:${format}:${zeroBuild.map((i) => i.addonId).join(",")}`, () => this.log.error("Pipeline step cannot run on any node-local engine", {
|
|
6111
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
6112
|
+
meta: {
|
|
6113
|
+
format,
|
|
6114
|
+
enginesTried: [...enginesTried],
|
|
6115
|
+
detail
|
|
6116
|
+
}
|
|
6117
|
+
}));
|
|
6118
|
+
throw new Error(message);
|
|
6119
|
+
}
|
|
6120
|
+
/**
|
|
6009
6121
|
* Single-flight gate around `ensureModelsForSteps`. Concurrent
|
|
6010
6122
|
* callers (every camera that fires motion in the same window calls
|
|
6011
6123
|
* `runPipeline` → `ensureModelsForSteps`) share the same in-flight
|
|
@@ -6032,6 +6144,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6032
6144
|
for (const step of needed) {
|
|
6033
6145
|
const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
|
|
6034
6146
|
if (modelEntry && !isModelDownloaded(this.modelsDir, modelEntry, format)) {
|
|
6147
|
+
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`);
|
|
6035
6148
|
this.log.info("Downloading model for step", { meta: {
|
|
6036
6149
|
modelId: step.modelId,
|
|
6037
6150
|
format,
|
|
@@ -6149,19 +6262,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6149
6262
|
}
|
|
6150
6263
|
async runPipelineBatchImpl(input) {
|
|
6151
6264
|
if (input.frames.length === 0) return { results: [] };
|
|
6152
|
-
const
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
device: this.currentEngine.device ?? null
|
|
6163
|
-
};
|
|
6164
|
-
const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat, resolveEngine);
|
|
6265
|
+
const dispatchResolution = this.resolveStepsForDispatch({
|
|
6266
|
+
steps: input.steps,
|
|
6267
|
+
deviceKey: input.deviceKey,
|
|
6268
|
+
engineOverride: input.engine,
|
|
6269
|
+
deviceId: input.deviceId,
|
|
6270
|
+
plane: void 0
|
|
6271
|
+
});
|
|
6272
|
+
const dispatchDeviceKey = dispatchResolution.deviceKey;
|
|
6273
|
+
const resolveFormat = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey).format : input.engine?.format ?? this.currentEngine.format;
|
|
6274
|
+
const benchmarkSteps = dispatchResolution.steps;
|
|
6165
6275
|
const enabledSteps = flattenEnabledVideoSteps(benchmarkSteps);
|
|
6166
6276
|
if (enabledSteps.length === 0) throw new Error("runPipelineBatch: no enabled steps");
|
|
6167
6277
|
const rootStep = enabledSteps[0];
|
|
@@ -6183,7 +6293,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6183
6293
|
await this.ensureEngineFactory();
|
|
6184
6294
|
const restoreEngine = await this.applyEngineOverride(input.engine);
|
|
6185
6295
|
try {
|
|
6186
|
-
const factory =
|
|
6296
|
+
const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
|
|
6187
6297
|
if (!factory) throw new Error("runPipelineBatch: factory not initialised");
|
|
6188
6298
|
if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
|
|
6189
6299
|
const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
|
|
@@ -6200,7 +6310,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6200
6310
|
frame,
|
|
6201
6311
|
deviceId: input.deviceId,
|
|
6202
6312
|
sessionId: input.sessionId,
|
|
6203
|
-
deviceKey:
|
|
6313
|
+
deviceKey: dispatchDeviceKey
|
|
6204
6314
|
}))) };
|
|
6205
6315
|
const items = input.frames.map((frame) => ({
|
|
6206
6316
|
raw: Buffer.from(frame.data),
|
|
@@ -6385,31 +6495,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6385
6495
|
* detections).
|
|
6386
6496
|
*/
|
|
6387
6497
|
gateDetectionsByZoneRules(deviceId, result) {
|
|
6388
|
-
if (deviceId <= 0
|
|
6498
|
+
if (deviceId <= 0) return result;
|
|
6389
6499
|
const proxy = this.deviceProxies.get(deviceId);
|
|
6390
6500
|
if (!proxy) return result;
|
|
6391
|
-
|
|
6392
|
-
const rules = proxy.state.zoneRules.value?.detection ?? [];
|
|
6393
|
-
if (zones.length === 0 || rules.length === 0) return result;
|
|
6394
|
-
const frameW = result.width;
|
|
6395
|
-
const frameH = result.height;
|
|
6396
|
-
if (frameW === 0 || frameH === 0) return result;
|
|
6397
|
-
const firstLevel = result.detections.filter((d) => d.kind === "first-level");
|
|
6398
|
-
const details = result.detections.filter((d) => d.kind === "detail");
|
|
6399
|
-
const { passed } = evaluateZoneRules(firstLevel, zones, rules, (det) => ({
|
|
6400
|
-
x: (det.bbox.x + det.bbox.width / 2) / frameW,
|
|
6401
|
-
y: (det.bbox.y + det.bbox.height / 2) / frameH
|
|
6402
|
-
}), (det) => det.macroClass);
|
|
6403
|
-
if (passed.length === firstLevel.length) return result;
|
|
6404
|
-
const passedIds = new Set(passed.map((d) => d.id));
|
|
6405
|
-
const filteredDetails = details.filter((d) => {
|
|
6406
|
-
const parentId = d.parentId;
|
|
6407
|
-
return parentId === void 0 || passedIds.has(parentId);
|
|
6408
|
-
});
|
|
6409
|
-
return {
|
|
6410
|
-
...result,
|
|
6411
|
-
detections: [...passed, ...filteredDetails]
|
|
6412
|
-
};
|
|
6501
|
+
return applyZoneRuleGate(result, proxy.state.zones.value?.zones ?? [], proxy.state.zoneRules.value?.detection ?? []);
|
|
6413
6502
|
}
|
|
6414
6503
|
async cacheFrameInPool(input) {
|
|
6415
6504
|
await this.ensureEngineFactory();
|
|
@@ -6426,13 +6515,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6426
6515
|
if (!this.engineFactory) return;
|
|
6427
6516
|
await this.engineFactory.uncacheFrame(input.frameId);
|
|
6428
6517
|
}
|
|
6518
|
+
/**
|
|
6519
|
+
* The tuning of the node's DEFAULT pool. Per-device pools each resolve their
|
|
6520
|
+
* own runtime's row, so this is not "the node's tuning" — the per-pool truth
|
|
6521
|
+
* is on each pool's `Python inference pool` log line and in `listLoadedEngines`.
|
|
6522
|
+
*/
|
|
6429
6523
|
async getEffectiveTuning() {
|
|
6430
|
-
const
|
|
6524
|
+
const p = resolvePoolProvisioning(this.currentEngine, this.executorOptions.provisioning);
|
|
6431
6525
|
return {
|
|
6432
|
-
batchMode:
|
|
6433
|
-
windowMs:
|
|
6434
|
-
maxBatchSize:
|
|
6435
|
-
concurrency:
|
|
6526
|
+
batchMode: p.batchMode,
|
|
6527
|
+
windowMs: p.windowMs,
|
|
6528
|
+
maxBatchSize: p.maxBatchSize,
|
|
6529
|
+
concurrency: p.concurrency
|
|
6436
6530
|
};
|
|
6437
6531
|
}
|
|
6438
6532
|
/**
|
|
@@ -6450,9 +6544,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6450
6544
|
modelsDir: this.modelsDir,
|
|
6451
6545
|
logger: this.log.child("engine"),
|
|
6452
6546
|
pythonPath: this.executorOptions.pythonPath ?? "",
|
|
6453
|
-
|
|
6454
|
-
tuning: this.executorOptions.tuning,
|
|
6455
|
-
numWorkers: this.executorOptions.numWorkers
|
|
6547
|
+
provisioning: this.executorOptions.provisioning
|
|
6456
6548
|
});
|
|
6457
6549
|
await this.engineFactory.initialize([]);
|
|
6458
6550
|
}
|
|
@@ -6491,13 +6583,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
6491
6583
|
modelsDir: this.modelsDir,
|
|
6492
6584
|
logger: this.log.child(`engine:${deviceKey}`),
|
|
6493
6585
|
pythonPath: this.executorOptions.pythonPath ?? "",
|
|
6494
|
-
|
|
6495
|
-
tuning: this.executorOptions.tuning,
|
|
6496
|
-
numWorkers: this.executorOptions.numWorkers
|
|
6586
|
+
provisioning: this.executorOptions.provisioning
|
|
6497
6587
|
});
|
|
6498
6588
|
try {
|
|
6499
6589
|
await factory.initialize([]);
|
|
6500
6590
|
} catch (err) {
|
|
6591
|
+
this.log.error("Device pool failed to initialize", { meta: {
|
|
6592
|
+
deviceKey,
|
|
6593
|
+
backend: engine.backend,
|
|
6594
|
+
device: engine.device ?? null,
|
|
6595
|
+
provisioning: factory.provisioning,
|
|
6596
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6597
|
+
} });
|
|
6501
6598
|
await factory.dispose().catch(() => void 0);
|
|
6502
6599
|
throw err;
|
|
6503
6600
|
}
|
|
@@ -6991,12 +7088,49 @@ function stepsToPipelineConfig(steps, engine) {
|
|
|
6991
7088
|
} : {}
|
|
6992
7089
|
};
|
|
6993
7090
|
}
|
|
6994
|
-
/** Flatten a step tree into a flat list (DFS). */
|
|
6995
7091
|
/** Flatten a step tree into a flat list (DFS) — delegates to shared utility. */
|
|
6996
7092
|
function flattenSteps(steps) {
|
|
6997
7093
|
return flattenEnabledVideoSteps(steps);
|
|
6998
7094
|
}
|
|
6999
7095
|
/**
|
|
7096
|
+
* Catalog root-vs-detail plane of a step: a non-null `inputClasses` on the
|
|
7097
|
+
* DEFINITION marks a crop child served per-track on the detail plane —
|
|
7098
|
+
* mirrors the executor's frame-plane skip (`executeChildren`) and the
|
|
7099
|
+
* runner's `deriveDetailSteps`. Unknown addonIds (already dropped from
|
|
7100
|
+
* resolved trees) default to root-plane.
|
|
7101
|
+
*/
|
|
7102
|
+
function isDetailPlaneStep(addonId) {
|
|
7103
|
+
try {
|
|
7104
|
+
return getStepDefinition(addonId).inputClasses !== null;
|
|
7105
|
+
} catch {
|
|
7106
|
+
return false;
|
|
7107
|
+
}
|
|
7108
|
+
}
|
|
7109
|
+
/**
|
|
7110
|
+
* True when the step's FULL catalog ships no model with a build for
|
|
7111
|
+
* `format` — the single-step form of `collectZeroBuildIssues`. Unknown/custom
|
|
7112
|
+
* addonIds (no catalog to check) are never zero-build, mirroring the
|
|
7113
|
+
* collector's skip.
|
|
7114
|
+
*/
|
|
7115
|
+
function stepHasZeroBuildsFor(addonId, format) {
|
|
7116
|
+
return collectZeroBuildIssues([{ addonId }], format).length > 0;
|
|
7117
|
+
}
|
|
7118
|
+
/**
|
|
7119
|
+
* The union of model formats a step's catalog ships ANY build for — the
|
|
7120
|
+
* "Available: …" half of the capability-gate error. Unknown/custom addonIds
|
|
7121
|
+
* yield an empty list (no catalog to enumerate), never a throw.
|
|
7122
|
+
*/
|
|
7123
|
+
function availableFormatsForStep(addonId) {
|
|
7124
|
+
try {
|
|
7125
|
+
const def = getStepDefinition(addonId);
|
|
7126
|
+
const formats = /* @__PURE__ */ new Set();
|
|
7127
|
+
for (const model of def.models) for (const format of Object.keys(model.formats)) formats.add(format);
|
|
7128
|
+
return Array.from(formats).toSorted();
|
|
7129
|
+
} catch {
|
|
7130
|
+
return [];
|
|
7131
|
+
}
|
|
7132
|
+
}
|
|
7133
|
+
/**
|
|
7000
7134
|
* Return a shallow-cloned executable tree with `rootStepId`'s `settings`
|
|
7001
7135
|
* merged with `overrides` (per-device values win). Non-matching root steps
|
|
7002
7136
|
* pass through unchanged. Used by `runFrame` to apply per-device detection
|
|
@@ -7108,21 +7242,22 @@ function toNodePlatform(value) {
|
|
|
7108
7242
|
return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
|
|
7109
7243
|
}
|
|
7110
7244
|
/**
|
|
7111
|
-
*
|
|
7112
|
-
*
|
|
7113
|
-
*
|
|
7114
|
-
* these and rebuilds the provider when they flip.
|
|
7245
|
+
* Slider keys on the "Inference executor" section that map 1:1 onto a
|
|
7246
|
+
* {@link RuntimePoolProfile} field. Used to inject the node-default runtime's
|
|
7247
|
+
* registry value as the field's suggested default in `getGlobalSettings`.
|
|
7115
7248
|
*/
|
|
7116
|
-
var
|
|
7249
|
+
var PROFILE_SLIDER_KEYS = [
|
|
7117
7250
|
"concurrency",
|
|
7118
|
-
"
|
|
7251
|
+
"numWorkers",
|
|
7119
7252
|
"windowMs",
|
|
7120
7253
|
"maxBatchSize",
|
|
7121
7254
|
"numStreams",
|
|
7122
|
-
"intraOpThreads"
|
|
7123
|
-
"numWorkers"
|
|
7255
|
+
"intraOpThreads"
|
|
7124
7256
|
];
|
|
7125
|
-
|
|
7257
|
+
function isProfileSliderKey(key) {
|
|
7258
|
+
return PROFILE_SLIDER_KEYS.some((k) => k === key);
|
|
7259
|
+
}
|
|
7260
|
+
var DetectionPipelineAddon = class extends BaseAddon {
|
|
7126
7261
|
provider = null;
|
|
7127
7262
|
/** Last non-null probed hardware PER NODE — reused when the probe transiently
|
|
7128
7263
|
* returns null so offered backends / device lists don't collapse. Keyed by
|
|
@@ -7137,14 +7272,17 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
|
|
|
7137
7272
|
* ENGINE_METRICS_HEARTBEAT_MS for liveness. */
|
|
7138
7273
|
lastEmittedEngineSnapshot = null;
|
|
7139
7274
|
/**
|
|
7140
|
-
* Last-applied
|
|
7141
|
-
*
|
|
7142
|
-
*
|
|
7143
|
-
*
|
|
7144
|
-
*
|
|
7145
|
-
*
|
|
7275
|
+
* Last-applied OPERATOR OVERRIDE, taken at the end of `onInitialize` and
|
|
7276
|
+
* refreshed at the end of `onConfigChanged`. Lets the change handler diff the
|
|
7277
|
+
* current config against the snapshot and decide whether a pool respawn is
|
|
7278
|
+
* necessary — most config edits (engine cascade, audio settings) don't touch
|
|
7279
|
+
* the tuning fields and don't deserve a multi-second model reload.
|
|
7280
|
+
*
|
|
7281
|
+
* The per-runtime registry values are compile-time constants resolved inside
|
|
7282
|
+
* each pool, so they can never be the reason for a respawn; only an operator
|
|
7283
|
+
* edit can.
|
|
7146
7284
|
*/
|
|
7147
|
-
|
|
7285
|
+
lastAppliedOverride = {};
|
|
7148
7286
|
/**
|
|
7149
7287
|
* Embedded Python path resolved once at boot via
|
|
7150
7288
|
* `ctx.deps.ensurePython()`. Empty string means the download failed
|
|
@@ -7312,17 +7450,16 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
|
|
|
7312
7450
|
...section,
|
|
7313
7451
|
fields: section.fields.map((field) => {
|
|
7314
7452
|
if (field.type === "select") {
|
|
7315
|
-
if (field.key === "batchMode"
|
|
7453
|
+
if (field.key === "batchMode") return {
|
|
7316
7454
|
...field,
|
|
7317
|
-
default: tuning
|
|
7455
|
+
default: tuning.batchMode
|
|
7318
7456
|
};
|
|
7319
7457
|
}
|
|
7320
7458
|
if (field.type === "slider" && "key" in field) {
|
|
7321
|
-
const tuned = tuning[field.key];
|
|
7322
7459
|
const sliderField = field;
|
|
7323
|
-
let patchedField =
|
|
7460
|
+
let patchedField = isProfileSliderKey(sliderField.key) ? {
|
|
7324
7461
|
...sliderField,
|
|
7325
|
-
default:
|
|
7462
|
+
default: tuning[sliderField.key]
|
|
7326
7463
|
} : sliderField;
|
|
7327
7464
|
if (sliderField.key === "concurrency" && backend === "coreml") patchedField = {
|
|
7328
7465
|
...patchedField,
|
|
@@ -7431,60 +7568,39 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
|
|
|
7431
7568
|
return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
|
|
7432
7569
|
}
|
|
7433
7570
|
/**
|
|
7434
|
-
*
|
|
7571
|
+
* The operator's explicit deviations from the per-runtime registry — and
|
|
7572
|
+
* NOTHING else.
|
|
7435
7573
|
*
|
|
7436
|
-
*
|
|
7437
|
-
*
|
|
7438
|
-
*
|
|
7439
|
-
*
|
|
7440
|
-
*
|
|
7574
|
+
* This used to be `resolveBackendTuning()`: it read `tuningFor(<the node's
|
|
7575
|
+
* default backend>)` and handed the whole resolved row to the provider, which
|
|
7576
|
+
* passed it verbatim to every pool it built. That is how an `openvino:npu`
|
|
7577
|
+
* pool on an onnx-default node came up with onnx's `concurrency: 4`. The
|
|
7578
|
+
* per-runtime values now resolve inside each pool
|
|
7579
|
+
* (`engine/pool-provisioning.ts`), keyed by that pool's own runtime, so
|
|
7580
|
+
* nothing backend-shaped may be computed here.
|
|
7441
7581
|
*
|
|
7442
|
-
*
|
|
7443
|
-
*
|
|
7444
|
-
*
|
|
7582
|
+
* Precedence, in full: an operator override below wins over the pool's
|
|
7583
|
+
* runtime registry row; an "Auto" field (slider `0`, select `''`) is not an
|
|
7584
|
+
* override. An override is node-wide by design — the operator set it against
|
|
7585
|
+
* a node, not against a runtime.
|
|
7445
7586
|
*/
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
|
|
7449
|
-
const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
|
|
7450
|
-
return {
|
|
7451
|
-
concurrency: num(t["concurrency"], 1),
|
|
7452
|
-
batchMode: batch(t["batchMode"], "window"),
|
|
7453
|
-
windowMs: num(t["windowMs"], 2),
|
|
7454
|
-
maxBatchSize: num(t["maxBatchSize"], 8),
|
|
7455
|
-
numStreams: num(t["numStreams"], 0),
|
|
7456
|
-
intraOpThreads: num(t["intraOpThreads"], 0),
|
|
7457
|
-
numWorkers: num(t["numWorkers"], 1)
|
|
7458
|
-
};
|
|
7587
|
+
resolveOperatorOverride() {
|
|
7588
|
+
return readOperatorOverride(this.config);
|
|
7459
7589
|
}
|
|
7460
7590
|
/**
|
|
7461
|
-
* Construct a fresh provider
|
|
7462
|
-
* truth for the provider wiring — shared by `onInitialize
|
|
7463
|
-
*
|
|
7591
|
+
* Construct a fresh provider carrying the operator override. Single source of
|
|
7592
|
+
* truth for the provider wiring — shared by `onInitialize` and the
|
|
7593
|
+
* `onConfigChanged` pool respawn. NOTHING backend-derived is passed: each
|
|
7594
|
+
* pool resolves its own runtime's registry row.
|
|
7464
7595
|
*/
|
|
7465
|
-
buildProvider(modelsDir,
|
|
7596
|
+
buildProvider(modelsDir, override) {
|
|
7466
7597
|
if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
|
|
7467
7598
|
return new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
|
|
7468
|
-
|
|
7469
|
-
tuning: {
|
|
7470
|
-
batchMode: tuning.batchMode,
|
|
7471
|
-
windowMs: tuning.windowMs,
|
|
7472
|
-
maxBatchSize: tuning.maxBatchSize,
|
|
7473
|
-
numStreams: tuning.numStreams,
|
|
7474
|
-
intraOpThreads: tuning.intraOpThreads
|
|
7475
|
-
},
|
|
7476
|
-
numWorkers: tuning.numWorkers,
|
|
7599
|
+
provisioning: override,
|
|
7477
7600
|
pythonPath: this.pythonPath,
|
|
7478
7601
|
pythonAddonDir: this.pythonAddonDir
|
|
7479
7602
|
});
|
|
7480
7603
|
}
|
|
7481
|
-
/**
|
|
7482
|
-
* Pool-bound tuning keys of `a` and `b` are equal (used to decide whether a
|
|
7483
|
-
* post-resolve / config-change respawn is actually needed).
|
|
7484
|
-
*/
|
|
7485
|
-
static poolTuningEqual(a, b) {
|
|
7486
|
-
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;
|
|
7487
|
-
}
|
|
7488
7604
|
async onInitialize() {
|
|
7489
7605
|
const modelsDir = await this.resolveModelsDir();
|
|
7490
7606
|
if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
|
|
@@ -7493,23 +7609,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
|
|
|
7493
7609
|
if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
|
|
7494
7610
|
else this.ctx.logger.warn("Detection engine boot continues without Python — selection will provision on demand", {});
|
|
7495
7611
|
await this.proactivelyInstallOpenvino();
|
|
7496
|
-
|
|
7497
|
-
this.provider = this.buildProvider(modelsDir, initialTuning);
|
|
7612
|
+
this.provider = this.buildProvider(modelsDir, this.resolveOperatorOverride());
|
|
7498
7613
|
await this.provider.init();
|
|
7499
7614
|
await this.provider.setApi(this.ctx);
|
|
7500
|
-
const resolvedTuning = this.resolveBackendTuning();
|
|
7501
|
-
if (!DetectionPipelineAddon.poolTuningEqual(initialTuning, resolvedTuning)) {
|
|
7502
|
-
this.ctx.logger.info("detection-pipeline tuning reconciled to resolved engine", { meta: {
|
|
7503
|
-
from: initialTuning,
|
|
7504
|
-
to: resolvedTuning
|
|
7505
|
-
} });
|
|
7506
|
-
this.provider = this.buildProvider(modelsDir, resolvedTuning);
|
|
7507
|
-
await this.provider.init();
|
|
7508
|
-
await this.provider.setApi(this.ctx);
|
|
7509
|
-
}
|
|
7510
7615
|
await this.provider.warmPool();
|
|
7511
7616
|
this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
|
|
7512
|
-
this.
|
|
7617
|
+
this.lastAppliedOverride = this.resolveOperatorOverride();
|
|
7513
7618
|
return [{
|
|
7514
7619
|
capability: pipelineExecutorCapability,
|
|
7515
7620
|
provider: this.provider
|
|
@@ -7595,36 +7700,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
|
|
|
7595
7700
|
}
|
|
7596
7701
|
}
|
|
7597
7702
|
/**
|
|
7598
|
-
* Snapshot the pool-bound subset of the EFFECTIVE tuning (post-
|
|
7599
|
-
* `resolveBackendTuning`). Stored config values for these fields are
|
|
7600
|
-
* ignored, so this snapshot only changes when `engineBackend` flips
|
|
7601
|
-
* onto a different `tuningFor` row.
|
|
7602
|
-
*/
|
|
7603
|
-
snapshotPoolConfig() {
|
|
7604
|
-
const t = this.resolveBackendTuning();
|
|
7605
|
-
return {
|
|
7606
|
-
concurrency: t.concurrency ?? 0,
|
|
7607
|
-
batchMode: t.batchMode,
|
|
7608
|
-
windowMs: t.windowMs,
|
|
7609
|
-
maxBatchSize: t.maxBatchSize,
|
|
7610
|
-
numStreams: t.numStreams,
|
|
7611
|
-
intraOpThreads: t.intraOpThreads,
|
|
7612
|
-
numWorkers: t.numWorkers
|
|
7613
|
-
};
|
|
7614
|
-
}
|
|
7615
|
-
poolConfigChanged() {
|
|
7616
|
-
const next = this.snapshotPoolConfig();
|
|
7617
|
-
const prev = this.lastAppliedPoolConfig;
|
|
7618
|
-
for (const k of POOL_BOUND_KEYS) if (next[k] !== prev[k]) return true;
|
|
7619
|
-
return false;
|
|
7620
|
-
}
|
|
7621
|
-
/**
|
|
7622
7703
|
* BaseAddon calls `onConfigChanged` after every settings write. This is the
|
|
7623
7704
|
* SOLE apply path for tuning changes — none of those fields declare
|
|
7624
7705
|
* `requiresRestart` anymore (an addon restart re-probes hardware + reloads
|
|
7625
|
-
* every model on every set, which is exactly what we want to avoid):
|
|
7626
|
-
*
|
|
7627
|
-
*
|
|
7706
|
+
* every model on every set, which is exactly what we want to avoid): a changed
|
|
7707
|
+
* OPERATOR OVERRIDE drives an in-place pool respawn. The per-runtime registry
|
|
7708
|
+
* values cannot change at runtime, so they can never trigger one. The engine
|
|
7628
7709
|
* SELECTION is no longer operator-facing — the node-default backend is derived
|
|
7629
7710
|
* once at boot (`setApi` → `resolveAutoEngine`) from static hardware, so there
|
|
7630
7711
|
* is no per-write engine re-select. The respawn applies optimistically — the
|
|
@@ -7632,14 +7713,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
|
|
|
7632
7713
|
* crashed), no addon bounce.
|
|
7633
7714
|
*/
|
|
7634
7715
|
async onConfigChanged() {
|
|
7635
|
-
|
|
7716
|
+
const next = this.resolveOperatorOverride();
|
|
7717
|
+
if (overridesEqual(next, this.lastAppliedOverride)) return;
|
|
7636
7718
|
if (!this.provider) {
|
|
7637
|
-
this.
|
|
7719
|
+
this.lastAppliedOverride = next;
|
|
7638
7720
|
return;
|
|
7639
7721
|
}
|
|
7640
|
-
this.ctx.logger.info("detection-pipeline tuning changed — respawning pool", { meta: {
|
|
7641
|
-
prev: this.
|
|
7642
|
-
next
|
|
7722
|
+
this.ctx.logger.info("detection-pipeline tuning override changed — respawning pool", { meta: {
|
|
7723
|
+
prev: this.lastAppliedOverride,
|
|
7724
|
+
next
|
|
7643
7725
|
} });
|
|
7644
7726
|
try {
|
|
7645
7727
|
await this.provider.shutdown();
|
|
@@ -7647,11 +7729,11 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
|
|
|
7647
7729
|
this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
7648
7730
|
}
|
|
7649
7731
|
const modelsDir = await this.resolveModelsDir();
|
|
7650
|
-
this.provider = this.buildProvider(modelsDir,
|
|
7732
|
+
this.provider = this.buildProvider(modelsDir, next);
|
|
7651
7733
|
await this.provider.init();
|
|
7652
7734
|
await this.provider.setApi(this.ctx);
|
|
7653
7735
|
await this.provider.warmPool();
|
|
7654
|
-
this.
|
|
7736
|
+
this.lastAppliedOverride = next;
|
|
7655
7737
|
this.ctx.logger.info("detection-pipeline pool respawn complete");
|
|
7656
7738
|
}
|
|
7657
7739
|
};
|