@camstack/addon-post-analysis 1.1.39 → 1.1.41

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.
@@ -1,4 +1,4 @@
1
- import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-CO07v2sR.mjs";
1
+ import { A as unknown, C as hydrateSchema, D as object, E as number, O as record, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, j as EventCategory, k as string, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-BHLsaU1n.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -8103,6 +8103,152 @@ function resolveTrackingSettings(raw) {
8103
8103
  };
8104
8104
  }
8105
8105
  //#endregion
8106
+ //#region src/pipeline-analytics/confirmation-gate-settings.ts
8107
+ /**
8108
+ * Per-device DETECTION CONFIRMATION GATE tuning.
8109
+ *
8110
+ * Before a NEW track is born (its `start` lifecycle + firstFrame media emitted)
8111
+ * the gate optionally re-runs object detection on the HI-RES NATIVE CROP of the
8112
+ * detection box. If the crop does not confirm a compatible object above
8113
+ * `minConfidence`, the birth is SUPPRESSED — it was a false positive from the
8114
+ * low-res detection frame (a static "person" phantom, a parked-truck ghost, a
8115
+ * misclassified static object).
8116
+ *
8117
+ * Ships DORMANT: `enabled` defaults to `false`, so behaviour is byte-identical
8118
+ * to today until an operator opts in per camera. The gate is fail-OPEN — any
8119
+ * missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
8120
+ * error, or timeout ALLOWS the birth (a real track is never suppressed because
8121
+ * confirmation was unavailable).
8122
+ *
8123
+ * Every field is independently overridable per camera; an unknown/invalid value
8124
+ * falls back to the field default (never throws on a bad blob) — mirrors
8125
+ * {@link resolveTrackingSettings}.
8126
+ */
8127
+ var ConfirmationGateSettingsSchema = object({
8128
+ /** Master switch. OFF by default → gate never runs, births unchanged. */
8129
+ enabled: boolean().default(false),
8130
+ /**
8131
+ * Minimum re-detection score (0..1) required in the crop for a birth to be
8132
+ * confirmed. A detection must clear this AND be class-compatible with the
8133
+ * track's class.
8134
+ */
8135
+ minConfidence: number().min(0).max(1).default(.4),
8136
+ /**
8137
+ * Minimum crop size (px, longest side in detection-frame pixel space) worth
8138
+ * re-detecting. A subject box smaller than this is too tiny to confirm
8139
+ * reliably, so the gate SKIPS it and fails open (confirms the birth). 0 =
8140
+ * confirm every birth regardless of size.
8141
+ */
8142
+ minCropPx: number().int().min(0).default(48),
8143
+ /**
8144
+ * Per-birth confirmation budget (ms). If the crop fetch + re-detection does
8145
+ * not resolve within this window the gate fails open (confirms the birth) so
8146
+ * the synchronous frame path never stalls on inference.
8147
+ */
8148
+ timeoutMs: number().int().min(1).default(300)
8149
+ });
8150
+ var CONFIRMATION_GATE_DEFAULTS = ConfirmationGateSettingsSchema.parse({});
8151
+ /**
8152
+ * Resolve a per-device store blob into typed confirmation-gate settings. The
8153
+ * gate's fields live under a nested `confirmationGate` key in the per-device
8154
+ * store. Each field is validated independently via `.catch(default)` so an
8155
+ * invalid/missing field falls back to its default and parse never throws.
8156
+ */
8157
+ function resolveConfirmationGateSettings(raw) {
8158
+ const blob = record(string(), unknown()).catch({}).parse(raw.confirmationGate);
8159
+ const s = ConfirmationGateSettingsSchema.shape;
8160
+ return {
8161
+ enabled: s.enabled.catch(CONFIRMATION_GATE_DEFAULTS.enabled).parse(blob.enabled),
8162
+ minConfidence: s.minConfidence.catch(CONFIRMATION_GATE_DEFAULTS.minConfidence).parse(blob.minConfidence),
8163
+ minCropPx: s.minCropPx.catch(CONFIRMATION_GATE_DEFAULTS.minCropPx).parse(blob.minCropPx),
8164
+ timeoutMs: s.timeoutMs.catch(CONFIRMATION_GATE_DEFAULTS.timeoutMs).parse(blob.timeoutMs)
8165
+ };
8166
+ }
8167
+ //#endregion
8168
+ //#region src/pipeline-analytics/confirmation-gate.ts
8169
+ /**
8170
+ * Map a fine/raw detector or classifier class name to its coarse group. The
8171
+ * runtime detector already emits coarse `macroClass` values (person/vehicle/
8172
+ * animal), but a track's `className` may carry a finer class from a downstream
8173
+ * classifier — this normalizes both sides to the same three groups. Anything
8174
+ * unrecognized maps to `other`, which the compatibility check treats as
8175
+ * fail-open (compatible with everything) rather than a hard mismatch.
8176
+ */
8177
+ function coarseGroupForClass(className) {
8178
+ const c = className.trim().toLowerCase();
8179
+ if (c === "person" || c === "people" || c === "pedestrian" || c === "human") return "person";
8180
+ if (c === "vehicle" || c === "car" || c === "truck" || c === "bus" || c === "van" || c === "motorcycle" || c === "motorbike" || c === "bicycle" || c === "bike") return "vehicle";
8181
+ if (c === "animal" || c === "dog" || c === "cat" || c === "bird" || c === "horse" || c === "sheep" || c === "cow" || c === "bear" || c === "deer" || c === "fox") return "animal";
8182
+ return "other";
8183
+ }
8184
+ /**
8185
+ * A re-detection in the crop is COMPATIBLE with the track's class when both map
8186
+ * to the same coarse group. When either side is unknown (`other`) we fail open
8187
+ * — treat it as compatible — so an unusual class never wrongly suppresses a
8188
+ * real track.
8189
+ */
8190
+ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
8191
+ const track = coarseGroupForClass(trackClassName);
8192
+ const det = coarseGroupForClass(detectionMacroClass);
8193
+ if (track === "other" || det === "other") return true;
8194
+ return track === det;
8195
+ }
8196
+ var failOpen = (trackId, reason) => ({
8197
+ trackId,
8198
+ confirmed: true,
8199
+ reason
8200
+ });
8201
+ function withTimeout(promise, timeoutMs) {
8202
+ return new Promise((resolve, reject) => {
8203
+ const timer = setTimeout(() => reject(/* @__PURE__ */ new Error("confirmation-timeout")), timeoutMs);
8204
+ promise.then((value) => {
8205
+ clearTimeout(timer);
8206
+ resolve(value);
8207
+ }, (err) => {
8208
+ clearTimeout(timer);
8209
+ reject(err instanceof Error ? err : new Error(String(err)));
8210
+ });
8211
+ });
8212
+ }
8213
+ async function runConfirmation(candidate, config, deps) {
8214
+ const crop = await deps.fetchCrop(candidate);
8215
+ if (!crop) return failOpen(candidate.trackId, "no-crop");
8216
+ const detections = await deps.redetect(crop);
8217
+ if (detections === null) return failOpen(candidate.trackId, "redetect-error");
8218
+ const confirmed = detections.some((d) => d.score >= config.minConfidence && isConfirmationCompatible(candidate.className, d.macroClass));
8219
+ return {
8220
+ trackId: candidate.trackId,
8221
+ confirmed,
8222
+ reason: confirmed ? "confirmed" : "suppressed"
8223
+ };
8224
+ }
8225
+ async function confirmOne(candidate, config, deps) {
8226
+ const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
8227
+ if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate.trackId, "below-min-crop");
8228
+ try {
8229
+ return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
8230
+ } catch {
8231
+ return failOpen(candidate.trackId, "timeout");
8232
+ }
8233
+ }
8234
+ /**
8235
+ * Confirm a batch of birth candidates CONCURRENTLY and return the set of
8236
+ * trackIds whose births may PROCEED. When the gate is disabled (or there are no
8237
+ * candidates) every candidate is confirmed — byte-identical to no gate. The
8238
+ * caller runs this once, then processes only the confirmed births, preserving
8239
+ * the original birth-loop ordering.
8240
+ */
8241
+ async function confirmBirths(candidates, config, deps) {
8242
+ if (!config.enabled || candidates.length === 0) return new Set(candidates.map((c) => c.trackId));
8243
+ const decisions = await Promise.all(candidates.map((c) => confirmOne(c, config, deps)));
8244
+ const confirmed = /* @__PURE__ */ new Set();
8245
+ for (const decision of decisions) {
8246
+ deps.onDecision?.(decision);
8247
+ if (decision.confirmed) confirmed.add(decision.trackId);
8248
+ }
8249
+ return confirmed;
8250
+ }
8251
+ //#endregion
8106
8252
  //#region src/pipeline-analytics/face-settings.ts
8107
8253
  /**
8108
8254
  * Per-device face-recognition settings. Cascade: a per-device override on top
@@ -11763,6 +11909,15 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
11763
11909
  * before re-reading. */
11764
11910
  var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
11765
11911
  var SETTINGS_CACHE_TTL_MS = 5e3;
11912
+ /** TTL for the per-node confirmation-gate detector-step cache (the node's
11913
+ * default object-detector step rarely changes). */
11914
+ var CONFIRMATION_DETECTOR_CACHE_TTL_MS = 6e4;
11915
+ /** Padding applied to a birth box before re-detecting it — a little context
11916
+ * around the subject improves the confirmation detector's recall. */
11917
+ var CONFIRMATION_CROP_PADDING = .15;
11918
+ /** Cap the confirmation crop width so a 4K native surface never floods the
11919
+ * transport just to confirm one small box. */
11920
+ var CONFIRMATION_CROP_MAX_WIDTH = 320;
11766
11921
  /** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
11767
11922
  * The `device.state-changed` push doesn't reliably reach a forked child, so
11768
11923
  * each cached proxy re-pulls both slices on this timer (see ensureProxy) —
@@ -11994,6 +12149,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11994
12149
  settingsCacheByDevice = /* @__PURE__ */ new Map();
11995
12150
  sensitivityCacheByDevice = /* @__PURE__ */ new Map();
11996
12151
  trackingCacheByDevice = /* @__PURE__ */ new Map();
12152
+ /** Per-device confirmation-gate settings cache (default off), TTL-mirrored. */
12153
+ confirmationGateCacheByDevice = /* @__PURE__ */ new Map();
12154
+ /** Per-node detector-step cache for the confirmation gate's re-detection —
12155
+ * the node's default root object-detector reduced to a single childless
12156
+ * step, resolved lazily from `pipelineExecutor.getGlobalSteps` and reused. */
12157
+ confirmationDetectorStepByNode = /* @__PURE__ */ new Map();
11997
12158
  faceCacheByDevice = /* @__PURE__ */ new Map();
11998
12159
  /** GLOBAL face-recognition master switch (addon store), TTL-cached. */
11999
12160
  faceGlobalEnabledCache = null;
@@ -12525,6 +12686,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12525
12686
  this.settingsCacheByDevice.delete(data.deviceId);
12526
12687
  this.sensitivityCacheByDevice.delete(data.deviceId);
12527
12688
  this.trackingCacheByDevice.delete(data.deviceId);
12689
+ this.confirmationGateCacheByDevice.delete(data.deviceId);
12528
12690
  this.faceCacheByDevice.delete(data.deviceId);
12529
12691
  this.mediaCacheByDevice.delete(data.deviceId);
12530
12692
  this.packageDropCacheByDevice.delete(data.deviceId);
@@ -12542,6 +12704,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12542
12704
  this.settingsCacheByDevice.delete(deviceId);
12543
12705
  this.sensitivityCacheByDevice.delete(deviceId);
12544
12706
  this.trackingCacheByDevice.delete(deviceId);
12707
+ this.confirmationGateCacheByDevice.delete(deviceId);
12545
12708
  this.faceCacheByDevice.delete(deviceId);
12546
12709
  this.mediaCacheByDevice.delete(deviceId);
12547
12710
  this.packageDropCacheByDevice.delete(deviceId);
@@ -12893,6 +13056,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12893
13056
  this.settingsCacheByDevice.clear();
12894
13057
  this.sensitivityCacheByDevice.clear();
12895
13058
  this.trackingCacheByDevice.clear();
13059
+ this.confirmationGateCacheByDevice.clear();
13060
+ this.confirmationDetectorStepByNode.clear();
12896
13061
  this.faceCacheByDevice.clear();
12897
13062
  this.faceGlobalEnabledCache = null;
12898
13063
  this.mediaCacheByDevice.clear();
@@ -13008,70 +13173,88 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13008
13173
  const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
13009
13174
  const firstFrameTargets = [];
13010
13175
  let newTrackCount = 0;
13011
- for (const id of currentTrackIds) if (!prevIds.has(id)) {
13176
+ const bornCandidates = [];
13177
+ for (const id of currentTrackIds) {
13178
+ if (prevIds.has(id)) continue;
13012
13179
  const t = result.tracked.find((x) => x.trackId === id);
13013
- if (t) {
13014
- if (classifyTrackAppearance({
13015
- inPrevActive: false,
13016
- positionsCount: positionsCountById.get(id) ?? 1
13017
- }) === "resurrection") {
13018
- log.info("track resumed", { meta: {
13019
- trackId: id,
13020
- className: t.className,
13021
- source,
13022
- resurrected: true
13023
- } });
13024
- continue;
13025
- }
13026
- newTrackCount += 1;
13027
- log.info("track started", { meta: {
13180
+ if (!t) continue;
13181
+ if (classifyTrackAppearance({
13182
+ inPrevActive: false,
13183
+ positionsCount: positionsCountById.get(id) ?? 1
13184
+ }) === "resurrection") {
13185
+ log.info("track resumed", { meta: {
13028
13186
  trackId: id,
13029
13187
  className: t.className,
13030
- source
13188
+ source,
13189
+ resurrected: true
13031
13190
  } });
13032
- if (this.eventMediaDispatcher && frameHandle) {
13033
- firstFrameTargets.push({
13034
- trackId: id,
13035
- timestamp: result.timestamp,
13036
- bbox: { ...t.bbox },
13037
- ...t.label ? { label: t.label } : {}
13038
- });
13039
- this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
13040
- }
13041
- this.ctx.eventBus.emit({
13042
- id: `pa-${randomUUID()}`,
13043
- timestamp: new Date(result.timestamp),
13044
- source: {
13045
- type: "addon",
13046
- id: "pipeline-analytics",
13047
- addonId: "pipeline-analytics"
13048
- },
13049
- category: EventCategory.PipelineAnalyticsTrackStarted,
13050
- data: {
13051
- deviceId,
13052
- trackId: id,
13053
- className: t.className
13054
- }
13055
- });
13056
- const startPayload = buildTrackLifecyclePayload({
13057
- deviceId,
13191
+ continue;
13192
+ }
13193
+ bornCandidates.push({
13194
+ id,
13195
+ t
13196
+ });
13197
+ }
13198
+ const confirmedBirths = await this.confirmTrackBirths(bornCandidates.map(({ id, t }) => ({
13199
+ trackId: id,
13200
+ className: t.className,
13201
+ bbox: t.bbox
13202
+ })), {
13203
+ deviceId,
13204
+ frameHandle,
13205
+ frameWidth: result.frameWidth,
13206
+ frameHeight: result.frameHeight
13207
+ });
13208
+ for (const { id, t } of bornCandidates) {
13209
+ if (!confirmedBirths.has(id)) continue;
13210
+ newTrackCount += 1;
13211
+ log.info("track started", { meta: {
13212
+ trackId: id,
13213
+ className: t.className,
13214
+ source
13215
+ } });
13216
+ if (this.eventMediaDispatcher && frameHandle) {
13217
+ firstFrameTargets.push({
13058
13218
  trackId: id,
13059
- phase: "start",
13060
- classes: [t.className],
13061
- bestClassName: t.className,
13062
- bestConfidence: t.confidence,
13063
- firstSeen: result.timestamp,
13064
- lastSeen: result.timestamp,
13065
- ...t.label !== void 0 ? { label: t.label } : {}
13066
- });
13067
- this.emitTrackLifecycle(startPayload, result.timestamp);
13068
- this.trackLifecycleUpdateMem.set(id, {
13069
- lastConfidence: t.confidence,
13070
- lastEmitAt: result.timestamp,
13071
- ...t.label !== void 0 ? { lastLabel: t.label } : {},
13072
- lastBboxArea: t.bbox.w * t.bbox.h
13219
+ timestamp: result.timestamp,
13220
+ bbox: { ...t.bbox },
13221
+ ...t.label ? { label: t.label } : {}
13073
13222
  });
13223
+ this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
13074
13224
  }
13225
+ this.ctx.eventBus.emit({
13226
+ id: `pa-${randomUUID()}`,
13227
+ timestamp: new Date(result.timestamp),
13228
+ source: {
13229
+ type: "addon",
13230
+ id: "pipeline-analytics",
13231
+ addonId: "pipeline-analytics"
13232
+ },
13233
+ category: EventCategory.PipelineAnalyticsTrackStarted,
13234
+ data: {
13235
+ deviceId,
13236
+ trackId: id,
13237
+ className: t.className
13238
+ }
13239
+ });
13240
+ const startPayload = buildTrackLifecyclePayload({
13241
+ deviceId,
13242
+ trackId: id,
13243
+ phase: "start",
13244
+ classes: [t.className],
13245
+ bestClassName: t.className,
13246
+ bestConfidence: t.confidence,
13247
+ firstSeen: result.timestamp,
13248
+ lastSeen: result.timestamp,
13249
+ ...t.label !== void 0 ? { label: t.label } : {}
13250
+ });
13251
+ this.emitTrackLifecycle(startPayload, result.timestamp);
13252
+ this.trackLifecycleUpdateMem.set(id, {
13253
+ lastConfidence: t.confidence,
13254
+ lastEmitAt: result.timestamp,
13255
+ ...t.label !== void 0 ? { lastLabel: t.label } : {},
13256
+ lastBboxArea: t.bbox.w * t.bbox.h
13257
+ });
13075
13258
  }
13076
13259
  let lostTrackCount = 0;
13077
13260
  for (const id of prevIds) if (!currentTrackIds.has(id)) {
@@ -13352,6 +13535,146 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13352
13535
  });
13353
13536
  return settings;
13354
13537
  }
13538
+ /** Per-device confirmation-gate settings (default OFF), TTL-cached like the
13539
+ * other per-device tunables. */
13540
+ async resolveDeviceConfirmationGateSettings(deviceId) {
13541
+ const now = Date.now();
13542
+ const cached = this.confirmationGateCacheByDevice.get(deviceId);
13543
+ if (cached && now < cached.expiresAt) return cached.settings;
13544
+ const settings = resolveConfirmationGateSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
13545
+ this.confirmationGateCacheByDevice.set(deviceId, {
13546
+ settings,
13547
+ expiresAt: now + SETTINGS_CACHE_TTL_MS
13548
+ });
13549
+ return settings;
13550
+ }
13551
+ /**
13552
+ * Narrowed, version-skew-safe handle onto the `pipelineExecutor` cap — same
13553
+ * structural-cast pattern as the `pipelineRunner` handle above. A node whose
13554
+ * executor predates these methods simply yields `undefined`, and the
13555
+ * confirmation gate then fails open (allows the birth).
13556
+ */
13557
+ pipelineExecutorApi() {
13558
+ const api = this.ctx?.api;
13559
+ if (!api) return void 0;
13560
+ return api.pipelineExecutor;
13561
+ }
13562
+ /**
13563
+ * Resolve (and cache) the confirmation gate's re-detection step for a node:
13564
+ * the node's DEFAULT root object-detector (from `getGlobalSteps`) reduced to a
13565
+ * single CHILDLESS step, so the confirmation run does detection ONLY — no
13566
+ * crop/classifier subtree. Cached per node (TTL) since the node default rarely
13567
+ * changes. `null` ⇒ no detector available ⇒ the gate fails open.
13568
+ */
13569
+ async resolveConfirmationDetectorStep(nodeId) {
13570
+ const now = Date.now();
13571
+ const cached = this.confirmationDetectorStepByNode.get(nodeId);
13572
+ if (cached && now < cached.expiresAt) return cached.step;
13573
+ let step = null;
13574
+ const executor = this.pipelineExecutorApi();
13575
+ if (executor?.getGlobalSteps) try {
13576
+ const steps = await executor.getGlobalSteps.query(void 0, nodePin(nodeId));
13577
+ const detector = steps?.find((s) => s.slot === "detector" && s.enabled) ?? steps?.find((s) => s.slot === "detector");
13578
+ if (detector) step = {
13579
+ addonId: detector.addonId,
13580
+ ...detector.modelId ? { modelId: detector.modelId } : {},
13581
+ enabled: true
13582
+ };
13583
+ } catch (err) {
13584
+ this.ctx.logger.debug("confirmation gate: getGlobalSteps failed", { meta: {
13585
+ nodeId,
13586
+ error: errMsg(err)
13587
+ } });
13588
+ }
13589
+ this.confirmationDetectorStepByNode.set(nodeId, {
13590
+ step,
13591
+ expiresAt: now + CONFIRMATION_DETECTOR_CACHE_TTL_MS
13592
+ });
13593
+ return step;
13594
+ }
13595
+ /**
13596
+ * Re-run object detection on a confirmation crop JPEG via the frame-owning
13597
+ * node's `pipelineExecutor` (pinned by `nodeId`, matching the native-crop
13598
+ * fetch). Returns the crop's detections, or `null` on any unavailability /
13599
+ * error so the gate fails open.
13600
+ */
13601
+ async redetectCropForConfirmation(nodeId, deviceId, cropJpeg) {
13602
+ const step = await this.resolveConfirmationDetectorStep(nodeId);
13603
+ if (!step) return null;
13604
+ const executor = this.pipelineExecutorApi();
13605
+ if (!executor?.runPipeline) return null;
13606
+ try {
13607
+ const detections = (await executor.runPipeline.mutate({
13608
+ steps: [step],
13609
+ image: new Uint8Array(cropJpeg),
13610
+ deviceId,
13611
+ plane: "frame"
13612
+ }, nodePin(nodeId)))?.detections;
13613
+ if (!detections) return null;
13614
+ return detections.map((d) => ({
13615
+ macroClass: d.macroClass,
13616
+ score: d.score
13617
+ }));
13618
+ } catch (err) {
13619
+ this.ctx.logger.debug("confirmation gate: re-detection failed — fail-open", {
13620
+ tags: { deviceId },
13621
+ meta: { error: errMsg(err) }
13622
+ });
13623
+ return null;
13624
+ }
13625
+ }
13626
+ /**
13627
+ * Confirm a batch of track-birth candidates before they are created. Returns
13628
+ * the set of trackIds whose births may PROCEED. When the gate is disabled
13629
+ * (default) or no crop path is available, every candidate is confirmed —
13630
+ * byte-identical to no gate. Confirmations run CONCURRENTLY (one inference per
13631
+ * candidate) and each failure path fails OPEN, so the synchronous frame path
13632
+ * is never blocked or reordered by a slow/failed re-detection.
13633
+ */
13634
+ async confirmTrackBirths(candidates, params) {
13635
+ const allConfirmed = () => new Set(candidates.map((c) => c.trackId));
13636
+ if (candidates.length === 0) return allConfirmed();
13637
+ const config = await this.resolveDeviceConfirmationGateSettings(params.deviceId);
13638
+ if (!config.enabled) return allConfirmed();
13639
+ const { frameHandle, frameWidth, frameHeight, deviceId } = params;
13640
+ const captureCrop = this.captureCrop;
13641
+ if (!frameHandle || !captureCrop || frameWidth <= 0 || frameHeight <= 0) {
13642
+ this.ctx.logger.debug("confirmation gate: no crop path — births allowed (fail-open)", {
13643
+ tags: { deviceId },
13644
+ meta: {
13645
+ candidates: candidates.length,
13646
+ hasHandle: Boolean(frameHandle)
13647
+ }
13648
+ });
13649
+ return allConfirmed();
13650
+ }
13651
+ const nodeId = frameHandle.nodeId;
13652
+ return confirmBirths(candidates, config, {
13653
+ fetchCrop: (candidate) => captureCrop(frameHandle, {
13654
+ x: candidate.bbox.x,
13655
+ y: candidate.bbox.y,
13656
+ w: candidate.bbox.w,
13657
+ h: candidate.bbox.h
13658
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, CONFIRMATION_CROP_MAX_WIDTH),
13659
+ redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
13660
+ onDecision: (decision) => {
13661
+ if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
13662
+ tags: { deviceId },
13663
+ meta: {
13664
+ trackId: decision.trackId,
13665
+ reason: decision.reason
13666
+ }
13667
+ });
13668
+ else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
13669
+ tags: { deviceId },
13670
+ meta: {
13671
+ trackId: decision.trackId,
13672
+ reason: decision.reason
13673
+ }
13674
+ });
13675
+ }
13676
+ });
13677
+ }
13355
13678
  async resolveGlobalFaceEnabled() {
13356
13679
  const now = Date.now();
13357
13680
  if (this.faceGlobalEnabledCache && now < this.faceGlobalEnabledCache.expiresAt) return this.faceGlobalEnabledCache.value;
@@ -16064,6 +16387,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
16064
16387
  this.settingsCacheByDevice.delete(input.deviceId);
16065
16388
  this.sensitivityCacheByDevice.delete(input.deviceId);
16066
16389
  this.trackingCacheByDevice.delete(input.deviceId);
16390
+ this.confirmationGateCacheByDevice.delete(input.deviceId);
16067
16391
  this.forgetDeviceProcessors(input.deviceId);
16068
16392
  return { success: true };
16069
16393
  }
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DrIydlxk.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BU06qNk5.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.1.39",
3
+ "version": "1.1.41",
4
4
  "description": "CamStack Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",