Compare commits

...

2 Commits

Author SHA1 Message Date
Josh Hawkins
6d5098a0c2
Add ability to mark review items as unreviewed (#20446)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* new body param

* use new body param in endpoint

* explicitly use new param in frontend endpoint

* use reviewsegment as type instead of list of strings

* add toggle function to mark as unreviewed when all selected are reviewed

* i18n

* fix tests
2025-10-12 08:10:56 -05:00
Josh Hawkins
a2ad77c36e
Add stationary scan duration for LPR (#20444) 2025-10-12 06:20:14 -06:00
9 changed files with 94 additions and 41 deletions

View File

@ -5,7 +5,7 @@ title: License Plate Recognition (LPR)
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street. Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. However, LPR does not run on stationary vehicles. LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
When a plate is recognized, the details are: When a plate is recognized, the details are:

View File

@ -4,3 +4,5 @@ from pydantic import BaseModel, conlist, constr
class ReviewModifyMultipleBody(BaseModel): class ReviewModifyMultipleBody(BaseModel):
# List of string with at least one element and each element with at least one char # List of string with at least one element and each element with at least one char
ids: conlist(constr(min_length=1), min_length=1) ids: conlist(constr(min_length=1), min_length=1)
# Whether to mark items as reviewed (True) or unreviewed (False)
reviewed: bool = True

View File

@ -435,22 +435,27 @@ async def set_multiple_reviewed(
UserReviewStatus.user_id == user_id, UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id, UserReviewStatus.review_segment == review_id,
) )
# If it exists and isnt reviewed, update it # Update based on the reviewed parameter
if not review_status.has_been_reviewed: if review_status.has_been_reviewed != body.reviewed:
review_status.has_been_reviewed = True review_status.has_been_reviewed = body.reviewed
review_status.save() review_status.save()
except DoesNotExist: except DoesNotExist:
try: try:
UserReviewStatus.create( UserReviewStatus.create(
user_id=user_id, user_id=user_id,
review_segment=ReviewSegment.get(id=review_id), review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=True, has_been_reviewed=body.reviewed,
) )
except (DoesNotExist, IntegrityError): except (DoesNotExist, IntegrityError):
pass pass
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Reviewed multiple items"}), content=(
{
"success": True,
"message": f"Marked multiple items as {'reviewed' if body.reviewed else 'unreviewed'}",
}
),
status_code=200, status_code=200,
) )

View File

@ -48,6 +48,9 @@ class LicensePlateProcessingMixin:
MODEL_CACHE_DIR, "paddleocr-onnx", "ppocr_keys_v1.txt" MODEL_CACHE_DIR, "paddleocr-onnx", "ppocr_keys_v1.txt"
) )
) )
# process plates that are stationary and have no position changes for 5 seconds
self.stationary_scan_duration = 5
self.batch_size = 6 self.batch_size = 6
# Object config # Object config
@ -1269,20 +1272,38 @@ class LicensePlateProcessingMixin:
) )
return return
# don't run for stationary car objects # don't run for non-stationary objects with no position changes to avoid processing uncertain moving objects
if obj_data.get("stationary") == True: # zero position_changes is the initial state after registering a new tracked object
# LPR will run 2 frames after detect.min_initialized is reached
if obj_data.get("position_changes", 0) == 0 and not obj_data.get(
"stationary", False
):
logger.debug( logger.debug(
f"{camera}: Not a processing license plate for a stationary car/motorcycle object." f"{camera}: Skipping LPR for non-stationary {obj_data['label']} object {id} with no position changes. (Detected in {self.config.cameras[camera].detect.min_initialized + 1} concurrent frames, threshold to run is {self.config.cameras[camera].detect.min_initialized + 2} frames)"
) )
return return
# don't run for objects with no position changes # run for stationary objects for a limited time after they become stationary
# this is the initial state after registering a new tracked object if obj_data.get("stationary") == True:
# LPR will run 2 frames after detect.min_initialized is reached threshold = self.config.cameras[camera].detect.stationary.threshold
if obj_data.get("position_changes", 0) == 0: if obj_data.get("motionless_count", 0) >= threshold:
logger.debug( frames_since_stationary = (
f"{camera}: Plate detected in {self.config.cameras[camera].detect.min_initialized + 1} concurrent frames, LPR frame threshold ({self.config.cameras[camera].detect.min_initialized + 2})" obj_data.get("motionless_count", 0) - threshold
) )
fps = self.config.cameras[camera].detect.fps
time_since_stationary = frames_since_stationary / fps
# only print this log for a short time to avoid log spam
if (
self.stationary_scan_duration
< time_since_stationary
<= self.stationary_scan_duration + 1
):
logger.debug(
f"{camera}: {obj_data.get('label', 'An')} object {id} has been stationary for > {self.stationary_scan_duration} seconds, skipping LPR."
)
if time_since_stationary > self.stationary_scan_duration:
return return
license_plate: Optional[dict[str, Any]] = None license_plate: Optional[dict[str, Any]] = None
@ -1424,7 +1445,7 @@ class LicensePlateProcessingMixin:
license_plate_frame, license_plate_frame,
) )
logger.debug(f"{camera}: Running plate recognition.") logger.debug(f"{camera}: Running plate recognition for id: {id}.")
# run detection, returns results sorted by confidence, best first # run detection, returns results sorted by confidence, best first
start = datetime.datetime.now().timestamp() start = datetime.datetime.now().timestamp()

View File

@ -416,7 +416,7 @@ class TestHttpReview(BaseTestHttp):
assert response.status_code == 200 assert response.status_code == 200
response = response.json() response = response.json()
assert response["success"] == True assert response["success"] == True
assert response["message"] == "Reviewed multiple items" assert response["message"] == "Marked multiple items as reviewed"
# Verify that in DB the review segment was not changed # Verify that in DB the review segment was not changed
with self.assertRaises(DoesNotExist): with self.assertRaises(DoesNotExist):
UserReviewStatus.get( UserReviewStatus.get(
@ -433,7 +433,7 @@ class TestHttpReview(BaseTestHttp):
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
assert response_json["success"] == True assert response_json["success"] == True
assert response_json["message"] == "Reviewed multiple items" assert response_json["message"] == "Marked multiple items as reviewed"
# Verify UserReviewStatus was created # Verify UserReviewStatus was created
user_review = UserReviewStatus.get( user_review = UserReviewStatus.get(
UserReviewStatus.user_id == self.user_id, UserReviewStatus.user_id == self.user_id,

View File

@ -106,6 +106,7 @@
"button": { "button": {
"export": "Export", "export": "Export",
"markAsReviewed": "Mark as reviewed", "markAsReviewed": "Mark as reviewed",
"markAsUnreviewed": "Mark as unreviewed",
"deleteNow": "Delete Now" "deleteNow": "Delete Now"
} }
}, },

View File

@ -1,10 +1,11 @@
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck, FaCircleXmark } from "react-icons/fa6";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import axios from "axios"; import axios from "axios";
import { Button, buttonVariants } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
import { HiTrash } from "react-icons/hi"; import { HiTrash } from "react-icons/hi";
import { ReviewSegment } from "@/types/review";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -20,8 +21,8 @@ import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
type ReviewActionGroupProps = { type ReviewActionGroupProps = {
selectedReviews: string[]; selectedReviews: ReviewSegment[];
setSelectedReviews: (ids: string[]) => void; setSelectedReviews: (reviews: ReviewSegment[]) => void;
onExport: (id: string) => void; onExport: (id: string) => void;
pullLatestData: () => void; pullLatestData: () => void;
}; };
@ -36,15 +37,24 @@ export default function ReviewActionGroup({
setSelectedReviews([]); setSelectedReviews([]);
}, [setSelectedReviews]); }, [setSelectedReviews]);
const onMarkAsReviewed = useCallback(async () => { const allReviewed = selectedReviews.every(
await axios.post(`reviews/viewed`, { ids: selectedReviews }); (review) => review.has_been_reviewed,
);
const onToggleReviewed = useCallback(async () => {
const ids = selectedReviews.map((review) => review.id);
await axios.post(`reviews/viewed`, {
ids,
reviewed: !allReviewed,
});
setSelectedReviews([]); setSelectedReviews([]);
pullLatestData(); pullLatestData();
}, [selectedReviews, setSelectedReviews, pullLatestData]); }, [selectedReviews, setSelectedReviews, pullLatestData, allReviewed]);
const onDelete = useCallback(() => { const onDelete = useCallback(() => {
const ids = selectedReviews.map((review) => review.id);
axios axios
.post(`reviews/delete`, { ids: selectedReviews }) .post(`reviews/delete`, { ids })
.then((resp) => { .then((resp) => {
if (resp.status === 200) { if (resp.status === 200) {
toast.success(t("recording.confirmDelete.toast.success"), { toast.success(t("recording.confirmDelete.toast.success"), {
@ -140,7 +150,7 @@ export default function ReviewActionGroup({
aria-label={t("recording.button.export")} aria-label={t("recording.button.export")}
size="sm" size="sm"
onClick={() => { onClick={() => {
onExport(selectedReviews[0]); onExport(selectedReviews[0].id);
onClearSelected(); onClearSelected();
}} }}
> >
@ -154,14 +164,24 @@ export default function ReviewActionGroup({
)} )}
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label={t("recording.button.markAsReviewed")} aria-label={
allReviewed
? t("recording.button.markAsUnreviewed")
: t("recording.button.markAsReviewed")
}
size="sm" size="sm"
onClick={onMarkAsReviewed} onClick={onToggleReviewed}
> >
{allReviewed ? (
<FaCircleXmark className="text-secondary-foreground" />
) : (
<FaCircleCheck className="text-secondary-foreground" /> <FaCircleCheck className="text-secondary-foreground" />
)}
{isDesktop && ( {isDesktop && (
<div className="text-primary"> <div className="text-primary">
{t("recording.button.markAsReviewed")} {allReviewed
? t("recording.button.markAsUnreviewed")
: t("recording.button.markAsReviewed")}
</div> </div>
)} )}
</Button> </Button>

View File

@ -356,6 +356,7 @@ export default function Events() {
if (itemsToMarkReviewed.length > 0) { if (itemsToMarkReviewed.length > 0) {
await axios.post(`reviews/viewed`, { await axios.post(`reviews/viewed`, {
ids: itemsToMarkReviewed, ids: itemsToMarkReviewed,
reviewed: true,
}); });
reloadData(); reloadData();
} }
@ -365,7 +366,10 @@ export default function Events() {
const markItemAsReviewed = useCallback( const markItemAsReviewed = useCallback(
async (review: ReviewSegment) => { async (review: ReviewSegment) => {
const resp = await axios.post(`reviews/viewed`, { ids: [review.id] }); const resp = await axios.post(`reviews/viewed`, {
ids: [review.id],
reviewed: true,
});
if (resp.status == 200) { if (resp.status == 200) {
updateSegments( updateSegments(

View File

@ -135,11 +135,11 @@ export default function EventView({
// review interaction // review interaction
const [selectedReviews, setSelectedReviews] = useState<string[]>([]); const [selectedReviews, setSelectedReviews] = useState<ReviewSegment[]>([]);
const onSelectReview = useCallback( const onSelectReview = useCallback(
(review: ReviewSegment, ctrl: boolean) => { (review: ReviewSegment, ctrl: boolean) => {
if (selectedReviews.length > 0 || ctrl) { if (selectedReviews.length > 0 || ctrl) {
const index = selectedReviews.indexOf(review.id); const index = selectedReviews.findIndex((r) => r.id === review.id);
if (index != -1) { if (index != -1) {
if (selectedReviews.length == 1) { if (selectedReviews.length == 1) {
@ -153,7 +153,7 @@ export default function EventView({
} }
} else { } else {
const copy = [...selectedReviews]; const copy = [...selectedReviews];
copy.push(review.id); copy.push(review);
setSelectedReviews(copy); setSelectedReviews(copy);
} }
} else { } else {
@ -175,7 +175,7 @@ export default function EventView({
} }
if (selectedReviews.length < currentReviewItems.length) { if (selectedReviews.length < currentReviewItems.length) {
setSelectedReviews(currentReviewItems.map((seg) => seg.id)); setSelectedReviews(currentReviewItems);
} else { } else {
setSelectedReviews([]); setSelectedReviews([]);
} }
@ -429,7 +429,7 @@ type DetectionReviewProps = {
currentItems: ReviewSegment[] | null; currentItems: ReviewSegment[] | null;
itemsToReview?: number; itemsToReview?: number;
relevantPreviews?: Preview[]; relevantPreviews?: Preview[];
selectedReviews: string[]; selectedReviews: ReviewSegment[];
severity: ReviewSeverity; severity: ReviewSeverity;
filter?: ReviewFilter; filter?: ReviewFilter;
timeRange: { before: number; after: number }; timeRange: { before: number; after: number };
@ -439,7 +439,7 @@ type DetectionReviewProps = {
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void; onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
onSelectAllReviews: () => void; onSelectAllReviews: () => void;
setSelectedReviews: (reviewIds: string[]) => void; setSelectedReviews: (reviews: ReviewSegment[]) => void;
pullLatestData: () => void; pullLatestData: () => void;
}; };
function DetectionReview({ function DetectionReview({
@ -667,7 +667,7 @@ function DetectionReview({
case "r": case "r":
if (selectedReviews.length > 0 && !modifiers.repeat) { if (selectedReviews.length > 0 && !modifiers.repeat) {
currentItems?.forEach((item) => { currentItems?.forEach((item) => {
if (selectedReviews.includes(item.id)) { if (selectedReviews.some((r) => r.id === item.id)) {
item.has_been_reviewed = true; item.has_been_reviewed = true;
markItemAsReviewed(item); markItemAsReviewed(item);
} }
@ -723,7 +723,7 @@ function DetectionReview({
> >
{!loading && currentItems {!loading && currentItems
? currentItems.map((value) => { ? currentItems.map((value) => {
const selected = selectedReviews.includes(value.id); const selected = selectedReviews.some((r) => r.id === value.id);
return ( return (
<div <div