Skip to content
Merged
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
5 changes: 0 additions & 5 deletions .github/ISSUE_TEMPLATE/bug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,3 @@ body:
required: true
- label: "Do Your changes passes all tests?"
required: false





1 change: 0 additions & 1 deletion .github/ISSUE_TEMPLATE/feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,3 @@ body:
required: true
- label: "Does it contain any style related issues?"
required: false

22 changes: 11 additions & 11 deletions .github/release-drafter-config.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
name-template: PictoPy v$RESOLVED_VERSION
tag-template: 'v$RESOLVED_VERSION'
tag-template: "v$RESOLVED_VERSION"

categories:
- title: 'Features:'
- title: "Features:"
labels:
- 'UI'
- 'enhancement'
- "UI"
- "enhancement"

- title: 'Bug Fixes:'
- title: "Bug Fixes:"
labels:
- 'bug'
- "bug"

- title: 'Documentation:'
- title: "Documentation:"
labels:
- 'documentation'
- "documentation"

- title: 'Others:'
- title: "Others:"
labels: []

change-template: '- $TITLE (#$NUMBER) by @$AUTHOR'
change-template: "- $TITLE (#$NUMBER) by @$AUTHOR"

template: |
# What's Changed
Expand All @@ -27,4 +27,4 @@ template: |

## Special thanks to all our contributors:

$CONTRIBUTORS
$CONTRIBUTORS
31 changes: 0 additions & 31 deletions .github/workflows/update-project-structure.yml

This file was deleted.

3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Logs
logs
.env
*.log
npm-debug.log*
yarn-debug.log*
Expand Down Expand Up @@ -29,4 +30,4 @@ videos_cache.txt
images_cache.txt
videos_cache.txt
venv/
frontend/dist
frontend/dist
1 change: 1 addition & 0 deletions backend/app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Microservice URLs
SYNC_MICROSERVICE_URL = "http://localhost:8001/api/v1"

CONFIDENCE_PERCENT = 0.6
# Object Detection Models:
SMALL_OBJ_DETECTION_MODEL = f"{MODEL_EXPORTS_PATH}/YOLOv11_Small.onnx"
NANO_OBJ_DETECTION_MODEL = f"{MODEL_EXPORTS_PATH}/YOLOv11_Nano.onnx"
Expand Down
69 changes: 69 additions & 0 deletions backend/app/database/faces.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,75 @@ def db_insert_face_embeddings_by_image_id(
image_id, embeddings, confidence, bbox, cluster_id
)

def get_all_face_embeddings():
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()

try:
cursor.execute("""
SELECT
f.embeddings,
f.bbox,
i.id,
i.path,
i.folder_id,
i.thumbnailPath,
i.metadata,
i.isTagged,
m.name as tag_name
FROM faces f
JOIN images i ON f.image_id=i.id
LEFT JOIN image_classes ic ON i.id = ic.image_id
LEFT JOIN mappings m ON ic.class_id = m.class_id
""")
results = cursor.fetchall()

images_dict = {}
for (
embeddings,
bbox,
image_id,
path,
folder_id,
thumbnail_path,
metadata,
is_tagged,
tag_name,
) in results:
if image_id not in images_dict:
try:
embeddings_json = json.loads(embeddings)
bbox_json = json.loads(bbox)
except json.JSONDecodeError:
continue;
images_dict[image_id] = {
"embeddings": embeddings_json,
"bbox": bbox_json,
"id": image_id,
"path": path,
"folder_id": folder_id,
"thumbnailPath": thumbnail_path,
"metadata": metadata,
"isTagged": bool(is_tagged),
"tags": [],
}

# Add tag if it exists
if tag_name:
images_dict[image_id]["tags"].append(tag_name)

# Convert to list and set tags to None if empty
images = []
for image_data in images_dict.values():
if not image_data["tags"]:
image_data["tags"] = None
images.append(image_data)

# Sort by path
images.sort(key=lambda x: x["path"])
return images
finally:
conn.close()

Comment on lines +136 to 205
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Embeddings aggregation bug: only first face per image kept; duplicates possible due to tag joins; bbox/JSON None handling.

  • Current logic initializes per-image data once and ignores subsequent faces, dropping embeddings/bboxes for additional faces.
  • LEFT JOIN with tags duplicates face rows; without face_id you’ll append duplicates if you start aggregating.
  • json.loads(bbox) will raise on NULL (TypeError), not caught.
  • Output key is bbox (singular) whereas frontend types expect bboxes?: [...].

Apply this refactor to correctly aggregate and dedupe faces per image and handle NULLs:

 def get_all_face_embeddings():
     conn = sqlite3.connect(DATABASE_PATH)
     cursor = conn.cursor()

     try:
-        cursor.execute("""
-            SELECT
-                f.embeddings,
-                f.bbox,
-                i.id, 
-                i.path, 
-                i.folder_id, 
-                i.thumbnailPath, 
-                i.metadata, 
-                i.isTagged,
-                m.name as tag_name
-            FROM faces f
-            JOIN images i ON f.image_id=i.id
-            LEFT JOIN image_classes ic ON i.id = ic.image_id
-            LEFT JOIN mappings m ON ic.class_id = m.class_id
-        """)
+        cursor.execute("""
+            SELECT
+                f.face_id,
+                f.embeddings,
+                f.bbox,
+                i.id,
+                i.path,
+                i.folder_id,
+                i.thumbnailPath,
+                i.metadata,
+                i.isTagged,
+                m.name as tag_name
+            FROM faces f
+            JOIN images i ON f.image_id = i.id
+            LEFT JOIN image_classes ic ON i.id = ic.image_id
+            LEFT JOIN mappings m ON ic.class_id = m.class_id
+        """)
         results = cursor.fetchall()

-        images_dict = {}
-        for (
-            embeddings,
-            bbox,
-            image_id,
-            path,
-            folder_id,
-            thumbnail_path,
-            metadata,
-            is_tagged,
-            tag_name,
-        ) in results:
-            if image_id not in images_dict:
-                try:
-                    embeddings_json = json.loads(embeddings)
-                    bbox_json = json.loads(bbox)
-                except json.JSONDecodeError:
-                    continue;
-                images_dict[image_id] = {
-                    "embeddings": embeddings_json,
-                    "bbox": bbox_json,
-                    "id": image_id,
-                    "path": path,
-                    "folder_id": folder_id,
-                    "thumbnailPath": thumbnail_path,
-                    "metadata": metadata,
-                    "isTagged": bool(is_tagged),
-                    "tags": [],
-                }
-
-            # Add tag if it exists
-            if tag_name:
-                images_dict[image_id]["tags"].append(tag_name)
+        images_dict = {}
+        seen_faces = {}  # image_id -> set(face_id)
+        for (
+            face_id,
+            embeddings_json_text,
+            bbox_json_text,
+            image_id,
+            path,
+            folder_id,
+            thumbnail_path,
+            metadata,
+            is_tagged,
+            tag_name,
+        ) in results:
+            if image_id not in images_dict:
+                images_dict[image_id] = {
+                    "embeddings": [],   # list[list[float]]
+                    "bboxes": [],       # list[dict]
+                    "id": image_id,
+                    "path": path,
+                    "folder_id": folder_id,
+                    "thumbnailPath": thumbnail_path,
+                    "metadata": metadata,
+                    "isTagged": bool(is_tagged),
+                    "tags": set(),
+                }
+                seen_faces[image_id] = set()
+
+            # Deduplicate the same face across tag join rows
+            if face_id in seen_faces[image_id]:
+                if tag_name:
+                    images_dict[image_id]["tags"].add(tag_name)
+                continue
+
+            # Parse embedding vector
+            try:
+                emb_vec = json.loads(embeddings_json_text)
+            except (json.JSONDecodeError, TypeError):
+                continue
+            images_dict[image_id]["embeddings"].append(emb_vec)
+
+            # Parse bbox if present
+            if bbox_json_text:
+                try:
+                    bbox_obj = json.loads(bbox_json_text)
+                except (json.JSONDecodeError, TypeError):
+                    bbox_obj = None
+            else:
+                bbox_obj = None
+            images_dict[image_id]["bboxes"].append(bbox_obj)
+
+            if tag_name:
+                images_dict[image_id]["tags"].add(tag_name)
+            seen_faces[image_id].add(face_id)

-        # Convert to list and set tags to None if empty
+        # Convert to list and set tags to None if empty
         images = []
         for image_data in images_dict.values():
-            if not image_data["tags"]:
-                image_data["tags"] = None
+            # finalize tags set -> list or None
+            tags_set = image_data.pop("tags", set())
+            image_data["tags"] = list(tags_set) if tags_set else None
             images.append(image_data)

Notes:

  • Exposes bboxes (plural) and a list of embeddings per image.
  • Prevents duplicates from tag join rows.
  • Handles NULL/invalid JSON gracefully.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def get_all_face_embeddings():
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
try:
cursor.execute("""
SELECT
f.embeddings,
f.bbox,
i.id,
i.path,
i.folder_id,
i.thumbnailPath,
i.metadata,
i.isTagged,
m.name as tag_name
FROM faces f
JOIN images i ON f.image_id=i.id
LEFT JOIN image_classes ic ON i.id = ic.image_id
LEFT JOIN mappings m ON ic.class_id = m.class_id
""")
results = cursor.fetchall()
images_dict = {}
for (
embeddings,
bbox,
image_id,
path,
folder_id,
thumbnail_path,
metadata,
is_tagged,
tag_name,
) in results:
if image_id not in images_dict:
try:
embeddings_json = json.loads(embeddings)
bbox_json = json.loads(bbox)
except json.JSONDecodeError:
continue;
images_dict[image_id] = {
"embeddings": embeddings_json,
"bbox": bbox_json,
"id": image_id,
"path": path,
"folder_id": folder_id,
"thumbnailPath": thumbnail_path,
"metadata": metadata,
"isTagged": bool(is_tagged),
"tags": [],
}
# Add tag if it exists
if tag_name:
images_dict[image_id]["tags"].append(tag_name)
# Convert to list and set tags to None if empty
images = []
for image_data in images_dict.values():
if not image_data["tags"]:
image_data["tags"] = None
images.append(image_data)
# Sort by path
images.sort(key=lambda x: x["path"])
return images
finally:
conn.close()
def get_all_face_embeddings():
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
try:
cursor.execute("""
SELECT
f.face_id,
f.embeddings,
f.bbox,
i.id,
i.path,
i.folder_id,
i.thumbnailPath,
i.metadata,
i.isTagged,
m.name as tag_name
FROM faces f
JOIN images i ON f.image_id = i.id
LEFT JOIN image_classes ic ON i.id = ic.image_id
LEFT JOIN mappings m ON ic.class_id = m.class_id
""")
results = cursor.fetchall()
images_dict = {}
seen_faces = {} # image_id -> set(face_id)
for (
face_id,
embeddings_json_text,
bbox_json_text,
image_id,
path,
folder_id,
thumbnail_path,
metadata,
is_tagged,
tag_name,
) in results:
if image_id not in images_dict:
images_dict[image_id] = {
"embeddings": [], # list[list[float]]
"bboxes": [], # list[dict]
"id": image_id,
"path": path,
"folder_id": folder_id,
"thumbnailPath": thumbnail_path,
"metadata": metadata,
"isTagged": bool(is_tagged),
"tags": set(),
}
seen_faces[image_id] = set()
# Deduplicate the same face across tag join rows
if face_id in seen_faces[image_id]:
if tag_name:
images_dict[image_id]["tags"].add(tag_name)
continue
# Parse embedding vector
try:
emb_vec = json.loads(embeddings_json_text)
except (json.JSONDecodeError, TypeError):
continue
images_dict[image_id]["embeddings"].append(emb_vec)
# Parse bbox if present
if bbox_json_text:
try:
bbox_obj = json.loads(bbox_json_text)
except (json.JSONDecodeError, TypeError):
bbox_obj = None
else:
bbox_obj = None
images_dict[image_id]["bboxes"].append(bbox_obj)
if tag_name:
images_dict[image_id]["tags"].add(tag_name)
seen_faces[image_id].add(face_id)
# Convert to list and set tags to None if empty
images = []
for image_data in images_dict.values():
# finalize tags set -> list or None
tags_set = image_data.pop("tags", set())
image_data["tags"] = list(tags_set) if tags_set else None
images.append(image_data)
# Sort by path
images.sort(key=lambda x: x["path"])
return images
finally:
conn.close()
🤖 Prompt for AI Agents
In backend/app/database/faces.py around lines 136–205, the current loop only
initializes one face per image (dropping subsequent faces), can produce
duplicate rows because tags are joined without face_id, treats NULL bbox
incorrectly (json.loads will raise TypeError) and returns key "bbox" when
frontend expects "bboxes". Fix by updating the query to SELECT f.id AS face_id,
f.embeddings, f.bbox, ...; build images_dict keyed by image_id and track seen
face_ids per image to avoid duplicates; for each row parse embeddings and bbox
with a safe helper that returns None on None or on JSONDecodeError/TypeError and
skip only that face element rather than the whole image; append each parsed
embedding to an "embeddings" list and each parsed bbox to a "bboxes" list on the
image entry; dedupe tags when appending and set tags to None (and
bboxes/embeddings to None) if empty; keep isTagged as bool, sort images by path
and return the list with the bboxes plural key.

def db_get_faces_unassigned_clusters() -> List[Dict[str, Union[FaceId, FaceEmbedding]]]:
"""
Expand Down
4 changes: 2 additions & 2 deletions backend/app/models/FaceDetector.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def __init__(self):
self._initialized = True
print("FaceDetector initialized with YOLO and FaceNet models.")

def detect_faces(self, image_id: int, image_path: str):
def detect_faces(self, image_id: str, image_path: str, forSearch: bool = False):
img = cv2.imread(image_path)
if img is None:
print(f"Failed to load image: {image_path}")
Expand Down Expand Up @@ -51,7 +51,7 @@ def detect_faces(self, image_id: int, image_path: str):
embedding = self.facenet.get_embedding(processed_face)
embeddings.append(embedding)

if embeddings:
if (not forSearch and embeddings):
db_insert_face_embeddings_by_image_id(
image_id, embeddings, confidence=confidences, bbox=bboxes
)
Expand Down
85 changes: 85 additions & 0 deletions backend/app/routes/face_clusters.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import logging
import uuid
import os
from typing import Optional, List
from pydantic import BaseModel
from app.config.settings import CONFIDENCE_PERCENT, DEFAULT_FACENET_MODEL
from fastapi import APIRouter, HTTPException, status
from app.database.face_clusters import (
db_get_cluster_by_id,
db_update_cluster,
db_get_all_clusters_with_face_counts,
db_get_images_by_cluster_id, # Add this import
)
from app.database.faces import get_all_face_embeddings
from app.models.FaceDetector import FaceDetector
from app.models.FaceNet import FaceNet
from app.schemas.face_clusters import (
RenameClusterRequest,
RenameClusterResponse,
Expand All @@ -17,8 +26,35 @@
GetClusterImagesData,
ImageInCluster,
)
from app.schemas.images import AddSingleImageRequest
from app.utils.FaceNet import FaceNet_util_cosine_similarity


class BoundingBox(BaseModel):
x: float
y: float
width: float
height: float


class ImageData(BaseModel):
id: str
path: str
folder_id: str
thumbnailPath: str
metadata: str
isTagged: bool
tags: Optional[List[str]] = None
bboxes: BoundingBox


class GetAllImagesResponse(BaseModel):
success: bool
message: str
data: List[ImageData]


logger = logging.getLogger(__name__)
router = APIRouter()


Expand Down Expand Up @@ -194,3 +230,52 @@ def get_cluster_images(cluster_id: str):
message=f"Unable to retrieve images for cluster: {str(e)}",
).model_dump(),
)


@router.post(
"/face-search",
responses={code: {"model": ErrorResponse} for code in [400, 500]},
)
def face_tagging(payload: AddSingleImageRequest):
image_path = payload.path
if not os.path.isfile(image_path):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse(
success=False,
error="Invalid file path",
message="The provided path is not a valid file",
).model_dump(),
)

fd = FaceDetector()
fn = FaceNet(DEFAULT_FACENET_MODEL)
try:
matches = []
image_id = str(uuid.uuid4())
result = fd.detect_faces(image_id, image_path, forSearch=True)
if not result or result["num_faces"] == 0:
return GetAllImagesResponse(success=True, message=f"Successfully retrieved {len(matches)} images", data=[])

process_face = result["processed_faces"][0]
new_embedding = fn.get_embedding(process_face)

images = get_all_face_embeddings()
if len(images) == 0:
return GetAllImagesResponse(success=True, message=f"Successfully retrieved {len(matches)} images", data=[])
else:
for image in images:
max_similarity = 0
similarity = FaceNet_util_cosine_similarity(new_embedding, image["embeddings"])
max_similarity = max(max_similarity, similarity)
if max_similarity >= CONFIDENCE_PERCENT:
matches.append(ImageData(id=image["id"], path=image["path"], folder_id=image["folder_id"], thumbnailPath=image["thumbnailPath"], metadata=image["metadata"], isTagged=image["isTagged"], tags=image["tags"], bboxes=image["bbox"]))

return GetAllImagesResponse(
success=True,
message=f"Successfully retrieved {len(matches)} images",
data=matches,
)
finally:
fd.close()
fn.close()
Loading
Loading