mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-25 01:28:22 +03:00
Reclassification (#22603)
* add ability to reclassify images * add ability to reclassify faces * work around radix pointer events issue again
This commit is contained in:
parent
91ef3b2ceb
commit
854ef320de
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user