@camstack/addon-post-analysis 1.2.245 → 1.2.247

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CzmhHYgz.js");
5
+ const require_dist = require("../dist-4yCJnUsG.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -59327,6 +59327,79 @@ var StationaryEvidenceLedger = class {
59327
59327
  }
59328
59328
  };
59329
59329
  //#endregion
59330
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-birth-guard.ts
59331
+ /**
59332
+ * The plausibility bar a stationary BIRTH must clear.
59333
+ *
59334
+ * ## Why a birth needs its own bar
59335
+ *
59336
+ * A birth was guarded by `isPlausibleThumbnailBox`, which only refuses a
59337
+ * NEAR-full-frame box (≥ 0.85 of the frame, or ≥ 0.95 of one dimension). That
59338
+ * is the right question for a thumbnail — "is this crop the whole washed-out
59339
+ * scene?" — and the wrong one for a parked object.
59340
+ *
59341
+ * Measured on device 617, 2026-09-14 08:33: an entry was born with a
59342
+ * 1669x1796 box in a 3840x2160 frame. That is 2,997,524 / 8,294,400 = **0.361
59343
+ * of the frame** — the dawn "envelope exploded" false positive, comfortably
59344
+ * under 0.85, so the thumbnail guard passed it. A third of the camera's view
59345
+ * is not a parked object.
59346
+ *
59347
+ * It did not stop at being wrong about itself. An entry that large reads as a
59348
+ * container: `entrySwallowsDetection` treats any much-smaller contained box as
59349
+ * a FRAGMENT of it, and the scene-sized escape hatch that exists for exactly
59350
+ * this (`SCENE_BOX_AREA_FRAC = 0.55`) sits ABOVE 0.361 — so the duplicate
59351
+ * collapse (D495) folded a genuinely different parked motorcycle into it and
59352
+ * deleted its row.
59353
+ *
59354
+ * ## The number
59355
+ *
59356
+ * {@link BIRTH_MAX_AREA_FRAC} = **0.30**, area fraction, refused at/above.
59357
+ *
59358
+ * - The measured false birth is 0.361 — refused with ~20% of margin, so a
59359
+ * slightly smaller explosion on the same camera is refused too.
59360
+ * - It sits BELOW `SCENE_BOX_AREA_FRAC` (0.55) by design: no entry can be
59361
+ * born into the band [0.30, 0.55) where it is large enough to swallow every
59362
+ * small box in the frame as a "fragment" and not large enough for the
59363
+ * scene-sized guard to disarm it. That band is the whole mechanism of the
59364
+ * 617 motorcycle loss.
59365
+ * - `SCENE_BOX_AREA_FRAC` is NOT moved to do this. It was measured against
59366
+ * device 3829's SUV at ~0.78 of 4K and it decides what the suppression
59367
+ * MATCHER holds; changing it changes what every camera suppresses. This
59368
+ * constant decides only what may be CREATED.
59369
+ * - The cost, stated: a genuine parked object closer to the lens than 30% of
59370
+ * the view no longer earns an entry on that camera (3829's SUV would not be
59371
+ * re-born under this bar). That is the trade — such an object is already
59372
+ * neutered as an entry by the scene-sized guard, while a false one at 0.36
59373
+ * actively destroys neighbouring rows.
59374
+ *
59375
+ * The near-full-frame and full-dimension rules are kept, not replaced: a
59376
+ * 0.96-frame-wide, 0.3-frame-high band is 0.29 of the area and still an
59377
+ * artifact, and `isPlausibleThumbnailBox` is the one place that rule lives.
59378
+ */
59379
+ /** Frame-area fraction at/above which a box may not be BORN as a parked
59380
+ * object. See the file docblock for the measurements behind 0.30. */
59381
+ var BIRTH_MAX_AREA_FRAC = .3;
59382
+ /** Area of `bbox` as a fraction of the frame. 0 when the frame is unknown —
59383
+ * a fraction of nothing is not a measurement (D393), and the caller that
59384
+ * wants a decision asks {@link isPlausibleStationaryBirthBox}. */
59385
+ function boxAreaFraction(bbox, frameWidth, frameHeight) {
59386
+ if (frameWidth <= 0 || frameHeight <= 0) return 0;
59387
+ return bbox.w * bbox.h / (frameWidth * frameHeight);
59388
+ }
59389
+ /**
59390
+ * True when `bbox` may become a stationary entry.
59391
+ *
59392
+ * Stricter than the best-thumbnail guard and a superset of it: the
59393
+ * near-full-frame / full-dimension artifact rules still apply, plus the
59394
+ * birth-specific area bar. Degenerate frame dimensions (≤0) are plausible —
59395
+ * there is no measurement to refuse on.
59396
+ */
59397
+ function isPlausibleStationaryBirthBox(bbox, frameWidth, frameHeight) {
59398
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
59399
+ if (!isPlausibleThumbnailBox(bbox, frameWidth, frameHeight)) return false;
59400
+ return boxAreaFraction(bbox, frameWidth, frameHeight) < BIRTH_MAX_AREA_FRAC;
59401
+ }
59402
+ //#endregion
59330
59403
  //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
59331
59404
  /**
59332
59405
  * @durable class=ledger owner=pipeline-analytics
@@ -59435,6 +59508,7 @@ var StationaryObjectRegistry = class {
59435
59508
  warmUpMs;
59436
59509
  ttlForDevice;
59437
59510
  onChange;
59511
+ requestConfirmationLook;
59438
59512
  /** Latest processed-frame timestamp per device — expiry counts OBSERVED
59439
59513
  * time, not wall-clock. A session-dispatch camera produces no frames
59440
59514
  * between motion sessions; that silence is not evidence the object left,
@@ -59506,6 +59580,7 @@ var StationaryObjectRegistry = class {
59506
59580
  this.warmUpMs = deps.warmUpMs ?? 2e3;
59507
59581
  this.ttlForDevice = deps.ttlForDevice;
59508
59582
  this.onChange = deps.onChange;
59583
+ this.requestConfirmationLook = deps.requestConfirmationLook;
59509
59584
  }
59510
59585
  static declare(store) {
59511
59586
  return DurableLedger.declare(store, STATIONARY_SPEC);
@@ -59675,7 +59750,8 @@ var StationaryObjectRegistry = class {
59675
59750
  this.onChange?.({
59676
59751
  phase: "departed",
59677
59752
  entry: e,
59678
- timestamp: input.timestamp
59753
+ timestamp: input.timestamp,
59754
+ observedAt: input.timestamp - wake.strikeAgeMs
59679
59755
  });
59680
59756
  }
59681
59757
  }
@@ -59700,7 +59776,7 @@ var StationaryObjectRegistry = class {
59700
59776
  * has.
59701
59777
  */
59702
59778
  async promote(entry, config) {
59703
- const occupant = findParkedOccupant(this.list(entry.deviceId), entry.className, entry.bbox, (config ?? this.matchConfig).suppressIou, (config ?? this.matchConfig).wakeAssocIou);
59779
+ const occupant = findParkedOccupant(this.censusTenants(entry, this.list(entry.deviceId), config), entry.className, entry.bbox, (config ?? this.matchConfig).suppressIou, (config ?? this.matchConfig).wakeAssocIou);
59704
59780
  if (occupant !== void 0) {
59705
59781
  const merged = this.absorbInto(occupant, entry);
59706
59782
  this.logger.info("stationary promotion absorbed — the spot is already censused", {
@@ -59725,13 +59801,44 @@ var StationaryObjectRegistry = class {
59725
59801
  this.onChange?.({
59726
59802
  phase: "appeared",
59727
59803
  entry,
59728
- timestamp: entry.becameStationaryAt
59804
+ timestamp: entry.becameStationaryAt,
59805
+ observedAt: entry.becameStationaryAt
59729
59806
  });
59730
59807
  return {
59731
59808
  outcome: "born",
59732
59809
  entry
59733
59810
  };
59734
59811
  }
59812
+ /**
59813
+ * The entries allowed to ABSORB another row — a strict subset of `candidates`.
59814
+ *
59815
+ * An entry that could not be BORN today may not eat its neighbours. Device
59816
+ * 617 held a 1669x1796 box (0.361 of a 4K frame) born before the birth bar
59817
+ * existed; `entrySwallowsDetection` reads every much-smaller contained box as
59818
+ * a fragment of it, and `SCENE_BOX_AREA_FRAC` (0.55) — the guard that exists
59819
+ * to stop exactly this — sits ABOVE 0.361, so the collapse folded a genuinely
59820
+ * different parked motorcycle into it and deleted the motorcycle's row.
59821
+ *
59822
+ * Absorbing is the only thing an oversized entry is refused. It keeps
59823
+ * suppressing, keeps being confirmed and keeps its own row — see
59824
+ * {@link sweep} for why it is not retired.
59825
+ */
59826
+ censusTenants(candidate, candidates, config) {
59827
+ const tenants = candidates.filter((e) => isPlausibleStationaryBirthBox(e.bbox, e.frameWidth, e.frameHeight));
59828
+ if (tenants.length === candidates.length) return tenants;
59829
+ const wouldHave = findParkedOccupant(candidates.filter((e) => !tenants.includes(e)), candidate.className, candidate.bbox, (config ?? this.matchConfig).suppressIou, (config ?? this.matchConfig).wakeAssocIou);
59830
+ if (wouldHave !== void 0) this.logger.info("stationary row kept apart from an oversized entry", {
59831
+ tags: { deviceId: candidate.deviceId },
59832
+ meta: {
59833
+ deviceId: candidate.deviceId,
59834
+ oversizedEntryId: wouldHave.id,
59835
+ oversizedBbox: wouldHave.bbox,
59836
+ className: candidate.className,
59837
+ candidateBbox: candidate.bbox
59838
+ }
59839
+ });
59840
+ return tenants;
59841
+ }
59735
59842
  /** Fold a refused promotion into the entry that already holds its spot. */
59736
59843
  absorbInto(occupant, refused) {
59737
59844
  const merged = {
@@ -59745,9 +59852,44 @@ var StationaryObjectRegistry = class {
59745
59852
  return merged;
59746
59853
  }
59747
59854
  /**
59748
- * Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
59749
- * flush any advanced `lastConfirmedAt`s to the store. Returns retired
59750
- * entries (for logging).
59855
+ * Does THIS node analyse `deviceId`?
59856
+ *
59857
+ * The registry's ownership signal, and the only one it has: a node receives
59858
+ * inference frames for the cameras it analyses and for no others, so an
59859
+ * observed clock ({@link noteFrame}) is the fact "this camera is mine". It is
59860
+ * the same authority the summary sealer uses (`analysedDeviceIds` off
59861
+ * `lastFrameDimsByDevice`, 2026-09-13, for the same defect on the same
59862
+ * shared table), and it is never cleared per-device, so an idle camera stays
59863
+ * ours and a camera we have never framed never becomes ours by accident.
59864
+ *
59865
+ * What it deliberately is NOT: a per-camera node assignment. `addon-post-
59866
+ * analysis` has none — post-processing is a cluster singleton chosen by
59867
+ * `postProcessingNodeState`, which the registry cannot see and which says
59868
+ * nothing per camera. A node that analyses nothing observes nothing, so this
59869
+ * predicate answers `false` for every row it hydrated, which is the outcome
59870
+ * the singleton would have produced.
59871
+ */
59872
+ analyses(deviceId) {
59873
+ return this.lastFrameAtByDevice.has(deviceId);
59874
+ }
59875
+ /**
59876
+ * Retire entries unconfirmed for longer than the TTL of OBSERVED time,
59877
+ * collapse duplicate rows, and flush any advanced `lastConfirmedAt`s to the
59878
+ * store. Returns retired entries (for logging).
59879
+ *
59880
+ * ONLY for the cameras this node analyses ({@link analyses}). Every node runs
59881
+ * this addon and every node hydrates the SAME table in {@link load}, so a
59882
+ * sweep over every hydrated device judges — and mutates — cameras it has no
59883
+ * frames for. Measured 2026-09-14: the duplicate collapse for device 617 was
59884
+ * logged by `MinidiGianluca.localdomain` and by `little-unraid`, the same
59885
+ * rows, seven seconds apart.
59886
+ *
59887
+ * An OVERSIZED entry (one that could not be born under the birth bar) is
59888
+ * deliberately NOT retired here. It has been confirmed by frames over time,
59889
+ * which is evidence a birth never had; it cannot be re-born under the new
59890
+ * bar, so the retirement would be permanent and would hand its object back
59891
+ * to the tracker forever. It is refused one thing — absorbing a neighbour
59892
+ * (see {@link censusTenants}) — and otherwise lives out its TTL.
59751
59893
  *
59752
59894
  * Expiry is measured against the device's latest processed-frame timestamp
59753
59895
  * ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
@@ -59759,9 +59901,13 @@ var StationaryObjectRegistry = class {
59759
59901
  */
59760
59902
  async sweep(now) {
59761
59903
  const retired = [];
59904
+ let foreign = 0;
59762
59905
  for (const [deviceId, ids] of this.idsByDevice) {
59763
59906
  const observedAt = this.lastFrameAtByDevice.get(deviceId);
59764
- if (observedAt === void 0) continue;
59907
+ if (observedAt === void 0) {
59908
+ foreign += 1;
59909
+ continue;
59910
+ }
59765
59911
  const ttl = this.ttlForDevice?.(deviceId) ?? this.entryTtlMs;
59766
59912
  for (const id of [...ids]) {
59767
59913
  const e = this.ledger.get(id);
@@ -59787,12 +59933,14 @@ var StationaryObjectRegistry = class {
59787
59933
  this.onChange?.({
59788
59934
  phase: "departed",
59789
59935
  entry: e,
59790
- timestamp: now
59936
+ timestamp: now,
59937
+ observedAt: e.lastConfirmedAt
59791
59938
  });
59792
59939
  }
59793
59940
  }
59794
59941
  if (ids.size === 0) this.idsByDevice.delete(deviceId);
59795
59942
  }
59943
+ if (foreign > 0) this.logger.debug("stationary sweep skipped cameras this node does not analyse", { meta: { devices: foreign } });
59796
59944
  const countedDevices = new Set([...this.absorbedSinceSweep.keys(), ...this.suppressedSinceSweep.keys()]);
59797
59945
  for (const deviceId of countedDevices) {
59798
59946
  const absorbed = this.absorbedSinceSweep.get(deviceId) ?? 0;
@@ -59830,7 +59978,10 @@ var StationaryObjectRegistry = class {
59830
59978
  }
59831
59979
  });
59832
59980
  this.strikeCountersByDevice.clear();
59833
- for (const deviceId of [...this.idsByDevice.keys()]) this.collapseDuplicates(deviceId);
59981
+ for (const deviceId of [...this.idsByDevice.keys()]) {
59982
+ if (!this.analyses(deviceId)) continue;
59983
+ this.collapseDuplicates(deviceId);
59984
+ }
59834
59985
  for (const id of [...this.dirty]) {
59835
59986
  this.dirty.delete(id);
59836
59987
  const entry = this.ledger.get(id);
@@ -59944,6 +60095,12 @@ var StationaryObjectRegistry = class {
59944
60095
  confirmMinGapMs: this.departureConfig.confirmMinGapMs
59945
60096
  }
59946
60097
  });
60098
+ this.requestConfirmationLook?.({
60099
+ deviceId,
60100
+ entryId: change.entryId,
60101
+ afterMs: this.departureConfig.confirmMinGapMs,
60102
+ frames: this.departureConfig.absenceMinReads
60103
+ });
59947
60104
  }
59948
60105
  }
59949
60106
  if (pending !== void 0 && pending.size === 0) this.departureStrikes.delete(deviceId);
@@ -59991,7 +60148,7 @@ var StationaryObjectRegistry = class {
59991
60148
  if (entries.length < 2) return;
59992
60149
  const survivors = [];
59993
60150
  for (const candidate of entries) {
59994
- const occupant = findParkedOccupant(survivors, candidate.className, candidate.bbox, this.matchConfig.suppressIou, this.matchConfig.wakeAssocIou);
60151
+ const occupant = findParkedOccupant(this.censusTenants(candidate, survivors), candidate.className, candidate.bbox, this.matchConfig.suppressIou, this.matchConfig.wakeAssocIou);
59995
60152
  if (occupant === void 0) {
59996
60153
  survivors.push(candidate);
59997
60154
  continue;
@@ -75618,6 +75775,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75618
75775
  * failure mode `observe` mode was already in). See `store/lts-store.ts`.
75619
75776
  */
75620
75777
  lts = null;
75778
+ /** The runner node that last produced a frame for a camera — see
75779
+ * `handleInferenceResult`. */
75780
+ lastRunnerNodeByDevice = /* @__PURE__ */ new Map();
75781
+ /** Pending out-of-band confirmation looks, keyed `deviceId:entryId`, so an
75782
+ * entry that re-arms while one is in flight does not stack timers. */
75783
+ pendingConfirmationLooks = /* @__PURE__ */ new Map();
75621
75784
  ltsFlushTimer = null;
75622
75785
  /**
75623
75786
  * `scene-monitor` — the operator's latched boolean over a reference region.
@@ -76181,7 +76344,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76181
76344
  store: api.settingsStore,
76182
76345
  logger: logger.child("StationaryRegistry"),
76183
76346
  ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
76184
- onChange: ({ phase, entry, timestamp }) => {
76347
+ requestConfirmationLook: (ask) => this.requestConfirmationLook(ask),
76348
+ onChange: ({ phase, entry, timestamp, observedAt }) => {
76185
76349
  this.ctx.eventBus.emit({
76186
76350
  id: `pa-stationary-${entry.id}-${phase}`,
76187
76351
  timestamp: new Date(timestamp),
@@ -76196,7 +76360,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76196
76360
  entryId: entry.id,
76197
76361
  className: entry.className,
76198
76362
  phase,
76199
- timestamp
76363
+ timestamp,
76364
+ observedAt
76200
76365
  }
76201
76366
  });
76202
76367
  this.packageDropDetector?.onStationaryChange({
@@ -78377,9 +78542,59 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78377
78542
  this.ncArtifactIndex = null;
78378
78543
  this.ncArtifactStore = null;
78379
78544
  }
78545
+ /**
78546
+ * Ask the runner to look at a camera once more, shortly after a departure was
78547
+ * armed — see `StationaryRegistryDeps.requestConfirmationLook` for why, and
78548
+ * the cap's `requestOccupancyBurst` for the two numbers that shape the ask.
78549
+ *
78550
+ * Pinned to the node whose frames we have been analysing: an unpinned call
78551
+ * would be served by whichever runner answers, and a runner that does not
78552
+ * hold the camera refuses it — correctly, but uselessly.
78553
+ *
78554
+ * Fire-and-forget by construction. This is an OPTIMISATION of a path that
78555
+ * already terminates on its own (the entry ages out), so a failure here must
78556
+ * cost nothing but a log line.
78557
+ */
78558
+ requestConfirmationLook(ask) {
78559
+ const key = `${String(ask.deviceId)}:${ask.entryId}`;
78560
+ if (this.pendingConfirmationLooks.has(key)) return;
78561
+ const nodeId = this.lastRunnerNodeByDevice.get(ask.deviceId);
78562
+ const runnerApi = this.ctx.api.pipelineRunner;
78563
+ if (nodeId === void 0 || !runnerApi?.requestOccupancyBurst) return;
78564
+ const timer = setTimeout(() => {
78565
+ this.pendingConfirmationLooks.delete(key);
78566
+ if (this.shuttingDown) return;
78567
+ runnerApi.requestOccupancyBurst.mutate({
78568
+ deviceId: ask.deviceId,
78569
+ frames: ask.frames,
78570
+ reason: "stationary-departure-confirm"
78571
+ }, require_dist.nodePin(nodeId)).then((answer) => {
78572
+ if (!answer.started) this.ctx.logger.debug("departure confirmation look refused", {
78573
+ tags: { deviceId: ask.deviceId },
78574
+ meta: {
78575
+ entryId: ask.entryId,
78576
+ nodeId,
78577
+ refusedBecause: answer.refusedBecause
78578
+ }
78579
+ });
78580
+ }).catch((err) => {
78581
+ this.ctx.logger.debug("departure confirmation look failed", {
78582
+ tags: { deviceId: ask.deviceId },
78583
+ meta: {
78584
+ entryId: ask.entryId,
78585
+ nodeId,
78586
+ error: String(err)
78587
+ }
78588
+ });
78589
+ });
78590
+ }, ask.afterMs);
78591
+ timer.unref?.();
78592
+ this.pendingConfirmationLooks.set(key, timer);
78593
+ }
78380
78594
  async handleInferenceResult(data) {
78381
78595
  if (this.shuttingDown) return;
78382
78596
  const { deviceId, frame } = data;
78597
+ if (data.nodeId.length > 0) this.lastRunnerNodeByDevice.set(deviceId, data.nodeId);
78383
78598
  const lag = this.frameLag.observe(deviceId, Date.now(), data.emittedAt, data.capturedAt);
78384
78599
  if (lag !== null) {
78385
78600
  const verdict = lagVerdict(lag);
@@ -81908,13 +82123,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
81908
82123
  const { deviceId, key, processor, track, row, firstSeen, frameWidth, frameHeight, timestamp } = input;
81909
82124
  const label = row.label;
81910
82125
  const keyFrameMediaId = row.keyFrameMediaId ?? this.residents.keyFrameMediaId(track.trackId);
81911
- const plausible = isPlausibleThumbnailBox(row.bestBbox, frameWidth, frameHeight);
81912
- if (!plausible) this.ctx.logger.info("stationary birth rejected (near-full-frame box)", {
82126
+ const plausible = isPlausibleStationaryBirthBox(row.bestBbox, frameWidth, frameHeight);
82127
+ if (!plausible) this.ctx.logger.info("stationary birth rejected — implausible box for a parked object", {
81913
82128
  tags: { deviceId },
81914
82129
  meta: {
82130
+ deviceId,
81915
82131
  trackId: track.trackId,
81916
82132
  className: row.className,
81917
82133
  bbox: row.bestBbox,
82134
+ frameWidth,
82135
+ frameHeight,
82136
+ areaFrac: Number(boxAreaFraction(row.bestBbox, frameWidth, frameHeight).toFixed(3)),
82137
+ maxAreaFrac: BIRTH_MAX_AREA_FRAC,
81918
82138
  agreed: row.agreed,
81919
82139
  seen: row.seen
81920
82140
  }