Skip to content
5 changes: 3 additions & 2 deletions backend/app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,10 @@ def _get_env_int(
PICTO_CLUSTERING_CONF_THRESHOLD = _get_env_float(
"PICTO_CLUSTERING_CONF_THRESHOLD", 0.45, min_value=0.0, max_value=1.0
)
# Laplacian variance is low (~40-75) on clean studio portraits, so keep this permissive.
PICTO_CLUSTERING_BLUR_THRESHOLD = _get_env_float(
"PICTO_CLUSTERING_BLUR_THRESHOLD", 80.0, min_value=0.0
"PICTO_CLUSTERING_BLUR_THRESHOLD", 20.0, min_value=0.0
)
PICTO_CLUSTERING_MIN_FACE_SIZE = _get_env_int(
"PICTO_CLUSTERING_MIN_FACE_SIZE", 1600, min_value=1
"PICTO_CLUSTERING_MIN_FACE_SIZE", 1000, min_value=1
)
28 changes: 21 additions & 7 deletions backend/app/database/face_clusters.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import math
import sqlite3
from typing import Optional, List, Dict, TypedDict, Union
from app.config.settings import DATABASE_PATH
Expand Down Expand Up @@ -241,6 +242,9 @@ def db_get_all_clusters_with_face_counts() -> (
):
"""
Retrieve all clusters with their face counts and stored face images.
Ordered by prominence: avg detection confidence weighted by log of face count,
so frequently-photographed people (likely the device owner) rank first
without letting large low-quality clusters outrank clean ones.

Returns:
List of dictionaries containing cluster_id, cluster_name, face_count, and face_image_base64
Expand All @@ -251,32 +255,36 @@ def db_get_all_clusters_with_face_counts() -> (
try:
cursor.execute(
"""
SELECT
fc.cluster_id,
fc.cluster_name,
SELECT
fc.cluster_id,
fc.cluster_name,
COUNT(f.face_id) as face_count,
fc.face_image_base64
fc.face_image_base64,
COALESCE(AVG(f.confidence), 0) as avg_confidence
FROM face_clusters fc
LEFT JOIN faces f ON fc.cluster_id = f.cluster_id
GROUP BY fc.cluster_id, fc.cluster_name, fc.face_image_base64
ORDER BY fc.cluster_id
"""
)

rows = cursor.fetchall()

clusters = []
for row in rows:
cluster_id, cluster_name, face_count, face_image_base64 = row
cluster_id, cluster_name, face_count, face_image_base64, avg_conf = row
clusters.append(
{
"cluster_id": cluster_id,
"cluster_name": cluster_name,
"face_count": face_count,
"face_image_base64": face_image_base64,
"_score": avg_conf * math.log2(1 + face_count),
}
)

clusters.sort(key=lambda c: c["_score"], reverse=True)
for cluster in clusters:
del cluster["_score"]
return clusters
finally:
conn.close()
Expand Down Expand Up @@ -311,13 +319,15 @@ def db_get_images_by_cluster_id(
FROM images i
INNER JOIN faces f ON i.id = f.image_id
WHERE f.cluster_id = ?
ORDER BY i.path
ORDER BY i.path, f.confidence DESC
""",
(cluster_id,),
)

rows = cursor.fetchall()

# One row per image: keep only the highest-confidence face
seen_image_ids = set()
images = []
for row in rows:
(
Expand All @@ -330,6 +340,10 @@ def db_get_images_by_cluster_id(
bbox_json,
) = row

if image_id in seen_image_ids:
continue
seen_image_ids.add(image_id)

import json

metadata_dict = json.loads(metadata) if metadata else None
Expand Down
27 changes: 22 additions & 5 deletions backend/app/database/faces.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,16 +230,20 @@ def db_get_faces_unassigned_clusters() -> List[Dict[str, Union[FaceId, FaceEmbed
cursor = conn.cursor()

try:
cursor.execute("SELECT face_id, embeddings FROM faces WHERE cluster_id IS NULL")
cursor.execute(
"SELECT face_id, image_id, embeddings FROM faces WHERE cluster_id IS NULL"
)

rows = cursor.fetchall()

faces = []
for row in rows:
face_id, embeddings_json = row
face_id, image_id, embeddings_json = row
# Convert JSON string back to numpy array
embeddings = np.array(json.loads(embeddings_json))
faces.append({"face_id": face_id, "embeddings": embeddings})
faces.append(
{"face_id": face_id, "image_id": image_id, "embeddings": embeddings}
)

return faces
finally:
Expand All @@ -261,7 +265,7 @@ def db_get_all_faces_with_cluster_names() -> (
try:
cursor.execute(
"""
SELECT f.face_id, f.embeddings, fc.cluster_name
SELECT f.face_id, f.image_id, f.embeddings, fc.cluster_name
FROM faces f
LEFT JOIN face_clusters fc ON f.cluster_id = fc.cluster_id
ORDER BY f.face_id
Expand All @@ -272,12 +276,13 @@ def db_get_all_faces_with_cluster_names() -> (

faces = []
for row in rows:
face_id, embeddings_json, cluster_name = row
face_id, image_id, embeddings_json, cluster_name = row
# Convert JSON string back to numpy array
embeddings = np.array(json.loads(embeddings_json))
faces.append(
{
"face_id": face_id,
"image_id": image_id,
"embeddings": embeddings,
"cluster_name": cluster_name,
}
Expand Down Expand Up @@ -352,6 +357,18 @@ def db_update_face_cluster_ids_batch(
conn.close()


def db_get_cluster_image_pairs() -> set:
"""Distinct (cluster_id, image_id) pairs for all cluster-assigned faces."""
conn = sqlite3.connect(DATABASE_PATH)
try:
rows = conn.execute(
"SELECT DISTINCT cluster_id, image_id FROM faces WHERE cluster_id IS NOT NULL"
).fetchall()
return set(rows)
finally:
conn.close()


def db_get_cluster_mean_embeddings() -> List[Dict[str, Union[str, FaceEmbedding]]]:
"""
Get cluster IDs and their corresponding mean face embeddings.
Expand Down
32 changes: 23 additions & 9 deletions backend/app/models/YOLO.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,22 @@ def get_output_details(self):
def prepare_input(self, image):
self.img_height, self.img_width = image.shape[:2]
input_img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
input_img = cv2.resize(input_img, (self.input_width, self.input_height))
input_img = input_img / 255.0
# Letterbox: resize preserving aspect ratio, pad the rest with gray
self.scale = min(
self.input_width / self.img_width, self.input_height / self.img_height
)
new_w = round(self.img_width * self.scale)
new_h = round(self.img_height * self.scale)
self.pad_x = (self.input_width - new_w) // 2
self.pad_y = (self.input_height - new_h) // 2
resized = cv2.resize(input_img, (new_w, new_h))
padded = np.full(
(self.input_height, self.input_width, 3), 114, dtype=input_img.dtype
)
padded[self.pad_y : self.pad_y + new_h, self.pad_x : self.pad_x + new_w] = (
resized
)
input_img = padded / 255.0
input_img = input_img.transpose(2, 0, 1)
input_tensor = input_img[np.newaxis, :, :, :].astype(np.float32)
return input_tensor
Expand All @@ -145,16 +159,16 @@ def extract_boxes(self, predictions):
boxes = predictions[:, :4]
boxes = self.rescale_boxes(boxes)
boxes = YOLO_util_xywh2xyxy(boxes)
boxes[:, [0, 2]] = boxes[:, [0, 2]].clip(0, self.img_width)
boxes[:, [1, 3]] = boxes[:, [1, 3]].clip(0, self.img_height)
return boxes

def rescale_boxes(self, boxes):
input_shape = np.array(
[self.input_width, self.input_height, self.input_width, self.input_height]
)
boxes = np.divide(boxes, input_shape, dtype=np.float32)
boxes *= np.array(
[self.img_width, self.img_height, self.img_width, self.img_height]
)
# Undo the letterbox: remove padding offset, then unscale (boxes are xywh)
boxes = boxes.astype(np.float32).copy()
boxes[:, 0] = (boxes[:, 0] - self.pad_x) / self.scale
boxes[:, 1] = (boxes[:, 1] - self.pad_y) / self.scale
boxes[:, 2:4] /= self.scale
return boxes

def draw_detections(self, image, draw_scores=True, mask_alpha=0.4):
Expand Down
71 changes: 70 additions & 1 deletion backend/app/utils/face_clusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
db_update_face_cluster_ids_batch,
db_get_faces_unassigned_clusters,
db_get_cluster_mean_embeddings,
db_get_cluster_image_pairs,
)
from app.database.face_clusters import db_delete_all_clusters, db_insert_clusters_batch
from app.database.metadata import (
Expand Down Expand Up @@ -230,11 +231,12 @@ def cluster_util_cluster_all_face_embeddings(
faces_data = db_get_all_faces_with_cluster_names()

if not faces_data:
return []
return [], 0

# Extract embeddings and face IDs with validation
embeddings = []
face_ids = []
image_ids = []
existing_cluster_names = []
invalid_count = 0

Expand All @@ -244,6 +246,7 @@ def cluster_util_cluster_all_face_embeddings(
# Validate embedding before adding
if _validate_embedding(embedding):
face_ids.append(face["face_id"])
image_ids.append(face.get("image_id"))
embeddings.append(embedding)
existing_cluster_names.append(face["cluster_name"])
else:
Expand Down Expand Up @@ -360,6 +363,10 @@ def cluster_util_cluster_all_face_embeddings(
results, merge_threshold=effective_merge_threshold
)

# Cannot-link constraint: faces co-occurring in one photo are different people
face_to_image = dict(zip(face_ids, image_ids))
results = _enforce_one_face_per_image(results, face_to_image)

return results, total_faces_skipped


Expand Down Expand Up @@ -422,6 +429,9 @@ def cluster_util_assign_cluster_to_faces_without_clusterId(

mean_embeddings_array = np.array(mean_embeddings)

# (cluster_id, image_id) pairs already taken; a photo's faces are distinct people
occupied_pairs = db_get_cluster_image_pairs()

# Prepare batch update data
face_cluster_mappings = []
skipped_invalid = 0
Expand Down Expand Up @@ -454,9 +464,18 @@ def cluster_util_assign_cluster_to_faces_without_clusterId(
nearest_cluster_idx = np.argmin(distances)
nearest_cluster_id = cluster_ids[nearest_cluster_idx]

image_id = face.get("image_id")
if (
image_id is not None
and (nearest_cluster_id, image_id) in occupied_pairs
):
continue

face_cluster_mappings.append(
{"face_id": face_id, "cluster_id": nearest_cluster_id}
)
if image_id is not None:
occupied_pairs.add((nearest_cluster_id, image_id))

if skipped_invalid > 0:
logger.warning(
Expand All @@ -468,6 +487,56 @@ def cluster_util_assign_cluster_to_faces_without_clusterId(
return face_cluster_mappings, total_faces_skipped


def _enforce_one_face_per_image(
results: List[ClusterResult], face_to_image: Dict[int, Optional[str]]
) -> List[ClusterResult]:
"""
Keep at most one face per image in each cluster: co-occurring faces belong
to different people. The face closest to the cluster centroid wins; the
rest are dropped from results (left unclustered).
"""
by_cluster = defaultdict(list)
for result in results:
by_cluster[result.cluster_uuid].append(result)

kept = []
dropped = 0
for cluster_results in by_cluster.values():
centroid = np.mean([r.embedding for r in cluster_results], axis=0)
centroid_norm = np.linalg.norm(centroid)

best_per_image: Dict[str, Tuple[float, ClusterResult]] = {}
for result in cluster_results:
image_id = face_to_image.get(result.face_id)
if image_id is None:
kept.append(result)
continue

emb_norm = np.linalg.norm(result.embedding)
if centroid_norm < 1e-6 or emb_norm < 1e-6:
similarity = 0.0
else:
similarity = float(
np.dot(result.embedding, centroid) / (emb_norm * centroid_norm)
)

prev = best_per_image.get(image_id)
if prev is None:
best_per_image[image_id] = (similarity, result)
else:
dropped += 1
if similarity > prev[0]:
best_per_image[image_id] = (similarity, result)

kept.extend(result for _, result in best_per_image.values())

if dropped:
logger.info(
f"Cannot-link constraint: dropped {dropped} same-image duplicate face(s) from clusters"
)
return kept


def _merge_similar_clusters(
results: List[ClusterResult], merge_threshold: float = 0.85
) -> List[ClusterResult]:
Expand Down
2 changes: 1 addition & 1 deletion backend/app/utils/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ def image_util_classify_and_face_detect_images(
db_insert_image_classes_batch(image_class_pairs)

# Step 3: Detect faces if "person" class is present
if classes and 0 in classes and 0 < classes.count(0) < 7:
if classes and 0 in classes:
result = face_detector.detect_faces(image_id, image_path)
if result:
total_faces_skipped += result.get("faces_skipped", 0)
Expand Down
7 changes: 4 additions & 3 deletions backend/scripts/reset_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ def delete_db_files():
print("No database files were found or deleted.")

if has_errors:
print("Error: Database reset failed because some files could not be deleted.", file=sys.stderr)
print(
"Error: Database reset failed because some files could not be deleted.",
file=sys.stderr,
)
sys.exit(1)


if __name__ == "__main__":
delete_db_files()


Loading