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})`}
-