From f4c36a4c1f2e735727122f39e331ea825157a744 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:00:37 +0530 Subject: [PATCH 1/2] fix(face-clustering): clamp adaptive eps to never exceed max_distance --- backend/app/config/settings.py | 2 +- backend/app/utils/face_clusters.py | 12 +- backend/tests/test_face_clusters.py | 81 +++++- docs/backend/backend_python/openapi.json | 344 ++++++++++++++++++----- 4 files changed, 357 insertions(+), 82 deletions(-) diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index 172b1d2da..cbc84fd97 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -122,7 +122,7 @@ def _get_env_int( ) 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_SIMILARITY_THRESHOLD", 0.65, 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 diff --git a/backend/app/utils/face_clusters.py b/backend/app/utils/face_clusters.py index 7e4ea59d7..63d45ad4e 100644 --- a/backend/app/utils/face_clusters.py +++ b/backend/app/utils/face_clusters.py @@ -286,8 +286,16 @@ def cluster_util_cluster_all_face_embeddings( estimated_eps = estimate_eps(embeddings_array, k=min_samples) if estimated_eps is not None: - logger.info(f"Adaptive eps estimated: {estimated_eps:.4f}") - eps = estimated_eps + clamped_eps = min(estimated_eps, max_distance) + if clamped_eps < estimated_eps: + logger.warning( + f"Adaptive eps {estimated_eps:.4f} exceeded max_distance " + f"{max_distance:.4f} (similarity_threshold={similarity_threshold}); " + f"clamping to {clamped_eps:.4f}" + ) + else: + logger.info(f"Adaptive eps estimated: {clamped_eps:.4f}") + eps = clamped_eps else: logger.warning( f"Too few embeddings for eps estimation, using config default: {eps}" diff --git a/backend/tests/test_face_clusters.py b/backend/tests/test_face_clusters.py index 2c09795c9..4f5d7d703 100644 --- a/backend/tests/test_face_clusters.py +++ b/backend/tests/test_face_clusters.py @@ -551,8 +551,87 @@ def test_estimate_eps_fallback(self): # 2 elements assert estimate_eps(np.random.randn(2, 512), k=2) is None + @patch("app.utils.face_clusters.db_get_all_faces_with_cluster_names") + def test_adaptive_eps_clamping_regression(self, mock_db_get): + """Test 4: Adaptive eps clamping under sparse datasets with singletons""" + # Create 9 embeddings: + # Identity A: 2 points (very close) + # Identity B: 2 points (very close) + # 5 Singleton points (completely random / orthogonal) + dim = 512 + np.random.seed(42) + + # Identity A + center_a = np.random.randn(dim) + center_a /= np.linalg.norm(center_a) + pt_a1 = center_a + np.random.randn(dim) * 0.01 + pt_a1 /= np.linalg.norm(pt_a1) + pt_a2 = center_a + np.random.randn(dim) * 0.01 + pt_a2 /= np.linalg.norm(pt_a2) + + # Identity B (orthogonal to A) + pt_b1 = np.random.randn(dim) + pt_b1 -= np.dot(pt_b1, center_a) * center_a + pt_b1 /= np.linalg.norm(pt_b1) + pt_b2 = pt_b1 + np.random.randn(dim) * 0.01 + pt_b2 /= np.linalg.norm(pt_b2) + + # 5 Singletons (mutually orthogonal to each other and A/B) + singletons = [] + for _ in range(5): + vec = np.random.randn(dim) + vec -= np.dot(vec, center_a) * center_a + vec -= np.dot(vec, pt_b1) * pt_b1 + for prev in singletons: + vec -= np.dot(vec, prev) * prev + vec /= np.linalg.norm(vec) + singletons.append(vec) + + all_embeddings = [pt_a1, pt_a2, pt_b1, pt_b2] + singletons + + # Mock database call + mock_db_get.return_value = [ + {"face_id": i, "embeddings": emb, "cluster_name": None} + for i, emb in enumerate(all_embeddings) + ] + + # Run clustering with similarity_threshold=0.85 -> max_distance = 0.15 + results, _ = cluster_util_cluster_all_face_embeddings( + min_samples=2, similarity_threshold=0.85 + ) + + # Group face_ids by their cluster UUIDs + clusters = {} + for r in results: + if r.cluster_uuid not in clusters: + clusters[r.cluster_uuid] = [] + clusters[r.cluster_uuid].append(r.face_id) + + cluster_a_uuid = None + cluster_b_uuid = None + + for cluster_uuid, face_ids in clusters.items(): + if 0 in face_ids: + cluster_a_uuid = cluster_uuid + assert 1 in face_ids, "Identity A faces should be grouped together" + assert all( + f in [0, 1] for f in face_ids + ), f"Identity A cluster contains unexpected faces: {face_ids}" + elif 2 in face_ids: + cluster_b_uuid = cluster_uuid + assert 3 in face_ids, "Identity B faces should be grouped together" + assert all( + f in [2, 3] for f in face_ids + ), f"Identity B cluster contains unexpected faces: {face_ids}" + + assert cluster_a_uuid is not None, "Identity A was not clustered" + assert cluster_b_uuid is not None, "Identity B was not clustered" + assert ( + cluster_a_uuid != cluster_b_uuid + ), "Identity A and Identity B should not be merged into the same cluster" + def test_quality_gate(self): - """Test 4: Quality gate unit tests""" + """Test 5: Quality gate unit tests""" # A sharp, large face crop should pass # Random noise image has high variance (sharp) np.random.seed(42) diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 90f83983b..ec7833446 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -18,7 +18,9 @@ "paths": { "/health": { "get": { - "tags": ["Health"], + "tags": [ + "Health" + ], "summary": "Root", "operationId": "root_health_get", "responses": { @@ -35,7 +37,9 @@ }, "/folders/add-folder": { "post": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Add Folder", "operationId": "add_folder_folders_add_folder_post", "requestBody": { @@ -114,7 +118,9 @@ }, "/folders/enable-ai-tagging": { "post": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Enable Ai Tagging", "description": "Enable AI tagging for multiple folders.", "operationId": "enable_ai_tagging_folders_enable_ai_tagging_post", @@ -174,7 +180,9 @@ }, "/folders/disable-ai-tagging": { "post": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Disable Ai Tagging", "description": "Disable AI tagging for multiple folders.", "operationId": "disable_ai_tagging_folders_disable_ai_tagging_post", @@ -234,7 +242,9 @@ }, "/folders/delete-folders": { "delete": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Delete Folders", "description": "Delete multiple folders by their IDs.", "operationId": "delete_folders_folders_delete_folders_delete", @@ -294,7 +304,9 @@ }, "/folders/sync-folder": { "post": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Sync Folder", "description": "Sync a folder by comparing filesystem folders with database entries and removing extra DB entries.", "operationId": "sync_folder_folders_sync_folder_post", @@ -364,7 +376,9 @@ }, "/folders/all-folders": { "get": { - "tags": ["Folders"], + "tags": [ + "Folders" + ], "summary": "Get All Folders", "description": "Get details of all folders in the database.", "operationId": "get_all_folders_folders_all_folders_get", @@ -394,7 +408,9 @@ }, "/albums/": { "get": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Get Albums", "operationId": "get_albums_albums__get", "parameters": [ @@ -433,7 +449,9 @@ } }, "post": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Create Album", "operationId": "create_album_albums__post", "requestBody": { @@ -472,7 +490,9 @@ }, "/albums/{album_id}": { "get": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Get Album", "operationId": "get_album_albums__album_id__get", "parameters": [ @@ -510,7 +530,9 @@ } }, "put": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Update Album", "operationId": "update_album_albums__album_id__put", "parameters": [ @@ -558,7 +580,9 @@ } }, "delete": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Delete Album", "operationId": "delete_album_albums__album_id__delete", "parameters": [ @@ -598,7 +622,9 @@ }, "/albums/{album_id}/images/get": { "post": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Get Album Images", "operationId": "get_album_images_albums__album_id__images_get_post", "parameters": [ @@ -648,7 +674,9 @@ }, "/albums/{album_id}/images": { "post": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Add Images To Album", "operationId": "add_images_to_album_albums__album_id__images_post", "parameters": [ @@ -696,7 +724,9 @@ } }, "delete": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Remove Images From Album", "operationId": "remove_images_from_album_albums__album_id__images_delete", "parameters": [ @@ -746,7 +776,9 @@ }, "/albums/{album_id}/images/{image_id}": { "delete": { - "tags": ["Albums"], + "tags": [ + "Albums" + ], "summary": "Remove Image From Album", "operationId": "remove_image_from_album_albums__album_id__images__image_id__delete", "parameters": [ @@ -795,7 +827,9 @@ }, "/images/": { "get": { - "tags": ["Images"], + "tags": [ + "Images" + ], "summary": "Get All Images", "description": "Get all images from the database.", "operationId": "get_all_images_images__get", @@ -855,7 +889,9 @@ }, "/images/toggle-favourite": { "post": { - "tags": ["Images"], + "tags": [ + "Images" + ], "summary": "Toggle Favourite", "description": "Toggle the favorite status of an image.", "operationId": "toggle_favourite_images_toggle_favourite_post", @@ -893,7 +929,9 @@ }, "/face-clusters/{cluster_id}": { "put": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Rename Cluster", "description": "Rename a face cluster by its ID.", "operationId": "rename_cluster_face_clusters__cluster_id__put", @@ -974,7 +1012,9 @@ }, "/face-clusters/": { "get": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Get All Clusters", "description": "Get metadata for all face clusters including face counts.", "operationId": "get_all_clusters_face_clusters__get", @@ -1004,7 +1044,9 @@ }, "/face-clusters/{cluster_id}/images": { "get": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Get Cluster Images", "description": "Get all images that contain faces belonging to a specific cluster.", "operationId": "get_cluster_images_face_clusters__cluster_id__images_get", @@ -1065,7 +1107,9 @@ }, "/face-clusters/face-search": { "post": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Face Tagging", "operationId": "face_tagging_face_clusters_face_search_post", "parameters": [ @@ -1140,7 +1184,9 @@ }, "/face-clusters/global-recluster": { "post": { - "tags": ["Face Clusters"], + "tags": [ + "Face Clusters" + ], "summary": "Trigger Global Reclustering", "description": "Manually trigger global face reclustering.\nThis forces full reclustering regardless of the 24-hour rule.", "operationId": "trigger_global_reclustering_face_clusters_global_recluster_post", @@ -1242,7 +1288,9 @@ }, "/user-preferences/": { "get": { - "tags": ["User Preferences"], + "tags": [ + "User Preferences" + ], "summary": "Get User Preferences", "description": "Get user preferences from metadata.", "operationId": "get_user_preferences_user_preferences__get", @@ -1270,7 +1318,9 @@ } }, "put": { - "tags": ["User Preferences"], + "tags": [ + "User Preferences" + ], "summary": "Update User Preferences", "description": "Update user preferences in metadata.", "operationId": "update_user_preferences_user_preferences__put", @@ -1330,7 +1380,9 @@ }, "/api/memories/generate": { "post": { - "tags": ["memories"], + "tags": [ + "memories" + ], "summary": "Generate Memories", "description": "SIMPLIFIED: Generate memories from ALL images.\n- GPS images \u2192 location-based memories\n- Non-GPS images \u2192 monthly date-based memories\n\nReturns simple breakdown: {location_count, date_count, total}", "operationId": "generate_memories_api_memories_generate_post", @@ -1402,7 +1454,9 @@ }, "/api/memories/timeline": { "get": { - "tags": ["memories"], + "tags": [ + "memories" + ], "summary": "Get Timeline", "description": "Get memories from the past N days as a timeline.\n\nThis endpoint:\n1. Calculates date range (today - N days to today)\n2. Fetches images within that date range\n3. Clusters them into memories\n4. Returns timeline of memories\n\nArgs:\n days: Number of days to look back (default: 365 = 1 year)\n location_radius_km: Location clustering radius (default: 5km)\n date_tolerance_days: Date tolerance for temporal clustering (default: 3)\n\nReturns:\n TimelineResponse with memories ordered by date\n\nRaises:\n HTTPException: If database query fails", "operationId": "get_timeline_api_memories_timeline_get", @@ -1474,7 +1528,9 @@ }, "/api/memories/on-this-day": { "get": { - "tags": ["memories"], + "tags": [ + "memories" + ], "summary": "Get On This Day", "description": "Get photos taken on this date in previous years.\n\nThis endpoint:\n1. Gets current month and day\n2. Searches for images from this month-day in all previous years\n3. Groups by year\n4. Returns images sorted by year (most recent first)\n\nReturns:\n OnThisDayResponse with images from this date in previous years\n\nRaises:\n HTTPException: If database query fails", "operationId": "get_on_this_day_api_memories_on_this_day_get", @@ -1492,7 +1548,9 @@ }, "/api/memories/locations": { "get": { - "tags": ["memories"], + "tags": [ + "memories" + ], "summary": "Get Locations", "description": "Get all unique locations where photos were taken.\n\nThis endpoint:\n1. Fetches all images with GPS coordinates\n2. Clusters them by location\n3. Returns location clusters with photo counts\n4. Includes sample images for each location\n\nArgs:\n location_radius_km: Location clustering radius (default: 5km)\n max_sample_images: Maximum sample images per location (default: 5)\n\nReturns:\n LocationsResponse with list of location clusters\n\nRaises:\n HTTPException: If database query fails", "operationId": "get_locations_api_memories_locations_get", @@ -1550,7 +1608,10 @@ }, "/shutdown": { "post": { - "tags": ["Shutdown", "Shutdown"], + "tags": [ + "Shutdown", + "Shutdown" + ], "summary": "Shutdown", "description": "Gracefully shutdown the PictoPy backend.\n\nThis endpoint schedules backend server termination after response is sent.\nThe frontend is responsible for shutting down the sync service separately.\n\nReturns:\n ShutdownResponse with status and message", "operationId": "shutdown_shutdown_post", @@ -1570,7 +1631,9 @@ }, "/models/status": { "get": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Get Model Status", "description": "Returns the installation status of all models in the registry.", "operationId": "get_model_status_models_status_get", @@ -1588,7 +1651,9 @@ }, "/models/hardware": { "get": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Get Hardware Recommendation", "description": "Returns hardware specs and the recommended model tier.", "operationId": "get_hardware_recommendation_models_hardware_get", @@ -1606,7 +1671,9 @@ }, "/models/{model_key}": { "delete": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Delete Model", "description": "Deletes a specific model from disk.", "operationId": "delete_model_models__model_key__delete", @@ -1645,7 +1712,9 @@ }, "/models/setup": { "post": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Setup Models", "description": "Initializes setup by starting downloads for a specific tier + required models.\nReturns a single task_id to track overall progress.", "operationId": "setup_models_models_setup_post", @@ -1683,7 +1752,9 @@ }, "/models/download/{model_key}": { "post": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Start Download Model", "description": "Starts download for a specific model by key. Returns a task_id.", "operationId": "start_download_model_models_download__model_key__post", @@ -1722,7 +1793,9 @@ }, "/models/download/{task_id}/progress": { "get": { - "tags": ["Models"], + "tags": [ + "Models" + ], "summary": "Download Progress", "description": "Streams SSE progress for a given download task_id.", "operationId": "download_progress_models_download__task_id__progress_get", @@ -1774,7 +1847,10 @@ } }, "type": "object", - "required": ["folder_id", "folder_path"], + "required": [ + "folder_id", + "folder_path" + ], "title": "AddFolderData" }, "AddFolderRequest": { @@ -1808,7 +1884,9 @@ } }, "type": "object", - "required": ["folder_path"], + "required": [ + "folder_path" + ], "title": "AddFolderRequest" }, "AddFolderResponse": { @@ -1851,7 +1929,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "AddFolderResponse" }, "Album": { @@ -1874,7 +1954,12 @@ } }, "type": "object", - "required": ["album_id", "album_name", "description", "is_hidden"], + "required": [ + "album_id", + "album_name", + "description", + "is_hidden" + ], "title": "Album" }, "ClusterMetadata": { @@ -1956,7 +2041,9 @@ } }, "type": "object", - "required": ["name"], + "required": [ + "name" + ], "title": "CreateAlbumRequest" }, "CreateAlbumResponse": { @@ -1971,7 +2058,10 @@ } }, "type": "object", - "required": ["success", "album_id"], + "required": [ + "success", + "album_id" + ], "title": "CreateAlbumResponse" }, "DeleteFoldersData": { @@ -1989,7 +2079,10 @@ } }, "type": "object", - "required": ["deleted_count", "folder_ids"], + "required": [ + "deleted_count", + "folder_ids" + ], "title": "DeleteFoldersData" }, "DeleteFoldersRequest": { @@ -2003,7 +2096,9 @@ } }, "type": "object", - "required": ["folder_ids"], + "required": [ + "folder_ids" + ], "title": "DeleteFoldersRequest" }, "DeleteFoldersResponse": { @@ -2046,7 +2141,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "DeleteFoldersResponse" }, "FaceSearchRequest": { @@ -2164,7 +2261,10 @@ } }, "type": "object", - "required": ["success", "image_ids"], + "required": [ + "success", + "image_ids" + ], "title": "GetAlbumImagesResponse" }, "GetAlbumResponse": { @@ -2178,7 +2278,10 @@ } }, "type": "object", - "required": ["success", "data"], + "required": [ + "success", + "data" + ], "title": "GetAlbumResponse" }, "GetAlbumsResponse": { @@ -2196,7 +2299,10 @@ } }, "type": "object", - "required": ["success", "albums"], + "required": [ + "success", + "albums" + ], "title": "GetAlbumsResponse" }, "GetAllFoldersData": { @@ -2214,7 +2320,10 @@ } }, "type": "object", - "required": ["folders", "total_count"], + "required": [ + "folders", + "total_count" + ], "title": "GetAllFoldersData" }, "GetAllFoldersResponse": { @@ -2257,7 +2366,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "GetAllFoldersResponse" }, "GetAllImagesResponse": { @@ -2279,7 +2390,11 @@ } }, "type": "object", - "required": ["success", "message", "data"], + "required": [ + "success", + "message", + "data" + ], "title": "GetAllImagesResponse" }, "GetClusterImagesData": { @@ -2312,7 +2427,11 @@ } }, "type": "object", - "required": ["cluster_id", "images", "total_images"], + "required": [ + "cluster_id", + "images", + "total_images" + ], "title": "GetClusterImagesData", "description": "Data model for cluster images response." }, @@ -2356,7 +2475,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "GetClusterImagesResponse", "description": "Response model for getting images in a cluster." }, @@ -2371,7 +2492,9 @@ } }, "type": "object", - "required": ["clusters"], + "required": [ + "clusters" + ], "title": "GetClustersData" }, "GetClustersResponse": { @@ -2414,7 +2537,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "GetClustersResponse" }, "GetUserPreferencesResponse": { @@ -2432,7 +2557,11 @@ } }, "type": "object", - "required": ["success", "message", "user_preferences"], + "required": [ + "success", + "message", + "user_preferences" + ], "title": "GetUserPreferencesResponse", "description": "Response model for getting user preferences" }, @@ -2504,7 +2633,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "GlobalReclusterResponse" }, "HTTPValidationError": { @@ -2587,7 +2718,9 @@ } }, "type": "object", - "required": ["image_ids"], + "required": [ + "image_ids" + ], "title": "ImageIdsRequest" }, "ImageInCluster": { @@ -2660,13 +2793,20 @@ } }, "type": "object", - "required": ["id", "path", "face_id"], + "required": [ + "id", + "path", + "face_id" + ], "title": "ImageInCluster", "description": "Represents an image that contains faces from a specific cluster." }, "InputType": { "type": "string", - "enum": ["path", "base64"], + "enum": [ + "path", + "base64" + ], "title": "InputType" }, "MetadataModel": { @@ -2901,7 +3041,10 @@ } }, "type": "object", - "required": ["cluster_id", "cluster_name"], + "required": [ + "cluster_id", + "cluster_name" + ], "title": "RenameClusterData" }, "RenameClusterRequest": { @@ -2912,7 +3055,9 @@ } }, "type": "object", - "required": ["cluster_name"], + "required": [ + "cluster_name" + ], "title": "RenameClusterRequest" }, "RenameClusterResponse": { @@ -2955,7 +3100,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "RenameClusterResponse" }, "SetupRequest": { @@ -2966,7 +3113,9 @@ } }, "type": "object", - "required": ["tier"], + "required": [ + "tier" + ], "title": "SetupRequest" }, "ShutdownResponse": { @@ -2981,7 +3130,10 @@ } }, "type": "object", - "required": ["status", "message"], + "required": [ + "status", + "message" + ], "title": "ShutdownResponse", "description": "Response model for shutdown endpoint." }, @@ -2997,7 +3149,10 @@ } }, "type": "object", - "required": ["success", "msg"], + "required": [ + "success", + "msg" + ], "title": "SuccessResponse" }, "SyncFolderData": { @@ -3056,7 +3211,10 @@ } }, "type": "object", - "required": ["folder_path", "folder_id"], + "required": [ + "folder_path", + "folder_id" + ], "title": "SyncFolderRequest" }, "SyncFolderResponse": { @@ -3099,7 +3257,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "SyncFolderResponse" }, "ToggleFavouriteRequest": { @@ -3110,7 +3270,9 @@ } }, "type": "object", - "required": ["image_id"], + "required": [ + "image_id" + ], "title": "ToggleFavouriteRequest" }, "UpdateAITaggingData": { @@ -3128,7 +3290,10 @@ } }, "type": "object", - "required": ["updated_count", "folder_ids"], + "required": [ + "updated_count", + "folder_ids" + ], "title": "UpdateAITaggingData" }, "UpdateAITaggingRequest": { @@ -3142,7 +3307,9 @@ } }, "type": "object", - "required": ["folder_ids"], + "required": [ + "folder_ids" + ], "title": "UpdateAITaggingRequest" }, "UpdateAITaggingResponse": { @@ -3185,7 +3352,9 @@ } }, "type": "object", - "required": ["success"], + "required": [ + "success" + ], "title": "UpdateAITaggingResponse" }, "UpdateAlbumRequest": { @@ -3234,7 +3403,10 @@ } }, "type": "object", - "required": ["name", "is_hidden"], + "required": [ + "name", + "is_hidden" + ], "title": "UpdateAlbumRequest" }, "UpdateUserPreferencesRequest": { @@ -3243,7 +3415,11 @@ "anyOf": [ { "type": "string", - "enum": ["nano", "small", "medium"] + "enum": [ + "nano", + "small", + "medium" + ] }, { "type": "null" @@ -3282,7 +3458,11 @@ } }, "type": "object", - "required": ["success", "message", "user_preferences"], + "required": [ + "success", + "message", + "user_preferences" + ], "title": "UpdateUserPreferencesResponse", "description": "Response model for updating user preferences" }, @@ -3290,7 +3470,11 @@ "properties": { "YOLO_model_size": { "type": "string", - "enum": ["nano", "small", "medium"], + "enum": [ + "nano", + "small", + "medium" + ], "title": "Yolo Model Size", "default": "small" }, @@ -3330,7 +3514,11 @@ } }, "type": "object", - "required": ["loc", "msg", "type"], + "required": [ + "loc", + "msg", + "type" + ], "title": "ValidationError" }, "app__schemas__face_clusters__ErrorResponse": { @@ -3448,4 +3636,4 @@ } } } -} +} \ No newline at end of file From 77f316738afaa40b8aa01caf4ede749bdbbe8869 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:33:28 +0530 Subject: [PATCH 2/2] fix: guard against non-positive eps after clamping and improve docs --- backend/app/utils/face_clusters.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/utils/face_clusters.py b/backend/app/utils/face_clusters.py index 63d45ad4e..fc5ea3c2e 100644 --- a/backend/app/utils/face_clusters.py +++ b/backend/app/utils/face_clusters.py @@ -220,7 +220,7 @@ def cluster_util_cluster_all_face_embeddings( Args: eps: DBSCAN epsilon parameter for maximum distance between samples (default: 0.75) min_samples: DBSCAN minimum samples parameter for core points (default: 2) - similarity_threshold: Minimum similarity to consider same person (default: 0.85, range: 0.75-0.90) + similarity_threshold: Minimum similarity to consider same person (default: 0.65, range: 0.65-0.90) merge_threshold: Similarity threshold for post-clustering merge (default: None, uses similarity_threshold) Returns: @@ -287,6 +287,8 @@ def cluster_util_cluster_all_face_embeddings( estimated_eps = estimate_eps(embeddings_array, k=min_samples) if estimated_eps is not None: clamped_eps = min(estimated_eps, max_distance) + # DBSCAN requires eps to be strictly positive + clamped_eps = max(clamped_eps, 1e-6) if clamped_eps < estimated_eps: logger.warning( f"Adaptive eps {estimated_eps:.4f} exceeded max_distance "