Edit license plate in Tracked Object Details (#17631)

* api endpoint

* frontend

* only allow admins to edit sub labels and plates
This commit is contained in:
Josh Hawkins 2025-04-10 09:27:01 -05:00 committed by GitHub
parent d8cde59aaf
commit 64db518837
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 212 additions and 21 deletions

View File

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

View File

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

View File

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

View File

@ -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})`}
<Tooltip>
<TooltipTrigger asChild>
<span>
<FaPencilAlt
className="size-4 cursor-pointer text-primary/40 hover:text-primary/80"
onClick={() => {
setIsSubLabelDialogOpen(true);
}}
/>
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.editSubLabel.title")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
{isAdmin && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<FaPencilAlt
className="size-4 cursor-pointer text-primary/40 hover:text-primary/80"
onClick={() => {
setIsSubLabelDialogOpen(true);
}}
/>
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.editSubLabel.title")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
</div>
</div>
{search?.data.recognized_license_plate && (
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
Recognized License Plate
{t("details.recognizedLicensePlate")}
</div>
<div className="flex flex-col space-y-0.5 text-sm">
<div className="flex flex-row items-center gap-2">
{search.data.recognized_license_plate}{" "}
{recognizedLicensePlateScore &&
` (${recognizedLicensePlateScore}%)`}
{isAdmin && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<FaPencilAlt
className="size-4 cursor-pointer text-primary/40 hover:text-primary/80"
onClick={() => {
setIsLPRDialogOpen(true);
}}
/>
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.editLPR.title")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
</div>
</div>
</div>
@ -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}
/>
<TextEntryDialog
open={isLPRDialogOpen}
setOpen={setIsLPRDialogOpen}
title={t("details.editLPR.title")}
description={
search.label
? t("details.editLPR.desc", {
label: t(search.label, { ns: "objects" }),
})
: t("details.editLPR.descNoLabel")
}
onSave={handleLPRSave}
defaultValue={search?.data.recognized_license_plate || ""}
allowEmpty={true}
/>
</div>
</div>
</div>