@camstack/addon-post-analysis 1.2.242 → 1.2.244

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 { $ as TimelapseRuleSchema, $t as boolean, A as NcConditionDescriptorSchema, At as plateGalleryCapability, B as NcTaxonomySchema, Bt as videoclipsCapability, C as MOTION_CLOSE_AFTER_MS, Ct as isSourceCap, D as NC_DEFAULT_SNOOZE_MINUTES, E as NC_CONDITION_CATALOG, Et as notificationRulesCapability, F as NcScheduleSchema, Ft as sceneMonitorCapability, Gt as DeviceType, Ht as errMsg$1, I as NcSnoozeInputSchema, It as storageOccupancyCapability, J as SCENE_DIVERGED, Jt as isDeviceScopedCap, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as createEvent, L as NcSnoozeSchema, Lt as subKindsOf, M as NcRulePatchSchema, Mt as readTimelapseGeneratedAt, N as NcRuleSchema, Nt as resolveLocationMode, Ot as pickClusterStepModels, P as NcRuleTargetSchema, Q as TimelapseRulePatchSchema, Qt as array, R as NcSnoozeSuppressedSchema, Rt as systemEventFilterApplies, St as isScheduleActive, T as NC_ALARM_SYSTEM_EVENT_KINDS, Tt as mayWriteToLocation, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as OpsLogEntrySchema, Vt as zoneAnalyticsCapability, W as RetrainStatusSchema, Wt as CamProfileSchema, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as sleep$1, Y as SceneMonitorSchema, Yt as nodePin, Z as TimelapseRuleInputSchema, Zt as _enum, _ as FULL_IMAGE_BBOX, _t as failureContributionCapability, a as CLUSTER_MODEL_SCOPED_STEPS, an as record, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isEventOwnerType, cn as EventCategory, ct as cosineSimilarity$1, dt as deriveRecordingMode, en as discriminatedUnion, et as TrackSourceSchema, f as DeclaredDevices, g as FIRST_LEVEL_MACRO_CLASSES, gt as faceGalleryCapability, h as EVENT_PAD_MS, ht as evictionPolicyOfLocation, i as BirthDecisionRecordSchema, in as partialRecord, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as readDeviceStateFrom, k as NC_TAXONOMY, kt as pipelineAnalyticsCapability, lt as customAction, m as EVENT_OWNER_TYPES, mt as evaluateSensorEdge, n as ArchivedDebugNoteSchema, nn as number, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as string, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as encodeVectorBase64, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as hydrateSchema, r as BaseDevice, rn as object, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as unknown, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as literal, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, ut as defineCustomActions, v as FailureCounters, w as MediaFileKindEnum, wt as kebabToCamel, x as MAX_BIRTH_DECISION_RECORDS, xt as isMediaOwnerType, y as LabelAttributionSchema, yt as isDetectionMacroClass, z as NcSystemEventKindSchema, zt as vectorDimFromBase64 } from "../dist-CCw4SKRp.mjs";
1
+ import { $ as TimelapseRuleSchema, $t as boolean, A as NcConditionDescriptorSchema, At as readTimelapseGeneratedAt, B as NcTaxonomySchema, Bt as errMsg$1, C as MOTION_CLOSE_AFTER_MS, Ct as mayWriteToLocation, D as NC_DEFAULT_SNOOZE_MINUTES, Dt as pipelineAnalyticsCapability, E as NC_CONDITION_CATALOG, Et as pickClusterStepModels, F as NcScheduleSchema, Ft as subKindsOf, Gt as customAction, Ht as CamProfileSchema, I as NcSnoozeInputSchema, It as systemEventFilterApplies, J as SCENE_DIVERGED, Jt as isDeviceScopedCap, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as defineCustomActions, L as NcSnoozeSchema, Lt as vectorDimFromBase64, M as NcRulePatchSchema, N as NcRuleSchema, Nt as sceneMonitorCapability, Ot as plateGalleryCapability, P as NcRuleTargetSchema, Pt as storageOccupancyCapability, Q as TimelapseRulePatchSchema, Qt as array, R as NcSnoozeSuppressedSchema, Rt as videoclipsCapability, St as kebabToCamel, T as NC_ALARM_SYSTEM_EVENT_KINDS, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as DeviceType, V as OpsLogEntrySchema, Vt as BaseAddon, W as RetrainStatusSchema, Wt as createEvent, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as sleep$1, Y as SceneMonitorSchema, Yt as nodePin, Z as TimelapseRuleInputSchema, Zt as _enum, _ as FULL_IMAGE_BBOX, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, an as record, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isScheduleActive, cn as EventCategory, ct as cosineSimilarity$1, dt as encodeVectorBase64, en as discriminatedUnion, et as TrackSourceSchema, f as DeclaredDevices, ft as evaluateSensorEdge, g as FIRST_LEVEL_MACRO_CLASSES, h as EVENT_PAD_MS, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as partialRecord, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as resolveLocationMode, k as NC_TAXONOMY, kt as readDeviceStateFrom, lt as deriveRecordingMode, m as EVENT_OWNER_TYPES, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as number, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as string, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as hydrateSchema, r as BaseDevice, rn as object, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as unknown, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as literal, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, v as FailureCounters, vt as isEventOwnerType, w as MediaFileKindEnum, wt as notificationRulesCapability, x as MAX_BIRTH_DECISION_RECORDS, xt as isSourceCap, y as LabelAttributionSchema, yt as isMediaOwnerType, z as NcSystemEventKindSchema, zt as zoneAnalyticsCapability } from "../dist-C-1xTyB-.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -55551,7 +55551,8 @@ function buildOverlayDetections(input) {
55551
55551
  ...det,
55552
55552
  track: {
55553
55553
  trackId: t.trackId,
55554
- zones: t.zones
55554
+ zones: t.zones,
55555
+ ...t.state !== void 0 ? { movementState: t.state } : {}
55555
55556
  },
55556
55557
  labels: hasName && !alreadyLabeled ? [...det.labels, {
55557
55558
  label: name,
@@ -57509,6 +57510,10 @@ function centroidInside(b, p) {
57509
57510
  }
57510
57511
  /** Parked-box coverage (intersection / entry area) that counts as swallowed. */
57511
57512
  var SWALLOW_MIN_COVERAGE = .5;
57513
+ /** Fraction of a detection's OWN area that must lie inside a parked entry for
57514
+ * the entry to swallow it. Deliberately near-total: a fragment of a parked
57515
+ * object does not stick out of it. */
57516
+ var FRAGMENT_MIN_CONTAINMENT = .9;
57512
57517
  function isSceneSizedBox(bbox, frameWidth, frameHeight) {
57513
57518
  const frame = frameWidth * frameHeight;
57514
57519
  return frame > 0 && bbox.w * bbox.h / frame >= .55;
@@ -57528,6 +57533,29 @@ function detectionSwallowsEntry(det, parked) {
57528
57533
  if (!centroidInside(det, centroid(parked))) return false;
57529
57534
  return intersectionArea$2(parked, det) / entryArea >= SWALLOW_MIN_COVERAGE;
57530
57535
  }
57536
+ /**
57537
+ * True when `det` is a FRAGMENT of the parked object — much smaller than the
57538
+ * entry and sitting (almost) wholly inside it.
57539
+ *
57540
+ * The mirror of {@link detectionSwallowsEntry}, and the case neither IoU
57541
+ * threshold can see. Measured on 617: a 34x137 box inside a 452x420 parked
57542
+ * motorcycle scores IoU 0.024 — BELOW `wakeAssocIou` (0.1), so the filter read
57543
+ * it as "no detection overlaps this entry" and let it open a track, accumulate
57544
+ * its own evidence and promote into a registry entry of its own. The camera
57545
+ * then reported two parked vehicles where there was one motorcycle.
57546
+ *
57547
+ * The size bound is the reciprocal of {@link SWALLOW_AREA_RATIO}, so the two
57548
+ * swallow rules meet without overlapping: a box between 1/1.35 and 1.35 of the
57549
+ * entry's area, contained, already scores IoU >= 0.74 and is answered by
57550
+ * `suppressIou`. Nothing is a fragment and a near-identical box at once.
57551
+ */
57552
+ function entrySwallowsDetection(parked, det) {
57553
+ const entryArea = parked.w * parked.h;
57554
+ const detArea = det.w * det.h;
57555
+ if (entryArea <= 0 || detArea <= 0) return false;
57556
+ if (detArea > entryArea / 1.35) return false;
57557
+ return intersectionArea$2(parked, det) / detArea >= FRAGMENT_MIN_CONTAINMENT;
57558
+ }
57531
57559
  function parkedEntryHoldsDetection(entry, detBbox, config) {
57532
57560
  const overlap = iou(entry.bbox, detBbox);
57533
57561
  if (overlap >= config.suppressIou) return {
@@ -57542,6 +57570,10 @@ function parkedEntryHoldsDetection(entry, detBbox, config) {
57542
57570
  strong: false,
57543
57571
  holds: false
57544
57572
  };
57573
+ if (entrySwallowsDetection(entry.bbox, detBbox)) return {
57574
+ strong: false,
57575
+ holds: true
57576
+ };
57545
57577
  return {
57546
57578
  strong: false,
57547
57579
  holds: overlap >= config.wakeAssocIou && centroidInside(entry.bbox, centroid(detBbox))
@@ -57560,6 +57592,34 @@ function bboxMatchesParkedEntry(bbox, className, entries, suppressIou = SUPPRESS
57560
57592
  }
57561
57593
  return false;
57562
57594
  }
57595
+ /**
57596
+ * The live entry already censusing this spot, if any — the ONE answer to "is
57597
+ * this place taken?", asked at BIRTH.
57598
+ *
57599
+ * It is deliberately the same predicate the per-frame filter uses
57600
+ * ({@link parkedEntryHoldsDetection}): a box the filter would suppress as a
57601
+ * parked object's own must not be able to become a second parked object. Device
57602
+ * 617 held four rows for one car and a fifth for a fragment of a motorcycle
57603
+ * because the birth path asked nothing at all.
57604
+ */
57605
+ function findParkedOccupant(entries, className, bbox, suppressIou = SUPPRESS_IOU, wakeAssocIou = WAKE_ASSOC_IOU) {
57606
+ const cls = normaliseStationaryClass(className);
57607
+ const config = {
57608
+ suppressIou,
57609
+ wakeAssocIou
57610
+ };
57611
+ let best;
57612
+ for (const e of entries) {
57613
+ if (normaliseStationaryClass(e.className) !== cls) continue;
57614
+ if (!parkedEntryHoldsDetection(e, bbox, config).holds) continue;
57615
+ const overlap = iou(e.bbox, bbox);
57616
+ if (best === void 0 || overlap > best.overlap) best = {
57617
+ entry: e,
57618
+ overlap
57619
+ };
57620
+ }
57621
+ return best?.entry;
57622
+ }
57563
57623
  /** Best-shot veto: current crop is a parked object the trail did not start on. */
57564
57624
  function thumbnailWouldShowParkedNotSubject(input) {
57565
57625
  if (!bboxMatchesParkedEntry(input.currentBbox, input.className, input.entries)) return false;
@@ -58545,85 +58605,6 @@ var StartedBirthRegistry = class {
58545
58605
  //#endregion
58546
58606
  //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
58547
58607
  /**
58548
- * stationary-evidence — how a parked object EARNS a registry entry.
58549
- *
58550
- * ## The criterion this replaces, and why it was measuring the wrong thing
58551
- *
58552
- * Until 2026-08-28 an object became stationary by TRACK CONTINUITY: one track
58553
- * had to stay alive and barely move for `PROMOTION_WINDOW_MS` (30 s). That
58554
- * rewards an object that is *seen well and often* — precisely the opposite of
58555
- * what the operator needs. Measured on device 617 (the parking lot), 24 h:
58556
- *
58557
- * - three cars are parked in view; the registry held exactly ONE entry;
58558
- * - the two it missed were re-discovered as **140 and 112 separate tracks**,
58559
- * median life **6.8 s**, p90 29 s. Not one of them could ever reach 30 s.
58560
- * - those two objects alone produced **13 494 of 18 402** vehicle
58561
- * observations (73%) — the whole re-spawn flood the registry exists to stop.
58562
- *
58563
- * The track dies; the OBJECT does not. So the evidence must outlive the track.
58564
- *
58565
- * ## The new criterion: a RATIO, not a count
58566
- *
58567
- * "The same thing, in the same place, most of the times it was seen."
58568
- *
58569
- * N — observations that AGREED with the candidate's anchor box (IoU ≥
58570
- * {@link StationaryEvidencePolicy.agreeIou}).
58571
- * M — observations OFFERED by a track once it bound to this candidate,
58572
- * agreeing or not.
58573
- *
58574
- * A count would rescue nothing: a badly-lit car at night is seen rarely, and
58575
- * any floor high enough to exclude a passer-by would exclude it too. A RATIO
58576
- * asks a question a rarely-seen object can still answer well — 8 sightings out
58577
- * of 10 in the same spot is stationary even though 10 is a small number.
58578
- *
58579
- * The dilution is the whole point, and it is why M counts a bound track's
58580
- * observations even after it walks away: a car that drives THROUGH a spot binds
58581
- * for a few frames and then contributes dozens of disagreeing ones. Measured on
58582
- * 617 the two populations do not overlap:
58583
- *
58584
- * | candidate | N / M | ratio |
58585
- * | ---------------------------- | ----------- | ----- |
58586
- * | parked car A (missed today) | 6405 / 6467 | 0.99 |
58587
- * | parked car B (missed today) | 343 / 343 | 1.00 |
58588
- * | car crossing the lot | 5 / 70 | 0.07 |
58589
- * | car turning through the lot | 14 / 91 | 0.15 |
58590
- * | car manoeuvring | 12 / 41 | 0.29 |
58591
- *
58592
- * The threshold sits in the empty band between 0.29 and 0.99.
58593
- *
58594
- * ## The span is what a ratio cannot say
58595
- *
58596
- * A ratio alone promotes a car that paused for four seconds — its handful of
58597
- * observations all agree. So evidence must also STRETCH: the agreeing
58598
- * observations must span {@link StationaryEvidencePolicy.minSpanMs}. Measured
58599
- * on 617, real parked cars span 2 min to 24 h; the two momentary stops in the
58600
- * same day spanned 4.2 s and 8.0 s. Setting the span to
58601
- * `DEFAULT_MAX_SESSION_HOLD_MS` also buys a structural guarantee: no single
58602
- * motion session, however long it is held open, can promote on its own.
58603
- *
58604
- * ## ONE ledger, two producers
58605
- *
58606
- * Evidence is anchored to a BOX, not to a track and not to a session, so an
58607
- * object confirmed half by an active motion session and half by a periodic
58608
- * burst still promotes. Occasion A (the session) is implemented and is
58609
- * sufficient on its own — it is free, because the pipeline is already decoding
58610
- * and already inferring. Occasion B (the burst) is a precaution for the cases
58611
- * where A could not have fired at all (hub restarted, camera offline, motion
58612
- * detection failed, object already parked at boot); it is NOT implemented yet,
58613
- * and {@link StationaryEvidenceSource} is the seam it will arrive through.
58614
- *
58615
- * ## Identity is IoU. There is no grid.
58616
- *
58617
- * The predecessor mechanism (`phantom-cells.ts`) quantises a point to a 32 px
58618
- * cell, and its write path passes a CENTROID while its read path passes a
58619
- * top-left CORNER — at 32 px every box bigger than 32 px lands in a different
58620
- * cell on read, and its unit test never crosses that boundary because it calls
58621
- * both halves with the same literal coordinates. This module has no such
58622
- * boundary to get wrong: a candidate is found by overlap against the boxes a
58623
- * device already holds, bounded by {@link EVIDENCE_MAX_CANDIDATES_PER_DEVICE}.
58624
- * Device 617 — the busiest lot in the fleet — holds 8.
58625
- */
58626
- /**
58627
58608
  * `vehicle` and its kin. Every number below is read off device 617 over 24 h
58628
58609
  * (18 402 vehicle observations, 290 tracks) with the three parked cars the
58629
58610
  * operator can see out of the window as the bench.
@@ -58645,11 +58626,11 @@ var VEHICLE_EVIDENCE_POLICY = {
58645
58626
  agreeIou: .5,
58646
58627
  minAgreed: 8,
58647
58628
  minAgreementRatio: .6,
58648
- minSpanMs: 12e4,
58649
- promotesByDefault: true
58629
+ minSpanMs: 12e4
58650
58630
  };
58651
58631
  /**
58652
- * `person`, and it ships OFF.
58632
+ * `person`, promoted like every other class since 2026-09-14 — and still the
58633
+ * strictest numbers in the file, because these are MEASURED, not cautious.
58653
58634
  *
58654
58635
  * This is not caution, it is a measurement. Replaying 24 h of person
58655
58636
  * observations from devices 617, 1443, 590 and 592 through this exact ledger:
@@ -58668,17 +58649,24 @@ var VEHICLE_EVIDENCE_POLICY = {
58668
58649
  *
58669
58650
  * Promoting a person is a BLINDING decision (D49 read in the direction that
58670
58651
  * destroys work: for a car the destructive read is the RETIREMENT — the flood
58671
- * comes back; for a person it is the BIRTH — we stop seeing them). So the
58672
- * class is available, tuned as strictly as the data allows, and switched off
58673
- * until an operator turns it on for a camera where they know what is standing
58674
- * there.
58652
+ * comes back; for a person it is the BIRTH — we stop seeing them). It used to
58653
+ * ship OFF behind a per-camera switch for that reason; the switch is gone —
58654
+ * an opt-in nobody turns on produces nothing, and it was producing nothing.
58655
+ *
58656
+ * What makes that affordable now is that agreement is no longer overlap alone:
58657
+ * `observationAgrees` requires the TRACKER to call the subject still, so a
58658
+ * person who merely pauses inside the anchor no longer feeds the candidate.
58659
+ * The statue and the poster still promote — which is what you want, they ARE
58660
+ * furniture — while the real person who stops for a moment no longer does.
58661
+ *
58662
+ * The thresholds stay where the measurement put them. They are the difference
58663
+ * between 22 promotions a day and 10.
58675
58664
  */
58676
58665
  var PERSON_EVIDENCE_POLICY = {
58677
58666
  agreeIou: .5,
58678
58667
  minAgreed: 40,
58679
58668
  minAgreementRatio: .95,
58680
- minSpanMs: 18e5,
58681
- promotesByDefault: false
58669
+ minSpanMs: 18e5
58682
58670
  };
58683
58671
  /** Classes with no policy of their own fall back to the vehicle numbers —
58684
58672
  * `animal`, `package` and friends are all "an object that ended up somewhere",
@@ -58697,7 +58685,7 @@ function stationaryEvidenceKey(deviceId, className, anchorId) {
58697
58685
  * Pure, and ordered cheapest-question-first so the reason a candidate was
58698
58686
  * refused is the FIRST thing it failed, not the last.
58699
58687
  */
58700
- function evaluateStationaryEvidence(row, policy, classEnabled) {
58688
+ function evaluateStationaryEvidence(row, policy) {
58701
58689
  const windowSeen = row.windowSeen + row.prevWindowSeen;
58702
58690
  const windowAgreed = row.windowAgreed + row.prevWindowAgreed;
58703
58691
  const ratio = windowSeen > 0 ? windowAgreed / windowSeen : 0;
@@ -58708,11 +58696,6 @@ function evaluateStationaryEvidence(row, policy, classEnabled) {
58708
58696
  ratio,
58709
58697
  spanMs
58710
58698
  };
58711
- if (!classEnabled) return {
58712
- kind: "accumulating",
58713
- shortfall: "class-not-promoted",
58714
- ...base
58715
- };
58716
58699
  if (row.agreed < policy.minAgreed) return {
58717
58700
  kind: "accumulating",
58718
58701
  shortfall: "too-few-agreements",
@@ -58787,6 +58770,32 @@ function matchEvidenceCandidate(rows, className, bbox, agreeIou) {
58787
58770
  };
58788
58771
  }
58789
58772
  /**
58773
+ * Does this observation count as EVIDENCE that the object is parked?
58774
+ *
58775
+ * Two questions, deliberately kept apart:
58776
+ *
58777
+ * - IDENTITY — is this the same object in the same place? That is the IoU,
58778
+ * and it is the only notion of sameness in this module.
58779
+ * - STILLNESS — is it going anywhere? That is the TRACKER's verdict, and
58780
+ * nothing here re-derives it.
58781
+ *
58782
+ * Before this, `agrees` was the IoU alone, so a long run of overlaps WAS the
58783
+ * stillness argument — a second derivation of a fact `StateAnalyzer` already
58784
+ * computes per track, slower and able to disagree with it. Now the ledger adds
58785
+ * only what the analyzer cannot know: that the same still object has been here
58786
+ * across a succession of tracks.
58787
+ *
58788
+ * `entering` is the tracker's undecided window — five frames that begin EVERY
58789
+ * track, including a parked car's re-detection. It is not disagreement, and
58790
+ * counting it as such would punish a candidate for the tracker's own warm-up.
58791
+ * An ABSENT verdict likewise falls back to overlap alone: a path that never had
58792
+ * an opinion must not be silently reclassified.
58793
+ */
58794
+ function observationAgrees(overlap, agreeIou, movementState) {
58795
+ if (overlap < agreeIou) return false;
58796
+ return movementState !== "moving" && movementState !== "left";
58797
+ }
58798
+ /**
58790
58799
  * Fold one observation into a candidate.
58791
58800
  *
58792
58801
  * `agrees` is decided by the CALLER, because M is what makes the ratio mean
@@ -59089,7 +59098,7 @@ var StationaryEvidenceLedger = class {
59089
59098
  * slow or failing store can never change what this frame decides. The
59090
59099
  * durable half is fired behind the verdict.
59091
59100
  */
59092
- observe(observation, classEnabled) {
59101
+ observe(observation) {
59093
59102
  const policy = evidencePolicyFor(observation.className);
59094
59103
  this.expire(observation.deviceId, observation.timestamp);
59095
59104
  const bindKey = observation.trackId === void 0 ? void 0 : `${observation.deviceId}:${observation.trackId}`;
@@ -59137,18 +59146,18 @@ var StationaryEvidenceLedger = class {
59137
59146
  this.capDevice(observation.deviceId);
59138
59147
  return {
59139
59148
  row,
59140
- verdict: evaluateStationaryEvidence(row, policy, classEnabled),
59149
+ verdict: evaluateStationaryEvidence(row, policy),
59141
59150
  promoted: false
59142
59151
  };
59143
59152
  }
59144
59153
  if (bindKey !== void 0) this.binding.set(bindKey, row.key);
59145
59154
  }
59146
- const agrees = overlap >= policy.agreeIou;
59155
+ const agrees = observationAgrees(overlap, policy.agreeIou, observation.movementState);
59147
59156
  const next = foldObservation(row, observation, agrees, overlap);
59148
59157
  this.ledger.stage(next);
59149
59158
  this.dirty.add(next.key);
59150
59159
  if (created) this.capDevice(observation.deviceId);
59151
- const verdict = evaluateStationaryEvidence(next, policy, classEnabled);
59160
+ const verdict = evaluateStationaryEvidence(next, policy);
59152
59161
  if (verdict.kind !== "promote") return {
59153
59162
  row: next,
59154
59163
  verdict,
@@ -59596,8 +59605,46 @@ var StationaryObjectRegistry = class {
59596
59605
  });
59597
59606
  }
59598
59607
  }
59599
- /** Promote a parked track into a persisted stationary entry. */
59600
- async promote(entry) {
59608
+ /**
59609
+ * Promote a parked track into a persisted stationary entry — or, when the
59610
+ * spot is already censused, CONFIRM the entry sitting on it.
59611
+ *
59612
+ * The occupancy question is asked here and nowhere else, with the same
59613
+ * predicate the per-frame filter uses (`findParkedOccupant`): a box the
59614
+ * filter would suppress as a parked object's own can never become a second
59615
+ * parked object. Until 2026-09-14 this method minted whatever it was handed,
59616
+ * and device 617 reported EIGHT parked vehicles — four of them one car, the
59617
+ * same box to the decimal, promoted again roughly once an hour because the
59618
+ * weak-continuation path (D410) keeps feeding the ledger from a track riding
59619
+ * a parked object. The re-promotion is not the anomaly and is not worth
59620
+ * suppressing: it is a fresh, independent confirmation that the thing is
59621
+ * still there. It just must not produce a neighbour.
59622
+ *
59623
+ * A merge never DOWNGRADES the tenant: it advances the clock and adopts an
59624
+ * identity the tenant lacks (the 617 duplicates arrived unlabelled while the
59625
+ * row they belonged to was already `Car`), and it never overwrites one it
59626
+ * has.
59627
+ */
59628
+ async promote(entry, config) {
59629
+ const occupant = findParkedOccupant(this.list(entry.deviceId), entry.className, entry.bbox, (config ?? this.matchConfig).suppressIou, (config ?? this.matchConfig).wakeAssocIou);
59630
+ if (occupant !== void 0) {
59631
+ const merged = this.absorbInto(occupant, entry);
59632
+ this.logger.info("stationary promotion absorbed — the spot is already censused", {
59633
+ tags: { deviceId: occupant.deviceId },
59634
+ meta: {
59635
+ deviceId: occupant.deviceId,
59636
+ entryId: occupant.id,
59637
+ className: occupant.className,
59638
+ refusedTrackId: entry.sourceTrackId,
59639
+ parkedForMs: entry.becameStationaryAt - occupant.becameStationaryAt,
59640
+ gainedLabel: occupant.label === void 0 && entry.label !== void 0
59641
+ }
59642
+ });
59643
+ return {
59644
+ outcome: "merged",
59645
+ entry: merged
59646
+ };
59647
+ }
59601
59648
  this.indexEntry(entry.deviceId, entry.id);
59602
59649
  this.dirty.delete(entry.id);
59603
59650
  await this.ledger.put(entry);
@@ -59606,6 +59653,22 @@ var StationaryObjectRegistry = class {
59606
59653
  entry,
59607
59654
  timestamp: entry.becameStationaryAt
59608
59655
  });
59656
+ return {
59657
+ outcome: "born",
59658
+ entry
59659
+ };
59660
+ }
59661
+ /** Fold a refused promotion into the entry that already holds its spot. */
59662
+ absorbInto(occupant, refused) {
59663
+ const merged = {
59664
+ ...occupant,
59665
+ lastConfirmedAt: Math.max(occupant.lastConfirmedAt, refused.becameStationaryAt),
59666
+ ...occupant.label === void 0 && refused.label !== void 0 ? { label: refused.label } : {},
59667
+ ...occupant.keyFrameMediaId === void 0 && refused.keyFrameMediaId !== void 0 ? { keyFrameMediaId: refused.keyFrameMediaId } : {}
59668
+ };
59669
+ this.ledger.stage(merged);
59670
+ this.dirty.add(merged.id);
59671
+ return merged;
59609
59672
  }
59610
59673
  /**
59611
59674
  * Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
@@ -59693,6 +59756,7 @@ var StationaryObjectRegistry = class {
59693
59756
  }
59694
59757
  });
59695
59758
  this.strikeCountersByDevice.clear();
59759
+ for (const deviceId of [...this.idsByDevice.keys()]) this.collapseDuplicates(deviceId);
59696
59760
  for (const id of [...this.dirty]) {
59697
59761
  this.dirty.delete(id);
59698
59762
  const entry = this.ledger.get(id);
@@ -59834,6 +59898,46 @@ var StationaryObjectRegistry = class {
59834
59898
  /** Retire one entry: index, staged advance, pending strike and durable row
59835
59899
  * all go. A strike that outlived its entry would arm the NEXT entry to reuse
59836
59900
  * the id — the registry mints UUIDs, but the invariant is cheap to keep. */
59901
+ /**
59902
+ * Fold every row that holds another row's spot into the OLDEST of them.
59903
+ *
59904
+ * The survivor is the oldest because it is the one carrying the object's real
59905
+ * `becameStationaryAt` — "parked since 14:30" is the fact the operator reads,
59906
+ * and the duplicates are all later re-discoveries of the same thing. It
59907
+ * inherits any identity the losers carry and that it lacks, and the losers'
59908
+ * rows are deleted outright: a retirement would announce a departure that
59909
+ * never happened.
59910
+ *
59911
+ * Uses the same predicate as the filter and the birth guard, so a collapse
59912
+ * can only ever remove a row that could not have been independently confirmed
59913
+ * anyway.
59914
+ */
59915
+ collapseDuplicates(deviceId) {
59916
+ const entries = [...this.list(deviceId)].sort((a, b) => a.becameStationaryAt - b.becameStationaryAt);
59917
+ if (entries.length < 2) return;
59918
+ const survivors = [];
59919
+ for (const candidate of entries) {
59920
+ const occupant = findParkedOccupant(survivors, candidate.className, candidate.bbox, this.matchConfig.suppressIou, this.matchConfig.wakeAssocIou);
59921
+ if (occupant === void 0) {
59922
+ survivors.push(candidate);
59923
+ continue;
59924
+ }
59925
+ const merged = this.absorbInto(occupant, candidate);
59926
+ survivors[survivors.indexOf(occupant)] = merged;
59927
+ this.dropEntry(deviceId, candidate.id);
59928
+ this.logger.info("stationary duplicate collapsed — two rows, one spot", {
59929
+ tags: { deviceId },
59930
+ meta: {
59931
+ deviceId,
59932
+ keptEntryId: occupant.id,
59933
+ droppedEntryId: candidate.id,
59934
+ className: candidate.className,
59935
+ keptSinceMs: occupant.becameStationaryAt,
59936
+ droppedSinceMs: candidate.becameStationaryAt
59937
+ }
59938
+ });
59939
+ }
59940
+ }
59837
59941
  dropEntry(deviceId, id) {
59838
59942
  this.idsByDevice.get(deviceId)?.delete(id);
59839
59943
  this.dirty.delete(id);
@@ -69876,18 +69980,6 @@ var StationarySettingsSchema = object({
69876
69980
  * session open (also the minimum track age). Creates nothing — birth is
69877
69981
  * decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
69878
69982
  sessionStillnessWindowMs: number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
69879
- /**
69880
- * Let a motionless PERSON become a stationary entry on this camera.
69881
- *
69882
- * Off, and it is a measurement rather than caution. Replaying 24 h of person
69883
- * observations from devices 617/1443/590/592 through the evidence ledger
69884
- * produces 6-22 entries a day depending on how strictly it is tuned, and none
69885
- * of them can be tuned to zero: device 592 alone yielded eleven candidates at
69886
- * a perfect agreement ratio between 02:00 and 05:00. A stationary entry
69887
- * SUPPRESSES its object, so a wrong one makes the camera blind to a person —
69888
- * turn this on only for a camera where you know what is standing there.
69889
- */
69890
- promotePersons: boolean().default(false),
69891
69983
  /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
69892
69984
  * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
69893
69985
  entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
@@ -69907,7 +69999,6 @@ function resolveStationarySettings(raw) {
69907
69999
  suppressIou: pick("suppressIou"),
69908
70000
  wakeAssocIou: pick("wakeAssocIou"),
69909
70001
  sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
69910
- promotePersons: pick("promotePersons"),
69911
70002
  entryTtlMs: pick("entryTtlMs")
69912
70003
  };
69913
70004
  }
@@ -70443,13 +70534,6 @@ function buildDetectionSettingsSections() {
70443
70534
  unit: "s",
70444
70535
  displayScale: 1e3
70445
70536
  },
70446
- {
70447
- type: "boolean",
70448
- key: "promotePersons",
70449
- label: "Also park motionless people",
70450
- description: "Off by default. A stationary entry SUPPRESSES its object, so a wrong one makes this camera blind to a person standing there. Replaying a day of real person detections produced entries on every camera tried, at every threshold tried — turn this on only where you know what is standing still.",
70451
- default: STATIONARY_DEFAULTS.promotePersons
70452
- },
70453
70537
  {
70454
70538
  type: "slider",
70455
70539
  key: "entryTtlMs",
@@ -73793,11 +73877,7 @@ var ZoneAnalyticsProvider = class {
73793
73877
  });
73794
73878
  }
73795
73879
  this.snapshots.set(input.deviceId, snapshot);
73796
- const noteOccupancy = this.ctx.onOccupancySample;
73797
- if (noteOccupancy !== void 0) {
73798
- noteOccupancy(input.deviceId, total, input.timestamp);
73799
- for (const zone of snapshot.zones) noteOccupancy(input.deviceId, zone.totalObjects, input.timestamp, zone.zoneId);
73800
- }
73880
+ this.noteLtsSamples(input.deviceId, snapshot, input.timestamp);
73801
73881
  this.appendHistory(input.deviceId, snapshot);
73802
73882
  this.emitSnapshot(input.deviceId, snapshot);
73803
73883
  this.sliceThrottle.push(input.deviceId, snapshot);
@@ -73864,11 +73944,30 @@ var ZoneAnalyticsProvider = class {
73864
73944
  });
73865
73945
  if (snapshot) {
73866
73946
  this.appendHistory(deviceId, snapshot);
73947
+ this.noteLtsSamples(deviceId, snapshot, now);
73867
73948
  this.emitSnapshot(deviceId, snapshot);
73868
73949
  this.sliceThrottle.push(deviceId, snapshot);
73869
73950
  }
73870
73951
  }
73871
73952
  }
73953
+ /**
73954
+ * Append this snapshot to the long-term occupancy series — frame-wide first
73955
+ * (`scope: ''`), then one sample per zone.
73956
+ *
73957
+ * Shared by BOTH producers on purpose. It used to live inline in
73958
+ * `recordFrame`, so the baseline sampler — the only producer a camera with no
73959
+ * frames flowing has — wrote the RAM ring, the NC watcher and the state slice
73960
+ * but never LTS. The long-term series therefore had a hole exactly where the
73961
+ * scene was nothing but parked objects: the one span an occupancy history is
73962
+ * asked about. The RAM ring showed a flat line over the same minutes, which
73963
+ * is what made the gap invisible.
73964
+ */
73965
+ noteLtsSamples(deviceId, snapshot, timestamp) {
73966
+ const note = this.ctx.onOccupancySample;
73967
+ if (note === void 0) return;
73968
+ note(deviceId, snapshot.frame.totalObjects, timestamp);
73969
+ for (const zone of snapshot.zones) note(deviceId, zone.totalObjects, timestamp, zone.zoneId);
73970
+ }
73872
73971
  /** Push one snapshot to the occupancy tap, isolating any consumer throw from
73873
73972
  * the frame path (occupancy notifications must never break analytics). */
73874
73973
  emitSnapshot(deviceId, snapshot) {
@@ -78203,7 +78302,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78203
78302
  }
78204
78303
  });
78205
78304
  }
78206
- await this.processFrame(deviceId, frame, data.source ?? "pipeline", data.frameHandle, data.detailSteps);
78305
+ await this.processFrame(deviceId, frame, data.source ?? "pipeline", data.frameHandle, data.detailSteps, data.occasion ?? "session");
78207
78306
  }
78208
78307
  /**
78209
78308
  * Run one detection frame through the analysis layers for a given
@@ -78213,7 +78312,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78213
78312
  * tracking/zone/event state never crosses between sources. Emits the
78214
78313
  * SAME canonical events, distinguished only by `source`.
78215
78314
  */
78216
- async processFrame(deviceId, frame, source, rawFrameHandle, detailSteps) {
78315
+ async processFrame(deviceId, frame, source, rawFrameHandle, detailSteps, occasion = "session") {
78217
78316
  if (this.shuttingDown) return;
78218
78317
  if (!await this.bindingCache.isActive(deviceId)) {
78219
78318
  const now = Date.now();
@@ -78621,7 +78720,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78621
78720
  const evidence = this.stationaryEvidence;
78622
78721
  for (const t of result.tracked) {
78623
78722
  if (packageClasses.has(t.className)) continue;
78624
- const classEnabled = t.className === "person" ? stationarySettings.promotePersons : evidencePolicyFor(t.className).promotesByDefault;
78625
78723
  const active = this.trackStore?.peekActive(t.trackId) ?? null;
78626
78724
  const observedLabel = active === null ? void 0 : displayLabel(active);
78627
78725
  const observedKeyFrame = this.residents.keyFrameMediaId(t.trackId);
@@ -78630,12 +78728,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78630
78728
  className: t.className,
78631
78729
  bbox: { ...t.bbox },
78632
78730
  timestamp: result.timestamp,
78633
- source: "session",
78731
+ source: occasion,
78634
78732
  trackId: t.trackId,
78635
78733
  ...observedKeyFrame !== void 0 ? { keyFrameMediaId: observedKeyFrame } : {},
78636
- ...observedLabel !== void 0 ? { label: observedLabel } : {}
78734
+ ...observedLabel !== void 0 ? { label: observedLabel } : {},
78735
+ movementState: t.state
78637
78736
  };
78638
- const outcome = evidence.observe(observation, classEnabled);
78737
+ const outcome = evidence.observe(observation);
78639
78738
  if (outcome === void 0 || !outcome.promoted) continue;
78640
78739
  this.birthStationaryFromEvidence({
78641
78740
  deviceId,
@@ -81738,7 +81837,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
81738
81837
  ...label !== void 0 ? { label } : {},
81739
81838
  ...keyFrameMediaId !== void 0 ? { keyFrameMediaId } : {}
81740
81839
  };
81741
- if (plausible) this.stationaryRegistry?.promote(entry);
81840
+ if (plausible) {
81841
+ const gate = this.stationarySettingsFromCache(deviceId);
81842
+ this.stationaryRegistry?.promote(entry, {
81843
+ suppressIou: gate.suppressIou,
81844
+ wakeAssocIou: gate.wakeAssocIou
81845
+ });
81846
+ }
81742
81847
  processor.dropTrack(track.trackId);
81743
81848
  this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
81744
81849
  this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
@@ -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-BBKcSFzP.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DqVUwKib.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.2.242",
3
+ "version": "1.2.244",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",