From da6abdaa1df72ca20c2f7873a73e1222ecee8464 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:01:38 +0000 Subject: [PATCH 01/11] Initial plan From 99207e66c3564c54c6ec8cfb5cdcbf89485b8ad5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:06:14 +0000 Subject: [PATCH 02/11] Updated plan: add batch classification assignment for multiple images Co-authored-by: Teagan42 <2989925+Teagan42@users.noreply.github.com> --- web/src/pages/FaceLibrary.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 7595b3cd9..89340f466 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -72,6 +72,10 @@ import { ClassificationItemData, ClassifiedEvent, } from "@/types/classification"; +import SearchDetailDialog, { + SearchTab, +} from "@/components/overlay/detail/SearchDetailDialog"; +import { SearchResult } from "@/types/search"; export default function FaceLibrary() { const { t } = useTranslation(["views/faceLibrary"]); From 8d0b4b83223e945cec85606cb2ffdb6dd95c639c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:07:11 +0000 Subject: [PATCH 03/11] Add batch face assignment to face library training view Co-authored-by: Teagan42 <2989925+Teagan42@users.noreply.github.com> --- web/src/pages/FaceLibrary.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 89340f466..167dd9325 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -193,6 +193,12 @@ export default function FaceLibrary() { ids: string[]; } | null>(null); + // detail dialog + const [selectedEvent, setSelectedEvent] = useState( + undefined, + ); + const [dialogTab, setDialogTab] = useState("tracking_details"); + const onDelete = useCallback( (name: string, ids: string[], isName: boolean = false) => { axios @@ -451,6 +457,10 @@ export default function FaceLibrary() { selectedFaces={selectedFaces} onClickFaces={onClickFaces} onRefresh={refreshFaces} + onClickEvent={(event) => { + setSelectedEvent(event); + setDialogTab("tracking_details"); + }} /> ) : ( )) )} + + setSelectedEvent(search as unknown as Event)} + setInputFocused={() => {}} + /> ); } @@ -675,6 +696,7 @@ type TrainingGridProps = { ) => FaceLibraryData | undefined), opts?: boolean | { revalidate?: boolean }, ) => Promise; + onClickEvent: (event: Event) => void; }; function TrainingGrid({ config, @@ -684,6 +706,7 @@ function TrainingGrid({ selectedFaces, onClickFaces, onRefresh, + onClickEvent, }: TrainingGridProps) { const { t } = useTranslation(["views/faceLibrary"]); @@ -763,6 +786,7 @@ function TrainingGrid({ selectedFaces={selectedFaces} onClickFaces={onClickFaces} onRefresh={onRefresh} + onClickEvent={onClickEvent} /> ); From 9a41eaceb9ea8ccb54e1012ed823314a53024d91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:08:10 +0000 Subject: [PATCH 04/11] Revised plan: focus on batch assignment and explore view assignment Co-authored-by: Teagan42 <2989925+Teagan42@users.noreply.github.com> --- web/src/pages/FaceLibrary.tsx | 88 ++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 167dd9325..cbcb7c7de 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -416,6 +416,86 @@ export default function FaceLibrary() { )} + {pageToggle === "train" && ( + { + // Batch train all selected faces + let successCount = 0; + let failCount = 0; + const totalCount = selectedFaces.length; + + selectedFaces.forEach((filename, index) => { + axios + .post(`/faces/train/${name}/classify`, { + training_file: filename, + }) + .then((resp) => { + if (resp.status == 200) { + successCount++; + } else { + failCount++; + } + + // Show final toast after all requests complete + if (index === totalCount - 1) { + if (successCount === totalCount) { + toast.success( + t("toast.success.batchTrainedFaces", { + count: successCount, + }), + { + position: "top-center", + }, + ); + } else if (successCount > 0) { + toast.warning( + t("toast.warning.partialBatchTrained", { + success: successCount, + total: totalCount, + }), + { + position: "top-center", + }, + ); + } else { + toast.error( + t("toast.error.batchTrainFailed", { + count: totalCount, + }), + { + position: "top-center", + }, + ); + } + setSelectedFaces([]); + refreshFaces(); + } + }) + .catch(() => { + failCount++; + if (index === totalCount - 1) { + toast.error( + t("toast.error.batchTrainFailed", { + count: totalCount, + }), + { + position: "top-center", + }, + ); + setSelectedFaces([]); + refreshFaces(); + } + }); + }); + }} + > + + + )} + + )} + + )} + {availableClassificationModels.length > 0 && + availableClassificationModels.map((modelName) => { + const model = config?.classification?.custom?.[modelName]; + if (!model) return null; + + const classes = Object.keys(modelAttributes?.[modelName] ?? {}); + if (classes.length === 0) return null; + + return ( + {}} + onCategorize={(category) => + onAssignToClassification(modelName, category) + } + > + + + ); + })} + + + )} + {isAdmin && search.data.type === "object" && config?.plus?.enabled && From ae6d6ce2e56822240ee5c50248aefad7b651e4de Mon Sep 17 00:00:00 2001 From: Teagan glenn Date: Sat, 21 Feb 2026 22:41:47 -0700 Subject: [PATCH 10/11] Fix classification/face batch actions and snapshot handling --- frigate/api/classification.py | 25 ++-- web/src/pages/FaceLibrary.tsx | 104 +++++++--------- .../classification/ModelTrainingView.tsx | 117 ++++++++---------- 3 files changed, 104 insertions(+), 142 deletions(-) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index 3df55551e..32a466a03 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -970,6 +970,17 @@ def categorize_classification_image(request: Request, name: str, body: dict = No snapshot = get_event_snapshot(event) + if snapshot is None: + return JSONResponse( + content=( + { + "success": False, + "message": f"Failed to read snapshot for event {event_id}.", + } + ), + status_code=500, + ) + # Get object bounding box for the first detection if not event.data.get("attributes") or len(event.data["attributes"]) == 0: return JSONResponse( @@ -987,19 +998,7 @@ def categorize_classification_image(request: Request, name: str, body: dict = No try: # Extract the crop from the snapshot - detect_config: DetectConfig = config.cameras[event.camera].detect - frame = cv2.imread(snapshot) - - if frame is None: - return JSONResponse( - content=( - { - "success": False, - "message": f"Failed to read snapshot for event {event_id}.", - } - ), - status_code=500, - ) + frame = snapshot height, width = frame.shape[:2] diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 411ecb672..039eb5ddf 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -410,73 +410,53 @@ export default function FaceLibrary() { { - // Batch train all selected faces - let successCount = 0; - let failCount = 0; - const totalCount = selectedFaces.length; - - selectedFaces.forEach((filename, index) => { + const requests = selectedFaces.map((filename) => axios .post(`/faces/train/${name}/classify`, { training_file: filename, }) - .then((resp) => { - if (resp.status == 200) { - successCount++; - } else { - failCount++; - } + .then(() => true) + .catch(() => false), + ); - // Show final toast after all requests complete - if (index === totalCount - 1) { - if (successCount === totalCount) { - toast.success( - t("toast.success.batchTrainedFaces", { - count: successCount, - }), - { - position: "top-center", - }, - ); - } else if (successCount > 0) { - toast.warning( - t("toast.warning.partialBatchTrained", { - success: successCount, - total: totalCount, - }), - { - position: "top-center", - }, - ); - } else { - toast.error( - t("toast.error.batchTrainFailed", { - count: totalCount, - }), - { - position: "top-center", - }, - ); - } - setSelectedFaces([]); - refreshFaces(); - } - }) - .catch(() => { - failCount++; - if (index === totalCount - 1) { - toast.error( - t("toast.error.batchTrainFailed", { - count: totalCount, - }), - { - position: "top-center", - }, - ); - setSelectedFaces([]); - refreshFaces(); - } - }); + 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.batchTrainedFaces", { + count: successCount, + }), + { + position: "top-center", + }, + ); + } else if (successCount > 0) { + toast.warning( + t("toast.warning.partialBatchTrained", { + success: successCount, + total: totalCount, + }), + { + position: "top-center", + }, + ); + } else { + toast.error( + t("toast.error.batchTrainFailed", { + count: totalCount, + }), + { + position: "top-center", + }, + ); + } + + setSelectedFaces([]); + refreshFaces(); }); }} > diff --git a/web/src/views/classification/ModelTrainingView.tsx b/web/src/views/classification/ModelTrainingView.tsx index 296bdb395..464807475 100644 --- a/web/src/views/classification/ModelTrainingView.tsx +++ b/web/src/views/classification/ModelTrainingView.tsx @@ -460,79 +460,62 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { {pageToggle === "train" && ( { - // Batch categorize all selected images - let successCount = 0; - let failCount = 0; - const totalCount = selectedImages.length; - - selectedImages.forEach((filename, index) => { + const requests = selectedImages.map((filename) => axios - .post(`/classification/${model.name}/dataset/categorize`, { - category, - training_file: filename, - }) - .then((resp) => { - if (resp.status == 200) { - successCount++; - } else { - failCount++; - } + .post( + `/classification/${model.name}/dataset/categorize`, + { + category, + training_file: filename, + }, + ) + .then(() => true) + .catch(() => false), + ); - // Show final toast after all requests complete - if (index === totalCount - 1) { - 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", - }, - ); - } - setSelectedImages([]); - refreshAll(); - } - }) - .catch(() => { - failCount++; - if (index === totalCount - 1) { - toast.error( - t("toast.error.batchCategorizeFailed", { - count: totalCount, - }), - { - position: "top-center", - }, - ); - setSelectedImages([]); - refreshAll(); - } - }); + 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", + }, + ); + } + + setSelectedImages([]); + refreshAll(); }); }} > From 86f82c6e0a002bf9f52d1dbb3d2d4aad53e7af04 Mon Sep 17 00:00:00 2001 From: Teagan glenn Date: Sat, 21 Feb 2026 22:46:57 -0700 Subject: [PATCH 11/11] Fix classification assignment classes lookup in detail dialog --- web/src/components/overlay/detail/SearchDetailDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index d6571a7f3..9f430fef5 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -1595,7 +1595,8 @@ function ObjectDetailsTab({ const model = config?.classification?.custom?.[modelName]; if (!model) return null; - const classes = Object.keys(modelAttributes?.[modelName] ?? {}); + const displayName = model.name || modelName; + const classes = modelAttributes?.[displayName] ?? []; if (classes.length === 0) return null; return (