From 64db518837058226cad6a9fb9db6fb889b2425c8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 10 Apr 2025 09:27:01 -0500 Subject: [PATCH] Edit license plate in Tracked Object Details (#17631) * api endpoint * frontend * only allow admins to edit sub labels and plates --- frigate/api/defs/request/events_body.py | 9 + frigate/api/event.py | 55 ++++++ web/public/locales/en/views/explore.json | 12 +- .../overlay/detail/SearchDetailDialog.tsx | 157 +++++++++++++++--- 4 files changed, 212 insertions(+), 21 deletions(-) diff --git a/frigate/api/defs/request/events_body.py b/frigate/api/defs/request/events_body.py index 0fefbe43f..0883d066f 100644 --- a/frigate/api/defs/request/events_body.py +++ b/frigate/api/defs/request/events_body.py @@ -13,6 +13,15 @@ class EventsSubLabelBody(BaseModel): ) +class EventsLPRBody(BaseModel): + recognizedLicensePlate: str = Field( + title="Recognized License Plate", max_length=100 + ) + recognizedLicensePlateScore: Optional[float] = Field( + title="Score for recognized license plate", default=None, gt=0.0, le=1.0 + ) + + class EventsDescriptionBody(BaseModel): description: Union[str, None] = Field(title="The description of the event") diff --git a/frigate/api/event.py b/frigate/api/event.py index 84994f20a..4287e829a 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -31,6 +31,7 @@ from frigate.api.defs.request.events_body import ( EventsDeleteBody, EventsDescriptionBody, EventsEndBody, + EventsLPRBody, EventsSubLabelBody, SubmitPlusBody, ) @@ -1101,6 +1102,60 @@ def set_sub_label( ) +@router.post( + "/events/{event_id}/recognized_license_plate", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) +def set_plate( + request: Request, + event_id: str, + body: EventsLPRBody, +): + try: + event: Event = Event.get(Event.id == event_id) + except DoesNotExist: + event = None + + if request.app.detected_frames_processor: + tracked_obj: TrackedObject = None + + for state in request.app.detected_frames_processor.camera_states.values(): + tracked_obj = state.tracked_objects.get(event_id) + + if tracked_obj is not None: + break + else: + tracked_obj = None + + if not event and not tracked_obj: + return JSONResponse( + content=( + {"success": False, "message": "Event " + event_id + " not found."} + ), + status_code=404, + ) + + new_plate = body.recognizedLicensePlate + new_score = body.recognizedLicensePlateScore + + if new_plate == "": + new_plate = None + new_score = None + + request.app.event_metadata_updater.publish( + EventMetadataTypeEnum.recognized_license_plate, (event_id, new_plate, new_score) + ) + + return JSONResponse( + content={ + "success": True, + "message": f"Event {event_id} license plate set to {new_plate if new_plate is not None else 'None'}", + }, + status_code=200, + ) + + @router.post( "/events/{event_id}/description", response_model=GenericResponse, diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index 2f5b5bcb1..8b9b39c84 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -91,11 +91,13 @@ "toast": { "success": { "regenerate": "A new description has been requested from {{provider}}. Depending on the speed of your provider, the new description may take some time to regenerate.", - "updatedSublabel": "Successfully updated sub label." + "updatedSublabel": "Successfully updated sub label.", + "updatedLPR": "Successfully updated license plate." }, "error": { "regenerate": "Failed to call {{provider}} for a new description: {{errorMessage}}", - "updatedSublabelFailed": "Failed to update sub label: {{errorMessage}}" + "updatedSublabelFailed": "Failed to update sub label: {{errorMessage}}", + "updatedLPRFailed": "Failed to update license plate: {{errorMessage}}" } } }, @@ -105,10 +107,16 @@ "desc": "Enter a new sub label for this {{label}}", "descNoLabel": "Enter a new sub label for this tracked object" }, + "editLPR": { + "title": "Edit license plate", + "desc": "Enter a new license plate value for this {{label}}", + "descNoLabel": "Enter a new license plate value for this tracked object" + }, "topScore": { "label": "Top Score", "info": "The top score is the highest median score for the tracked object, so this may differ from the score shown on the search result thumbnail." }, + "recognizedLicensePlate": "Recognized License Plate", "estimatedSpeed": "Estimated Speed", "objects": "Objects", "camera": "Camera", diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 98b093b8f..6762890d4 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -76,6 +76,7 @@ import { FaPencilAlt } from "react-icons/fa"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; import { useTranslation } from "react-i18next"; import { TbFaceId } from "react-icons/tb"; +import { useIsAdmin } from "@/hooks/use-is-admin"; const SEARCH_TABS = [ "details", @@ -295,10 +296,15 @@ function ObjectDetailsTab({ const mutate = useGlobalMutation(); + // users + + const isAdmin = useIsAdmin(); + // data const [desc, setDesc] = useState(search?.data.description); const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false); + const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false); const handleDescriptionFocus = useCallback(() => { setInputFocused(true); @@ -557,6 +563,83 @@ function ObjectDetailsTab({ [search, apiHost, mutate, setSearch, t], ); + // recognized plate + + const handleLPRSave = useCallback( + (text: string) => { + if (!search) return; + + // set score to 1.0 if we're manually entering a new plate + const plateScore = text === "" ? undefined : 1.0; + + axios + .post(`${apiHost}api/events/${search.id}/recognized_license_plate`, { + recognizedLicensePlate: text, + recognizedLicensePlateScore: plateScore, + }) + .then((response) => { + if (response.status === 200) { + toast.success(t("details.item.toast.success.updatedLPR"), { + position: "top-center", + }); + + mutate( + (key) => + typeof key === "string" && + (key.includes("events") || + key.includes("events/search") || + key.includes("events/explore")), + (currentData: SearchResult[][] | SearchResult[] | undefined) => { + if (!currentData) return currentData; + return currentData.flat().map((event) => + event.id === search.id + ? { + ...event, + data: { + ...event.data, + recognized_license_plate: text, + recognized_license_plate_score: plateScore, + }, + } + : event, + ); + }, + { + optimisticData: true, + rollbackOnError: true, + revalidate: false, + }, + ); + + setSearch({ + ...search, + data: { + ...search.data, + recognized_license_plate: text, + recognized_license_plate_score: plateScore, + }, + }); + setIsLPRDialogOpen(false); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error( + t("details.item.toast.error.updatedLPRFailed", { + errorMessage, + }), + { + position: "top-center", + }, + ); + }); + }, + [search, apiHost, mutate, setSearch, t], + ); + // face training const hasFace = useMemo(() => { @@ -609,35 +692,56 @@ function ObjectDetailsTab({ {getIconForLabel(search.label, "size-4 text-primary")} {t(search.label, { ns: "objects" })} {search.sub_label && ` (${search.sub_label})`} - - - - { - setIsSubLabelDialogOpen(true); - }} - /> - - - - - {t("details.editSubLabel.title")} - - - + {isAdmin && ( + + + + { + setIsSubLabelDialogOpen(true); + }} + /> + + + + + {t("details.editSubLabel.title")} + + + + )} {search?.data.recognized_license_plate && (
- Recognized License Plate + {t("details.recognizedLicensePlate")}
{search.data.recognized_license_plate}{" "} {recognizedLicensePlateScore && ` (${recognizedLicensePlateScore}%)`} + {isAdmin && ( + + + + { + setIsLPRDialogOpen(true); + }} + /> + + + + + {t("details.editLPR.title")} + + + + )}
@@ -859,7 +963,7 @@ function ObjectDetailsTab({ description={ search.label ? t("details.editSubLabel.desc", { - label: t(search.label, { an: "objects" }), + label: t(search.label, { ns: "objects" }), }) : t("details.editSubLabel.descNoLabel") } @@ -867,6 +971,21 @@ function ObjectDetailsTab({ defaultValue={search?.sub_label || ""} allowEmpty={true} /> +