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
47 changes: 47 additions & 0 deletions backend/app/routes/face_clusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
ErrorResponse,
GetClustersResponse,
GetClustersData,
GlobalReclusterResponse,
GlobalReclusterData,
ClusterMetadata,
GetClusterImagesResponse,
GetClusterImagesData,
Expand Down Expand Up @@ -297,3 +299,48 @@ def face_tagging(
finally:
if input_type == InputType.base64 and image_path and os.path.exists(image_path):
os.remove(image_path)


@router.post(
"/global-recluster",
response_model=GlobalReclusterResponse,
responses={code: {"model": ErrorResponse} for code in [500]},
)
def trigger_global_reclustering():
"""
Manually trigger global face reclustering.
This forces full reclustering regardless of the 24-hour rule.
"""
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)

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

logger.info("Global reclustering completed successfully")

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

except Exception as e:
logger.error(f"Global reclustering failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ErrorResponse(
success=False,
error="Internal server error",
message=f"Global reclustering failed: {str(e)}",
).model_dump(),
)
11 changes: 11 additions & 0 deletions backend/app/schemas/face_clusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,14 @@ class GetClusterImagesResponse(BaseModel):
message: Optional[str] = None
error: Optional[str] = None
data: Optional[GetClusterImagesData] = None


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


class GlobalReclusterResponse(BaseModel):
success: bool
message: Optional[str] = None
error: Optional[str] = None
data: Optional[GlobalReclusterData] = None
14 changes: 12 additions & 2 deletions backend/app/utils/face_clusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,16 @@ def cluster_util_is_reclustering_needed(metadata) -> bool:
return False


def cluster_util_face_clusters_sync():
def cluster_util_face_clusters_sync(force_full_reclustering: bool = False):
"""
Smart face clustering with transaction safety.
Decides between full reclustering or incremental assignment based on 24-hour rule and face count.

Args:
force_full_reclustering: If True, forces full reclustering regardless of 24-hour rule
"""
metadata = db_get_metadata()
if cluster_util_is_reclustering_needed(metadata):
if force_full_reclustering or cluster_util_is_reclustering_needed(metadata):
# Perform clustering operation
results = cluster_util_cluster_all_face_embeddings()

Expand Down Expand Up @@ -130,9 +137,12 @@ def cluster_util_face_clusters_sync():
current_metadata = metadata or {}
current_metadata["reclustering_time"] = datetime.now().timestamp()
db_update_metadata(current_metadata)

return len(cluster_list)
else:
face_cluster_mappings = cluster_util_assign_cluster_to_faces_without_clusterId()
db_update_face_cluster_ids_batch(face_cluster_mappings)
return len(face_cluster_mappings)


def cluster_util_cluster_all_face_embeddings(
Expand Down
94 changes: 94 additions & 0 deletions docs/backend/backend_python/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,38 @@
}
}
},
"/face-clusters/global-recluster": {
"post": {
"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",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GlobalReclusterResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/app__schemas__face_clusters__ErrorResponse"
}
}
}
}
}
}
},
"/user-preferences/": {
"get": {
"tags": [
Expand Down Expand Up @@ -1962,6 +1994,68 @@
"title": "GetUserPreferencesResponse",
"description": "Response model for getting user preferences"
},
"GlobalReclusterData": {
"properties": {
"clusters_created": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Clusters Created"
}
},
"type": "object",
"title": "GlobalReclusterData"
},
"GlobalReclusterResponse": {
"properties": {
"success": {
"type": "boolean",
"title": "Success"
},
"message": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Message"
},
"error": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Error"
},
"data": {
"anyOf": [
{
"$ref": "#/components/schemas/GlobalReclusterData"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"success"
],
"title": "GlobalReclusterResponse"
},
"HTTPValidationError": {
"properties": {
"detail": {
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api/api-functions/face_clusters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,10 @@ export const fetchSearchedFacesBase64 = async (
);
return response.data;
};

export const triggerGlobalReclustering = async (): Promise<APIResponse> => {
const response = await apiClient.post<APIResponse>(
faceClustersEndpoints.globalRecluster,
);
return response.data;
};
1 change: 1 addition & 0 deletions frontend/src/api/apiEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const faceClustersEndpoints = {
searchForFacesBase64: '/face-clusters/face-search?input_type=base64',
renameCluster: (clusterId: string) => `/face-clusters/${clusterId}`,
getClusterImages: (clusterId: string) => `/face-clusters/${clusterId}/images`,
globalRecluster: '/face-clusters/global-recluster',
};

export const foldersEndpoints = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import React, { useState } from 'react';
import { Settings as SettingsIcon, RefreshCw, Server } from 'lucide-react';
import {
Settings as SettingsIcon,
RefreshCw,
Server,
Users,
} from 'lucide-react';

import { Button } from '@/components/ui/button';
import UpdateDialog from '@/components/Updater/UpdateDialog';
Expand All @@ -10,6 +15,9 @@ import { useUpdater } from '@/hooks/useUpdater';
import { useDispatch } from 'react-redux';
import { showLoader, hideLoader } from '@/features/loaderSlice';
import { showInfoDialog } from '@/features/infoDialogSlice';
import { triggerGlobalReclustering } from '@/api/api-functions/face_clusters';
import { usePictoMutation } from '@/hooks/useQueryExtension';
import { useMutationFeedback } from '@/hooks/useMutationFeedback';

/**
* Component for application controls in settings
Expand All @@ -29,6 +37,38 @@ const ApplicationControlsCard: React.FC = () => {

const [updateDialogOpen, setUpdateDialogOpen] = useState<boolean>(false);

const reclusterMutation = usePictoMutation({
mutationFn: triggerGlobalReclustering,
autoInvalidateTags: ['clusters'],
});

const feedbackOptions = React.useMemo(
() => ({
loadingMessage: 'Starting global face reclustering...',
successTitle: 'Reclustering Completed',
successMessage:
reclusterMutation.successMessage ||
'Global face reclustering completed successfully.',
errorTitle: 'Reclustering Failed',
errorMessage:
reclusterMutation.errorMessage ||
'Failed to complete global face reclustering.',
// You can use reclusterMutation.successData?.clusters_created to show the number of clusters created if needed
// Example: `Clusters created: ${reclusterMutation.successData?.clusters_created}`
}),
[
reclusterMutation.successMessage,
reclusterMutation.errorMessage,
reclusterMutation.successData,
],
);

useMutationFeedback(reclusterMutation, feedbackOptions);

const onGlobalReclusterClick = () => {
reclusterMutation.mutate(undefined);
};

const onCheckUpdatesClick = () => {
const checkUpdates = async () => {
dispatch(showLoader('Checking for updates...'));
Expand Down Expand Up @@ -86,9 +126,9 @@ const ApplicationControlsCard: React.FC = () => {
<SettingsCard
icon={SettingsIcon}
title="Application Controls"
description="Manage updates and server operations"
description="Manage updates, server operations, and face clustering"
>
<div className="flex w-50 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Button
onClick={onCheckUpdatesClick}
variant="outline"
Expand All @@ -110,6 +150,18 @@ const ApplicationControlsCard: React.FC = () => {
<div className="font-medium">Restart Server</div>
</div>
</Button>

<Button
onClick={onGlobalReclusterClick}
variant="outline"
className="flex h-12 w-full gap-3"
title="Rebuild all face clusters from scratch using latest clustering algorithms"
>
<Users className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<div className="text-left">
<div className="font-medium">Recluster Faces</div>
</div>
</Button>
</div>
</SettingsCard>

Expand Down
Loading