Skip to content

Commit 07a2e78

Browse files
Merge pull request #1290 from rohan-pandeyy/feat/adaptive-density-aware-face-clustering
Face Quality and Adaptive Density-Aware Face Clustering
2 parents 144786b + 188d84f commit 07a2e78

17 files changed

Lines changed: 613 additions & 55 deletions

File tree

backend/app/config/settings.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
from __future__ import annotations
2+
3+
import logging
14
import os
25
import sys
3-
46
from platformdirs import user_data_dir
57

8+
logger = logging.getLogger(__name__)
9+
10+
611
if getattr(sys, "frozen", False):
712
MODEL_EXPORTS_PATH = os.path.join(user_data_dir("PictoPy"), "models")
813
else:
@@ -35,3 +40,99 @@
3540
DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db")
3641
THUMBNAIL_IMAGES_PATH = os.path.join(user_data_dir("PictoPy"), "thumbnails")
3742
IMAGES_PATH = "./images"
43+
44+
45+
def _get_env_float(
46+
name: str,
47+
default: float,
48+
min_value: float | None = None,
49+
max_value: float | None = None,
50+
) -> float:
51+
raw = os.getenv(name)
52+
if raw is None:
53+
return default
54+
try:
55+
value = float(raw)
56+
except ValueError:
57+
logger.warning(
58+
"Invalid value %r for %s (expected float); using default %s",
59+
raw,
60+
name,
61+
default,
62+
)
63+
return default
64+
if (min_value is not None and value < min_value) or (
65+
max_value is not None and value > max_value
66+
):
67+
logger.warning(
68+
"Out-of-range value %s for %s (expected [%s, %s]); using default %s",
69+
value,
70+
name,
71+
min_value,
72+
max_value,
73+
default,
74+
)
75+
return default
76+
return value
77+
78+
79+
def _get_env_int(
80+
name: str,
81+
default: int,
82+
min_value: int | None = None,
83+
max_value: int | None = None,
84+
) -> int:
85+
raw = os.getenv(name)
86+
if raw is None:
87+
return default
88+
try:
89+
value = int(raw)
90+
except ValueError:
91+
logger.warning(
92+
"Invalid value %r for %s (expected int); using default %s",
93+
raw,
94+
name,
95+
default,
96+
)
97+
return default
98+
if (min_value is not None and value < min_value) or (
99+
max_value is not None and value > max_value
100+
):
101+
logger.warning(
102+
"Out-of-range value %s for %s (expected [%s, %s]); using default %s",
103+
value,
104+
name,
105+
min_value,
106+
max_value,
107+
default,
108+
)
109+
return default
110+
return value
111+
112+
113+
# Clustering Configuration
114+
PICTO_CLUSTERING_EPS = _get_env_float("PICTO_CLUSTERING_EPS", 0.75, min_value=0.0)
115+
PICTO_CLUSTERING_MIN_SAMPLES = _get_env_int(
116+
"PICTO_CLUSTERING_MIN_SAMPLES", 2, min_value=1
117+
)
118+
if PICTO_CLUSTERING_MIN_SAMPLES < 2:
119+
logger.warning(
120+
f"PICTO_CLUSTERING_MIN_SAMPLES={PICTO_CLUSTERING_MIN_SAMPLES} is invalid "
121+
f"(minimum is 2). Resetting to 2 to prevent cluster chaining."
122+
)
123+
PICTO_CLUSTERING_MIN_SAMPLES = 2
124+
PICTO_CLUSTERING_SIMILARITY_THRESHOLD = _get_env_float(
125+
"PICTO_CLUSTERING_SIMILARITY_THRESHOLD", 0.85, min_value=0.0, max_value=1.0
126+
)
127+
PICTO_CLUSTERING_MERGE_THRESHOLD = _get_env_float(
128+
"PICTO_CLUSTERING_MERGE_THRESHOLD", 0.7, min_value=0.0, max_value=1.0
129+
)
130+
PICTO_CLUSTERING_CONF_THRESHOLD = _get_env_float(
131+
"PICTO_CLUSTERING_CONF_THRESHOLD", 0.45, min_value=0.0, max_value=1.0
132+
)
133+
PICTO_CLUSTERING_BLUR_THRESHOLD = _get_env_float(
134+
"PICTO_CLUSTERING_BLUR_THRESHOLD", 80.0, min_value=0.0
135+
)
136+
PICTO_CLUSTERING_MIN_FACE_SIZE = _get_env_int(
137+
"PICTO_CLUSTERING_MIN_FACE_SIZE", 1600, min_value=1
138+
)

backend/app/database/images.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,18 +457,22 @@ def db_toggle_image_favourite_status(image_id: str) -> bool:
457457
finally:
458458
conn.close()
459459

460+
460461
def db_get_image_by_id(image_id: str) -> Optional[dict]:
461462
"""
462463
Get a single image by ID with its favorite status.
463464
"""
464465
conn = _connect()
465466
cursor = conn.cursor()
466467
try:
467-
cursor.execute("""
468+
cursor.execute(
469+
"""
468470
SELECT id, path, folder_id, thumbnailPath, metadata, isTagged, isFavourite
469471
FROM images
470472
WHERE id = ?
471-
""", (image_id,))
473+
""",
474+
(image_id,),
475+
)
472476
row = cursor.fetchone()
473477
if not row:
474478
return None
@@ -488,6 +492,7 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]:
488492
finally:
489493
conn.close()
490494

495+
491496
# ============================================================================
492497
# MEMORIES FEATURE - Location and Time-based Queries
493498
# ============================================================================

backend/app/models/FaceDetector.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
from app.models.YOLO import YOLO
88
from app.database.faces import db_insert_face_embeddings_by_image_id
99
from app.logging.setup_logging import get_logger
10+
from app.config.settings import (
11+
PICTO_CLUSTERING_CONF_THRESHOLD,
12+
PICTO_CLUSTERING_BLUR_THRESHOLD,
13+
PICTO_CLUSTERING_MIN_FACE_SIZE,
14+
)
15+
from app.utils.face_quality import face_passes_quality_gate
1016

1117
# Initialize logger
1218
logger = get_logger(__name__)
@@ -16,7 +22,7 @@ class FaceDetector:
1622
def __init__(self):
1723
self.yolo_detector = YOLO(
1824
YOLO_util_get_model_path("face"),
19-
conf_threshold=0.45,
25+
conf_threshold=PICTO_CLUSTERING_CONF_THRESHOLD,
2026
iou_threshold=0.45,
2127
)
2228
self.facenet = FaceNet(FaceNet_util_get_model_path())
@@ -34,26 +40,38 @@ def detect_faces(self, image_id: str, image_path: str, forSearch: bool = False):
3440
logger.info(f"Detected {len(boxes)} faces in image {image_id}.")
3541

3642
processed_faces, embeddings, bboxes, confidences = [], [], [], []
43+
faces_skipped = 0
3744

3845
for box, score in zip(boxes, scores):
39-
if score > self.yolo_detector.conf_threshold:
40-
x1, y1, x2, y2 = map(int, box)
46+
x1, y1, x2, y2 = map(int, box)
4147

42-
# Create bounding box dictionary in JSON format
43-
bbox = {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1}
44-
bboxes.append(bbox)
45-
confidences.append(float(score))
48+
padding = 20
49+
face_img = img[
50+
max(0, y1 - padding) : min(img.shape[0], y2 + padding),
51+
max(0, x1 - padding) : min(img.shape[1], x2 + padding),
52+
]
4653

47-
padding = 20
48-
face_img = img[
49-
max(0, y1 - padding) : min(img.shape[0], y2 + padding),
50-
max(0, x1 - padding) : min(img.shape[1], x2 + padding),
51-
]
52-
processed_face = FaceNet_util_preprocess_image(face_img)
53-
processed_faces.append(processed_face)
54+
if not face_passes_quality_gate(
55+
face_crop=face_img,
56+
bbox=(x1, y1, x2, y2),
57+
conf_score=float(score),
58+
conf_threshold=self.yolo_detector.conf_threshold,
59+
blur_threshold=PICTO_CLUSTERING_BLUR_THRESHOLD,
60+
min_face_size=PICTO_CLUSTERING_MIN_FACE_SIZE,
61+
):
62+
faces_skipped += 1
63+
continue
5464

55-
embedding = self.facenet.get_embedding(processed_face)
56-
embeddings.append(embedding)
65+
# Create bounding box dictionary in JSON format
66+
bbox = {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1}
67+
bboxes.append(bbox)
68+
confidences.append(float(score))
69+
70+
processed_face = FaceNet_util_preprocess_image(face_img)
71+
processed_faces.append(processed_face)
72+
73+
embedding = self.facenet.get_embedding(processed_face)
74+
embeddings.append(embedding)
5775

5876
if not forSearch and embeddings:
5977
db_insert_face_embeddings_by_image_id(
@@ -64,6 +82,7 @@ def detect_faces(self, image_id: str, image_path: str, forSearch: bool = False):
6482
"ids": f"{class_ids}",
6583
"processed_faces": processed_faces,
6684
"num_faces": len(embeddings),
85+
"faces_skipped": faces_skipped,
6786
}
6887

6988
def close(self):

backend/app/routes/face_clusters.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
db_get_cluster_by_id,
1010
db_update_cluster,
1111
db_get_all_clusters_with_face_counts,
12-
db_get_images_by_cluster_id, # Add this import
12+
db_get_images_by_cluster_id,
1313
)
14+
from app.utils.face_clusters import cluster_util_face_clusters_sync
1415
from app.schemas.face_clusters import (
1516
RenameClusterRequest,
1617
RenameClusterResponse,
@@ -313,24 +314,27 @@ def trigger_global_reclustering():
313314
try:
314315
logger.info("Starting manual global face reclustering...")
315316

316-
# Use the smart clustering function with force flag set to True
317-
from app.utils.face_clusters import cluster_util_face_clusters_sync
318-
319-
result = cluster_util_face_clusters_sync(force_full_reclustering=True)
317+
result, total_faces_skipped = cluster_util_face_clusters_sync(
318+
force_full_reclustering=True
319+
)
320320

321321
if result == 0:
322322
return GlobalReclusterResponse(
323323
success=True,
324324
message="No faces found to cluster",
325-
data=GlobalReclusterData(clusters_created=0),
325+
data=GlobalReclusterData(
326+
clusters_created=0, faces_skipped=total_faces_skipped
327+
),
326328
)
327329

328330
logger.info("Global reclustering completed successfully")
329331

330332
return GlobalReclusterResponse(
331333
success=True,
332334
message="Global reclustering completed successfully.",
333-
data=GlobalReclusterData(clusters_created=result),
335+
data=GlobalReclusterData(
336+
clusters_created=result, faces_skipped=total_faces_skipped
337+
),
334338
)
335339

336340
except Exception as e:

backend/app/routes/images.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,15 @@ def toggle_favourite(req: ToggleFavouriteRequest):
106106
success = db_toggle_image_favourite_status(image_id)
107107
if not success:
108108
raise HTTPException(
109-
status_code=status.HTTP_404_NOT_FOUND,
110-
detail="Image not found or failed to toggle"
109+
status_code=status.HTTP_404_NOT_FOUND,
110+
detail="Image not found or failed to toggle",
111111
)
112112
# Fetch updated status to return
113113
image = db_get_image_by_id(image_id)
114114
if not image:
115115
raise HTTPException(
116-
status_code=status.HTTP_404_NOT_FOUND,
117-
detail="Image not found after toggle"
116+
status_code=status.HTTP_404_NOT_FOUND,
117+
detail="Image not found after toggle",
118118
)
119119
return {
120120
"success": True,
@@ -126,10 +126,11 @@ def toggle_favourite(req: ToggleFavouriteRequest):
126126
except Exception as e:
127127
logger.error(f"error in /toggle-favourite route: {e}")
128128
raise HTTPException(
129-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
130-
detail=f"Internal server error: {e}"
129+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
130+
detail=f"Internal server error: {e}",
131131
)
132132

133+
133134
class ImageInfoResponse(BaseModel):
134135
id: str
135136
path: str

backend/app/schemas/face_clusters.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class GetClusterImagesResponse(BaseModel):
7676

7777
class GlobalReclusterData(BaseModel):
7878
clusters_created: Optional[int] = None
79+
faces_skipped: Optional[int] = None
7980

8081

8182
class GlobalReclusterResponse(BaseModel):

0 commit comments

Comments
 (0)