Skip to content

Commit bd5a932

Browse files
committed
Faces: Mark the cluster width the guard tests as an open decision
The prose says the guard bounds every member and the code now tests a percentile of them. The permissive direction measures better, so which one it should read is a decision rather than a defect - recorded instead of asserted either way.
1 parent b7dd084 commit bd5a932

1 file changed

Lines changed: 2 additions & 0 deletions

File tree

internal/ai/face/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ Two caveats apply to the benchmark-derived recommendations. The measured centroi
171171
**A face that lies between two people is what merges them, and no detection threshold reaches it.** Such a face is typically larger, sharper and scored at least as confidently as an ordinary one - a size floor keeps it in preference to the rest - so `FACE_CLUSTER_SIZE` and `FACE_CLUSTER_SCORE` select *for* it rather than against it. Two mechanisms act where the leverage is instead, and each covers what the other cannot:
172172

173173
- **`ClusterDist` does not bound how wide a cluster may be.** DBSCAN links a face to a *neighbor*, so a line of bridge faces chains two people into one group whose extent nothing limits. `face.ClusterFits` states the width a cluster may reach and still accept its own members - `ClusterRadius + MatchDist`, because `ClampSampleRadius` stops the stored radius widening past `ClusterRadius` - and `splitWideClusters` re-clusters a group that exceeds it at a shorter link distance, cut by a flat factor rather than one sized to the overrun. A group that already fits is passed through, so a library that does not chain pays nothing; one that stays wide after `ClusterSplitRounds` is reported and **discarded** rather than created - so a gentler cut needs more rounds to reach the same separation, and the two limits have to be read together. `PHOTOPRISM_FACE_CLUSTER_SPLIT_ROUNDS` therefore has three meanings that do not order the way their numbers do: `-1` (`ClusterSplitOff`) keeps a wide group whole, `0` discards every one of them, and a positive budget splits that many times. Its flag is hidden rather than absent, so `photoprism faces status` reports what a run used. This is also the only protection an *anonymous* cluster has: `Face.ResolveCollision` returns early when a cluster has no subject, so a chain that forms, absorbs several identities, and is then named as one of them is never reported by it. Two limits: it bounds *width* rather than purity, so a short chain whose extent stays inside the accept distance still passes; and it runs where a cluster is created, so the migration and merge paths, which build centroids of their own, are not covered by it.
174+
175+
**The width the guard tests is now a percentile of the members rather than a bound on them** (`face.ClusterPercentile`), so "accepts its own members" holds for that share of them and a group carrying a smaller foreign population passes where it previously did not. Measured on the reference corpus the permissive direction is favorable - at `cluster-dist` 0.72 the maximum discards 2,720 of 11,738 grouped markers to prevent 2 markers of contamination - so which of the two the guard should read is an open decision rather than a defect to revert. Nothing yet pins split behavior at one percentile against another.
174176
- **`selectBestFace` refuses a coin toss.** Once the clusters are separate, a bridge face is admitted by both and given to whichever is marginally closer; `Face.UpdateMatchStats` then widens that cluster toward the other, so the mistake compounds instead of staying local. `FACE_MATCH_MARGIN` is how much the nearest has to beat the runner-up by, and a marker inside that margin is left unassigned - which is recoverable, where a wrong assignment is not. **Only two clusters naming two different people count as a contest.** A second cluster of the same subject is exempt, since a person may own several and either answer names the same face. A cluster carrying no name is exempt too, in either direction: one close enough to contend is the same person in another group rather than a rival, and counting it made naming counterproductive - every cluster an operator names raises a boundary against every unnamed one, so the recognition count *fell* with each pass over a fragmented library. The exemption lifts for an *anchored* marker whose winner has no name, where the toss mints an identity rather than picking a cluster: `Marker.SetFace` lets an anonymous cluster adopt the marker's subject and `Face.SetSubjectUID` then spreads it over every automatic marker of that cluster. Every contender inside the margin is weighed rather than the runner-up alone, or two of one subject's clusters would fill both places and hide a third holding someone else.
175177

176178
#### Quality & Overlap Thresholds

0 commit comments

Comments
 (0)