Merge pull request #9 from constructorfleet/copilot/add-recent-classification-details

Add batch assignment and event-based training for face recognition and classification models
This commit is contained in:
Teagan Glenn 2026-02-21 23:06:09 -07:00 committed by GitHub
commit 310d52de4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 425 additions and 14 deletions

View File

@ -890,7 +890,8 @@ def rename_classification_category(
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Categorize a classification image", summary="Categorize a classification image",
description="""Categorizes a specific classification image for a given classification model and category. description="""Categorizes a specific classification image for a given classification model and category.
The image must exist in the specified category. Returns a success message or an error if the name or category is invalid.""", Accepts either a training file from the train directory or an event_id to extract
the object crop from. Returns a success message or an error if the name or category is invalid.""",
) )
def categorize_classification_image(request: Request, name: str, body: dict = None): def categorize_classification_image(request: Request, name: str, body: dict = None):
config: FrigateConfig = request.app.frigate_config config: FrigateConfig = request.app.frigate_config
@ -909,19 +910,17 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
json: dict[str, Any] = body or {} json: dict[str, Any] = body or {}
category = sanitize_filename(json.get("category", "")) category = sanitize_filename(json.get("category", ""))
training_file_name = sanitize_filename(json.get("training_file", "")) training_file_name = sanitize_filename(json.get("training_file", ""))
training_file = os.path.join( event_id = json.get("event_id")
CLIPS_DIR, sanitize_filename(name), "train", training_file_name
)
if training_file_name and not os.path.isfile(training_file): if not training_file_name and not event_id:
return JSONResponse( return JSONResponse(
content=( content=(
{ {
"success": False, "success": False,
"message": f"Invalid filename or no file exists: {training_file_name}", "message": "A training file or event_id must be passed.",
} }
), ),
status_code=404, status_code=400,
) )
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
@ -933,10 +932,116 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
os.makedirs(new_file_folder, exist_ok=True) os.makedirs(new_file_folder, exist_ok=True)
# use opencv because webp images can not be used to train if training_file_name:
img = cv2.imread(training_file) # Use existing training file
cv2.imwrite(os.path.join(new_file_folder, new_name), img) training_file = os.path.join(
os.unlink(training_file) CLIPS_DIR, sanitize_filename(name), "train", training_file_name
)
if not os.path.isfile(training_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid filename or no file exists: {training_file_name}",
}
),
status_code=404,
)
# use opencv because webp images can not be used to train
img = cv2.imread(training_file)
cv2.imwrite(os.path.join(new_file_folder, new_name), img)
os.unlink(training_file)
else:
# Extract from event
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid event_id or no event exists: {event_id}",
}
),
status_code=404,
)
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(
content=(
{
"success": False,
"message": f"Event {event_id} has no detection attributes.",
}
),
status_code=400,
)
# Use the first attribute's box
box = event.data["attributes"][0]["box"]
try:
# Extract the crop from the snapshot
frame = snapshot
height, width = frame.shape[:2]
# Convert relative coordinates to absolute
x1 = int(box[0] * width)
y1 = int(box[1] * height)
x2 = int(box[2] * width)
y2 = int(box[3] * height)
# Ensure coordinates are within frame boundaries
x1 = max(0, x1)
y1 = max(0, y1)
x2 = min(width, x2)
y2 = min(height, y2)
# Extract the crop
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
return JSONResponse(
content=(
{
"success": False,
"message": f"Failed to extract crop from event {event_id}.",
}
),
status_code=500,
)
# Save the crop
cv2.imwrite(os.path.join(new_file_folder, new_name), crop)
except Exception as e:
logger.error(f"Failed to extract classification crop: {e}")
return JSONResponse(
content=(
{
"success": False,
"message": f"Failed to process event {event_id}: {str(e)}",
}
),
status_code=500,
)
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Successfully categorized image."}), content=({"success": True, "message": "Successfully categorized image."}),

View File

@ -13,7 +13,8 @@
"trainModel": "Train Model", "trainModel": "Train Model",
"addClassification": "Add Classification", "addClassification": "Add Classification",
"deleteModels": "Delete Models", "deleteModels": "Delete Models",
"editModel": "Edit Model" "editModel": "Edit Model",
"categorizeImages": "Classify Images"
}, },
"tooltip": { "tooltip": {
"trainingInProgress": "Model is currently training", "trainingInProgress": "Model is currently training",
@ -28,6 +29,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",
"batchCategorized": "Successfully classified {{count}} images",
"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",
@ -38,10 +40,14 @@
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}", "deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
"deleteModelFailed": "Failed to delete model: {{errorMessage}}", "deleteModelFailed": "Failed to delete model: {{errorMessage}}",
"categorizeFailed": "Failed to categorize image: {{errorMessage}}", "categorizeFailed": "Failed to categorize image: {{errorMessage}}",
"batchCategorizeFailed": "Failed to classify {{count}} images",
"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}}"
},
"warning": {
"partialBatchCategorized": "Classified {{success}} of {{total}} images successfully."
} }
}, },
"deleteCategory": { "deleteCategory": {

View File

@ -143,6 +143,15 @@
}, },
"recognizedLicensePlate": "Recognized License Plate", "recognizedLicensePlate": "Recognized License Plate",
"attributes": "Classification Attributes", "attributes": "Classification Attributes",
"assignment": {
"title": "Assign To",
"assignToFace": "Assign to Face",
"assignToClassification": "Assign to {{model}}",
"faceSuccess": "Successfully assigned to face: {{name}}",
"faceFailed": "Failed to assign to face: {{errorMessage}}",
"classificationSuccess": "Successfully assigned to {{model}} - {{category}}",
"classificationFailed": "Failed to assign classification: {{errorMessage}}"
},
"estimatedSpeed": "Estimated Speed", "estimatedSpeed": "Estimated Speed",
"objects": "Objects", "objects": "Objects",
"camera": "Camera", "camera": "Camera",

View File

@ -53,7 +53,8 @@
"renameFace": "Rename Face", "renameFace": "Rename Face",
"deleteFace": "Delete Face", "deleteFace": "Delete Face",
"uploadImage": "Upload Image", "uploadImage": "Upload Image",
"reprocessFace": "Reprocess Face" "reprocessFace": "Reprocess Face",
"trainFaces": "Train Faces"
}, },
"imageEntry": { "imageEntry": {
"validation": { "validation": {
@ -77,6 +78,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.",
"batchTrainedFaces": "Successfully trained {{count}} faces.",
"updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})." "updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})."
}, },
"error": { "error": {
@ -86,7 +88,11 @@
"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}}",
"batchTrainFailed": "Failed to train {{count}} faces.",
"updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}" "updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}"
},
"warning": {
"partialBatchTrained": "Trained {{success}} of {{total}} faces successfully."
} }
} }
} }

View File

@ -35,6 +35,7 @@ type ClassificationSelectionDialogProps = {
modelName: string; modelName: string;
image: string; image: string;
onRefresh: () => void; onRefresh: () => void;
onCategorize?: (category: string) => void; // Optional custom categorize handler
children: ReactNode; children: ReactNode;
}; };
export default function ClassificationSelectionDialog({ export default function ClassificationSelectionDialog({
@ -43,12 +44,20 @@ export default function ClassificationSelectionDialog({
modelName, modelName,
image, image,
onRefresh, onRefresh,
onCategorize,
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 custom categorize handler is provided, use it instead
if (onCategorize) {
onCategorize(category);
return;
}
// Default behavior: categorize single image
axios axios
.post(`/classification/${modelName}/dataset/categorize`, { .post(`/classification/${modelName}/dataset/categorize`, {
category, category,
@ -72,7 +81,7 @@ export default function ClassificationSelectionDialog({
}); });
}); });
}, },
[modelName, image, onRefresh, t], [modelName, image, onRefresh, onCategorize, t],
); );
const isChildButton = useMemo( const isChildButton = useMemo(

View File

@ -94,6 +94,11 @@ import { useDetailStream } from "@/context/detail-stream-context";
import { PiSlidersHorizontalBold } from "react-icons/pi"; import { PiSlidersHorizontalBold } from "react-icons/pi";
import { HiSparkles } from "react-icons/hi"; import { HiSparkles } from "react-icons/hi";
import { useAudioTranscriptionProcessState } from "@/api/ws"; import { useAudioTranscriptionProcessState } from "@/api/ws";
import FaceSelectionDialog from "@/components/overlay/FaceSelectionDialog";
import ClassificationSelectionDialog from "@/components/overlay/ClassificationSelectionDialog";
import { FaceLibraryData } from "@/types/face";
import AddFaceIcon from "@/components/icons/AddFaceIcon";
import { TbCategoryPlus } from "react-icons/tb";
const SEARCH_TABS = ["snapshot", "tracking_details"] as const; const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
export type SearchTab = (typeof SEARCH_TABS)[number]; export type SearchTab = (typeof SEARCH_TABS)[number];
@ -702,6 +707,21 @@ function ObjectDetailsTab({
: null, : null,
); );
// Fetch available faces for assignment
const { data: faceData } = useSWR<FaceLibraryData>(
config?.face_recognition?.enabled ? "faces" : null,
);
const availableFaceNames = useMemo(() => {
if (!faceData) return [];
return Object.keys(faceData).filter((name) => name !== "train").sort();
}, [faceData]);
const availableClassificationModels = useMemo(() => {
if (!config?.classification?.custom) return [];
return Object.keys(config.classification.custom).sort();
}, [config]);
// mutation / revalidation // mutation / revalidation
const mutate = useGlobalMutation(); const mutate = useGlobalMutation();
@ -1216,6 +1236,85 @@ function ObjectDetailsTab({
}); });
}, [search, t]); }, [search, t]);
// face and classification assignment
const onAssignToFace = useCallback(
(faceName: string) => {
if (!search) {
return;
}
axios
.post(`/faces/train/${faceName}/classify`, {
event_id: search.id,
})
.then((resp) => {
if (resp.status == 200) {
toast.success(t("details.assignment.faceSuccess", { name: faceName }), {
position: "top-center",
});
// Refresh the event data
mutate((key) => isEventsKey(key));
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("details.assignment.faceFailed", { errorMessage }),
{
position: "top-center",
},
);
});
},
[search, t, mutate, isEventsKey],
);
const onAssignToClassification = useCallback(
(modelName: string, category: string) => {
if (!search) {
return;
}
axios
.post(`/classification/${modelName}/dataset/categorize`, {
event_id: search.id,
category,
})
.then((resp) => {
if (resp.status == 200) {
toast.success(
t("details.assignment.classificationSuccess", {
model: modelName,
category,
}),
{
position: "top-center",
},
);
// Refresh the event data
mutate((key) => isEventsKey(key));
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("details.assignment.classificationFailed", { errorMessage }),
{
position: "top-center",
},
);
});
},
[search, t, mutate, isEventsKey],
);
// audio transcription processing state // audio transcription processing state
const { payload: audioTranscriptionProcessState } = const { payload: audioTranscriptionProcessState } =
@ -1474,6 +1573,56 @@ function ObjectDetailsTab({
</div> </div>
</div> </div>
{isAdmin && (availableFaceNames.length > 0 || availableClassificationModels.length > 0) && (
<div className="flex flex-col gap-3">
<div className="text-sm text-primary/40">
{t("details.assignment.title")}
</div>
<div className="flex flex-row gap-2">
{config?.face_recognition?.enabled && availableFaceNames.length > 0 && (
<FaceSelectionDialog
faceNames={availableFaceNames}
onTrainAttempt={onAssignToFace}
>
<Button variant="outline" size="sm" className="flex gap-2">
<AddFaceIcon className="size-4" />
{t("details.assignment.assignToFace")}
</Button>
</FaceSelectionDialog>
)}
{availableClassificationModels.length > 0 &&
availableClassificationModels.map((modelName) => {
const model = config?.classification?.custom?.[modelName];
if (!model) return null;
const displayName = model.name || modelName;
const classes = modelAttributes?.[displayName] ?? [];
if (classes.length === 0) return null;
return (
<ClassificationSelectionDialog
key={modelName}
classes={classes}
modelName={modelName}
image="" // Not needed for event-based assignment
onRefresh={() => {}}
onCategorize={(category) =>
onAssignToClassification(modelName, category)
}
>
<Button variant="outline" size="sm" className="flex gap-2">
<TbCategoryPlus className="size-4" />
{t("details.assignment.assignToClassification", {
model: modelName,
})}
</Button>
</ClassificationSelectionDialog>
);
})}
</div>
</div>
)}
{isAdmin && {isAdmin &&
search.data.type === "object" && search.data.type === "object" &&
config?.plus?.enabled && config?.plus?.enabled &&

View File

@ -406,6 +406,66 @@ export default function FaceLibrary() {
</> </>
)} )}
</div> </div>
{pageToggle === "train" && (
<FaceSelectionDialog
faceNames={faces}
onTrainAttempt={(name) => {
const requests = selectedFaces.map((filename) =>
axios
.post(`/faces/train/${name}/classify`, {
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.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();
});
}}
>
<Button className="flex gap-2">
<AddFaceIcon className="size-7 rounded-md p-1 text-secondary-foreground" />
{isDesktop && t("button.trainFaces")}
</Button>
</FaceSelectionDialog>
)}
<Button <Button
className="flex gap-2" className="flex gap-2"
onClick={() => onClick={() =>

View File

@ -458,6 +458,73 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
</> </>
)} )}
</div> </div>
{pageToggle === "train" && (
<ClassificationSelectionDialog
classes={Object.keys(dataset || {})}
modelName={model.name}
image={selectedImages[0]}
onRefresh={refreshAll}
onCategorize={(category) => {
const requests = selectedImages.map((filename) =>
axios
.post(
`/classification/${model.name}/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",
},
);
}
setSelectedImages([]);
refreshAll();
});
}}
>
<Button className="flex gap-2">
<TbCategoryPlus className="size-7 rounded-md p-1 text-secondary-foreground" />
{isDesktop && t("button.categorizeImages")}
</Button>
</ClassificationSelectionDialog>
)}
<Button <Button
className="flex gap-2" className="flex gap-2"
onClick={() => setDeleteDialogOpen(selectedImages)} onClick={() => setDeleteDialogOpen(selectedImages)}