@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.
@@ -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-DjW7k4lv.js");
5
+ const require_dist = require("../dist-DxZAf0Hs.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");
@@ -55625,7 +55625,8 @@ function buildOverlayDetections(input) {
55625
55625
  ...det,
55626
55626
  track: {
55627
55627
  trackId: t.trackId,
55628
- zones: t.zones
55628
+ zones: t.zones,
55629
+ ...t.state !== void 0 ? { movementState: t.state } : {}
55629
55630
  },
55630
55631
  labels: hasName && !alreadyLabeled ? [...det.labels, {
55631
55632
  label: name,
@@ -57583,6 +57584,10 @@ function centroidInside(b, p) {
57583
57584
  }
57584
57585
  /** Parked-box coverage (intersection / entry area) that counts as swallowed. */
57585
57586
  var SWALLOW_MIN_COVERAGE = .5;
57587
+ /** Fraction of a detection's OWN area that must lie inside a parked entry for
57588
+ * the entry to swallow it. Deliberately near-total: a fragment of a parked
57589
+ * object does not stick out of it. */
57590
+ var FRAGMENT_MIN_CONTAINMENT = .9;
57586
57591
  function isSceneSizedBox(bbox, frameWidth, frameHeight) {
57587
57592
  const frame = frameWidth * frameHeight;
57588
57593
  return frame > 0 && bbox.w * bbox.h / frame >= .55;
@@ -57602,6 +57607,29 @@ function detectionSwallowsEntry(det, parked) {
57602
57607
  if (!centroidInside(det, centroid(parked))) return false;
57603
57608
  return intersectionArea$2(parked, det) / entryArea >= SWALLOW_MIN_COVERAGE;
57604
57609
  }
57610
+ /**
57611
+ * True when `det` is a FRAGMENT of the parked object — much smaller than the
57612
+ * entry and sitting (almost) wholly inside it.
57613
+ *
57614
+ * The mirror of {@link detectionSwallowsEntry}, and the case neither IoU
57615
+ * threshold can see. Measured on 617: a 34x137 box inside a 452x420 parked
57616
+ * motorcycle scores IoU 0.024 — BELOW `wakeAssocIou` (0.1), so the filter read
57617
+ * it as "no detection overlaps this entry" and let it open a track, accumulate
57618
+ * its own evidence and promote into a registry entry of its own. The camera
57619
+ * then reported two parked vehicles where there was one motorcycle.
57620
+ *
57621
+ * The size bound is the reciprocal of {@link SWALLOW_AREA_RATIO}, so the two
57622
+ * swallow rules meet without overlapping: a box between 1/1.35 and 1.35 of the
57623
+ * entry's area, contained, already scores IoU >= 0.74 and is answered by
57624
+ * `suppressIou`. Nothing is a fragment and a near-identical box at once.
57625
+ */
57626
+ function entrySwallowsDetection(parked, det) {
57627
+ const entryArea = parked.w * parked.h;
57628
+ const detArea = det.w * det.h;
57629
+ if (entryArea <= 0 || detArea <= 0) return false;
57630
+ if (detArea > entryArea / 1.35) return false;
57631
+ return intersectionArea$2(parked, det) / detArea >= FRAGMENT_MIN_CONTAINMENT;
57632
+ }
57605
57633
  function parkedEntryHoldsDetection(entry, detBbox, config) {
57606
57634
  const overlap = iou(entry.bbox, detBbox);
57607
57635
  if (overlap >= config.suppressIou) return {
@@ -57616,6 +57644,10 @@ function parkedEntryHoldsDetection(entry, detBbox, config) {
57616
57644
  strong: false,
57617
57645
  holds: false
57618
57646
  };
57647
+ if (entrySwallowsDetection(entry.bbox, detBbox)) return {
57648
+ strong: false,
57649
+ holds: true
57650
+ };
57619
57651
  return {
57620
57652
  strong: false,
57621
57653
  holds: overlap >= config.wakeAssocIou && centroidInside(entry.bbox, centroid(detBbox))
@@ -57634,6 +57666,34 @@ function bboxMatchesParkedEntry(bbox, className, entries, suppressIou = SUPPRESS
57634
57666
  }
57635
57667
  return false;
57636
57668
  }
57669
+ /**
57670
+ * The live entry already censusing this spot, if any — the ONE answer to "is
57671
+ * this place taken?", asked at BIRTH.
57672
+ *
57673
+ * It is deliberately the same predicate the per-frame filter uses
57674
+ * ({@link parkedEntryHoldsDetection}): a box the filter would suppress as a
57675
+ * parked object's own must not be able to become a second parked object. Device
57676
+ * 617 held four rows for one car and a fifth for a fragment of a motorcycle
57677
+ * because the birth path asked nothing at all.
57678
+ */
57679
+ function findParkedOccupant(entries, className, bbox, suppressIou = SUPPRESS_IOU, wakeAssocIou = WAKE_ASSOC_IOU) {
57680
+ const cls = normaliseStationaryClass(className);
57681
+ const config = {
57682
+ suppressIou,
57683
+ wakeAssocIou
57684
+ };
57685
+ let best;
57686
+ for (const e of entries) {
57687
+ if (normaliseStationaryClass(e.className) !== cls) continue;
57688
+ if (!parkedEntryHoldsDetection(e, bbox, config).holds) continue;
57689
+ const overlap = iou(e.bbox, bbox);
57690
+ if (best === void 0 || overlap > best.overlap) best = {
57691
+ entry: e,
57692
+ overlap
57693
+ };
57694
+ }
57695
+ return best?.entry;
57696
+ }
57637
57697
  /** Best-shot veto: current crop is a parked object the trail did not start on. */
57638
57698
  function thumbnailWouldShowParkedNotSubject(input) {
57639
57699
  if (!bboxMatchesParkedEntry(input.currentBbox, input.className, input.entries)) return false;
@@ -58619,85 +58679,6 @@ var StartedBirthRegistry = class {
58619
58679
  //#endregion
58620
58680
  //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
58621
58681
  /**
58622
- * stationary-evidence — how a parked object EARNS a registry entry.
58623
- *
58624
- * ## The criterion this replaces, and why it was measuring the wrong thing
58625
- *
58626
- * Until 2026-08-28 an object became stationary by TRACK CONTINUITY: one track
58627
- * had to stay alive and barely move for `PROMOTION_WINDOW_MS` (30 s). That
58628
- * rewards an object that is *seen well and often* — precisely the opposite of
58629
- * what the operator needs. Measured on device 617 (the parking lot), 24 h:
58630
- *
58631
- * - three cars are parked in view; the registry held exactly ONE entry;
58632
- * - the two it missed were re-discovered as **140 and 112 separate tracks**,
58633
- * median life **6.8 s**, p90 29 s. Not one of them could ever reach 30 s.
58634
- * - those two objects alone produced **13 494 of 18 402** vehicle
58635
- * observations (73%) — the whole re-spawn flood the registry exists to stop.
58636
- *
58637
- * The track dies; the OBJECT does not. So the evidence must outlive the track.
58638
- *
58639
- * ## The new criterion: a RATIO, not a count
58640
- *
58641
- * "The same thing, in the same place, most of the times it was seen."
58642
- *
58643
- * N — observations that AGREED with the candidate's anchor box (IoU ≥
58644
- * {@link StationaryEvidencePolicy.agreeIou}).
58645
- * M — observations OFFERED by a track once it bound to this candidate,
58646
- * agreeing or not.
58647
- *
58648
- * A count would rescue nothing: a badly-lit car at night is seen rarely, and
58649
- * any floor high enough to exclude a passer-by would exclude it too. A RATIO
58650
- * asks a question a rarely-seen object can still answer well — 8 sightings out
58651
- * of 10 in the same spot is stationary even though 10 is a small number.
58652
- *
58653
- * The dilution is the whole point, and it is why M counts a bound track's
58654
- * observations even after it walks away: a car that drives THROUGH a spot binds
58655
- * for a few frames and then contributes dozens of disagreeing ones. Measured on
58656
- * 617 the two populations do not overlap:
58657
- *
58658
- * | candidate | N / M | ratio |
58659
- * | ---------------------------- | ----------- | ----- |
58660
- * | parked car A (missed today) | 6405 / 6467 | 0.99 |
58661
- * | parked car B (missed today) | 343 / 343 | 1.00 |
58662
- * | car crossing the lot | 5 / 70 | 0.07 |
58663
- * | car turning through the lot | 14 / 91 | 0.15 |
58664
- * | car manoeuvring | 12 / 41 | 0.29 |
58665
- *
58666
- * The threshold sits in the empty band between 0.29 and 0.99.
58667
- *
58668
- * ## The span is what a ratio cannot say
58669
- *
58670
- * A ratio alone promotes a car that paused for four seconds — its handful of
58671
- * observations all agree. So evidence must also STRETCH: the agreeing
58672
- * observations must span {@link StationaryEvidencePolicy.minSpanMs}. Measured
58673
- * on 617, real parked cars span 2 min to 24 h; the two momentary stops in the
58674
- * same day spanned 4.2 s and 8.0 s. Setting the span to
58675
- * `DEFAULT_MAX_SESSION_HOLD_MS` also buys a structural guarantee: no single
58676
- * motion session, however long it is held open, can promote on its own.
58677
- *
58678
- * ## ONE ledger, two producers
58679
- *
58680
- * Evidence is anchored to a BOX, not to a track and not to a session, so an
58681
- * object confirmed half by an active motion session and half by a periodic
58682
- * burst still promotes. Occasion A (the session) is implemented and is
58683
- * sufficient on its own — it is free, because the pipeline is already decoding
58684
- * and already inferring. Occasion B (the burst) is a precaution for the cases
58685
- * where A could not have fired at all (hub restarted, camera offline, motion
58686
- * detection failed, object already parked at boot); it is NOT implemented yet,
58687
- * and {@link StationaryEvidenceSource} is the seam it will arrive through.
58688
- *
58689
- * ## Identity is IoU. There is no grid.
58690
- *
58691
- * The predecessor mechanism (`phantom-cells.ts`) quantises a point to a 32 px
58692
- * cell, and its write path passes a CENTROID while its read path passes a
58693
- * top-left CORNER — at 32 px every box bigger than 32 px lands in a different
58694
- * cell on read, and its unit test never crosses that boundary because it calls
58695
- * both halves with the same literal coordinates. This module has no such
58696
- * boundary to get wrong: a candidate is found by overlap against the boxes a
58697
- * device already holds, bounded by {@link EVIDENCE_MAX_CANDIDATES_PER_DEVICE}.
58698
- * Device 617 — the busiest lot in the fleet — holds 8.
58699
- */
58700
- /**
58701
58682
  * `vehicle` and its kin. Every number below is read off device 617 over 24 h
58702
58683
  * (18 402 vehicle observations, 290 tracks) with the three parked cars the
58703
58684
  * operator can see out of the window as the bench.
@@ -58719,11 +58700,11 @@ var VEHICLE_EVIDENCE_POLICY = {
58719
58700
  agreeIou: .5,
58720
58701
  minAgreed: 8,
58721
58702
  minAgreementRatio: .6,
58722
- minSpanMs: 12e4,
58723
- promotesByDefault: true
58703
+ minSpanMs: 12e4
58724
58704
  };
58725
58705
  /**
58726
- * `person`, and it ships OFF.
58706
+ * `person`, promoted like every other class since 2026-09-14 — and still the
58707
+ * strictest numbers in the file, because these are MEASURED, not cautious.
58727
58708
  *
58728
58709
  * This is not caution, it is a measurement. Replaying 24 h of person
58729
58710
  * observations from devices 617, 1443, 590 and 592 through this exact ledger:
@@ -58742,17 +58723,24 @@ var VEHICLE_EVIDENCE_POLICY = {
58742
58723
  *
58743
58724
  * Promoting a person is a BLINDING decision (D49 read in the direction that
58744
58725
  * destroys work: for a car the destructive read is the RETIREMENT — the flood
58745
- * comes back; for a person it is the BIRTH — we stop seeing them). So the
58746
- * class is available, tuned as strictly as the data allows, and switched off
58747
- * until an operator turns it on for a camera where they know what is standing
58748
- * there.
58726
+ * comes back; for a person it is the BIRTH — we stop seeing them). It used to
58727
+ * ship OFF behind a per-camera switch for that reason; the switch is gone —
58728
+ * an opt-in nobody turns on produces nothing, and it was producing nothing.
58729
+ *
58730
+ * What makes that affordable now is that agreement is no longer overlap alone:
58731
+ * `observationAgrees` requires the TRACKER to call the subject still, so a
58732
+ * person who merely pauses inside the anchor no longer feeds the candidate.
58733
+ * The statue and the poster still promote — which is what you want, they ARE
58734
+ * furniture — while the real person who stops for a moment no longer does.
58735
+ *
58736
+ * The thresholds stay where the measurement put them. They are the difference
58737
+ * between 22 promotions a day and 10.
58749
58738
  */
58750
58739
  var PERSON_EVIDENCE_POLICY = {
58751
58740
  agreeIou: .5,
58752
58741
  minAgreed: 40,
58753
58742
  minAgreementRatio: .95,
58754
- minSpanMs: 18e5,
58755
- promotesByDefault: false
58743
+ minSpanMs: 18e5
58756
58744
  };
58757
58745
  /** Classes with no policy of their own fall back to the vehicle numbers —
58758
58746
  * `animal`, `package` and friends are all "an object that ended up somewhere",
@@ -58771,7 +58759,7 @@ function stationaryEvidenceKey(deviceId, className, anchorId) {
58771
58759
  * Pure, and ordered cheapest-question-first so the reason a candidate was
58772
58760
  * refused is the FIRST thing it failed, not the last.
58773
58761
  */
58774
- function evaluateStationaryEvidence(row, policy, classEnabled) {
58762
+ function evaluateStationaryEvidence(row, policy) {
58775
58763
  const windowSeen = row.windowSeen + row.prevWindowSeen;
58776
58764
  const windowAgreed = row.windowAgreed + row.prevWindowAgreed;
58777
58765
  const ratio = windowSeen > 0 ? windowAgreed / windowSeen : 0;
@@ -58782,11 +58770,6 @@ function evaluateStationaryEvidence(row, policy, classEnabled) {
58782
58770
  ratio,
58783
58771
  spanMs
58784
58772
  };
58785
- if (!classEnabled) return {
58786
- kind: "accumulating",
58787
- shortfall: "class-not-promoted",
58788
- ...base
58789
- };
58790
58773
  if (row.agreed < policy.minAgreed) return {
58791
58774
  kind: "accumulating",
58792
58775
  shortfall: "too-few-agreements",
@@ -58861,6 +58844,32 @@ function matchEvidenceCandidate(rows, className, bbox, agreeIou) {
58861
58844
  };
58862
58845
  }
58863
58846
  /**
58847
+ * Does this observation count as EVIDENCE that the object is parked?
58848
+ *
58849
+ * Two questions, deliberately kept apart:
58850
+ *
58851
+ * - IDENTITY — is this the same object in the same place? That is the IoU,
58852
+ * and it is the only notion of sameness in this module.
58853
+ * - STILLNESS — is it going anywhere? That is the TRACKER's verdict, and
58854
+ * nothing here re-derives it.
58855
+ *
58856
+ * Before this, `agrees` was the IoU alone, so a long run of overlaps WAS the
58857
+ * stillness argument — a second derivation of a fact `StateAnalyzer` already
58858
+ * computes per track, slower and able to disagree with it. Now the ledger adds
58859
+ * only what the analyzer cannot know: that the same still object has been here
58860
+ * across a succession of tracks.
58861
+ *
58862
+ * `entering` is the tracker's undecided window — five frames that begin EVERY
58863
+ * track, including a parked car's re-detection. It is not disagreement, and
58864
+ * counting it as such would punish a candidate for the tracker's own warm-up.
58865
+ * An ABSENT verdict likewise falls back to overlap alone: a path that never had
58866
+ * an opinion must not be silently reclassified.
58867
+ */
58868
+ function observationAgrees(overlap, agreeIou, movementState) {
58869
+ if (overlap < agreeIou) return false;
58870
+ return movementState !== "moving" && movementState !== "left";
58871
+ }
58872
+ /**
58864
58873
  * Fold one observation into a candidate.
58865
58874
  *
58866
58875
  * `agrees` is decided by the CALLER, because M is what makes the ratio mean
@@ -59163,7 +59172,7 @@ var StationaryEvidenceLedger = class {
59163
59172
  * slow or failing store can never change what this frame decides. The
59164
59173
  * durable half is fired behind the verdict.
59165
59174
  */
59166
- observe(observation, classEnabled) {
59175
+ observe(observation) {
59167
59176
  const policy = evidencePolicyFor(observation.className);
59168
59177
  this.expire(observation.deviceId, observation.timestamp);
59169
59178
  const bindKey = observation.trackId === void 0 ? void 0 : `${observation.deviceId}:${observation.trackId}`;
@@ -59211,18 +59220,18 @@ var StationaryEvidenceLedger = class {
59211
59220
  this.capDevice(observation.deviceId);
59212
59221
  return {
59213
59222
  row,
59214
- verdict: evaluateStationaryEvidence(row, policy, classEnabled),
59223
+ verdict: evaluateStationaryEvidence(row, policy),
59215
59224
  promoted: false
59216
59225
  };
59217
59226
  }
59218
59227
  if (bindKey !== void 0) this.binding.set(bindKey, row.key);
59219
59228
  }
59220
- const agrees = overlap >= policy.agreeIou;
59229
+ const agrees = observationAgrees(overlap, policy.agreeIou, observation.movementState);
59221
59230
  const next = foldObservation(row, observation, agrees, overlap);
59222
59231
  this.ledger.stage(next);
59223
59232
  this.dirty.add(next.key);
59224
59233
  if (created) this.capDevice(observation.deviceId);
59225
- const verdict = evaluateStationaryEvidence(next, policy, classEnabled);
59234
+ const verdict = evaluateStationaryEvidence(next, policy);
59226
59235
  if (verdict.kind !== "promote") return {
59227
59236
  row: next,
59228
59237
  verdict,
@@ -59670,8 +59679,46 @@ var StationaryObjectRegistry = class {
59670
59679
  });
59671
59680
  }
59672
59681
  }
59673
- /** Promote a parked track into a persisted stationary entry. */
59674
- async promote(entry) {
59682
+ /**
59683
+ * Promote a parked track into a persisted stationary entry — or, when the
59684
+ * spot is already censused, CONFIRM the entry sitting on it.
59685
+ *
59686
+ * The occupancy question is asked here and nowhere else, with the same
59687
+ * predicate the per-frame filter uses (`findParkedOccupant`): a box the
59688
+ * filter would suppress as a parked object's own can never become a second
59689
+ * parked object. Until 2026-09-14 this method minted whatever it was handed,
59690
+ * and device 617 reported EIGHT parked vehicles — four of them one car, the
59691
+ * same box to the decimal, promoted again roughly once an hour because the
59692
+ * weak-continuation path (D410) keeps feeding the ledger from a track riding
59693
+ * a parked object. The re-promotion is not the anomaly and is not worth
59694
+ * suppressing: it is a fresh, independent confirmation that the thing is
59695
+ * still there. It just must not produce a neighbour.
59696
+ *
59697
+ * A merge never DOWNGRADES the tenant: it advances the clock and adopts an
59698
+ * identity the tenant lacks (the 617 duplicates arrived unlabelled while the
59699
+ * row they belonged to was already `Car`), and it never overwrites one it
59700
+ * has.
59701
+ */
59702
+ async promote(entry, config) {
59703
+ const occupant = findParkedOccupant(this.list(entry.deviceId), entry.className, entry.bbox, (config ?? this.matchConfig).suppressIou, (config ?? this.matchConfig).wakeAssocIou);
59704
+ if (occupant !== void 0) {
59705
+ const merged = this.absorbInto(occupant, entry);
59706
+ this.logger.info("stationary promotion absorbed — the spot is already censused", {
59707
+ tags: { deviceId: occupant.deviceId },
59708
+ meta: {
59709
+ deviceId: occupant.deviceId,
59710
+ entryId: occupant.id,
59711
+ className: occupant.className,
59712
+ refusedTrackId: entry.sourceTrackId,
59713
+ parkedForMs: entry.becameStationaryAt - occupant.becameStationaryAt,
59714
+ gainedLabel: occupant.label === void 0 && entry.label !== void 0
59715
+ }
59716
+ });
59717
+ return {
59718
+ outcome: "merged",
59719
+ entry: merged
59720
+ };
59721
+ }
59675
59722
  this.indexEntry(entry.deviceId, entry.id);
59676
59723
  this.dirty.delete(entry.id);
59677
59724
  await this.ledger.put(entry);
@@ -59680,6 +59727,22 @@ var StationaryObjectRegistry = class {
59680
59727
  entry,
59681
59728
  timestamp: entry.becameStationaryAt
59682
59729
  });
59730
+ return {
59731
+ outcome: "born",
59732
+ entry
59733
+ };
59734
+ }
59735
+ /** Fold a refused promotion into the entry that already holds its spot. */
59736
+ absorbInto(occupant, refused) {
59737
+ const merged = {
59738
+ ...occupant,
59739
+ lastConfirmedAt: Math.max(occupant.lastConfirmedAt, refused.becameStationaryAt),
59740
+ ...occupant.label === void 0 && refused.label !== void 0 ? { label: refused.label } : {},
59741
+ ...occupant.keyFrameMediaId === void 0 && refused.keyFrameMediaId !== void 0 ? { keyFrameMediaId: refused.keyFrameMediaId } : {}
59742
+ };
59743
+ this.ledger.stage(merged);
59744
+ this.dirty.add(merged.id);
59745
+ return merged;
59683
59746
  }
59684
59747
  /**
59685
59748
  * Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
@@ -59767,6 +59830,7 @@ var StationaryObjectRegistry = class {
59767
59830
  }
59768
59831
  });
59769
59832
  this.strikeCountersByDevice.clear();
59833
+ for (const deviceId of [...this.idsByDevice.keys()]) this.collapseDuplicates(deviceId);
59770
59834
  for (const id of [...this.dirty]) {
59771
59835
  this.dirty.delete(id);
59772
59836
  const entry = this.ledger.get(id);
@@ -59908,6 +59972,46 @@ var StationaryObjectRegistry = class {
59908
59972
  /** Retire one entry: index, staged advance, pending strike and durable row
59909
59973
  * all go. A strike that outlived its entry would arm the NEXT entry to reuse
59910
59974
  * the id — the registry mints UUIDs, but the invariant is cheap to keep. */
59975
+ /**
59976
+ * Fold every row that holds another row's spot into the OLDEST of them.
59977
+ *
59978
+ * The survivor is the oldest because it is the one carrying the object's real
59979
+ * `becameStationaryAt` — "parked since 14:30" is the fact the operator reads,
59980
+ * and the duplicates are all later re-discoveries of the same thing. It
59981
+ * inherits any identity the losers carry and that it lacks, and the losers'
59982
+ * rows are deleted outright: a retirement would announce a departure that
59983
+ * never happened.
59984
+ *
59985
+ * Uses the same predicate as the filter and the birth guard, so a collapse
59986
+ * can only ever remove a row that could not have been independently confirmed
59987
+ * anyway.
59988
+ */
59989
+ collapseDuplicates(deviceId) {
59990
+ const entries = [...this.list(deviceId)].sort((a, b) => a.becameStationaryAt - b.becameStationaryAt);
59991
+ if (entries.length < 2) return;
59992
+ const survivors = [];
59993
+ for (const candidate of entries) {
59994
+ const occupant = findParkedOccupant(survivors, candidate.className, candidate.bbox, this.matchConfig.suppressIou, this.matchConfig.wakeAssocIou);
59995
+ if (occupant === void 0) {
59996
+ survivors.push(candidate);
59997
+ continue;
59998
+ }
59999
+ const merged = this.absorbInto(occupant, candidate);
60000
+ survivors[survivors.indexOf(occupant)] = merged;
60001
+ this.dropEntry(deviceId, candidate.id);
60002
+ this.logger.info("stationary duplicate collapsed — two rows, one spot", {
60003
+ tags: { deviceId },
60004
+ meta: {
60005
+ deviceId,
60006
+ keptEntryId: occupant.id,
60007
+ droppedEntryId: candidate.id,
60008
+ className: candidate.className,
60009
+ keptSinceMs: occupant.becameStationaryAt,
60010
+ droppedSinceMs: candidate.becameStationaryAt
60011
+ }
60012
+ });
60013
+ }
60014
+ }
59911
60015
  dropEntry(deviceId, id) {
59912
60016
  this.idsByDevice.get(deviceId)?.delete(id);
59913
60017
  this.dirty.delete(id);
@@ -69950,18 +70054,6 @@ var StationarySettingsSchema = require_dist.object({
69950
70054
  * session open (also the minimum track age). Creates nothing — birth is
69951
70055
  * decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
69952
70056
  sessionStillnessWindowMs: require_dist.number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
69953
- /**
69954
- * Let a motionless PERSON become a stationary entry on this camera.
69955
- *
69956
- * Off, and it is a measurement rather than caution. Replaying 24 h of person
69957
- * observations from devices 617/1443/590/592 through the evidence ledger
69958
- * produces 6-22 entries a day depending on how strictly it is tuned, and none
69959
- * of them can be tuned to zero: device 592 alone yielded eleven candidates at
69960
- * a perfect agreement ratio between 02:00 and 05:00. A stationary entry
69961
- * SUPPRESSES its object, so a wrong one makes the camera blind to a person —
69962
- * turn this on only for a camera where you know what is standing there.
69963
- */
69964
- promotePersons: require_dist.boolean().default(false),
69965
70057
  /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
69966
70058
  * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
69967
70059
  entryTtlMs: require_dist.number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
@@ -69981,7 +70073,6 @@ function resolveStationarySettings(raw) {
69981
70073
  suppressIou: pick("suppressIou"),
69982
70074
  wakeAssocIou: pick("wakeAssocIou"),
69983
70075
  sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
69984
- promotePersons: pick("promotePersons"),
69985
70076
  entryTtlMs: pick("entryTtlMs")
69986
70077
  };
69987
70078
  }
@@ -70517,13 +70608,6 @@ function buildDetectionSettingsSections() {
70517
70608
  unit: "s",
70518
70609
  displayScale: 1e3
70519
70610
  },
70520
- {
70521
- type: "boolean",
70522
- key: "promotePersons",
70523
- label: "Also park motionless people",
70524
- 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.",
70525
- default: STATIONARY_DEFAULTS.promotePersons
70526
- },
70527
70611
  {
70528
70612
  type: "slider",
70529
70613
  key: "entryTtlMs",
@@ -73867,11 +73951,7 @@ var ZoneAnalyticsProvider = class {
73867
73951
  });
73868
73952
  }
73869
73953
  this.snapshots.set(input.deviceId, snapshot);
73870
- const noteOccupancy = this.ctx.onOccupancySample;
73871
- if (noteOccupancy !== void 0) {
73872
- noteOccupancy(input.deviceId, total, input.timestamp);
73873
- for (const zone of snapshot.zones) noteOccupancy(input.deviceId, zone.totalObjects, input.timestamp, zone.zoneId);
73874
- }
73954
+ this.noteLtsSamples(input.deviceId, snapshot, input.timestamp);
73875
73955
  this.appendHistory(input.deviceId, snapshot);
73876
73956
  this.emitSnapshot(input.deviceId, snapshot);
73877
73957
  this.sliceThrottle.push(input.deviceId, snapshot);
@@ -73938,11 +74018,30 @@ var ZoneAnalyticsProvider = class {
73938
74018
  });
73939
74019
  if (snapshot) {
73940
74020
  this.appendHistory(deviceId, snapshot);
74021
+ this.noteLtsSamples(deviceId, snapshot, now);
73941
74022
  this.emitSnapshot(deviceId, snapshot);
73942
74023
  this.sliceThrottle.push(deviceId, snapshot);
73943
74024
  }
73944
74025
  }
73945
74026
  }
74027
+ /**
74028
+ * Append this snapshot to the long-term occupancy series — frame-wide first
74029
+ * (`scope: ''`), then one sample per zone.
74030
+ *
74031
+ * Shared by BOTH producers on purpose. It used to live inline in
74032
+ * `recordFrame`, so the baseline sampler — the only producer a camera with no
74033
+ * frames flowing has — wrote the RAM ring, the NC watcher and the state slice
74034
+ * but never LTS. The long-term series therefore had a hole exactly where the
74035
+ * scene was nothing but parked objects: the one span an occupancy history is
74036
+ * asked about. The RAM ring showed a flat line over the same minutes, which
74037
+ * is what made the gap invisible.
74038
+ */
74039
+ noteLtsSamples(deviceId, snapshot, timestamp) {
74040
+ const note = this.ctx.onOccupancySample;
74041
+ if (note === void 0) return;
74042
+ note(deviceId, snapshot.frame.totalObjects, timestamp);
74043
+ for (const zone of snapshot.zones) note(deviceId, zone.totalObjects, timestamp, zone.zoneId);
74044
+ }
73946
74045
  /** Push one snapshot to the occupancy tap, isolating any consumer throw from
73947
74046
  * the frame path (occupancy notifications must never break analytics). */
73948
74047
  emitSnapshot(deviceId, snapshot) {
@@ -78277,7 +78376,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78277
78376
  }
78278
78377
  });
78279
78378
  }
78280
- await this.processFrame(deviceId, frame, data.source ?? "pipeline", data.frameHandle, data.detailSteps);
78379
+ await this.processFrame(deviceId, frame, data.source ?? "pipeline", data.frameHandle, data.detailSteps, data.occasion ?? "session");
78281
78380
  }
78282
78381
  /**
78283
78382
  * Run one detection frame through the analysis layers for a given
@@ -78287,7 +78386,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78287
78386
  * tracking/zone/event state never crosses between sources. Emits the
78288
78387
  * SAME canonical events, distinguished only by `source`.
78289
78388
  */
78290
- async processFrame(deviceId, frame, source, rawFrameHandle, detailSteps) {
78389
+ async processFrame(deviceId, frame, source, rawFrameHandle, detailSteps, occasion = "session") {
78291
78390
  if (this.shuttingDown) return;
78292
78391
  if (!await this.bindingCache.isActive(deviceId)) {
78293
78392
  const now = Date.now();
@@ -78695,7 +78794,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78695
78794
  const evidence = this.stationaryEvidence;
78696
78795
  for (const t of result.tracked) {
78697
78796
  if (packageClasses.has(t.className)) continue;
78698
- const classEnabled = t.className === "person" ? stationarySettings.promotePersons : evidencePolicyFor(t.className).promotesByDefault;
78699
78797
  const active = this.trackStore?.peekActive(t.trackId) ?? null;
78700
78798
  const observedLabel = active === null ? void 0 : displayLabel(active);
78701
78799
  const observedKeyFrame = this.residents.keyFrameMediaId(t.trackId);
@@ -78704,12 +78802,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78704
78802
  className: t.className,
78705
78803
  bbox: { ...t.bbox },
78706
78804
  timestamp: result.timestamp,
78707
- source: "session",
78805
+ source: occasion,
78708
78806
  trackId: t.trackId,
78709
78807
  ...observedKeyFrame !== void 0 ? { keyFrameMediaId: observedKeyFrame } : {},
78710
- ...observedLabel !== void 0 ? { label: observedLabel } : {}
78808
+ ...observedLabel !== void 0 ? { label: observedLabel } : {},
78809
+ movementState: t.state
78711
78810
  };
78712
- const outcome = evidence.observe(observation, classEnabled);
78811
+ const outcome = evidence.observe(observation);
78713
78812
  if (outcome === void 0 || !outcome.promoted) continue;
78714
78813
  this.birthStationaryFromEvidence({
78715
78814
  deviceId,
@@ -81812,7 +81911,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
81812
81911
  ...label !== void 0 ? { label } : {},
81813
81912
  ...keyFrameMediaId !== void 0 ? { keyFrameMediaId } : {}
81814
81913
  };
81815
- if (plausible) this.stationaryRegistry?.promote(entry);
81914
+ if (plausible) {
81915
+ const gate = this.stationarySettingsFromCache(deviceId);
81916
+ this.stationaryRegistry?.promote(entry, {
81917
+ suppressIou: gate.suppressIou,
81918
+ wakeAssocIou: gate.wakeAssocIou
81919
+ });
81920
+ }
81816
81921
  processor.dropTrack(track.trackId);
81817
81922
  this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
81818
81923
  this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {