Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion backend/app/config/settings.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from __future__ import annotations

import logging
import os
import sys

from platformdirs import user_data_dir

logger = logging.getLogger(__name__)


if getattr(sys, "frozen", False):
MODEL_EXPORTS_PATH = os.path.join(user_data_dir("PictoPy"), "models")
else:
Expand Down Expand Up @@ -35,3 +40,99 @@
DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db")
THUMBNAIL_IMAGES_PATH = os.path.join(user_data_dir("PictoPy"), "thumbnails")
IMAGES_PATH = "./images"


def _get_env_float(
name: str,
default: float,
min_value: float | None = None,
max_value: float | None = None,
) -> float:
raw = os.getenv(name)
if raw is None:
return default
try:
value = float(raw)
except ValueError:
logger.warning(
"Invalid value %r for %s (expected float); using default %s",
raw,
name,
default,
)
return default
if (min_value is not None and value < min_value) or (
max_value is not None and value > max_value
):
logger.warning(
"Out-of-range value %s for %s (expected [%s, %s]); using default %s",
value,
name,
min_value,
max_value,
default,
)
return default
return value


def _get_env_int(
name: str,
default: int,
min_value: int | None = None,
max_value: int | None = None,
) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError:
logger.warning(
"Invalid value %r for %s (expected int); using default %s",
raw,
name,
default,
)
return default
if (min_value is not None and value < min_value) or (
max_value is not None and value > max_value
):
logger.warning(
"Out-of-range value %s for %s (expected [%s, %s]); using default %s",
value,
name,
min_value,
max_value,
default,
)
return default
return value


# Clustering Configuration
PICTO_CLUSTERING_EPS = _get_env_float("PICTO_CLUSTERING_EPS", 0.75, min_value=0.0)
PICTO_CLUSTERING_MIN_SAMPLES = _get_env_int(
"PICTO_CLUSTERING_MIN_SAMPLES", 2, min_value=1
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if PICTO_CLUSTERING_MIN_SAMPLES < 2:
logger.warning(
f"PICTO_CLUSTERING_MIN_SAMPLES={PICTO_CLUSTERING_MIN_SAMPLES} is invalid "
f"(minimum is 2). Resetting to 2 to prevent cluster chaining."
)
PICTO_CLUSTERING_MIN_SAMPLES = 2
PICTO_CLUSTERING_SIMILARITY_THRESHOLD = _get_env_float(
"PICTO_CLUSTERING_SIMILARITY_THRESHOLD", 0.85, min_value=0.0, max_value=1.0
)
PICTO_CLUSTERING_MERGE_THRESHOLD = _get_env_float(
"PICTO_CLUSTERING_MERGE_THRESHOLD", 0.7, min_value=0.0, max_value=1.0
)
PICTO_CLUSTERING_CONF_THRESHOLD = _get_env_float(
"PICTO_CLUSTERING_CONF_THRESHOLD", 0.45, min_value=0.0, max_value=1.0
)
PICTO_CLUSTERING_BLUR_THRESHOLD = _get_env_float(
"PICTO_CLUSTERING_BLUR_THRESHOLD", 80.0, min_value=0.0
)
PICTO_CLUSTERING_MIN_FACE_SIZE = _get_env_int(
"PICTO_CLUSTERING_MIN_FACE_SIZE", 1600, min_value=1
)
51 changes: 35 additions & 16 deletions backend/app/models/FaceDetector.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
from app.models.YOLO import YOLO
from app.database.faces import db_insert_face_embeddings_by_image_id
from app.logging.setup_logging import get_logger
from app.config.settings import (
PICTO_CLUSTERING_CONF_THRESHOLD,
PICTO_CLUSTERING_BLUR_THRESHOLD,
PICTO_CLUSTERING_MIN_FACE_SIZE,
)
from app.utils.face_quality import face_passes_quality_gate

# Initialize logger
logger = get_logger(__name__)
Expand All @@ -16,7 +22,7 @@ class FaceDetector:
def __init__(self):
self.yolo_detector = YOLO(
YOLO_util_get_model_path("face"),
conf_threshold=0.45,
conf_threshold=PICTO_CLUSTERING_CONF_THRESHOLD,
iou_threshold=0.45,
)
self.facenet = FaceNet(FaceNet_util_get_model_path())
Expand All @@ -34,26 +40,38 @@ def detect_faces(self, image_id: str, image_path: str, forSearch: bool = False):
logger.info(f"Detected {len(boxes)} faces in image {image_id}.")

processed_faces, embeddings, bboxes, confidences = [], [], [], []
faces_skipped = 0

for box, score in zip(boxes, scores):
if score > self.yolo_detector.conf_threshold:
x1, y1, x2, y2 = map(int, box)
x1, y1, x2, y2 = map(int, box)

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

padding = 20
face_img = img[
max(0, y1 - padding) : min(img.shape[0], y2 + padding),
max(0, x1 - padding) : min(img.shape[1], x2 + padding),
]
processed_face = FaceNet_util_preprocess_image(face_img)
processed_faces.append(processed_face)
if not face_passes_quality_gate(
face_crop=face_img,
bbox=(x1, y1, x2, y2),
conf_score=float(score),
conf_threshold=self.yolo_detector.conf_threshold,
blur_threshold=PICTO_CLUSTERING_BLUR_THRESHOLD,
min_face_size=PICTO_CLUSTERING_MIN_FACE_SIZE,
):
faces_skipped += 1
continue

embedding = self.facenet.get_embedding(processed_face)
embeddings.append(embedding)
# Create bounding box dictionary in JSON format
bbox = {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1}
bboxes.append(bbox)
confidences.append(float(score))

processed_face = FaceNet_util_preprocess_image(face_img)
processed_faces.append(processed_face)

embedding = self.facenet.get_embedding(processed_face)
embeddings.append(embedding)

if not forSearch and embeddings:
db_insert_face_embeddings_by_image_id(
Expand All @@ -64,6 +82,7 @@ def detect_faces(self, image_id: str, image_path: str, forSearch: bool = False):
"ids": f"{class_ids}",
"processed_faces": processed_faces,
"num_faces": len(embeddings),
"faces_skipped": faces_skipped,
}

def close(self):
Expand Down
18 changes: 11 additions & 7 deletions backend/app/routes/face_clusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
db_get_cluster_by_id,
db_update_cluster,
db_get_all_clusters_with_face_counts,
db_get_images_by_cluster_id, # Add this import
db_get_images_by_cluster_id,
)
from app.utils.face_clusters import cluster_util_face_clusters_sync
from app.schemas.face_clusters import (
RenameClusterRequest,
RenameClusterResponse,
Expand Down Expand Up @@ -313,24 +314,27 @@ def trigger_global_reclustering():
try:
logger.info("Starting manual global face reclustering...")

# Use the smart clustering function with force flag set to True
from app.utils.face_clusters import cluster_util_face_clusters_sync

result = cluster_util_face_clusters_sync(force_full_reclustering=True)
result, total_faces_skipped = cluster_util_face_clusters_sync(
force_full_reclustering=True
)

if result == 0:
return GlobalReclusterResponse(
success=True,
message="No faces found to cluster",
data=GlobalReclusterData(clusters_created=0),
data=GlobalReclusterData(
clusters_created=0, faces_skipped=total_faces_skipped
),
)

logger.info("Global reclustering completed successfully")

return GlobalReclusterResponse(
success=True,
message="Global reclustering completed successfully.",
data=GlobalReclusterData(clusters_created=result),
data=GlobalReclusterData(
clusters_created=result, faces_skipped=total_faces_skipped
),
)

except Exception as e:
Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas/face_clusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class GetClusterImagesResponse(BaseModel):

class GlobalReclusterData(BaseModel):
clusters_created: Optional[int] = None
faces_skipped: Optional[int] = None


class GlobalReclusterResponse(BaseModel):
Expand Down
Loading
Loading