mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-10 09:07:37 +03:00
Add checkbox selection mode for classification and face grids
This commit is contained in:
parent
310d52de4e
commit
8b65ce3946
@ -14,7 +14,11 @@
|
|||||||
"addClassification": "Add Classification",
|
"addClassification": "Add Classification",
|
||||||
"deleteModels": "Delete Models",
|
"deleteModels": "Delete Models",
|
||||||
"editModel": "Edit Model",
|
"editModel": "Edit Model",
|
||||||
"categorizeImages": "Classify Images"
|
"categorizeImages": "Classify Images",
|
||||||
|
"enableSelection": "Enable Selection",
|
||||||
|
"disableSelection": "Disable Selection",
|
||||||
|
"selectImage": "Select Image",
|
||||||
|
"selectGroup": "Select Group"
|
||||||
},
|
},
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"trainingInProgress": "Model is currently training",
|
"trainingInProgress": "Model is currently training",
|
||||||
|
|||||||
@ -54,7 +54,11 @@
|
|||||||
"deleteFace": "Delete Face",
|
"deleteFace": "Delete Face",
|
||||||
"uploadImage": "Upload Image",
|
"uploadImage": "Upload Image",
|
||||||
"reprocessFace": "Reprocess Face",
|
"reprocessFace": "Reprocess Face",
|
||||||
"trainFaces": "Train Faces"
|
"trainFaces": "Train Faces",
|
||||||
|
"enableSelection": "Enable Selection",
|
||||||
|
"disableSelection": "Disable Selection",
|
||||||
|
"selectImage": "Select Image",
|
||||||
|
"selectGroup": "Select Group"
|
||||||
},
|
},
|
||||||
"imageEntry": {
|
"imageEntry": {
|
||||||
"validation": {
|
"validation": {
|
||||||
|
|||||||
@ -44,6 +44,7 @@ type ClassificationCardProps = {
|
|||||||
i18nLibrary: string;
|
i18nLibrary: string;
|
||||||
showArea?: boolean;
|
showArea?: boolean;
|
||||||
count?: number;
|
count?: number;
|
||||||
|
topLeftContent?: React.ReactNode;
|
||||||
onClick: (data: ClassificationItemData, meta: boolean) => void;
|
onClick: (data: ClassificationItemData, meta: boolean) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
@ -61,6 +62,7 @@ export const ClassificationCard = forwardRef<
|
|||||||
i18nLibrary,
|
i18nLibrary,
|
||||||
showArea = true,
|
showArea = true,
|
||||||
count,
|
count,
|
||||||
|
topLeftContent,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
},
|
},
|
||||||
@ -143,6 +145,15 @@ export const ClassificationCard = forwardRef<
|
|||||||
onLoad={() => setImageLoaded(true)}
|
onLoad={() => setImageLoaded(true)}
|
||||||
src={`${baseUrl}${data.filepath}`}
|
src={`${baseUrl}${data.filepath}`}
|
||||||
/>
|
/>
|
||||||
|
{topLeftContent && (
|
||||||
|
<div
|
||||||
|
className="absolute left-2 top-2 z-10"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{topLeftContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ImageShadowOverlay upperClassName="z-0" lowerClassName="h-[30%] z-0" />
|
<ImageShadowOverlay upperClassName="z-0" lowerClassName="h-[30%] z-0" />
|
||||||
{count && (
|
{count && (
|
||||||
<div className="absolute right-2 top-2 flex flex-row items-center gap-1">
|
<div className="absolute right-2 top-2 flex flex-row items-center gap-1">
|
||||||
@ -199,6 +210,7 @@ type GroupedClassificationCardProps = {
|
|||||||
i18nLibrary: string;
|
i18nLibrary: string;
|
||||||
objectType: string;
|
objectType: string;
|
||||||
noClassificationLabel?: string;
|
noClassificationLabel?: string;
|
||||||
|
topLeftContent?: React.ReactNode;
|
||||||
onClick: (data: ClassificationItemData | undefined) => void;
|
onClick: (data: ClassificationItemData | undefined) => void;
|
||||||
children?: (data: ClassificationItemData) => React.ReactNode;
|
children?: (data: ClassificationItemData) => React.ReactNode;
|
||||||
};
|
};
|
||||||
@ -209,6 +221,7 @@ export function GroupedClassificationCard({
|
|||||||
selectedItems,
|
selectedItems,
|
||||||
i18nLibrary,
|
i18nLibrary,
|
||||||
noClassificationLabel = "details.none",
|
noClassificationLabel = "details.none",
|
||||||
|
topLeftContent,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}: GroupedClassificationCardProps) {
|
}: GroupedClassificationCardProps) {
|
||||||
@ -295,6 +308,7 @@ export function GroupedClassificationCard({
|
|||||||
clickable={true}
|
clickable={true}
|
||||||
i18nLibrary={i18nLibrary}
|
i18nLibrary={i18nLibrary}
|
||||||
count={group.length}
|
count={group.length}
|
||||||
|
topLeftContent={topLeftContent}
|
||||||
onClick={(_, meta) => {
|
onClick={(_, meta) => {
|
||||||
if (meta || selectedItems.length > 0) {
|
if (meta || selectedItems.length > 0) {
|
||||||
onClick(undefined);
|
onClick(undefined);
|
||||||
|
|||||||
@ -33,9 +33,10 @@ type ClassificationSelectionDialogProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
classes: string[];
|
classes: string[];
|
||||||
modelName: string;
|
modelName: string;
|
||||||
image: string;
|
image?: string;
|
||||||
|
images?: string[];
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCategorize?: (category: string) => void; // Optional custom categorize handler
|
onCategorize?: (category: string, images: string[]) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export default function ClassificationSelectionDialog({
|
export default function ClassificationSelectionDialog({
|
||||||
@ -43,6 +44,7 @@ export default function ClassificationSelectionDialog({
|
|||||||
classes,
|
classes,
|
||||||
modelName,
|
modelName,
|
||||||
image,
|
image,
|
||||||
|
images,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCategorize,
|
onCategorize,
|
||||||
children,
|
children,
|
||||||
@ -51,37 +53,98 @@ export default function ClassificationSelectionDialog({
|
|||||||
|
|
||||||
const onCategorizeImage = useCallback(
|
const onCategorizeImage = useCallback(
|
||||||
(category: string) => {
|
(category: string) => {
|
||||||
|
const targetImages = images?.length ? images : image ? [image] : [];
|
||||||
|
|
||||||
// If custom categorize handler is provided, use it instead
|
// If custom categorize handler is provided, use it instead
|
||||||
if (onCategorize) {
|
if (onCategorize) {
|
||||||
onCategorize(category);
|
onCategorize(category, targetImages);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default behavior: categorize single image
|
if (targetImages.length === 0) {
|
||||||
axios
|
toast.error(t("toast.error.batchCategorizeFailed", { count: 0 }), {
|
||||||
.post(`/classification/${modelName}/dataset/categorize`, {
|
position: "top-center",
|
||||||
category,
|
});
|
||||||
training_file: image,
|
return;
|
||||||
})
|
}
|
||||||
.then((resp) => {
|
|
||||||
if (resp.status == 200) {
|
if (targetImages.length === 1) {
|
||||||
toast.success(t("toast.success.categorizedImage"), {
|
// Default behavior: categorize a single image.
|
||||||
|
axios
|
||||||
|
.post(`/classification/${modelName}/dataset/categorize`, {
|
||||||
|
category,
|
||||||
|
training_file: targetImages[0],
|
||||||
|
})
|
||||||
|
.then((resp) => {
|
||||||
|
if (resp.status == 200) {
|
||||||
|
toast.success(t("toast.success.categorizedImage"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
onRefresh();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("toast.error.categorizeFailed", { errorMessage }), {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
onRefresh();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage =
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data?.detail ||
|
|
||||||
"Unknown error";
|
|
||||||
toast.error(t("toast.error.categorizeFailed", { errorMessage }), {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
});
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = targetImages.map((filename) =>
|
||||||
|
axios
|
||||||
|
.post(`/classification/${modelName}/dataset/categorize`, {
|
||||||
|
category,
|
||||||
|
training_file: filename,
|
||||||
|
})
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.allSettled(requests).then((results) => {
|
||||||
|
const successCount = results.filter(
|
||||||
|
(result) => result.status === "fulfilled" && result.value,
|
||||||
|
).length;
|
||||||
|
const totalCount = results.length;
|
||||||
|
|
||||||
|
if (successCount === totalCount) {
|
||||||
|
toast.success(
|
||||||
|
t("toast.success.batchCategorized", {
|
||||||
|
count: successCount,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
toast.warning(
|
||||||
|
t("toast.warning.partialBatchCategorized", {
|
||||||
|
success: successCount,
|
||||||
|
total: totalCount,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("toast.error.batchCategorizeFailed", {
|
||||||
|
count: totalCount,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRefresh();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[modelName, image, onRefresh, onCategorize, t],
|
[modelName, image, images, onRefresh, onCategorize, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isChildButton = useMemo(
|
const isChildButton = useMemo(
|
||||||
@ -105,7 +168,7 @@ export default function ClassificationSelectionDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className ?? "flex"}>
|
<div className={className ?? "flex"} data-card-action="true">
|
||||||
<TextEntryDialog
|
<TextEntryDialog
|
||||||
open={newClass}
|
open={newClass}
|
||||||
setOpen={setNewClass}
|
setOpen={setNewClass}
|
||||||
|
|||||||
@ -57,6 +57,7 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
LuFolderCheck,
|
LuFolderCheck,
|
||||||
LuImagePlus,
|
LuImagePlus,
|
||||||
|
LuListChecks,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
LuRefreshCw,
|
LuRefreshCw,
|
||||||
LuScanFace,
|
LuScanFace,
|
||||||
@ -72,6 +73,7 @@ import {
|
|||||||
ClassificationItemData,
|
ClassificationItemData,
|
||||||
ClassifiedEvent,
|
ClassifiedEvent,
|
||||||
} from "@/types/classification";
|
} from "@/types/classification";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
export default function FaceLibrary() {
|
export default function FaceLibrary() {
|
||||||
const { t } = useTranslation(["views/faceLibrary"]);
|
const { t } = useTranslation(["views/faceLibrary"]);
|
||||||
@ -152,10 +154,21 @@ export default function FaceLibrary() {
|
|||||||
// face multiselect
|
// face multiselect
|
||||||
|
|
||||||
const [selectedFaces, setSelectedFaces] = useState<string[]>([]);
|
const [selectedFaces, setSelectedFaces] = useState<string[]>([]);
|
||||||
|
const [selectionModeEnabled, setSelectionModeEnabled] = useState(false);
|
||||||
|
|
||||||
|
const toggleSelectionMode = useCallback(() => {
|
||||||
|
setSelectionModeEnabled((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (!next) {
|
||||||
|
setSelectedFaces([]);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onClickFaces = useCallback(
|
const onClickFaces = useCallback(
|
||||||
(images: string[], ctrl: boolean) => {
|
(images: string[], ctrl: boolean) => {
|
||||||
if (selectedFaces.length == 0 && !ctrl) {
|
if (!selectionModeEnabled && selectedFaces.length == 0 && !ctrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +194,7 @@ export default function FaceLibrary() {
|
|||||||
|
|
||||||
setSelectedFaces(newSelectedFaces);
|
setSelectedFaces(newSelectedFaces);
|
||||||
},
|
},
|
||||||
[selectedFaces, setSelectedFaces],
|
[selectionModeEnabled, selectedFaces, setSelectedFaces],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState<{
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState<{
|
||||||
@ -466,6 +479,19 @@ export default function FaceLibrary() {
|
|||||||
</Button>
|
</Button>
|
||||||
</FaceSelectionDialog>
|
</FaceSelectionDialog>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
className="flex gap-2"
|
||||||
|
variant={selectionModeEnabled ? "select" : "default"}
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
>
|
||||||
|
<LuListChecks className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
{isDesktop &&
|
||||||
|
t(
|
||||||
|
selectionModeEnabled
|
||||||
|
? "button.disableSelection"
|
||||||
|
: "button.enableSelection",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@ -478,6 +504,19 @@ export default function FaceLibrary() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex gap-2"
|
||||||
|
variant={selectionModeEnabled ? "select" : "default"}
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
>
|
||||||
|
<LuListChecks className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
{isDesktop &&
|
||||||
|
t(
|
||||||
|
selectionModeEnabled
|
||||||
|
? "button.disableSelection"
|
||||||
|
: "button.enableSelection",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
|
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
|
||||||
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
|
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
{isDesktop && t("button.addFace")}
|
{isDesktop && t("button.addFace")}
|
||||||
@ -505,6 +544,7 @@ export default function FaceLibrary() {
|
|||||||
attemptImages={trainImages}
|
attemptImages={trainImages}
|
||||||
faceNames={faces}
|
faceNames={faces}
|
||||||
selectedFaces={selectedFaces}
|
selectedFaces={selectedFaces}
|
||||||
|
showSelectionCheckboxes={selectionModeEnabled}
|
||||||
onClickFaces={onClickFaces}
|
onClickFaces={onClickFaces}
|
||||||
onRefresh={refreshFaces}
|
onRefresh={refreshFaces}
|
||||||
/>
|
/>
|
||||||
@ -514,6 +554,7 @@ export default function FaceLibrary() {
|
|||||||
faceImages={faceImages}
|
faceImages={faceImages}
|
||||||
pageToggle={pageToggle}
|
pageToggle={pageToggle}
|
||||||
selectedFaces={selectedFaces}
|
selectedFaces={selectedFaces}
|
||||||
|
showSelectionCheckboxes={selectionModeEnabled}
|
||||||
onClickFaces={onClickFaces}
|
onClickFaces={onClickFaces}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
@ -721,6 +762,7 @@ type TrainingGridProps = {
|
|||||||
attemptImages: string[];
|
attemptImages: string[];
|
||||||
faceNames: string[];
|
faceNames: string[];
|
||||||
selectedFaces: string[];
|
selectedFaces: string[];
|
||||||
|
showSelectionCheckboxes: boolean;
|
||||||
onClickFaces: (images: string[], ctrl: boolean) => void;
|
onClickFaces: (images: string[], ctrl: boolean) => void;
|
||||||
onRefresh: (
|
onRefresh: (
|
||||||
data?:
|
data?:
|
||||||
@ -738,6 +780,7 @@ function TrainingGrid({
|
|||||||
attemptImages,
|
attemptImages,
|
||||||
faceNames,
|
faceNames,
|
||||||
selectedFaces,
|
selectedFaces,
|
||||||
|
showSelectionCheckboxes,
|
||||||
onClickFaces,
|
onClickFaces,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: TrainingGridProps) {
|
}: TrainingGridProps) {
|
||||||
@ -817,6 +860,7 @@ function TrainingGrid({
|
|||||||
event={event}
|
event={event}
|
||||||
faceNames={faceNames}
|
faceNames={faceNames}
|
||||||
selectedFaces={selectedFaces}
|
selectedFaces={selectedFaces}
|
||||||
|
showSelectionCheckboxes={showSelectionCheckboxes}
|
||||||
onClickFaces={onClickFaces}
|
onClickFaces={onClickFaces}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
@ -833,6 +877,7 @@ type FaceAttemptGroupProps = {
|
|||||||
event?: Event;
|
event?: Event;
|
||||||
faceNames: string[];
|
faceNames: string[];
|
||||||
selectedFaces: string[];
|
selectedFaces: string[];
|
||||||
|
showSelectionCheckboxes: boolean;
|
||||||
onClickFaces: (image: string[], ctrl: boolean) => void;
|
onClickFaces: (image: string[], ctrl: boolean) => void;
|
||||||
onRefresh: (
|
onRefresh: (
|
||||||
data?:
|
data?:
|
||||||
@ -850,6 +895,7 @@ function FaceAttemptGroup({
|
|||||||
event,
|
event,
|
||||||
faceNames,
|
faceNames,
|
||||||
selectedFaces,
|
selectedFaces,
|
||||||
|
showSelectionCheckboxes,
|
||||||
onClickFaces,
|
onClickFaces,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: FaceAttemptGroupProps) {
|
}: FaceAttemptGroupProps) {
|
||||||
@ -999,6 +1045,29 @@ function FaceAttemptGroup({
|
|||||||
};
|
};
|
||||||
}, [event]);
|
}, [event]);
|
||||||
|
|
||||||
|
const toggleGroupSelection = useCallback(() => {
|
||||||
|
const selectedCount = group.filter((face) =>
|
||||||
|
selectedFaces.includes(face.filename),
|
||||||
|
).length;
|
||||||
|
const allSelected = selectedCount === group.length;
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
onClickFaces(
|
||||||
|
group
|
||||||
|
.filter((face) => selectedFaces.includes(face.filename))
|
||||||
|
.map((face) => face.filename),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onClickFaces(
|
||||||
|
group
|
||||||
|
.filter((face) => !selectedFaces.includes(face.filename))
|
||||||
|
.map((face) => face.filename),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [group, onClickFaces, selectedFaces]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GroupedClassificationCard
|
<GroupedClassificationCard
|
||||||
group={group}
|
group={group}
|
||||||
@ -1008,6 +1077,24 @@ function FaceAttemptGroup({
|
|||||||
i18nLibrary="views/faceLibrary"
|
i18nLibrary="views/faceLibrary"
|
||||||
objectType="person"
|
objectType="person"
|
||||||
noClassificationLabel="details.unknown"
|
noClassificationLabel="details.unknown"
|
||||||
|
topLeftContent={
|
||||||
|
showSelectionCheckboxes ? (
|
||||||
|
<div className="rounded bg-black/60 p-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
group.filter((face) => selectedFaces.includes(face.filename))
|
||||||
|
.length === group.length
|
||||||
|
? true
|
||||||
|
: group.some((face) => selectedFaces.includes(face.filename))
|
||||||
|
? "indeterminate"
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={toggleGroupSelection}
|
||||||
|
aria-label={t("button.selectGroup")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
onClick={(data) => {
|
onClick={(data) => {
|
||||||
if (data) {
|
if (data) {
|
||||||
onClickFaces([data.filename], true);
|
onClickFaces([data.filename], true);
|
||||||
@ -1045,6 +1132,7 @@ type FaceGridProps = {
|
|||||||
faceImages: string[];
|
faceImages: string[];
|
||||||
pageToggle: string;
|
pageToggle: string;
|
||||||
selectedFaces: string[];
|
selectedFaces: string[];
|
||||||
|
showSelectionCheckboxes: boolean;
|
||||||
onClickFaces: (images: string[], ctrl: boolean) => void;
|
onClickFaces: (images: string[], ctrl: boolean) => void;
|
||||||
onDelete: (name: string, ids: string[]) => void;
|
onDelete: (name: string, ids: string[]) => void;
|
||||||
};
|
};
|
||||||
@ -1053,6 +1141,7 @@ function FaceGrid({
|
|||||||
faceImages,
|
faceImages,
|
||||||
pageToggle,
|
pageToggle,
|
||||||
selectedFaces,
|
selectedFaces,
|
||||||
|
showSelectionCheckboxes,
|
||||||
onClickFaces,
|
onClickFaces,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: FaceGridProps) {
|
}: FaceGridProps) {
|
||||||
@ -1088,9 +1177,22 @@ function FaceGrid({
|
|||||||
filepath: `clips/faces/${pageToggle}/${image}`,
|
filepath: `clips/faces/${pageToggle}/${image}`,
|
||||||
}}
|
}}
|
||||||
selected={selectedFaces.includes(image)}
|
selected={selectedFaces.includes(image)}
|
||||||
clickable={selectedFaces.length > 0}
|
clickable={selectedFaces.length > 0 || showSelectionCheckboxes}
|
||||||
i18nLibrary="views/faceLibrary"
|
i18nLibrary="views/faceLibrary"
|
||||||
onClick={(data, meta) => onClickFaces([data.filename], meta)}
|
topLeftContent={
|
||||||
|
showSelectionCheckboxes ? (
|
||||||
|
<div className="rounded bg-black/60 p-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedFaces.includes(image)}
|
||||||
|
onCheckedChange={() => onClickFaces([image], true)}
|
||||||
|
aria-label={t("button.selectImage")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={(data, meta) =>
|
||||||
|
onClickFaces([data.filename], meta || showSelectionCheckboxes)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { isDesktop, isMobileOnly } from "react-device-detect";
|
import { isDesktop, isMobileOnly } from "react-device-detect";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
import { LuListChecks, LuPencil, LuTrash2 } from "react-icons/lu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import ClassificationSelectionDialog from "@/components/overlay/ClassificationSelectionDialog";
|
import ClassificationSelectionDialog from "@/components/overlay/ClassificationSelectionDialog";
|
||||||
@ -76,6 +76,7 @@ import SearchDetailDialog, {
|
|||||||
import { SearchResult } from "@/types/search";
|
import { SearchResult } from "@/types/search";
|
||||||
import { HiSparkles } from "react-icons/hi";
|
import { HiSparkles } from "react-icons/hi";
|
||||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
type ModelTrainingViewProps = {
|
type ModelTrainingViewProps = {
|
||||||
model: CustomClassificationModelConfig;
|
model: CustomClassificationModelConfig;
|
||||||
@ -150,10 +151,21 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
// image multiselect
|
// image multiselect
|
||||||
|
|
||||||
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
||||||
|
const [selectionModeEnabled, setSelectionModeEnabled] = useState(false);
|
||||||
|
|
||||||
|
const toggleSelectionMode = useCallback(() => {
|
||||||
|
setSelectionModeEnabled((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (!next) {
|
||||||
|
setSelectedImages([]);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onClickImages = useCallback(
|
const onClickImages = useCallback(
|
||||||
(images: string[], ctrl: boolean) => {
|
(images: string[], ctrl: boolean) => {
|
||||||
if (selectedImages.length == 0 && !ctrl) {
|
if (!selectionModeEnabled && selectedImages.length == 0 && !ctrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,7 +191,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
|
|
||||||
setSelectedImages(newSelectedImages);
|
setSelectedImages(newSelectedImages);
|
||||||
},
|
},
|
||||||
[selectedImages, setSelectedImages],
|
[selectionModeEnabled, selectedImages, setSelectedImages],
|
||||||
);
|
);
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
@ -525,6 +537,19 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</ClassificationSelectionDialog>
|
</ClassificationSelectionDialog>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
className="flex gap-2"
|
||||||
|
variant={selectionModeEnabled ? "select" : "default"}
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
>
|
||||||
|
<LuListChecks className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
{isDesktop &&
|
||||||
|
t(
|
||||||
|
selectionModeEnabled
|
||||||
|
? "button.disableSelection"
|
||||||
|
: "button.enableSelection",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
onClick={() => setDeleteDialogOpen(selectedImages)}
|
onClick={() => setDeleteDialogOpen(selectedImages)}
|
||||||
@ -535,6 +560,19 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex gap-2"
|
||||||
|
variant={selectionModeEnabled ? "select" : "default"}
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
>
|
||||||
|
<LuListChecks className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
{isDesktop &&
|
||||||
|
t(
|
||||||
|
selectionModeEnabled
|
||||||
|
? "button.disableSelection"
|
||||||
|
: "button.enableSelection",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<TrainFilterDialog
|
<TrainFilterDialog
|
||||||
filter={trainFilter}
|
filter={trainFilter}
|
||||||
filterValues={{ classes: Object.keys(dataset || {}) }}
|
filterValues={{ classes: Object.keys(dataset || {}) }}
|
||||||
@ -593,6 +631,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
trainImages={trainImages || []}
|
trainImages={trainImages || []}
|
||||||
trainFilter={trainFilter}
|
trainFilter={trainFilter}
|
||||||
selectedImages={selectedImages}
|
selectedImages={selectedImages}
|
||||||
|
showSelectionCheckboxes={selectionModeEnabled}
|
||||||
onRefresh={refreshAll}
|
onRefresh={refreshAll}
|
||||||
onClickImages={onClickImages}
|
onClickImages={onClickImages}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@ -604,6 +643,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
categoryName={pageToggle}
|
categoryName={pageToggle}
|
||||||
images={dataset?.[pageToggle] || []}
|
images={dataset?.[pageToggle] || []}
|
||||||
selectedImages={selectedImages}
|
selectedImages={selectedImages}
|
||||||
|
showSelectionCheckboxes={selectionModeEnabled}
|
||||||
onClickImages={onClickImages}
|
onClickImages={onClickImages}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
@ -839,6 +879,7 @@ type DatasetGridProps = {
|
|||||||
categoryName: string;
|
categoryName: string;
|
||||||
images: string[];
|
images: string[];
|
||||||
selectedImages: string[];
|
selectedImages: string[];
|
||||||
|
showSelectionCheckboxes: boolean;
|
||||||
onClickImages: (images: string[], ctrl: boolean) => void;
|
onClickImages: (images: string[], ctrl: boolean) => void;
|
||||||
onDelete: (ids: string[]) => void;
|
onDelete: (ids: string[]) => void;
|
||||||
};
|
};
|
||||||
@ -848,6 +889,7 @@ function DatasetGrid({
|
|||||||
categoryName,
|
categoryName,
|
||||||
images,
|
images,
|
||||||
selectedImages,
|
selectedImages,
|
||||||
|
showSelectionCheckboxes,
|
||||||
onClickImages,
|
onClickImages,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: DatasetGridProps) {
|
}: DatasetGridProps) {
|
||||||
@ -872,10 +914,23 @@ function DatasetGrid({
|
|||||||
name: "",
|
name: "",
|
||||||
}}
|
}}
|
||||||
showArea={false}
|
showArea={false}
|
||||||
clickable={selectedImages.length > 0}
|
clickable={selectedImages.length > 0 || showSelectionCheckboxes}
|
||||||
selected={selectedImages.includes(image)}
|
selected={selectedImages.includes(image)}
|
||||||
i18nLibrary="views/classificationModel"
|
i18nLibrary="views/classificationModel"
|
||||||
onClick={(data, _) => onClickImages([data.filename], true)}
|
topLeftContent={
|
||||||
|
showSelectionCheckboxes ? (
|
||||||
|
<div className="rounded bg-black/60 p-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedImages.includes(image)}
|
||||||
|
onCheckedChange={() => onClickImages([image], true)}
|
||||||
|
aria-label={t("button.selectImage")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={(data, meta) =>
|
||||||
|
onClickImages([data.filename], meta || showSelectionCheckboxes)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
@ -905,6 +960,7 @@ type TrainGridProps = {
|
|||||||
trainImages: string[];
|
trainImages: string[];
|
||||||
trainFilter?: TrainFilter;
|
trainFilter?: TrainFilter;
|
||||||
selectedImages: string[];
|
selectedImages: string[];
|
||||||
|
showSelectionCheckboxes: boolean;
|
||||||
onClickImages: (images: string[], ctrl: boolean) => void;
|
onClickImages: (images: string[], ctrl: boolean) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onDelete: (ids: string[]) => void;
|
onDelete: (ids: string[]) => void;
|
||||||
@ -916,6 +972,7 @@ function TrainGrid({
|
|||||||
trainImages,
|
trainImages,
|
||||||
trainFilter,
|
trainFilter,
|
||||||
selectedImages,
|
selectedImages,
|
||||||
|
showSelectionCheckboxes,
|
||||||
onClickImages,
|
onClickImages,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onDelete,
|
onDelete,
|
||||||
@ -972,6 +1029,7 @@ function TrainGrid({
|
|||||||
classes={classes}
|
classes={classes}
|
||||||
trainData={trainData}
|
trainData={trainData}
|
||||||
selectedImages={selectedImages}
|
selectedImages={selectedImages}
|
||||||
|
showSelectionCheckboxes={showSelectionCheckboxes}
|
||||||
onClickImages={onClickImages}
|
onClickImages={onClickImages}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@ -986,6 +1044,7 @@ function TrainGrid({
|
|||||||
classes={classes}
|
classes={classes}
|
||||||
trainData={trainData}
|
trainData={trainData}
|
||||||
selectedImages={selectedImages}
|
selectedImages={selectedImages}
|
||||||
|
showSelectionCheckboxes={showSelectionCheckboxes}
|
||||||
onClickImages={onClickImages}
|
onClickImages={onClickImages}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
@ -998,6 +1057,7 @@ type StateTrainGridProps = {
|
|||||||
classes: string[];
|
classes: string[];
|
||||||
trainData?: ClassificationItemData[];
|
trainData?: ClassificationItemData[];
|
||||||
selectedImages: string[];
|
selectedImages: string[];
|
||||||
|
showSelectionCheckboxes: boolean;
|
||||||
onClickImages: (images: string[], ctrl: boolean) => void;
|
onClickImages: (images: string[], ctrl: boolean) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onDelete: (ids: string[]) => void;
|
onDelete: (ids: string[]) => void;
|
||||||
@ -1008,9 +1068,12 @@ function StateTrainGrid({
|
|||||||
classes,
|
classes,
|
||||||
trainData,
|
trainData,
|
||||||
selectedImages,
|
selectedImages,
|
||||||
|
showSelectionCheckboxes,
|
||||||
onClickImages,
|
onClickImages,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: StateTrainGridProps) {
|
}: StateTrainGridProps) {
|
||||||
|
const { t } = useTranslation(["views/classificationModel"]);
|
||||||
|
|
||||||
const threshold = useMemo(() => {
|
const threshold = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
recognition: model.threshold,
|
recognition: model.threshold,
|
||||||
@ -1031,15 +1094,29 @@ function StateTrainGrid({
|
|||||||
data={data}
|
data={data}
|
||||||
threshold={threshold}
|
threshold={threshold}
|
||||||
selected={selectedImages.includes(data.filename)}
|
selected={selectedImages.includes(data.filename)}
|
||||||
clickable={selectedImages.length > 0}
|
clickable={selectedImages.length > 0 || showSelectionCheckboxes}
|
||||||
i18nLibrary="views/classificationModel"
|
i18nLibrary="views/classificationModel"
|
||||||
showArea={false}
|
showArea={false}
|
||||||
onClick={(data, meta) => onClickImages([data.filename], meta)}
|
topLeftContent={
|
||||||
|
showSelectionCheckboxes ? (
|
||||||
|
<div className="rounded bg-black/60 p-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedImages.includes(data.filename)}
|
||||||
|
onCheckedChange={() => onClickImages([data.filename], true)}
|
||||||
|
aria-label={t("button.selectImage")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={(data, meta) =>
|
||||||
|
onClickImages([data.filename], meta || showSelectionCheckboxes)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ClassificationSelectionDialog
|
<ClassificationSelectionDialog
|
||||||
classes={classes}
|
classes={classes}
|
||||||
modelName={model.name}
|
modelName={model.name}
|
||||||
image={data.filename}
|
image={data.filename}
|
||||||
|
images={selectedImages}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
>
|
>
|
||||||
<BlurredIconButton>
|
<BlurredIconButton>
|
||||||
@ -1059,6 +1136,7 @@ type ObjectTrainGridProps = {
|
|||||||
classes: string[];
|
classes: string[];
|
||||||
trainData?: ClassificationItemData[];
|
trainData?: ClassificationItemData[];
|
||||||
selectedImages: string[];
|
selectedImages: string[];
|
||||||
|
showSelectionCheckboxes: boolean;
|
||||||
onClickImages: (images: string[], ctrl: boolean) => void;
|
onClickImages: (images: string[], ctrl: boolean) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
@ -1068,9 +1146,12 @@ function ObjectTrainGrid({
|
|||||||
classes,
|
classes,
|
||||||
trainData,
|
trainData,
|
||||||
selectedImages,
|
selectedImages,
|
||||||
|
showSelectionCheckboxes,
|
||||||
onClickImages,
|
onClickImages,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: ObjectTrainGridProps) {
|
}: ObjectTrainGridProps) {
|
||||||
|
const { t } = useTranslation(["views/classificationModel"]);
|
||||||
|
|
||||||
// item data
|
// item data
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
@ -1172,6 +1253,32 @@ function ObjectTrainGrid({
|
|||||||
[selectedImages, onClickImages],
|
[selectedImages, onClickImages],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toggleGroupSelection = useCallback(
|
||||||
|
(group: ClassificationItemData[]) => {
|
||||||
|
const selectedCount = group.filter((item) =>
|
||||||
|
selectedImages.includes(item.filename),
|
||||||
|
).length;
|
||||||
|
const allSelected = selectedCount === group.length;
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
onClickImages(
|
||||||
|
group
|
||||||
|
.filter((item) => selectedImages.includes(item.filename))
|
||||||
|
.map((item) => item.filename),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onClickImages(
|
||||||
|
group
|
||||||
|
.filter((item) => !selectedImages.includes(item.filename))
|
||||||
|
.map((item) => item.filename),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClickImages, selectedImages],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchDetailDialog
|
<SearchDetailDialog
|
||||||
@ -1205,6 +1312,27 @@ function ObjectTrainGrid({
|
|||||||
i18nLibrary="views/classificationModel"
|
i18nLibrary="views/classificationModel"
|
||||||
objectType={model.object_config?.objects?.at(0) ?? "Object"}
|
objectType={model.object_config?.objects?.at(0) ?? "Object"}
|
||||||
noClassificationLabel="details.none"
|
noClassificationLabel="details.none"
|
||||||
|
topLeftContent={
|
||||||
|
showSelectionCheckboxes ? (
|
||||||
|
<div className="rounded bg-black/60 p-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
group.filter((item) =>
|
||||||
|
selectedImages.includes(item.filename),
|
||||||
|
).length === group.length
|
||||||
|
? true
|
||||||
|
: group.some((item) =>
|
||||||
|
selectedImages.includes(item.filename),
|
||||||
|
)
|
||||||
|
? "indeterminate"
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={() => toggleGroupSelection(group)}
|
||||||
|
aria-label={t("button.selectGroup")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
onClick={(data) => {
|
onClick={(data) => {
|
||||||
if (data) {
|
if (data) {
|
||||||
onClickImages([data.filename], true);
|
onClickImages([data.filename], true);
|
||||||
@ -1219,6 +1347,7 @@ function ObjectTrainGrid({
|
|||||||
classes={classes}
|
classes={classes}
|
||||||
modelName={model.name}
|
modelName={model.name}
|
||||||
image={data.filename}
|
image={data.filename}
|
||||||
|
images={selectedImages}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
>
|
>
|
||||||
<BlurredIconButton>
|
<BlurredIconButton>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user