@@ -23,6 +23,8 @@ final class FaceClusterAnalyzer {
2323 public const DIMENSIONS = 128 ;
2424 public const MAX_OVERLAP_NEW_CLUSTER = 0.1 ;
2525 public const MIN_OVERLAP_EXISTING_CLUSTER = 0.5 ;
26+ public const REFERENCE_SAMPLE_BUDGET_SHARE = 0.5 ;
27+ public const MIN_REFERENCE_SAMPLE_SIZE = 2 ;
2628
2729 private FaceDetectionMapper $ faceDetections ;
2830 private FaceClusterMapper $ faceClusters ;
@@ -57,15 +59,25 @@ public function calculateClusters(string $userId, int $batchSize = 0): void {
5759 $ existingClusters = $ this ->faceClusters ->findByUserId ($ userId );
5860 /** @var array<int,int> $maxVotesByCluster */
5961 $ maxVotesByCluster = [];
62+ $ referenceSampleSize = $ this ->getReferenceSampleSize (count ($ existingClusters ), $ batchSize );
6063 foreach ($ existingClusters as $ existingCluster ) {
61- $ sampled = $ this ->faceDetections ->findClusterSample ($ existingCluster ->getId (), $ this -> getReferenceSampleSize ( count ( $ existingClusters )) );
64+ $ sampled = $ this ->faceDetections ->findClusterSample ($ existingCluster ->getId (), $ referenceSampleSize );
6265 $ sampledDetections = array_merge ($ sampledDetections , $ sampled );
6366 $ maxVotesByCluster [$ existingCluster ->getId ()] = count ($ sampled );
6467 }
6568
69+ // Every existing cluster must be represented, otherwise its faces would be clustered
70+ // into a duplicate cluster. If that alone exceeds the batch budget, we cannot honour it.
71+ if ($ batchSize > 0 && count ($ sampledDetections ) > $ batchSize ) {
72+ $ this ->logger ->warning ('ClusterDebug: The batch size of ' . $ batchSize . ' detections is too small for the ' . count ($ existingClusters ) . ' existing clusters of user ' . $ userId . '; loading ' . count ($ sampledDetections ) . ' reference detections instead. Consider raising the PHP memory limit. ' );
73+ }
74+
6675 if ($ batchSize > 0 ) {
6776 $ rejectedDetections = $ this ->faceDetections ->sampleRejectedDetectionsByUserId ($ userId , $ this ->getRejectSampleSize ($ batchSize ), self ::MIN_DETECTION_SIZE , self ::MIN_DETECTION_SIZE );
68- $ requestedFreshDetectionCount = max ($ batchSize - count ($ rejectedDetections ) - count ($ sampledDetections ), 500 );
77+ // Guarantee forward progress even when samples and rejects have eaten the whole
78+ // budget, but keep the floor relative so a small batch size stays a small batch.
79+ $ freshDetectionFloor = min (500 , (int )round ($ batchSize * (1.0 - self ::REFERENCE_SAMPLE_BUDGET_SHARE )));
80+ $ requestedFreshDetectionCount = max ($ batchSize - count ($ rejectedDetections ) - count ($ sampledDetections ), $ freshDetectionFloor );
6981 $ freshDetections = $ this ->faceDetections ->findUnclusteredByUserId ($ userId , $ requestedFreshDetectionCount , self ::MIN_DETECTION_SIZE , self ::MIN_DETECTION_SIZE );
7082 } else {
7183 $ freshDetections = $ this ->faceDetections ->findUnclusteredByUserId ($ userId , 0 , self ::MIN_DETECTION_SIZE , self ::MIN_DETECTION_SIZE );
@@ -266,12 +278,24 @@ private function getMinSampleSize(int $batchSize) : int {
266278
267279 /**
268280 * Grows to ~5000 detections for ~200-800 clusters (detections per cluster drop exponentially)
269- * and then grows linearly with 5 detections per cluster
281+ * and then grows linearly with 5 detections per cluster.
282+ *
283+ * Capped so that all reference samples together stay within their share of the batch
284+ * budget: without that cap the ~5000+ sampled detections dwarf a batch size derived
285+ * from a low memory limit, and the budget would bound nothing.
270286 * @param int $numberClusters
287+ * @param int $batchSize 0 for an unbounded run
271288 * @return int
272289 */
273- private function getReferenceSampleSize (int $ numberClusters ) : int {
274- return (int )round (75.0 * 2.0 ** (-0.007 * $ numberClusters ) + 5.0 );
290+ private function getReferenceSampleSize (int $ numberClusters , int $ batchSize = 0 ) : int {
291+ $ sampleSize = (int )round (75.0 * 2.0 ** (-0.007 * $ numberClusters ) + 5.0 );
292+
293+ if ($ batchSize <= 0 || $ numberClusters === 0 ) {
294+ return $ sampleSize ;
295+ }
296+
297+ $ sizePerClusterBudget = (int )floor ($ batchSize * self ::REFERENCE_SAMPLE_BUDGET_SHARE / $ numberClusters );
298+ return max (self ::MIN_REFERENCE_SAMPLE_SIZE , min ($ sampleSize , $ sizePerClusterBudget ));
275299 }
276300
277301 private function getRejectSampleSize (int $ batchSize ): int {
0 commit comments