Add checkbox selection mode for classification and face grids

This commit is contained in:
Teagan glenn 2026-02-21 23:49:15 -07:00
parent 310d52de4e
commit 8b65ce3946
6 changed files with 354 additions and 38 deletions

View File

@ -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",

View File

@ -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": {

View File

@ -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);

View File

@ -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}

View File

@ -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>

View File

@ -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>