mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-08-04 13:47:37 +02:00
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:
parent
d8cde59aaf
commit
64db518837
@ -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):
|
class EventsDescriptionBody(BaseModel):
|
||||||
description: Union[str, None] = Field(title="The description of the event")
|
description: Union[str, None] = Field(title="The description of the event")
|
||||||
|
|
||||||
|
@ -31,6 +31,7 @@ from frigate.api.defs.request.events_body import (
|
|||||||
EventsDeleteBody,
|
EventsDeleteBody,
|
||||||
EventsDescriptionBody,
|
EventsDescriptionBody,
|
||||||
EventsEndBody,
|
EventsEndBody,
|
||||||
|
EventsLPRBody,
|
||||||
EventsSubLabelBody,
|
EventsSubLabelBody,
|
||||||
SubmitPlusBody,
|
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(
|
@router.post(
|
||||||
"/events/{event_id}/description",
|
"/events/{event_id}/description",
|
||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
|
@ -91,11 +91,13 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"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.",
|
"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": {
|
"error": {
|
||||||
"regenerate": "Failed to call {{provider}} for a new description: {{errorMessage}}",
|
"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}}",
|
"desc": "Enter a new sub label for this {{label}}",
|
||||||
"descNoLabel": "Enter a new sub label for this tracked object"
|
"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": {
|
"topScore": {
|
||||||
"label": "Top Score",
|
"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."
|
"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",
|
"estimatedSpeed": "Estimated Speed",
|
||||||
"objects": "Objects",
|
"objects": "Objects",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
|
@ -76,6 +76,7 @@ import { FaPencilAlt } from "react-icons/fa";
|
|||||||
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TbFaceId } from "react-icons/tb";
|
import { TbFaceId } from "react-icons/tb";
|
||||||
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
|
||||||
const SEARCH_TABS = [
|
const SEARCH_TABS = [
|
||||||
"details",
|
"details",
|
||||||
@ -295,10 +296,15 @@ function ObjectDetailsTab({
|
|||||||
|
|
||||||
const mutate = useGlobalMutation();
|
const mutate = useGlobalMutation();
|
||||||
|
|
||||||
|
// users
|
||||||
|
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
// data
|
// data
|
||||||
|
|
||||||
const [desc, setDesc] = useState(search?.data.description);
|
const [desc, setDesc] = useState(search?.data.description);
|
||||||
const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false);
|
const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false);
|
||||||
|
const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false);
|
||||||
|
|
||||||
const handleDescriptionFocus = useCallback(() => {
|
const handleDescriptionFocus = useCallback(() => {
|
||||||
setInputFocused(true);
|
setInputFocused(true);
|
||||||
@ -557,6 +563,83 @@ function ObjectDetailsTab({
|
|||||||
[search, apiHost, mutate, setSearch, t],
|
[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
|
// face training
|
||||||
|
|
||||||
const hasFace = useMemo(() => {
|
const hasFace = useMemo(() => {
|
||||||
@ -609,35 +692,56 @@ function ObjectDetailsTab({
|
|||||||
{getIconForLabel(search.label, "size-4 text-primary")}
|
{getIconForLabel(search.label, "size-4 text-primary")}
|
||||||
{t(search.label, { ns: "objects" })}
|
{t(search.label, { ns: "objects" })}
|
||||||
{search.sub_label && ` (${search.sub_label})`}
|
{search.sub_label && ` (${search.sub_label})`}
|
||||||
<Tooltip>
|
{isAdmin && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<span>
|
<TooltipTrigger asChild>
|
||||||
<FaPencilAlt
|
<span>
|
||||||
className="size-4 cursor-pointer text-primary/40 hover:text-primary/80"
|
<FaPencilAlt
|
||||||
onClick={() => {
|
className="size-4 cursor-pointer text-primary/40 hover:text-primary/80"
|
||||||
setIsSubLabelDialogOpen(true);
|
onClick={() => {
|
||||||
}}
|
setIsSubLabelDialogOpen(true);
|
||||||
/>
|
}}
|
||||||
</span>
|
/>
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipPortal>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipPortal>
|
||||||
{t("details.editSubLabel.title")}
|
<TooltipContent>
|
||||||
</TooltipContent>
|
{t("details.editSubLabel.title")}
|
||||||
</TooltipPortal>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{search?.data.recognized_license_plate && (
|
{search?.data.recognized_license_plate && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-sm text-primary/40">
|
<div className="text-sm text-primary/40">
|
||||||
Recognized License Plate
|
{t("details.recognizedLicensePlate")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col space-y-0.5 text-sm">
|
<div className="flex flex-col space-y-0.5 text-sm">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{search.data.recognized_license_plate}{" "}
|
{search.data.recognized_license_plate}{" "}
|
||||||
{recognizedLicensePlateScore &&
|
{recognizedLicensePlateScore &&
|
||||||
` (${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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -859,7 +963,7 @@ function ObjectDetailsTab({
|
|||||||
description={
|
description={
|
||||||
search.label
|
search.label
|
||||||
? t("details.editSubLabel.desc", {
|
? t("details.editSubLabel.desc", {
|
||||||
label: t(search.label, { an: "objects" }),
|
label: t(search.label, { ns: "objects" }),
|
||||||
})
|
})
|
||||||
: t("details.editSubLabel.descNoLabel")
|
: t("details.editSubLabel.descNoLabel")
|
||||||
}
|
}
|
||||||
@ -867,6 +971,21 @@ function ObjectDetailsTab({
|
|||||||
defaultValue={search?.sub_label || ""}
|
defaultValue={search?.sub_label || ""}
|
||||||
allowEmpty={true}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user