add ability to reclassify images

This commit is contained in:
Josh Hawkins 2026-03-23 18:14:50 -05:00
parent 573a5ede62
commit 1ca138ff55
4 changed files with 177 additions and 7 deletions

View File

@ -787,6 +787,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

@ -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(
@ -118,14 +137,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 +173,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

@ -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,6 +853,19 @@ 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