Reclassification (#22603)

* add ability to reclassify images

* add ability to reclassify faces

* work around radix pointer events issue again
This commit is contained in:
Josh Hawkins 2026-03-24 08:18:06 -05:00 committed by GitHub
parent 91ef3b2ceb
commit 854ef320de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 324 additions and 13 deletions

View File

@ -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( @router.post(
"/faces/{name}/delete", "/faces/{name}/delete",
response_model=GenericResponse, 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( @router.put(
"/classification/{name}/dataset/{old_category}/rename", "/classification/{name}/dataset/{old_category}/rename",
response_model=GenericResponse, response_model=GenericResponse,

View File

@ -26,6 +26,7 @@
"deletedModel_one": "Successfully deleted {{count}} model", "deletedModel_one": "Successfully deleted {{count}} model",
"deletedModel_other": "Successfully deleted {{count}} models", "deletedModel_other": "Successfully deleted {{count}} models",
"categorizedImage": "Successfully Classified Image", "categorizedImage": "Successfully Classified Image",
"reclassifiedImage": "Successfully Reclassified Image",
"trainedModel": "Successfully trained model.", "trainedModel": "Successfully trained model.",
"trainingModel": "Successfully started model training.", "trainingModel": "Successfully started model training.",
"updatedModel": "Successfully updated model configuration", "updatedModel": "Successfully updated model configuration",
@ -43,7 +44,8 @@
"trainingFailed": "Model training failed. Check Frigate logs for details.", "trainingFailed": "Model training failed. Check Frigate logs for details.",
"trainingFailedToStart": "Failed to start model training: {{errorMessage}}", "trainingFailedToStart": "Failed to start model training: {{errorMessage}}",
"updateModelFailed": "Failed to update model: {{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": { "deleteCategory": {
@ -92,6 +94,8 @@
}, },
"categorizeImageAs": "Classify Image As:", "categorizeImageAs": "Classify Image As:",
"categorizeImage": "Classify Image", "categorizeImage": "Classify Image",
"reclassifyImageAs": "Reclassify Image As:",
"reclassifyImage": "Reclassify Image",
"menu": { "menu": {
"objects": "Objects", "objects": "Objects",
"states": "States" "states": "States"

View File

@ -66,6 +66,8 @@
"nofaces": "No faces available", "nofaces": "No faces available",
"trainFaceAs": "Train Face as:", "trainFaceAs": "Train Face as:",
"trainFace": "Train Face", "trainFace": "Train Face",
"reclassifyFaceAs": "Reclassify Face as:",
"reclassifyFace": "Reclassify Face",
"toast": { "toast": {
"success": { "success": {
"uploadedImage": "Successfully uploaded image.", "uploadedImage": "Successfully uploaded image.",
@ -77,6 +79,7 @@
"deletedName_other": "{{count}} faces have been successfully deleted.", "deletedName_other": "{{count}} faces have been successfully deleted.",
"renamedFace": "Successfully renamed face to {{name}}", "renamedFace": "Successfully renamed face to {{name}}",
"trainedFace": "Successfully trained face.", "trainedFace": "Successfully trained face.",
"reclassifiedFace": "Successfully reclassified face.",
"updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})." "updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})."
}, },
"error": { "error": {
@ -86,6 +89,7 @@
"deleteNameFailed": "Failed to delete name: {{errorMessage}}", "deleteNameFailed": "Failed to delete name: {{errorMessage}}",
"renameFaceFailed": "Failed to rename face: {{errorMessage}}", "renameFaceFailed": "Failed to rename face: {{errorMessage}}",
"trainFailed": "Failed to train: {{errorMessage}}", "trainFailed": "Failed to train: {{errorMessage}}",
"reclassifyFailed": "Failed to reclassify face: {{errorMessage}}",
"updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}" "updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}"
} }
} }

View File

@ -34,7 +34,11 @@ type ClassificationSelectionDialogProps = {
classes: string[]; classes: string[];
modelName: string; modelName: string;
image: string; image: string;
onRefresh: () => void; onRefresh?: () => void;
onCategorize?: (category: string) => void;
excludeCategory?: string;
dialogLabel?: string;
tooltipLabel?: string;
children: ReactNode; children: ReactNode;
}; };
export default function ClassificationSelectionDialog({ export default function ClassificationSelectionDialog({
@ -43,12 +47,21 @@ export default function ClassificationSelectionDialog({
modelName, modelName,
image, image,
onRefresh, onRefresh,
onCategorize,
excludeCategory,
dialogLabel,
tooltipLabel,
children, children,
}: ClassificationSelectionDialogProps) { }: ClassificationSelectionDialogProps) {
const { t } = useTranslation(["views/classificationModel"]); const { t } = useTranslation(["views/classificationModel"]);
const onCategorizeImage = useCallback( const onCategorizeImage = useCallback(
(category: string) => { (category: string) => {
if (onCategorize) {
onCategorize(category);
return;
}
axios axios
.post(`/classification/${modelName}/dataset/categorize`, { .post(`/classification/${modelName}/dataset/categorize`, {
category, category,
@ -59,7 +72,7 @@ export default function ClassificationSelectionDialog({
toast.success(t("toast.success.categorizedImage"), { toast.success(t("toast.success.categorizedImage"), {
position: "top-center", position: "top-center",
}); });
onRefresh(); onRefresh?.();
} }
}) })
.catch((error) => { .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( const isChildButton = useMemo(
@ -111,6 +130,7 @@ export default function ClassificationSelectionDialog({
</SelectorTrigger> </SelectorTrigger>
<SelectorContent <SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")} className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
onCloseAutoFocus={(e) => e.preventDefault()}
> >
{isMobile && ( {isMobile && (
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
@ -118,14 +138,16 @@ export default function ClassificationSelectionDialog({
<DrawerDescription>Details</DrawerDescription> <DrawerDescription>Details</DrawerDescription>
</DrawerHeader> </DrawerHeader>
)} )}
<DropdownMenuLabel>{t("categorizeImageAs")}</DropdownMenuLabel> <DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel>
<div <div
className={cn( className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto", "flex max-h-[40dvh] flex-col overflow-y-auto",
isMobile && "gap-2 pb-4", isMobile && "gap-2 pb-4",
)} )}
> >
{classes {filteredClasses
.sort((a, b) => { .sort((a, b) => {
if (a === "none") return 1; if (a === "none") return 1;
if (b === "none") return -1; if (b === "none") return -1;
@ -152,7 +174,7 @@ export default function ClassificationSelectionDialog({
</div> </div>
</SelectorContent> </SelectorContent>
</Selector> </Selector>
<TooltipContent>{t("categorizeImage")}</TooltipContent> <TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
); );

View File

@ -30,17 +30,29 @@ import { Button } from "../ui/button";
type FaceSelectionDialogProps = { type FaceSelectionDialogProps = {
className?: string; className?: string;
faceNames: string[]; faceNames: string[];
excludeName?: string;
dialogLabel?: string;
tooltipLabel?: string;
onTrainAttempt: (name: string) => void; onTrainAttempt: (name: string) => void;
children: ReactNode; children: ReactNode;
}; };
export default function FaceSelectionDialog({ export default function FaceSelectionDialog({
className, className,
faceNames, faceNames,
excludeName,
dialogLabel,
tooltipLabel,
onTrainAttempt, onTrainAttempt,
children, children,
}: FaceSelectionDialogProps) { }: FaceSelectionDialogProps) {
const { t } = useTranslation(["views/faceLibrary"]); const { t } = useTranslation(["views/faceLibrary"]);
const filteredNames = useMemo(
() =>
excludeName ? faceNames.filter((n) => n !== excludeName) : faceNames,
[faceNames, excludeName],
);
const isChildButton = useMemo( const isChildButton = useMemo(
() => React.isValidElement(children) && children.type === Button, () => React.isValidElement(children) && children.type === Button,
[children], [children],
@ -79,6 +91,7 @@ export default function FaceSelectionDialog({
</SelectorTrigger> </SelectorTrigger>
<SelectorContent <SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")} className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
onCloseAutoFocus={(e) => e.preventDefault()}
> >
{isMobile && ( {isMobile && (
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
@ -86,14 +99,16 @@ export default function FaceSelectionDialog({
<DrawerDescription>Details</DrawerDescription> <DrawerDescription>Details</DrawerDescription>
</DrawerHeader> </DrawerHeader>
)} )}
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel> <DropdownMenuLabel>
{dialogLabel ?? t("trainFaceAs")}
</DropdownMenuLabel>
<div <div
className={cn( className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden", "flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
isMobile && "gap-2 pb-4", isMobile && "gap-2 pb-4",
)} )}
> >
{faceNames.sort().map((faceName) => ( {filteredNames.sort().map((faceName) => (
<SelectorItem <SelectorItem
key={faceName} key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize" className="flex cursor-pointer gap-2 smart-capitalize"
@ -112,7 +127,7 @@ export default function FaceSelectionDialog({
</div> </div>
</SelectorContent> </SelectorContent>
</Selector> </Selector>
<TooltipContent>{t("trainFace")}</TooltipContent> <TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
); );

View File

@ -266,6 +266,34 @@ export default function FaceLibrary() {
[setPageToggle, refreshFaces, t], [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 // keyboard
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
@ -452,10 +480,12 @@ export default function FaceLibrary() {
<FaceGrid <FaceGrid
contentRef={contentRef} contentRef={contentRef}
faceImages={faceImages} faceImages={faceImages}
faceNames={faces}
pageToggle={pageToggle} pageToggle={pageToggle}
selectedFaces={selectedFaces} selectedFaces={selectedFaces}
onClickFaces={onClickFaces} onClickFaces={onClickFaces}
onDelete={onDelete} onDelete={onDelete}
onReclassify={onReclassify}
/> />
)) ))
)} )}
@ -601,11 +631,11 @@ function LibrarySelector({
className="group flex items-center justify-between p-0" className="group flex items-center justify-between p-0"
> >
<div <div
className="flex-grow cursor-pointer" className="flex-grow cursor-pointer px-2 py-1.5"
onClick={() => setPageToggle(face)} onClick={() => setPageToggle(face)}
> >
{face} {face}
<span className="ml-2 px-2 py-1.5 text-muted-foreground"> <span className="ml-2 text-muted-foreground">
({faceData?.[face].length}) ({faceData?.[face].length})
</span> </span>
</div> </div>
@ -983,18 +1013,22 @@ function FaceAttemptGroup({
type FaceGridProps = { type FaceGridProps = {
contentRef: MutableRefObject<HTMLDivElement | null>; contentRef: MutableRefObject<HTMLDivElement | null>;
faceImages: string[]; faceImages: string[];
faceNames: string[];
pageToggle: string; pageToggle: string;
selectedFaces: string[]; selectedFaces: string[];
onClickFaces: (images: string[], ctrl: boolean) => void; onClickFaces: (images: string[], ctrl: boolean) => void;
onDelete: (name: string, ids: string[]) => void; onDelete: (name: string, ids: string[]) => void;
onReclassify: (image: string, newName: string) => void;
}; };
function FaceGrid({ function FaceGrid({
contentRef, contentRef,
faceImages, faceImages,
faceNames,
pageToggle, pageToggle,
selectedFaces, selectedFaces,
onClickFaces, onClickFaces,
onDelete, onDelete,
onReclassify,
}: FaceGridProps) { }: FaceGridProps) {
const { t } = useTranslation(["views/faceLibrary"]); const { t } = useTranslation(["views/faceLibrary"]);
@ -1032,6 +1066,17 @@ function FaceGrid({
i18nLibrary="views/faceLibrary" i18nLibrary="views/faceLibrary"
onClick={(data, meta) => onClickFaces([data.filename], meta)} onClick={(data, meta) => onClickFaces([data.filename], meta)}
> >
<FaceSelectionDialog
faceNames={faceNames}
excludeName={pageToggle}
dialogLabel={t("reclassifyFaceAs")}
tooltipLabel={t("reclassifyFace")}
onTrainAttempt={(newName) => onReclassify(image, newName)}
>
<BlurredIconButton>
<AddFaceIcon className="size-5" />
</BlurredIconButton>
</FaceSelectionDialog>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<LuTrash2 <LuTrash2

View File

@ -304,6 +304,37 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
[pageToggle, model, refreshTrain, refreshDataset, t], [pageToggle, model, refreshTrain, refreshDataset, t],
); );
const onReclassify = useCallback(
(image: string, newCategory: string) => {
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 // keyboard
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
@ -535,10 +566,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
contentRef={contentRef} contentRef={contentRef}
modelName={model.name} modelName={model.name}
categoryName={pageToggle} categoryName={pageToggle}
classes={Object.keys(dataset || {})}
images={dataset?.[pageToggle] || []} images={dataset?.[pageToggle] || []}
selectedImages={selectedImages} selectedImages={selectedImages}
onClickImages={onClickImages} onClickImages={onClickImages}
onDelete={onDelete} onDelete={onDelete}
onReclassify={onReclassify}
/> />
)} )}
</div> </div>
@ -776,19 +809,23 @@ type DatasetGridProps = {
contentRef: MutableRefObject<HTMLDivElement | null>; contentRef: MutableRefObject<HTMLDivElement | null>;
modelName: string; modelName: string;
categoryName: string; categoryName: string;
classes: string[];
images: string[]; images: string[];
selectedImages: string[]; selectedImages: string[];
onClickImages: (images: string[], ctrl: boolean) => void; onClickImages: (images: string[], ctrl: boolean) => void;
onDelete: (ids: string[]) => void; onDelete: (ids: string[]) => void;
onReclassify: (image: string, newCategory: string) => void;
}; };
function DatasetGrid({ function DatasetGrid({
contentRef, contentRef,
modelName, modelName,
categoryName, categoryName,
classes,
images, images,
selectedImages, selectedImages,
onClickImages, onClickImages,
onDelete, onDelete,
onReclassify,
}: DatasetGridProps) { }: DatasetGridProps) {
const { t } = useTranslation(["views/classificationModel"]); const { t } = useTranslation(["views/classificationModel"]);
@ -816,10 +853,23 @@ function DatasetGrid({
i18nLibrary="views/classificationModel" i18nLibrary="views/classificationModel"
onClick={(data, _) => onClickImages([data.filename], true)} onClick={(data, _) => onClickImages([data.filename], true)}
> >
<ClassificationSelectionDialog
classes={classes}
modelName={modelName}
image={image}
excludeCategory={categoryName}
dialogLabel={t("reclassifyImageAs")}
tooltipLabel={t("reclassifyImage")}
onCategorize={(newCat) => onReclassify(image, newCat)}
>
<BlurredIconButton>
<TbCategoryPlus className="size-5" />
</BlurredIconButton>
</ClassificationSelectionDialog>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<LuTrash2 <LuTrash2
className="size-5 cursor-pointer text-primary-variant hover:text-danger" className="size-5 cursor-pointer text-gray-200 hover:text-danger"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete([image]); onDelete([image]);