Skip to content

Commit 584f333

Browse files
Merge pull request #1304 from rohan-pandeyy/feat/multi-person-face-search-&-ranking
UI Based Multi-Person Face Image Search with Ranked Results
2 parents f17b5f9 + 8b70391 commit 584f333

14 files changed

Lines changed: 942 additions & 41 deletions

File tree

backend/app/database/face_clusters.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,65 @@ def db_get_images_by_cluster_id(
349349
return images
350350
finally:
351351
conn.close()
352+
353+
354+
def db_get_images_by_face_clusters(
355+
cluster_ids: List[str], # TEXT UUIDs — NOT integers
356+
match_mode: str = "match_any", # "match_any" | "match_all"
357+
) -> List[Dict]:
358+
"""
359+
Return images containing the requested face cluster identities,
360+
ranked by how many of those identities appear in each image.
361+
"""
362+
if not cluster_ids:
363+
return []
364+
365+
placeholders = ", ".join("?" * len(cluster_ids))
366+
params: list = list(cluster_ids)
367+
368+
base_sql = f"""
369+
SELECT
370+
i.id AS image_id,
371+
i.path AS image_path,
372+
i.thumbnailPath AS thumbnail_path,
373+
i.metadata,
374+
COUNT(DISTINCT f.cluster_id) AS match_count
375+
FROM images i
376+
INNER JOIN faces f ON i.id = f.image_id
377+
WHERE f.cluster_id IN ({placeholders})
378+
GROUP BY i.id, i.path, i.thumbnailPath, i.metadata
379+
{{having}}
380+
ORDER BY match_count DESC
381+
"""
382+
383+
if match_mode == "match_all":
384+
having = "HAVING COUNT(DISTINCT f.cluster_id) = ?"
385+
params.append(len(cluster_ids))
386+
else:
387+
having = ""
388+
389+
sql = base_sql.format(having=having)
390+
391+
import json
392+
393+
conn = sqlite3.connect(DATABASE_PATH)
394+
try:
395+
cursor = conn.cursor()
396+
cursor.execute(sql, params)
397+
rows = cursor.fetchall()
398+
results = []
399+
for row in rows:
400+
image_id, image_path, thumbnail_path, metadata_raw, match_count = row
401+
metadata = json.loads(metadata_raw) if metadata_raw else None
402+
results.append(
403+
{
404+
"image_id": image_id,
405+
"image_path": image_path,
406+
"thumbnail_path": thumbnail_path,
407+
"metadata": metadata,
408+
"match_count": match_count,
409+
}
410+
)
411+
return results
412+
finally:
413+
conn.close()

backend/app/routes/face_clusters.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
db_update_cluster,
1111
db_get_all_clusters_with_face_counts,
1212
db_get_images_by_cluster_id,
13+
db_get_images_by_face_clusters,
1314
)
1415
from app.utils.face_clusters import cluster_util_face_clusters_sync
1516
from app.schemas.face_clusters import (
@@ -25,6 +26,10 @@
2526
GetClusterImagesResponse,
2627
GetClusterImagesData,
2728
ImageInCluster,
29+
MultiPersonSearchRequest,
30+
MultiPersonSearchResponse,
31+
MultiPersonSearchData,
32+
MultiPersonSearchImage,
2833
)
2934
from app.schemas.images import FaceSearchRequest, InputType
3035
from app.utils.faceSearch import perform_face_search
@@ -347,3 +352,65 @@ def trigger_global_reclustering():
347352
message=f"Global reclustering failed: {str(e)}",
348353
).model_dump(),
349354
)
355+
356+
357+
@router.post(
358+
"/multi-search",
359+
response_model=MultiPersonSearchResponse,
360+
responses={code: {"model": ErrorResponse} for code in [400, 404, 500]},
361+
)
362+
def search_images_by_multiple_faces(body: MultiPersonSearchRequest):
363+
"""Search for images containing multiple face identities, ranked by match count."""
364+
try:
365+
if not body.cluster_ids:
366+
raise HTTPException(
367+
status_code=status.HTTP_400_BAD_REQUEST,
368+
detail=ErrorResponse(
369+
success=False,
370+
error="Validation Error",
371+
message="cluster_ids cannot be empty.",
372+
).model_dump(),
373+
)
374+
if body.match_mode not in ("match_any", "match_all"):
375+
raise HTTPException(
376+
status_code=status.HTTP_400_BAD_REQUEST,
377+
detail=ErrorResponse(
378+
success=False,
379+
error="Validation Error",
380+
message="match_mode must be 'match_any' or 'match_all'.",
381+
).model_dump(),
382+
)
383+
384+
rows = db_get_images_by_face_clusters(body.cluster_ids, body.match_mode)
385+
386+
images = [
387+
MultiPersonSearchImage(
388+
id=row["image_id"],
389+
path=row["image_path"],
390+
thumbnailPath=row["thumbnail_path"],
391+
metadata=row["metadata"],
392+
match_count=row["match_count"],
393+
)
394+
for row in rows
395+
]
396+
397+
return MultiPersonSearchResponse(
398+
success=True,
399+
message=f"Found {len(images)} image(s) matching the selected people.",
400+
data=MultiPersonSearchData(
401+
images=images,
402+
total=len(images),
403+
match_mode=body.match_mode,
404+
),
405+
)
406+
except HTTPException:
407+
raise
408+
except Exception as e:
409+
raise HTTPException(
410+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
411+
detail=ErrorResponse(
412+
success=False,
413+
error="Internal server error",
414+
message=f"Multi-person search failed: {str(e)}",
415+
).model_dump(),
416+
)

backend/app/schemas/face_clusters.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,29 @@ class GlobalReclusterResponse(BaseModel):
8484
message: Optional[str] = None
8585
error: Optional[str] = None
8686
data: Optional[GlobalReclusterData] = None
87+
88+
89+
class MultiPersonSearchRequest(BaseModel):
90+
cluster_ids: List[str]
91+
match_mode: str = "match_any"
92+
93+
94+
class MultiPersonSearchImage(BaseModel):
95+
id: str
96+
path: str
97+
thumbnailPath: Optional[str] = None
98+
metadata: Optional[Dict[str, Any]] = None
99+
match_count: int
100+
101+
102+
class MultiPersonSearchData(BaseModel):
103+
images: List[MultiPersonSearchImage]
104+
total: int
105+
match_mode: str
106+
107+
108+
class MultiPersonSearchResponse(BaseModel):
109+
success: bool
110+
message: Optional[str] = None
111+
error: Optional[str] = None
112+
data: Optional[MultiPersonSearchData] = None

0 commit comments

Comments
 (0)