@camstack/addon-post-analysis 1.2.139 → 1.2.141

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.
@@ -30502,6 +30502,8 @@ var DEFAULT_TRACKER_CONFIG = {
30502
30502
  rescueIouThreshold: .1,
30503
30503
  rescueCentroidFactor: .75,
30504
30504
  resurrectionWindowMs: 8e3,
30505
+ resurrectionCrowdGuard: true,
30506
+ resurrectionCrowdedIouThreshold: .3,
30505
30507
  stationarySpeedPx: 2,
30506
30508
  dedupEnabled: true,
30507
30509
  dedupSpawnIou: .6,
@@ -30751,6 +30753,35 @@ var SortTracker = class SortTracker {
30751
30753
  };
30752
30754
  }
30753
30755
  /**
30756
+ * How many same-association-group subjects OTHER than `corpse` share the
30757
+ * scene with it: every track LIVE on this frame, plus any other resurrectable
30758
+ * corpse whose lifetime OVERLAPPED the candidate's.
30759
+ *
30760
+ * The overlap clause on the graveyard half is what keeps this guard off the
30761
+ * case the resurrection pass exists for. A single flickering subject leaves a
30762
+ * TRAIL of corpses behind it — that trail is the fragmentation the pass
30763
+ * repairs — but those fragments never coexist: each is born only after the
30764
+ * previous one died. Two distinct people do coexist. Counting corpses without
30765
+ * the overlap test would let a subject's own debris read as a crowd and
30766
+ * re-fragment the very case being protected.
30767
+ *
30768
+ * A live track is counted unconditionally, overlap or not: the subject that
30769
+ * steals an id is very often one whose OWN track was born after the corpse
30770
+ * died (camera 618 — the toddler's track post-dates the older boy's).
30771
+ */
30772
+ sameGroupPeerCount(corpse, live) {
30773
+ const group = this.assocGroup(corpse.class);
30774
+ let peers = 0;
30775
+ for (const t of live) if (t !== corpse && this.assocGroup(t.class) === group) peers++;
30776
+ for (const other of this.lostTracks) {
30777
+ if (other === corpse) continue;
30778
+ if (!other.resurrectable) continue;
30779
+ if (this.assocGroup(other.class) !== group) continue;
30780
+ if (other.firstSeen <= corpse.lastSeen && corpse.firstSeen <= other.lastSeen) peers++;
30781
+ }
30782
+ return peers;
30783
+ }
30784
+ /**
30754
30785
  * Loose same-group gate used by the rescue and resurrection passes: accept
30755
30786
  * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
30756
30787
  * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always group-
@@ -30865,6 +30896,7 @@ var SortTracker = class SortTracker {
30865
30896
  }
30866
30897
  update(detections, timestamp, frameContext) {
30867
30898
  const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
30899
+ const resurrectedScene = /* @__PURE__ */ new Map();
30868
30900
  if (this.config.maxTrackLifetimeMs > 0) {
30869
30901
  const alive = [];
30870
30902
  for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
@@ -30975,6 +31007,10 @@ var SortTracker = class SortTracker {
30975
31007
  let best;
30976
31008
  let bestScore = -1;
30977
31009
  let bestGate = "iou";
31010
+ let bestPeers = 0;
31011
+ let refused;
31012
+ let refusedGate = "iou";
31013
+ let refusedScore = -1;
30978
31014
  for (const lost of this.lostTracks) {
30979
31015
  if (!lost.resurrectable) continue;
30980
31016
  if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
@@ -30982,13 +31018,27 @@ var SortTracker = class SortTracker {
30982
31018
  if (gate === null) continue;
30983
31019
  if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
30984
31020
  const score = iou$2(lost.bbox, det.bbox);
31021
+ const peers = this.sameGroupPeerCount(lost, surviving);
31022
+ if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
31023
+ if (score > refusedScore) {
31024
+ refused = lost;
31025
+ refusedGate = gate;
31026
+ refusedScore = score;
31027
+ }
31028
+ continue;
31029
+ }
30985
31030
  if (score > bestScore) {
30986
31031
  best = lost;
30987
31032
  bestScore = score;
30988
31033
  bestGate = gate;
31034
+ bestPeers = peers;
30989
31035
  }
30990
31036
  }
30991
- if (!best) continue;
31037
+ if (!best) {
31038
+ if (refused !== void 0) this.reportBind("resurrection-refused", refusedGate, refused, det, timestamp);
31039
+ continue;
31040
+ }
31041
+ resurrectedScene.set(best, bestPeers > 0 ? "multi-subject" : "sole-subject");
30992
31042
  this.reportBind("resurrection", bestGate, best, det, timestamp);
30993
31043
  this.lostTracks = this.lostTracks.filter((t) => t !== best);
30994
31044
  best.bbox = det.bbox;
@@ -31056,17 +31106,21 @@ var SortTracker = class SortTracker {
31056
31106
  }
31057
31107
  this.tracks = surviving;
31058
31108
  this.resolveDuplicates();
31059
- return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
31060
- class: this.resolveReportedClass(t),
31061
- originalClass: t.originalClass,
31062
- score: t.score,
31063
- bbox: t.bbox,
31064
- trackId: t.id,
31065
- trackAge: t.hits,
31066
- velocity: t.velocity,
31067
- path: [...t.path],
31068
- matchedThisFrame: t.lastSeen === timestamp
31069
- }));
31109
+ return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => {
31110
+ const scene = resurrectedScene.get(t);
31111
+ return {
31112
+ class: this.resolveReportedClass(t),
31113
+ originalClass: t.originalClass,
31114
+ score: t.score,
31115
+ bbox: t.bbox,
31116
+ trackId: t.id,
31117
+ trackAge: t.hits,
31118
+ velocity: t.velocity,
31119
+ path: [...t.path],
31120
+ matchedThisFrame: t.lastSeen === timestamp,
31121
+ ...scene !== void 0 ? { resurrectionScene: scene } : {}
31122
+ };
31123
+ });
31070
31124
  }
31071
31125
  /**
31072
31126
  * Remove a track from BOTH the live and graveyard sets so it neither emits
@@ -31780,6 +31834,7 @@ var FrameProcessor = class {
31780
31834
  zones: zonesByTrack.get(td.trackId) ?? [],
31781
31835
  state,
31782
31836
  matchedThisFrame: td.matchedThisFrame !== false,
31837
+ ...td.resurrectionScene !== void 0 ? { resurrectionScene: td.resurrectionScene } : {},
31783
31838
  ...labelPatch !== void 0 ? { labelPatch } : {},
31784
31839
  ...emb !== void 0 ? {
31785
31840
  embedding: emb.embedding,
@@ -38750,8 +38805,77 @@ var FaceRecognizer = class {
38750
38805
  }
38751
38806
  });
38752
38807
  }
38808
+ /**
38809
+ * The track id this face is held under was re-attached from the graveyard in a
38810
+ * scene that had other same-class subjects in it, so the id's continuity is no
38811
+ * longer self-evident. Stop vouching for what is held, without throwing it
38812
+ * away (D282).
38813
+ *
38814
+ * Three things happen, and the asymmetry between them is the whole decision:
38815
+ *
38816
+ * • the crop and the embedding are KEPT. Discarding them would punish exactly
38817
+ * the case the resurrection pass exists to serve — one subject flickering in
38818
+ * and out — and a good face is not cheap to come by.
38819
+ * • `recognizedIdentityId` is STRIPPED, along with the peak match/suggestion
38820
+ * scores. An identity is a claim about WHO, and the one fact just
38821
+ * established is that nobody knows who this id is on any more.
38822
+ * • the face becomes non-promotable: {@link onTrackEnd} will not write it to
38823
+ * the gallery while the flag stands.
38824
+ *
38825
+ * The aggregate is deliberately left alone — see the note in `onTrackEnd`.
38826
+ */
38827
+ quarantineHeldFace(deviceId, trackId) {
38828
+ const held = this.bestFace.get(trackId);
38829
+ if (held === void 0 || held.unverified === true) return;
38830
+ const quarantined = {
38831
+ score: held.score,
38832
+ embedding: held.embedding,
38833
+ embeddingModelId: held.embeddingModelId,
38834
+ bbox: held.bbox,
38835
+ timestamp: held.timestamp,
38836
+ unverified: true,
38837
+ ...held.crop !== void 0 ? { crop: held.crop } : {},
38838
+ ...held.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: held.nativeFaceShortSidePx } : {}
38839
+ };
38840
+ this.bestFace.set(trackId, quarantined);
38841
+ this.bestMatchScore.delete(trackId);
38842
+ this.bestSuggestion.delete(trackId);
38843
+ this.deps.logger.info("face: held face unverified — track id re-attached in a crowded scene", {
38844
+ tags: {
38845
+ deviceId,
38846
+ trackId
38847
+ },
38848
+ meta: {
38849
+ heldScore: held.score,
38850
+ heldAt: held.timestamp,
38851
+ strippedIdentityId: held.recognizedIdentityId ?? null,
38852
+ hasCrop: held.crop !== void 0
38853
+ }
38854
+ });
38855
+ }
38856
+ /**
38857
+ * Ask a FRESH face whether the quarantined held face is still about the same
38858
+ * person, at the same bar the gallery uses to say two vectors are one person
38859
+ * (`similarityThreshold` — one knob over one band, D222).
38860
+ *
38861
+ * Only a face-BEARING frame may answer. An embedding inherited onto the person
38862
+ * track on a face-less frame is the held vector's own descendant, so it would
38863
+ * confirm itself; callers gate on `faceBbox` being present.
38864
+ */
38865
+ verifyHeldFace(held, probe, threshold) {
38866
+ if (held.embeddingModelId !== probe.embeddingModelId || held.embedding.length !== probe.embedding.length) return {
38867
+ verdict: "incomparable",
38868
+ cosine: 0
38869
+ };
38870
+ const cosine = require_dist.cosineSimilarity(new Float32Array(held.embedding), new Float32Array(probe.embedding));
38871
+ return {
38872
+ verdict: cosine >= threshold ? "confirmed" : "rejected",
38873
+ cosine
38874
+ };
38875
+ }
38753
38876
  async processFrame(input) {
38754
38877
  const { settings } = input;
38878
+ for (const t of input.tracked) if (t.resurrectionScene === "multi-subject") this.quarantineHeldFace(input.deviceId, t.trackId);
38755
38879
  const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence && (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.minFacePx);
38756
38880
  if (candidates.length === 0) return;
38757
38881
  this.deps.logger.debug("face: frame candidates", {
@@ -38811,6 +38935,35 @@ var FaceRecognizer = class {
38811
38935
  }
38812
38936
  const labelWork = [];
38813
38937
  for (const c of candidates) {
38938
+ const quarantined = this.bestFace.get(c.trackId);
38939
+ if (quarantined?.unverified === true && c.faceBbox !== void 0) {
38940
+ const { verdict, cosine } = this.verifyHeldFace(quarantined, {
38941
+ embedding: c.embedding,
38942
+ embeddingModelId: c.embeddingModelId
38943
+ }, settings.similarityThreshold);
38944
+ if (verdict === "confirmed") this.bestFace.set(c.trackId, {
38945
+ ...quarantined,
38946
+ unverified: false
38947
+ });
38948
+ else if (verdict === "rejected") {
38949
+ this.bestFace.delete(c.trackId);
38950
+ this.bestTracker.delete(c.trackId);
38951
+ this.aggregates.delete(c.trackId);
38952
+ }
38953
+ if (verdict !== "confirmed") this.deps.logger.info("face: held face not confirmed after a crowded re-attach", {
38954
+ tags: {
38955
+ deviceId: input.deviceId,
38956
+ trackId: c.trackId
38957
+ },
38958
+ meta: {
38959
+ verdict,
38960
+ cosine: Number(cosine.toFixed(3)),
38961
+ threshold: settings.similarityThreshold,
38962
+ heldAt: quarantined.timestamp,
38963
+ droppedHeldFace: verdict === "rejected"
38964
+ }
38965
+ });
38966
+ }
38814
38967
  const match = matches.get(c.trackId) ?? null;
38815
38968
  const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
38816
38969
  this.aggregates.set(c.trackId, state);
@@ -38981,6 +39134,20 @@ var FaceRecognizer = class {
38981
39134
  } });
38982
39135
  return;
38983
39136
  }
39137
+ if (held.unverified === true) {
39138
+ this.deps.logger.info("face: dropping unverified held face at close", {
39139
+ tags: {
39140
+ deviceId,
39141
+ trackId
39142
+ },
39143
+ meta: {
39144
+ score: held.score,
39145
+ heldAt: held.timestamp,
39146
+ hasCrop: held.crop !== void 0
39147
+ }
39148
+ });
39149
+ return;
39150
+ }
38984
39151
  if (held.crop === void 0) {
38985
39152
  const now = Date.now();
38986
39153
  if (now - this.lastImagelessLogAt >= FACE_IMAGELESS_LOG_THROTTLE_MS) {
@@ -30477,6 +30477,8 @@ var DEFAULT_TRACKER_CONFIG = {
30477
30477
  rescueIouThreshold: .1,
30478
30478
  rescueCentroidFactor: .75,
30479
30479
  resurrectionWindowMs: 8e3,
30480
+ resurrectionCrowdGuard: true,
30481
+ resurrectionCrowdedIouThreshold: .3,
30480
30482
  stationarySpeedPx: 2,
30481
30483
  dedupEnabled: true,
30482
30484
  dedupSpawnIou: .6,
@@ -30726,6 +30728,35 @@ var SortTracker = class SortTracker {
30726
30728
  };
30727
30729
  }
30728
30730
  /**
30731
+ * How many same-association-group subjects OTHER than `corpse` share the
30732
+ * scene with it: every track LIVE on this frame, plus any other resurrectable
30733
+ * corpse whose lifetime OVERLAPPED the candidate's.
30734
+ *
30735
+ * The overlap clause on the graveyard half is what keeps this guard off the
30736
+ * case the resurrection pass exists for. A single flickering subject leaves a
30737
+ * TRAIL of corpses behind it — that trail is the fragmentation the pass
30738
+ * repairs — but those fragments never coexist: each is born only after the
30739
+ * previous one died. Two distinct people do coexist. Counting corpses without
30740
+ * the overlap test would let a subject's own debris read as a crowd and
30741
+ * re-fragment the very case being protected.
30742
+ *
30743
+ * A live track is counted unconditionally, overlap or not: the subject that
30744
+ * steals an id is very often one whose OWN track was born after the corpse
30745
+ * died (camera 618 — the toddler's track post-dates the older boy's).
30746
+ */
30747
+ sameGroupPeerCount(corpse, live) {
30748
+ const group = this.assocGroup(corpse.class);
30749
+ let peers = 0;
30750
+ for (const t of live) if (t !== corpse && this.assocGroup(t.class) === group) peers++;
30751
+ for (const other of this.lostTracks) {
30752
+ if (other === corpse) continue;
30753
+ if (!other.resurrectable) continue;
30754
+ if (this.assocGroup(other.class) !== group) continue;
30755
+ if (other.firstSeen <= corpse.lastSeen && corpse.firstSeen <= other.lastSeen) peers++;
30756
+ }
30757
+ return peers;
30758
+ }
30759
+ /**
30729
30760
  * Loose same-group gate used by the rescue and resurrection passes: accept
30730
30761
  * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
30731
30762
  * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always group-
@@ -30840,6 +30871,7 @@ var SortTracker = class SortTracker {
30840
30871
  }
30841
30872
  update(detections, timestamp, frameContext) {
30842
30873
  const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
30874
+ const resurrectedScene = /* @__PURE__ */ new Map();
30843
30875
  if (this.config.maxTrackLifetimeMs > 0) {
30844
30876
  const alive = [];
30845
30877
  for (const track of this.tracks) if (timestamp - track.firstSeen > this.config.maxTrackLifetimeMs) {
@@ -30950,6 +30982,10 @@ var SortTracker = class SortTracker {
30950
30982
  let best;
30951
30983
  let bestScore = -1;
30952
30984
  let bestGate = "iou";
30985
+ let bestPeers = 0;
30986
+ let refused;
30987
+ let refusedGate = "iou";
30988
+ let refusedScore = -1;
30953
30989
  for (const lost of this.lostTracks) {
30954
30990
  if (!lost.resurrectable) continue;
30955
30991
  if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
@@ -30957,13 +30993,27 @@ var SortTracker = class SortTracker {
30957
30993
  if (gate === null) continue;
30958
30994
  if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
30959
30995
  const score = iou$2(lost.bbox, det.bbox);
30996
+ const peers = this.sameGroupPeerCount(lost, surviving);
30997
+ if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
30998
+ if (score > refusedScore) {
30999
+ refused = lost;
31000
+ refusedGate = gate;
31001
+ refusedScore = score;
31002
+ }
31003
+ continue;
31004
+ }
30960
31005
  if (score > bestScore) {
30961
31006
  best = lost;
30962
31007
  bestScore = score;
30963
31008
  bestGate = gate;
31009
+ bestPeers = peers;
30964
31010
  }
30965
31011
  }
30966
- if (!best) continue;
31012
+ if (!best) {
31013
+ if (refused !== void 0) this.reportBind("resurrection-refused", refusedGate, refused, det, timestamp);
31014
+ continue;
31015
+ }
31016
+ resurrectedScene.set(best, bestPeers > 0 ? "multi-subject" : "sole-subject");
30967
31017
  this.reportBind("resurrection", bestGate, best, det, timestamp);
30968
31018
  this.lostTracks = this.lostTracks.filter((t) => t !== best);
30969
31019
  best.bbox = det.bbox;
@@ -31031,17 +31081,21 @@ var SortTracker = class SortTracker {
31031
31081
  }
31032
31082
  this.tracks = surviving;
31033
31083
  this.resolveDuplicates();
31034
- return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => ({
31035
- class: this.resolveReportedClass(t),
31036
- originalClass: t.originalClass,
31037
- score: t.score,
31038
- bbox: t.bbox,
31039
- trackId: t.id,
31040
- trackAge: t.hits,
31041
- velocity: t.velocity,
31042
- path: [...t.path],
31043
- matchedThisFrame: t.lastSeen === timestamp
31044
- }));
31084
+ return this.tracks.filter((t) => this.isConfirmedForEmit(t)).map((t) => {
31085
+ const scene = resurrectedScene.get(t);
31086
+ return {
31087
+ class: this.resolveReportedClass(t),
31088
+ originalClass: t.originalClass,
31089
+ score: t.score,
31090
+ bbox: t.bbox,
31091
+ trackId: t.id,
31092
+ trackAge: t.hits,
31093
+ velocity: t.velocity,
31094
+ path: [...t.path],
31095
+ matchedThisFrame: t.lastSeen === timestamp,
31096
+ ...scene !== void 0 ? { resurrectionScene: scene } : {}
31097
+ };
31098
+ });
31045
31099
  }
31046
31100
  /**
31047
31101
  * Remove a track from BOTH the live and graveyard sets so it neither emits
@@ -31755,6 +31809,7 @@ var FrameProcessor = class {
31755
31809
  zones: zonesByTrack.get(td.trackId) ?? [],
31756
31810
  state,
31757
31811
  matchedThisFrame: td.matchedThisFrame !== false,
31812
+ ...td.resurrectionScene !== void 0 ? { resurrectionScene: td.resurrectionScene } : {},
31758
31813
  ...labelPatch !== void 0 ? { labelPatch } : {},
31759
31814
  ...emb !== void 0 ? {
31760
31815
  embedding: emb.embedding,
@@ -38687,8 +38742,77 @@ var FaceRecognizer = class {
38687
38742
  }
38688
38743
  });
38689
38744
  }
38745
+ /**
38746
+ * The track id this face is held under was re-attached from the graveyard in a
38747
+ * scene that had other same-class subjects in it, so the id's continuity is no
38748
+ * longer self-evident. Stop vouching for what is held, without throwing it
38749
+ * away (D282).
38750
+ *
38751
+ * Three things happen, and the asymmetry between them is the whole decision:
38752
+ *
38753
+ * • the crop and the embedding are KEPT. Discarding them would punish exactly
38754
+ * the case the resurrection pass exists to serve — one subject flickering in
38755
+ * and out — and a good face is not cheap to come by.
38756
+ * • `recognizedIdentityId` is STRIPPED, along with the peak match/suggestion
38757
+ * scores. An identity is a claim about WHO, and the one fact just
38758
+ * established is that nobody knows who this id is on any more.
38759
+ * • the face becomes non-promotable: {@link onTrackEnd} will not write it to
38760
+ * the gallery while the flag stands.
38761
+ *
38762
+ * The aggregate is deliberately left alone — see the note in `onTrackEnd`.
38763
+ */
38764
+ quarantineHeldFace(deviceId, trackId) {
38765
+ const held = this.bestFace.get(trackId);
38766
+ if (held === void 0 || held.unverified === true) return;
38767
+ const quarantined = {
38768
+ score: held.score,
38769
+ embedding: held.embedding,
38770
+ embeddingModelId: held.embeddingModelId,
38771
+ bbox: held.bbox,
38772
+ timestamp: held.timestamp,
38773
+ unverified: true,
38774
+ ...held.crop !== void 0 ? { crop: held.crop } : {},
38775
+ ...held.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: held.nativeFaceShortSidePx } : {}
38776
+ };
38777
+ this.bestFace.set(trackId, quarantined);
38778
+ this.bestMatchScore.delete(trackId);
38779
+ this.bestSuggestion.delete(trackId);
38780
+ this.deps.logger.info("face: held face unverified — track id re-attached in a crowded scene", {
38781
+ tags: {
38782
+ deviceId,
38783
+ trackId
38784
+ },
38785
+ meta: {
38786
+ heldScore: held.score,
38787
+ heldAt: held.timestamp,
38788
+ strippedIdentityId: held.recognizedIdentityId ?? null,
38789
+ hasCrop: held.crop !== void 0
38790
+ }
38791
+ });
38792
+ }
38793
+ /**
38794
+ * Ask a FRESH face whether the quarantined held face is still about the same
38795
+ * person, at the same bar the gallery uses to say two vectors are one person
38796
+ * (`similarityThreshold` — one knob over one band, D222).
38797
+ *
38798
+ * Only a face-BEARING frame may answer. An embedding inherited onto the person
38799
+ * track on a face-less frame is the held vector's own descendant, so it would
38800
+ * confirm itself; callers gate on `faceBbox` being present.
38801
+ */
38802
+ verifyHeldFace(held, probe, threshold) {
38803
+ if (held.embeddingModelId !== probe.embeddingModelId || held.embedding.length !== probe.embedding.length) return {
38804
+ verdict: "incomparable",
38805
+ cosine: 0
38806
+ };
38807
+ const cosine = cosineSimilarity$1(new Float32Array(held.embedding), new Float32Array(probe.embedding));
38808
+ return {
38809
+ verdict: cosine >= threshold ? "confirmed" : "rejected",
38810
+ cosine
38811
+ };
38812
+ }
38690
38813
  async processFrame(input) {
38691
38814
  const { settings } = input;
38815
+ for (const t of input.tracked) if (t.resurrectionScene === "multi-subject") this.quarantineHeldFace(input.deviceId, t.trackId);
38692
38816
  const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence && (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.minFacePx);
38693
38817
  if (candidates.length === 0) return;
38694
38818
  this.deps.logger.debug("face: frame candidates", {
@@ -38748,6 +38872,35 @@ var FaceRecognizer = class {
38748
38872
  }
38749
38873
  const labelWork = [];
38750
38874
  for (const c of candidates) {
38875
+ const quarantined = this.bestFace.get(c.trackId);
38876
+ if (quarantined?.unverified === true && c.faceBbox !== void 0) {
38877
+ const { verdict, cosine } = this.verifyHeldFace(quarantined, {
38878
+ embedding: c.embedding,
38879
+ embeddingModelId: c.embeddingModelId
38880
+ }, settings.similarityThreshold);
38881
+ if (verdict === "confirmed") this.bestFace.set(c.trackId, {
38882
+ ...quarantined,
38883
+ unverified: false
38884
+ });
38885
+ else if (verdict === "rejected") {
38886
+ this.bestFace.delete(c.trackId);
38887
+ this.bestTracker.delete(c.trackId);
38888
+ this.aggregates.delete(c.trackId);
38889
+ }
38890
+ if (verdict !== "confirmed") this.deps.logger.info("face: held face not confirmed after a crowded re-attach", {
38891
+ tags: {
38892
+ deviceId: input.deviceId,
38893
+ trackId: c.trackId
38894
+ },
38895
+ meta: {
38896
+ verdict,
38897
+ cosine: Number(cosine.toFixed(3)),
38898
+ threshold: settings.similarityThreshold,
38899
+ heldAt: quarantined.timestamp,
38900
+ droppedHeldFace: verdict === "rejected"
38901
+ }
38902
+ });
38903
+ }
38751
38904
  const match = matches.get(c.trackId) ?? null;
38752
38905
  const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
38753
38906
  this.aggregates.set(c.trackId, state);
@@ -38918,6 +39071,20 @@ var FaceRecognizer = class {
38918
39071
  } });
38919
39072
  return;
38920
39073
  }
39074
+ if (held.unverified === true) {
39075
+ this.deps.logger.info("face: dropping unverified held face at close", {
39076
+ tags: {
39077
+ deviceId,
39078
+ trackId
39079
+ },
39080
+ meta: {
39081
+ score: held.score,
39082
+ heldAt: held.timestamp,
39083
+ hasCrop: held.crop !== void 0
39084
+ }
39085
+ });
39086
+ return;
39087
+ }
38921
39088
  if (held.crop === void 0) {
38922
39089
  const now = Date.now();
38923
39090
  if (now - this.lastImagelessLogAt >= FACE_IMAGELESS_LOG_THROTTLE_MS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.139",
3
+ "version": "1.2.141",
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",