Improve LPR regex support (#19767)

* add regex support to events api for recognized_license_plate

* frontend

add ability to use regexes in the plate search box and add select all/clear all links to quickly select all filtered plates
This commit is contained in:
Josh Hawkins 2025-08-26 08:11:37 -05:00 committed by GitHub
parent 22e981c38c
commit 6c3f99150c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 133 additions and 67 deletions

View File

@ -166,43 +166,32 @@ def events(params: EventsQueryParams = Depends()):
clauses.append((sub_label_clause)) clauses.append((sub_label_clause))
if recognized_license_plate != "all": if recognized_license_plate != "all":
# use matching so joined recognized_license_plates are included
# for example a recognized license plate 'ABC123' would get events
# with recognized license plates 'ABC123' and 'ABC123, XYZ789'
recognized_license_plate_clauses = []
filtered_recognized_license_plates = recognized_license_plate.split(",") filtered_recognized_license_plates = recognized_license_plate.split(",")
clauses_for_plates = []
if "None" in filtered_recognized_license_plates: if "None" in filtered_recognized_license_plates:
filtered_recognized_license_plates.remove("None") filtered_recognized_license_plates.remove("None")
recognized_license_plate_clauses.append( clauses_for_plates.append(Event.data["recognized_license_plate"].is_null())
(Event.data["recognized_license_plate"].is_null())
# regex vs exact matching
normal_plates = []
for plate in filtered_recognized_license_plates:
if plate.startswith("^") or any(ch in plate for ch in ".[]?+*"):
clauses_for_plates.append(
Event.data["recognized_license_plate"].cast("text").regexp(plate)
)
else:
normal_plates.append(plate)
# if there are any plain string plates, match them with IN
if normal_plates:
clauses_for_plates.append(
Event.data["recognized_license_plate"].cast("text").in_(normal_plates)
) )
for recognized_license_plate in filtered_recognized_license_plates: recognized_license_plate_clause = reduce(operator.or_, clauses_for_plates)
# Exact matching plus list inclusion clauses.append(recognized_license_plate_clause)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
== recognized_license_plate
)
)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
% f"*{recognized_license_plate},*"
)
)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
% f"*, {recognized_license_plate}*"
)
)
recognized_license_plate_clause = reduce(
operator.or_, recognized_license_plate_clauses
)
clauses.append((recognized_license_plate_clause))
if zones != "all": if zones != "all":
# use matching so events with multiple zones # use matching so events with multiple zones
@ -516,42 +505,31 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
event_filters.append((reduce(operator.or_, zone_clauses))) event_filters.append((reduce(operator.or_, zone_clauses)))
if recognized_license_plate != "all": if recognized_license_plate != "all":
# use matching so joined recognized_license_plates are included
# for example an recognized_license_plate 'ABC123' would get events
# with recognized_license_plates 'ABC123' and 'ABC123, XYZ789'
recognized_license_plate_clauses = []
filtered_recognized_license_plates = recognized_license_plate.split(",") filtered_recognized_license_plates = recognized_license_plate.split(",")
clauses_for_plates = []
if "None" in filtered_recognized_license_plates: if "None" in filtered_recognized_license_plates:
filtered_recognized_license_plates.remove("None") filtered_recognized_license_plates.remove("None")
recognized_license_plate_clauses.append( clauses_for_plates.append(Event.data["recognized_license_plate"].is_null())
(Event.data["recognized_license_plate"].is_null())
# regex vs exact matching
normal_plates = []
for plate in filtered_recognized_license_plates:
if plate.startswith("^") or any(ch in plate for ch in ".[]?+*"):
clauses_for_plates.append(
Event.data["recognized_license_plate"].cast("text").regexp(plate)
)
else:
normal_plates.append(plate)
# if there are any plain string plates, match them with IN
if normal_plates:
clauses_for_plates.append(
Event.data["recognized_license_plate"].cast("text").in_(normal_plates)
) )
for recognized_license_plate in filtered_recognized_license_plates: recognized_license_plate_clause = reduce(operator.or_, clauses_for_plates)
# Exact matching plus list inclusion
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
== recognized_license_plate
)
)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
% f"*{recognized_license_plate},*"
)
)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
% f"*, {recognized_license_plate}*"
)
)
recognized_license_plate_clause = reduce(
operator.or_, recognized_license_plate_clauses
)
event_filters.append((recognized_license_plate_clause)) event_filters.append((recognized_license_plate_clause))
if after: if after:

View File

@ -1,3 +1,4 @@
import re
import sqlite3 import sqlite3
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
@ -14,6 +15,10 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn: sqlite3.Connection = super()._connect(*args, **kwargs) conn: sqlite3.Connection = super()._connect(*args, **kwargs)
if self.load_vec_extension: if self.load_vec_extension:
self._load_vec_extension(conn) self._load_vec_extension(conn)
# register REGEXP support
self._register_regexp(conn)
return conn return conn
def _load_vec_extension(self, conn: sqlite3.Connection) -> None: def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
@ -21,6 +26,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn.load_extension(self.sqlite_vec_path) conn.load_extension(self.sqlite_vec_path)
conn.enable_load_extension(False) conn.enable_load_extension(False)
def _register_regexp(self, conn: sqlite3.Connection) -> None:
def regexp(expr: str, item: str) -> bool:
if item is None:
return False
try:
return re.search(expr, item) is not None
except re.error:
return False
conn.create_function("REGEXP", 2, regexp)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
ids = ",".join(["?" for _ in event_ids]) ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids) self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids)

View File

@ -127,6 +127,8 @@
"loading": "Loading recognized license plates…", "loading": "Loading recognized license plates…",
"placeholder": "Type to search license plates…", "placeholder": "Type to search license plates…",
"noLicensePlatesFound": "No license plates found.", "noLicensePlatesFound": "No license plates found.",
"selectPlatesFromList": "Select one or more plates from the list." "selectPlatesFromList": "Select one or more plates from the list.",
"selectAll": "Select all",
"clearAll": "Clear all"
} }
} }

View File

@ -41,7 +41,7 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { LuCheck } from "react-icons/lu"; import { LuCheck, LuSquareCheck, LuX } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
type SearchFilterDialogProps = { type SearchFilterDialogProps = {
@ -923,13 +923,19 @@ export function RecognizedLicensePlatesFilterContent({
} }
}; };
if (allRecognizedLicensePlates && allRecognizedLicensePlates.length === 0) {
return null;
}
const filterItems = (value: string, search: string) => { const filterItems = (value: string, search: string) => {
if (!search) return 1; // Show all items if no search input if (!search) return 1; // Show all items if no search input
// If wrapped in /.../, treat as raw regex
if (search.startsWith("/") && search.endsWith("/") && search.length > 2) {
try {
const regex = new RegExp(search.slice(1, -1), "i");
return regex.test(value) ? 1 : -1;
} catch {
return -1;
}
}
if (search.includes("*") || search.includes("?")) { if (search.includes("*") || search.includes("?")) {
const escapedSearch = search const escapedSearch = search
.replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/[.+^${}()|[\]\\]/g, "\\$&")
@ -943,6 +949,46 @@ export function RecognizedLicensePlatesFilterContent({
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
}; };
const filteredPlates = useMemo(() => {
if (!allRecognizedLicensePlates) return [];
return allRecognizedLicensePlates.filter(
(plate) => filterItems(plate, inputValue) > 0,
);
}, [allRecognizedLicensePlates, inputValue]);
const handleSelectAllVisible = () => {
const allVisibleSelected = filteredPlates.every((plate) =>
selectedRecognizedLicensePlates.includes(plate),
);
let newSelected;
if (allVisibleSelected) {
// clear all
newSelected = selectedRecognizedLicensePlates.filter(
(plate) => !filteredPlates.includes(plate),
);
} else {
// select all
newSelected = Array.from(
new Set([...selectedRecognizedLicensePlates, ...filteredPlates]),
);
}
setSelectedRecognizedLicensePlates(newSelected);
setRecognizedLicensePlates(
newSelected.length === 0 ? undefined : newSelected,
);
};
const handleClearAll = () => {
setSelectedRecognizedLicensePlates([]);
setRecognizedLicensePlates(undefined);
};
if (allRecognizedLicensePlates && allRecognizedLicensePlates.length === 0) {
return null;
}
return ( return (
<div className="overflow-x-hidden"> <div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" /> <DropdownMenuSeparator className="mb-3" />
@ -1010,6 +1056,30 @@ export function RecognizedLicensePlatesFilterContent({
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{t("recognizedLicensePlates.selectPlatesFromList")} {t("recognizedLicensePlates.selectPlatesFromList")}
</p> </p>
<div className="mt-2 flex items-center justify-between text-sm text-muted-foreground">
{filteredPlates.length > 0 &&
!filteredPlates.every((plate) =>
selectedRecognizedLicensePlates.includes(plate),
) ? (
<button
onClick={handleSelectAllVisible}
className="flex items-center gap-1 text-sm text-primary hover:underline"
>
<LuSquareCheck className="size-4" />
{t("recognizedLicensePlates.selectAll")}
</button>
) : null}
{selectedRecognizedLicensePlates.length > 0 && (
<button
onClick={handleClearAll}
className="flex items-center gap-1 text-sm text-primary hover:underline"
>
<LuX className="size-4" />
{t("recognizedLicensePlates.clearAll")}
</button>
)}
</div>
</> </>
)} )}
</div> </div>