Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor processors and add LPR postprocessing #16722

Merged
merged 17 commits into from
Feb 21, 2025
Merged
8 changes: 5 additions & 3 deletions docs/docs/configuration/license_plate_recognition.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ lpr:

Ensure that your camera is configured to detect objects of type `car`, and that a car is actually being detected by Frigate. Otherwise, LPR will not run.

Like the other real-time processors in Frigate, license plate recognition runs on the camera stream defined by the `detect` role in your config. To ensure optimal performance, select a suitable resolution for this stream in your camera's firmware that fits your specific scene and requirements.

## Advanced Configuration

Fine-tune the LPR feature using these optional parameters:
Expand All @@ -52,7 +54,7 @@ Fine-tune the LPR feature using these optional parameters:
- Note: If you are using a Frigate+ model and you set the `threshold` in your objects config for `license_plate` higher than this value, recognition will never run. It's best to ensure these values match, or this `detection_threshold` is lower than your object config `threshold`.
- **`min_area`**: Defines the minimum size (in pixels) a license plate must be before recognition runs.
- Default: `1000` pixels.
- Depending on the resolution of your cameras, you can increase this value to ignore small or distant plates.
- Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates.

### Recognition

Expand Down Expand Up @@ -114,7 +116,7 @@ lpr:
Ensure that:

- Your camera has a clear, well-lit view of the plate.
- The plate is large enough in the image (try adjusting `min_area`).
- The plate is large enough in the image (try adjusting `min_area`) or increasing the resolution of your camera's stream.
- A `car` is detected first, as LPR only runs on recognized vehicles.

If you are using a Frigate+ model or a custom model that detects license plates, ensure that `license_plate` is added to your list of objects to track.
Expand Down Expand Up @@ -143,7 +145,7 @@ Use `match_distance` to allow small character mismatches. Alternatively, define
- View MQTT messages for `frigate/events` to verify detected plates.
- Adjust `detection_threshold` and `recognition_threshold` settings.
- If you are using a Frigate+ model or a model that detects license plates, watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected with a `car`.
- Enable debug logs for LPR by adding `frigate.data_processing.real_time.license_plate_processor: debug` to your `logger` configuration. These logs are _very_ verbose, so only enable this when necessary.
- Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only enable this when necessary.

### Will LPR slow down my system?

Expand Down
36 changes: 36 additions & 0 deletions frigate/api/classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
from fastapi import APIRouter, Request, UploadFile
from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filename
from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict

from frigate.api.defs.tags import Tags
from frigate.const import FACE_DIR
from frigate.embeddings import EmbeddingsContext
from frigate.models import Event

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -176,3 +179,36 @@ def deregister_faces(request: Request, name: str, body: dict = None):
content=({"success": True, "message": "Successfully deleted faces."}),
status_code=200,
)


@router.put("/lpr/reprocess")
def reprocess_license_plate(request: Request, event_id: str):
if not request.app.frigate_config.lpr.enabled:
message = "License plate recognition is not enabled."
logger.error(message)
return JSONResponse(
content=(
{
"success": False,
"message": message,
}
),
status_code=400,
)

try:
event = Event.get(Event.id == event_id)
except DoesNotExist:
message = f"Event {event_id} not found"
logger.error(message)
return JSONResponse(
content=({"success": False, "message": message}), status_code=404
)

context: EmbeddingsContext = request.app.embeddings
response = context.reprocess_plate(model_to_dict(event))

return JSONResponse(
content=response,
status_code=200,
)
1 change: 1 addition & 0 deletions frigate/comms/embeddings_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class EmbeddingsRequestEnum(Enum):
generate_search = "generate_search"
register_face = "register_face"
reprocess_face = "reprocess_face"
reprocess_plate = "reprocess_plate"


class EmbeddingsResponder:
Expand Down
36 changes: 36 additions & 0 deletions frigate/comms/recordings_updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Facilitates communication between processes."""

import logging
from enum import Enum

from .zmq_proxy import Publisher, Subscriber

logger = logging.getLogger(__name__)


class RecordingsDataTypeEnum(str, Enum):
all = ""
recordings_available_through = "recordings_available_through"


class RecordingsDataPublisher(Publisher):
"""Publishes latest recording data."""

topic_base = "recordings/"

def __init__(self, topic: RecordingsDataTypeEnum) -> None:
topic = topic.value
super().__init__(topic)

def publish(self, payload: tuple[str, float]) -> None:
super().publish(payload)


class RecordingsDataSubscriber(Subscriber):
"""Receives latest recording data."""

topic_base = "recordings/"

def __init__(self, topic: RecordingsDataTypeEnum) -> None:
topic = topic.value
super().__init__(topic)
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,21 @@
from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset
from shapely.geometry import Polygon

from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.const import FRIGATE_LOCALHOST
from frigate.embeddings.onnx.lpr_embedding import (
LicensePlateDetector,
PaddleOCRClassification,
PaddleOCRDetection,
PaddleOCRRecognition,
)
from frigate.util.image import area

from ..types import DataProcessorMetrics
from .api import RealTimeProcessorApi

logger = logging.getLogger(__name__)

WRITE_DEBUG_IMAGES = False


class LicensePlateProcessor(RealTimeProcessorApi):
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
super().__init__(config, metrics)
self.requestor = InterProcessRequestor()
self.lpr_config = config.lpr
class LicensePlateProcessingMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self.requires_license_plate_detection = (
"license_plate" not in self.config.objects.all_objects
)
self.detected_license_plates: dict[str, dict[str, any]] = {}

self.ctc_decoder = CTCDecoder()

Expand All @@ -52,42 +39,6 @@ def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
self.box_thresh = 0.8
self.mask_thresh = 0.8

self.lpr_detection_model = None
self.lpr_classification_model = None
self.lpr_recognition_model = None

if self.config.lpr.enabled:
self.detection_model = PaddleOCRDetection(
model_size="large",
requestor=self.requestor,
device="CPU",
)

self.classification_model = PaddleOCRClassification(
model_size="large",
requestor=self.requestor,
device="CPU",
)

self.recognition_model = PaddleOCRRecognition(
model_size="large",
requestor=self.requestor,
device="CPU",
)

self.yolov9_detection_model = LicensePlateDetector(
model_size="large",
requestor=self.requestor,
device="CPU",
)

if self.lpr_config.enabled:
# all models need to be loaded to run LPR
self.detection_model._load_model_and_utils()
self.classification_model._load_model_and_utils()
self.recognition_model._load_model_and_utils()
self.yolov9_detection_model._load_model_and_utils()

def _detect(self, image: np.ndarray) -> List[np.ndarray]:
"""
Detect possible license plates in the input image by first resizing and normalizing it,
Expand All @@ -114,7 +65,7 @@ def _detect(self, image: np.ndarray) -> List[np.ndarray]:
resized_image,
)

outputs = self.detection_model([normalized_image])[0]
outputs = self.model_runner.detection_model([normalized_image])[0]
outputs = outputs[0, :, :]

boxes, _ = self._boxes_from_bitmap(outputs, outputs > self.mask_thresh, w, h)
Expand Down Expand Up @@ -143,7 +94,7 @@ def _classify(
norm_img = norm_img[np.newaxis, :]
norm_images.append(norm_img)

outputs = self.classification_model(norm_images)
outputs = self.model_runner.classification_model(norm_images)

return self._process_classification_output(images, outputs)

Expand Down Expand Up @@ -183,7 +134,7 @@ def _recognize(
norm_image = norm_image[np.newaxis, :]
norm_images.append(norm_image)

outputs = self.recognition_model(norm_images)
outputs = self.model_runner.recognition_model(norm_images)
return self.ctc_decoder(outputs)

def _process_license_plate(
Expand All @@ -199,9 +150,9 @@ def _process_license_plate(
Tuple[List[str], List[float], List[int]]: Detected license plate texts, confidence scores, and areas of the plates.
"""
if (
self.detection_model.runner is None
or self.classification_model.runner is None
or self.recognition_model.runner is None
self.model_runner.detection_model.runner is None
or self.model_runner.classification_model.runner is None
or self.model_runner.recognition_model.runner is None
):
# we might still be downloading the models
logger.debug("Model runners not loaded")
Expand Down Expand Up @@ -665,7 +616,9 @@ def _preprocess_recognition_image(
input_w = int(input_h * max_wh_ratio)

# check for model-specific input width
model_input_w = self.recognition_model.runner.ort.get_inputs()[0].shape[3]
model_input_w = self.model_runner.recognition_model.runner.ort.get_inputs()[
0
].shape[3]
if isinstance(model_input_w, int) and model_input_w > 0:
input_w = model_input_w

Expand Down Expand Up @@ -732,19 +685,13 @@ def _crop_license_plate(image: np.ndarray, points: np.ndarray) -> np.ndarray:
image = np.rot90(image, k=3)
return image

def __update_metrics(self, duration: float) -> None:
"""
Update inference metrics.
"""
self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10

def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]:
"""
Use a lightweight YOLOv9 model to detect license plates for users without Frigate+
Return the dimensions of the detected plate as [x1, y1, x2, y2].
"""
predictions = self.yolov9_detection_model(input)
predictions = self.model_runner.yolov9_detection_model(input)

confidence_threshold = self.lpr_config.detection_threshold

Expand All @@ -770,8 +717,8 @@ def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]:

# Return the top scoring bounding box if found
if top_box is not None:
# expand box by 15% to help with OCR
expansion = (top_box[2:] - top_box[:2]) * 0.1
# expand box by 30% to help with OCR
expansion = (top_box[2:] - top_box[:2]) * 0.30

# Expand box
expanded_box = np.array(
Expand Down Expand Up @@ -869,9 +816,8 @@ def _should_keep_previous_plate(
# 5. Return True if we should keep the previous plate (i.e., if it scores higher)
return prev_score > curr_score

def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
def lpr_process(self, obj_data: dict[str, any], frame: np.ndarray):
"""Look for license plates in image."""
start = datetime.datetime.now().timestamp()

id = obj_data["id"]

Expand Down Expand Up @@ -934,7 +880,7 @@ def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):

# check that license plate is valid
# double the value because we've doubled the size of the car
if license_plate_area < self.config.lpr.min_area * 2:
if license_plate_area < self.lpr_config.min_area * 2:
logger.debug("License plate is less than min_area")
return

Expand Down Expand Up @@ -972,7 +918,7 @@ def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
# check that license plate is valid
if (
not license_plate_box
or area(license_plate_box) < self.config.lpr.min_area
or area(license_plate_box) < self.lpr_config.min_area
):
logger.debug(f"Invalid license plate box {license_plate}")
return
Expand Down Expand Up @@ -1078,10 +1024,9 @@ def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
"plate": top_plate,
"char_confidences": top_char_confidences,
"area": top_area,
"obj_data": obj_data,
}

self.__update_metrics(datetime.datetime.now().timestamp() - start)

def handle_request(self, topic, request_data) -> dict[str, any] | None:
return

Expand Down
31 changes: 31 additions & 0 deletions frigate/data_processing/common/license_plate/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from frigate.embeddings.onnx.lpr_embedding import (
LicensePlateDetector,
PaddleOCRClassification,
PaddleOCRDetection,
PaddleOCRRecognition,
)

from ...types import DataProcessorModelRunner


class LicensePlateModelRunner(DataProcessorModelRunner):
def __init__(self, requestor, device: str = "CPU", model_size: str = "large"):
super().__init__(requestor, device, model_size)
self.detection_model = PaddleOCRDetection(
model_size=model_size, requestor=requestor, device=device
)
self.classification_model = PaddleOCRClassification(
model_size=model_size, requestor=requestor, device=device
)
self.recognition_model = PaddleOCRRecognition(
model_size=model_size, requestor=requestor, device=device
)
self.yolov9_detection_model = LicensePlateDetector(
model_size=model_size, requestor=requestor, device=device
)

# Load all models once
self.detection_model._load_model_and_utils()
self.classification_model._load_model_and_utils()
self.recognition_model._load_model_and_utils()
self.yolov9_detection_model._load_model_and_utils()
10 changes: 8 additions & 2 deletions frigate/data_processing/post/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@

from frigate.config import FrigateConfig

from ..types import DataProcessorMetrics, PostProcessDataEnum
from ..types import DataProcessorMetrics, DataProcessorModelRunner, PostProcessDataEnum

logger = logging.getLogger(__name__)


class PostProcessorApi(ABC):
@abstractmethod
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics) -> None:
def __init__(
self,
config: FrigateConfig,
metrics: DataProcessorMetrics,
model_runner: DataProcessorModelRunner,
) -> None:
self.config = config
self.metrics = metrics
self.model_runner = model_runner
pass

@abstractmethod
Expand Down
Loading
Loading