diff --git a/frigate/api/classification.py b/frigate/api/classification.py index 5e1087d17..dea4d28b2 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -338,6 +338,82 @@ async def recognize_face(request: Request, file: UploadFile): ) +@router.post( + "/faces/{name}/reclassify", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Reclassify a face image to a different name", + description="""Moves a single face image from one person's folder to another. + The image is moved and renamed, and the face classifier is cleared to + incorporate the change. Returns a success message or an error if the + image or target name is invalid.""", +) +def reclassify_face_image(request: Request, name: str, body: dict = None): + if not request.app.frigate_config.face_recognition.enabled: + return JSONResponse( + status_code=400, + content={"message": "Face recognition is not enabled.", "success": False}, + ) + + json: dict[str, Any] = body or {} + image_id = sanitize_filename(json.get("id", "")) + new_name = sanitize_filename(json.get("new_name", "")) + + if not image_id or not new_name: + return JSONResponse( + content=( + { + "success": False, + "message": "Both 'id' and 'new_name' are required.", + } + ), + status_code=400, + ) + + if new_name == name: + return JSONResponse( + content=( + { + "success": False, + "message": "New name must differ from the current name.", + } + ), + status_code=400, + ) + + source_folder = os.path.join(FACE_DIR, sanitize_filename(name)) + source_file = os.path.join(source_folder, image_id) + + if not os.path.isfile(source_file): + return JSONResponse( + content=( + { + "success": False, + "message": f"Image not found: {image_id}", + } + ), + status_code=404, + ) + + target_filename = f"{new_name}-{datetime.datetime.now().timestamp()}.webp" + target_folder = os.path.join(FACE_DIR, new_name) + + os.makedirs(target_folder, exist_ok=True) + shutil.move(source_file, os.path.join(target_folder, target_filename)) + + # Clean up empty source folder + if os.path.exists(source_folder) and not os.listdir(source_folder): + os.rmdir(source_folder) + + context: EmbeddingsContext = request.app.embeddings + context.clear_face_classifier() + + return JSONResponse( + content=({"success": True, "message": "Successfully reclassified face."}), + status_code=200, + ) + + @router.post( "/faces/{name}/delete", response_model=GenericResponse, @@ -787,6 +863,101 @@ def delete_classification_dataset_images( ) +@router.post( + "/classification/{name}/dataset/{category}/reclassify", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Reclassify a dataset image to a different category", + description="""Moves a single dataset image from one category to another. + The image is re-saved as PNG in the target category and removed from the source.""", +) +def reclassify_classification_image( + request: Request, name: str, category: str, body: dict = None +): + config: FrigateConfig = request.app.frigate_config + + if name not in config.classification.custom: + return JSONResponse( + content=( + { + "success": False, + "message": f"{name} is not a known classification model.", + } + ), + status_code=404, + ) + + json: dict[str, Any] = body or {} + image_id = sanitize_filename(json.get("id", "")) + new_category = sanitize_filename(json.get("new_category", "")) + + if not image_id or not new_category: + return JSONResponse( + content=( + { + "success": False, + "message": "Both 'id' and 'new_category' are required.", + } + ), + status_code=400, + ) + + if new_category == category: + return JSONResponse( + content=( + { + "success": False, + "message": "New category must differ from the current category.", + } + ), + status_code=400, + ) + + sanitized_name = sanitize_filename(name) + source_folder = os.path.join( + CLIPS_DIR, sanitized_name, "dataset", sanitize_filename(category) + ) + source_file = os.path.join(source_folder, image_id) + + if not os.path.isfile(source_file): + return JSONResponse( + content=( + { + "success": False, + "message": f"Image not found: {image_id}", + } + ), + status_code=404, + ) + + random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + timestamp = datetime.datetime.now().timestamp() + new_name = f"{new_category}-{timestamp}-{random_id}.png" + target_folder = os.path.join(CLIPS_DIR, sanitized_name, "dataset", new_category) + + os.makedirs(target_folder, exist_ok=True) + + img = cv2.imread(source_file) + cv2.imwrite(os.path.join(target_folder, new_name), img) + os.unlink(source_file) + + # Clean up empty source folder (unless it is "none") + if ( + os.path.exists(source_folder) + and not os.listdir(source_folder) + and category.lower() != "none" + ): + os.rmdir(source_folder) + + # Mark dataset as changed so UI knows retraining is needed + write_training_metadata(sanitized_name, 0) + + return JSONResponse( + content=({"success": True, "message": "Successfully reclassified image."}), + status_code=200, + ) + + @router.put( "/classification/{name}/dataset/{old_category}/rename", response_model=GenericResponse, diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json index 17f881a3c..3206ad033 100644 --- a/web/public/locales/en/views/classificationModel.json +++ b/web/public/locales/en/views/classificationModel.json @@ -26,6 +26,7 @@ "deletedModel_one": "Successfully deleted {{count}} model", "deletedModel_other": "Successfully deleted {{count}} models", "categorizedImage": "Successfully Classified Image", + "reclassifiedImage": "Successfully Reclassified Image", "trainedModel": "Successfully trained model.", "trainingModel": "Successfully started model training.", "updatedModel": "Successfully updated model configuration", @@ -43,7 +44,8 @@ "trainingFailed": "Model training failed. Check Frigate logs for details.", "trainingFailedToStart": "Failed to start model training: {{errorMessage}}", "updateModelFailed": "Failed to update model: {{errorMessage}}", - "renameCategoryFailed": "Failed to rename class: {{errorMessage}}" + "renameCategoryFailed": "Failed to rename class: {{errorMessage}}", + "reclassifyFailed": "Failed to reclassify image: {{errorMessage}}" } }, "deleteCategory": { @@ -92,6 +94,8 @@ }, "categorizeImageAs": "Classify Image As:", "categorizeImage": "Classify Image", + "reclassifyImageAs": "Reclassify Image As:", + "reclassifyImage": "Reclassify Image", "menu": { "objects": "Objects", "states": "States" diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index 354049156..4f8a9cf2d 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -66,6 +66,8 @@ "nofaces": "No faces available", "trainFaceAs": "Train Face as:", "trainFace": "Train Face", + "reclassifyFaceAs": "Reclassify Face as:", + "reclassifyFace": "Reclassify Face", "toast": { "success": { "uploadedImage": "Successfully uploaded image.", @@ -77,6 +79,7 @@ "deletedName_other": "{{count}} faces have been successfully deleted.", "renamedFace": "Successfully renamed face to {{name}}", "trainedFace": "Successfully trained face.", + "reclassifiedFace": "Successfully reclassified face.", "updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})." }, "error": { @@ -86,6 +89,7 @@ "deleteNameFailed": "Failed to delete name: {{errorMessage}}", "renameFaceFailed": "Failed to rename face: {{errorMessage}}", "trainFailed": "Failed to train: {{errorMessage}}", + "reclassifyFailed": "Failed to reclassify face: {{errorMessage}}", "updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}" } } diff --git a/web/src/components/overlay/ClassificationSelectionDialog.tsx b/web/src/components/overlay/ClassificationSelectionDialog.tsx index f99011423..a7a208e2f 100644 --- a/web/src/components/overlay/ClassificationSelectionDialog.tsx +++ b/web/src/components/overlay/ClassificationSelectionDialog.tsx @@ -34,7 +34,11 @@ type ClassificationSelectionDialogProps = { classes: string[]; modelName: string; image: string; - onRefresh: () => void; + onRefresh?: () => void; + onCategorize?: (category: string) => void; + excludeCategory?: string; + dialogLabel?: string; + tooltipLabel?: string; children: ReactNode; }; export default function ClassificationSelectionDialog({ @@ -43,12 +47,21 @@ export default function ClassificationSelectionDialog({ modelName, image, onRefresh, + onCategorize, + excludeCategory, + dialogLabel, + tooltipLabel, children, }: ClassificationSelectionDialogProps) { const { t } = useTranslation(["views/classificationModel"]); const onCategorizeImage = useCallback( (category: string) => { + if (onCategorize) { + onCategorize(category); + return; + } + axios .post(`/classification/${modelName}/dataset/categorize`, { category, @@ -59,7 +72,7 @@ export default function ClassificationSelectionDialog({ toast.success(t("toast.success.categorizedImage"), { position: "top-center", }); - onRefresh(); + onRefresh?.(); } }) .catch((error) => { @@ -72,7 +85,13 @@ export default function ClassificationSelectionDialog({ }); }); }, - [modelName, image, onRefresh, t], + [modelName, image, onRefresh, onCategorize, t], + ); + + const filteredClasses = useMemo( + () => + excludeCategory ? classes.filter((c) => c !== excludeCategory) : classes, + [classes, excludeCategory], ); const isChildButton = useMemo( @@ -111,6 +130,7 @@ export default function ClassificationSelectionDialog({ e.preventDefault()} > {isMobile && ( @@ -118,14 +138,16 @@ export default function ClassificationSelectionDialog({ Details )} - {t("categorizeImageAs")} + + {dialogLabel ?? t("categorizeImageAs")} +
- {classes + {filteredClasses .sort((a, b) => { if (a === "none") return 1; if (b === "none") return -1; @@ -152,7 +174,7 @@ export default function ClassificationSelectionDialog({
- {t("categorizeImage")} + {tooltipLabel ?? t("categorizeImage")} ); diff --git a/web/src/components/overlay/FaceSelectionDialog.tsx b/web/src/components/overlay/FaceSelectionDialog.tsx index 78cf7ce12..b76ee9c5d 100644 --- a/web/src/components/overlay/FaceSelectionDialog.tsx +++ b/web/src/components/overlay/FaceSelectionDialog.tsx @@ -30,17 +30,29 @@ import { Button } from "../ui/button"; type FaceSelectionDialogProps = { className?: string; faceNames: string[]; + excludeName?: string; + dialogLabel?: string; + tooltipLabel?: string; onTrainAttempt: (name: string) => void; children: ReactNode; }; export default function FaceSelectionDialog({ className, faceNames, + excludeName, + dialogLabel, + tooltipLabel, onTrainAttempt, children, }: FaceSelectionDialogProps) { const { t } = useTranslation(["views/faceLibrary"]); + const filteredNames = useMemo( + () => + excludeName ? faceNames.filter((n) => n !== excludeName) : faceNames, + [faceNames, excludeName], + ); + const isChildButton = useMemo( () => React.isValidElement(children) && children.type === Button, [children], @@ -79,6 +91,7 @@ export default function FaceSelectionDialog({ e.preventDefault()} > {isMobile && ( @@ -86,14 +99,16 @@ export default function FaceSelectionDialog({ Details )} - {t("trainFaceAs")} + + {dialogLabel ?? t("trainFaceAs")} +
- {faceNames.sort().map((faceName) => ( + {filteredNames.sort().map((faceName) => ( - {t("trainFace")} + {tooltipLabel ?? t("trainFace")}
); diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 666057110..ee9213626 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -266,6 +266,34 @@ export default function FaceLibrary() { [setPageToggle, refreshFaces, t], ); + const onReclassify = useCallback( + (image: string, newName: string) => { + axios + .post(`/faces/${pageToggle}/reclassify`, { + id: image, + new_name: newName, + }) + .then((resp) => { + if (resp.status == 200) { + toast.success(t("toast.success.reclassifiedFace"), { + position: "top-center", + }); + refreshFaces(); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("toast.error.reclassifyFailed", { errorMessage }), { + position: "top-center", + }); + }); + }, + [pageToggle, refreshFaces, t], + ); + // keyboard const contentRef = useRef(null); @@ -452,10 +480,12 @@ export default function FaceLibrary() { )) )} @@ -601,11 +631,11 @@ function LibrarySelector({ className="group flex items-center justify-between p-0" >
setPageToggle(face)} > {face} - + ({faceData?.[face].length})
@@ -983,18 +1013,22 @@ function FaceAttemptGroup({ type FaceGridProps = { contentRef: MutableRefObject; faceImages: string[]; + faceNames: string[]; pageToggle: string; selectedFaces: string[]; onClickFaces: (images: string[], ctrl: boolean) => void; onDelete: (name: string, ids: string[]) => void; + onReclassify: (image: string, newName: string) => void; }; function FaceGrid({ contentRef, faceImages, + faceNames, pageToggle, selectedFaces, onClickFaces, onDelete, + onReclassify, }: FaceGridProps) { const { t } = useTranslation(["views/faceLibrary"]); @@ -1032,6 +1066,17 @@ function FaceGrid({ i18nLibrary="views/faceLibrary" onClick={(data, meta) => onClickFaces([data.filename], meta)} > + onReclassify(image, newName)} + > + + + + { + axios + .post( + `/classification/${model.name}/dataset/${pageToggle}/reclassify`, + { + id: image, + new_category: newCategory, + }, + ) + .then((resp) => { + if (resp.status == 200) { + toast.success(t("toast.success.reclassifiedImage"), { + position: "top-center", + }); + refreshDataset(); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("toast.error.reclassifyFailed", { errorMessage }), { + position: "top-center", + }); + }); + }, + [pageToggle, model, refreshDataset, t], + ); + // keyboard const contentRef = useRef(null); @@ -535,10 +566,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { contentRef={contentRef} modelName={model.name} categoryName={pageToggle} + classes={Object.keys(dataset || {})} images={dataset?.[pageToggle] || []} selectedImages={selectedImages} onClickImages={onClickImages} onDelete={onDelete} + onReclassify={onReclassify} /> )} @@ -776,19 +809,23 @@ type DatasetGridProps = { contentRef: MutableRefObject; modelName: string; categoryName: string; + classes: string[]; images: string[]; selectedImages: string[]; onClickImages: (images: string[], ctrl: boolean) => void; onDelete: (ids: string[]) => void; + onReclassify: (image: string, newCategory: string) => void; }; function DatasetGrid({ contentRef, modelName, categoryName, + classes, images, selectedImages, onClickImages, onDelete, + onReclassify, }: DatasetGridProps) { const { t } = useTranslation(["views/classificationModel"]); @@ -816,10 +853,23 @@ function DatasetGrid({ i18nLibrary="views/classificationModel" onClick={(data, _) => onClickImages([data.filename], true)} > + onReclassify(image, newCat)} + > + + + + { e.stopPropagation(); onDelete([image]);