mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-05 13:07:44 +03:00
Merge branch 'blakeblackshear:dev' into dev
This commit is contained in:
commit
3dccf69692
@ -86,6 +86,9 @@ RUN apt-get -qq update \
|
|||||||
libx264-163 libx265-199 libegl1 \
|
libx264-163 libx265-199 libegl1 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Fixes "Error loading shared libs"
|
||||||
|
RUN mkdir -p /etc/ld.so.conf.d && echo /usr/lib/ffmpeg/jetson/lib/ > /etc/ld.so.conf.d/ffmpeg.conf
|
||||||
|
|
||||||
COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
|
COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
|
||||||
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||||
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
||||||
|
|||||||
@ -136,7 +136,7 @@ def get_jwt_secret() -> str:
|
|||||||
logger.debug("Using jwt secret from .jwt_secret file in config directory.")
|
logger.debug("Using jwt secret from .jwt_secret file in config directory.")
|
||||||
with open(jwt_secret_file) as f:
|
with open(jwt_secret_file) as f:
|
||||||
try:
|
try:
|
||||||
jwt_secret = f.readline()
|
jwt_secret = f.readline().strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Unable to read jwt token from .jwt_secret file in config directory. A new jwt token will be created at each startup."
|
"Unable to read jwt token from .jwt_secret file in config directory. A new jwt token will be created at each startup."
|
||||||
@ -259,17 +259,24 @@ def auth(request: Request):
|
|||||||
# pass the user header value from the upstream proxy if a mapping is specified
|
# pass the user header value from the upstream proxy if a mapping is specified
|
||||||
# or use anonymous if none are specified
|
# or use anonymous if none are specified
|
||||||
user_header = proxy_config.header_map.user
|
user_header = proxy_config.header_map.user
|
||||||
role_header = proxy_config.header_map.get("role", "Remote-Role")
|
role_header = proxy_config.header_map.role
|
||||||
success_response.headers["remote-user"] = (
|
success_response.headers["remote-user"] = (
|
||||||
request.headers.get(user_header, default="anonymous")
|
request.headers.get(user_header, default="anonymous")
|
||||||
if user_header
|
if user_header
|
||||||
else "anonymous"
|
else "anonymous"
|
||||||
)
|
)
|
||||||
success_response.headers["remote-role"] = (
|
role_header = proxy_config.header_map.role
|
||||||
|
role = (
|
||||||
request.headers.get(role_header, default="viewer")
|
request.headers.get(role_header, default="viewer")
|
||||||
if role_header
|
if role_header
|
||||||
else "viewer"
|
else "viewer"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# if comma-separated with "admin", use "admin", else "viewer"
|
||||||
|
success_response.headers["remote-role"] = (
|
||||||
|
"admin" if role and "admin" in role else "viewer"
|
||||||
|
)
|
||||||
|
|
||||||
return success_response
|
return success_response
|
||||||
|
|
||||||
# now apply authentication
|
# now apply authentication
|
||||||
@ -359,14 +366,8 @@ def auth(request: Request):
|
|||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
def profile(request: Request):
|
def profile(request: Request):
|
||||||
username = request.headers.get("remote-user", "anonymous")
|
username = request.headers.get("remote-user", "anonymous")
|
||||||
if username != "anonymous":
|
role = request.headers.get("remote-role", "viewer")
|
||||||
try:
|
|
||||||
user = User.get_by_id(username)
|
|
||||||
role = getattr(user, "role", "viewer")
|
|
||||||
except DoesNotExist:
|
|
||||||
role = "viewer" # Fallback if user deleted
|
|
||||||
else:
|
|
||||||
role = None
|
|
||||||
return JSONResponse(content={"username": username, "role": role})
|
return JSONResponse(content={"username": username, "role": role})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -40,6 +40,7 @@ from frigate.api.defs.response.event_response import (
|
|||||||
)
|
)
|
||||||
from frigate.api.defs.response.generic_response import GenericResponse
|
from frigate.api.defs.response.generic_response import GenericResponse
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
|
from frigate.comms.event_metadata_updater import EventMetadataTypeEnum
|
||||||
from frigate.const import CLIPS_DIR
|
from frigate.const import CLIPS_DIR
|
||||||
from frigate.embeddings import EmbeddingsContext
|
from frigate.embeddings import EmbeddingsContext
|
||||||
from frigate.events.external import ExternalEventProcessor
|
from frigate.events.external import ExternalEventProcessor
|
||||||
@ -969,27 +970,16 @@ def set_sub_label(
|
|||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
if not body.camera:
|
|
||||||
return JSONResponse(
|
|
||||||
content=(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"message": "Event "
|
|
||||||
+ event_id
|
|
||||||
+ " not found and camera is not provided.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
event = None
|
event = None
|
||||||
|
|
||||||
if request.app.detected_frames_processor:
|
if request.app.detected_frames_processor:
|
||||||
tracked_obj: TrackedObject = (
|
tracked_obj: TrackedObject = None
|
||||||
request.app.detected_frames_processor.camera_states[
|
|
||||||
event.camera if event else body.camera
|
for state in request.app.detected_frames_processor.camera_states.values():
|
||||||
].tracked_objects.get(event_id)
|
tracked_obj = state.tracked_objects.get(event_id)
|
||||||
)
|
|
||||||
|
if tracked_obj is not None:
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
tracked_obj = None
|
tracked_obj = None
|
||||||
|
|
||||||
@ -1008,23 +998,9 @@ def set_sub_label(
|
|||||||
new_sub_label = None
|
new_sub_label = None
|
||||||
new_score = None
|
new_score = None
|
||||||
|
|
||||||
if tracked_obj:
|
request.app.event_metadata_updater.publish(
|
||||||
tracked_obj.obj_data["sub_label"] = (new_sub_label, new_score)
|
EventMetadataTypeEnum.sub_label, (event_id, new_sub_label, new_score)
|
||||||
|
)
|
||||||
# update timeline items
|
|
||||||
Timeline.update(
|
|
||||||
data=Timeline.data.update({"sub_label": (new_sub_label, new_score)})
|
|
||||||
).where(Timeline.source_id == event_id).execute()
|
|
||||||
|
|
||||||
if event:
|
|
||||||
event.sub_label = new_sub_label
|
|
||||||
data = event.data
|
|
||||||
if new_sub_label is None:
|
|
||||||
data["sub_label_score"] = None
|
|
||||||
elif new_score is not None:
|
|
||||||
data["sub_label_score"] = new_score
|
|
||||||
event.data = data
|
|
||||||
event.save()
|
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
@ -1105,7 +1081,9 @@ def regenerate_description(
|
|||||||
camera_config = request.app.frigate_config.cameras[event.camera]
|
camera_config = request.app.frigate_config.cameras[event.camera]
|
||||||
|
|
||||||
if camera_config.genai.enabled:
|
if camera_config.genai.enabled:
|
||||||
request.app.event_metadata_updater.publish((event.id, params.source))
|
request.app.event_metadata_updater.publish(
|
||||||
|
EventMetadataTypeEnum.regenerate_description, (event.id, params.source)
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=(
|
content=(
|
||||||
|
|||||||
@ -20,10 +20,7 @@ from frigate.camera import CameraMetrics, PTZMetrics
|
|||||||
from frigate.comms.base_communicator import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.comms.config_updater import ConfigPublisher
|
from frigate.comms.config_updater import ConfigPublisher
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.event_metadata_updater import (
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
EventMetadataPublisher,
|
|
||||||
EventMetadataTypeEnum,
|
|
||||||
)
|
|
||||||
from frigate.comms.inter_process import InterProcessCommunicator
|
from frigate.comms.inter_process import InterProcessCommunicator
|
||||||
from frigate.comms.mqtt import MqttClient
|
from frigate.comms.mqtt import MqttClient
|
||||||
from frigate.comms.webpush import WebPushClient
|
from frigate.comms.webpush import WebPushClient
|
||||||
@ -327,9 +324,7 @@ class FrigateApp:
|
|||||||
def init_inter_process_communicator(self) -> None:
|
def init_inter_process_communicator(self) -> None:
|
||||||
self.inter_process_communicator = InterProcessCommunicator()
|
self.inter_process_communicator = InterProcessCommunicator()
|
||||||
self.inter_config_updater = ConfigPublisher()
|
self.inter_config_updater = ConfigPublisher()
|
||||||
self.event_metadata_updater = EventMetadataPublisher(
|
self.event_metadata_updater = EventMetadataPublisher()
|
||||||
EventMetadataTypeEnum.regenerate_description
|
|
||||||
)
|
|
||||||
self.inter_zmq_proxy = ZmqProxy()
|
self.inter_zmq_proxy = ZmqProxy()
|
||||||
|
|
||||||
def init_onvif(self) -> None:
|
def init_onvif(self) -> None:
|
||||||
@ -600,6 +595,7 @@ class FrigateApp:
|
|||||||
User.insert(
|
User.insert(
|
||||||
{
|
{
|
||||||
User.username: "admin",
|
User.username: "admin",
|
||||||
|
User.role: "admin",
|
||||||
User.password_hash: password_hash,
|
User.password_hash: password_hash,
|
||||||
User.notification_tokens: [],
|
User.notification_tokens: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from frigate.events.types import RegenerateDescriptionEnum
|
|
||||||
|
|
||||||
from .zmq_proxy import Publisher, Subscriber
|
from .zmq_proxy import Publisher, Subscriber
|
||||||
|
|
||||||
@ -14,6 +11,7 @@ logger = logging.getLogger(__name__)
|
|||||||
class EventMetadataTypeEnum(str, Enum):
|
class EventMetadataTypeEnum(str, Enum):
|
||||||
all = ""
|
all = ""
|
||||||
regenerate_description = "regenerate_description"
|
regenerate_description = "regenerate_description"
|
||||||
|
sub_label = "sub_label"
|
||||||
|
|
||||||
|
|
||||||
class EventMetadataPublisher(Publisher):
|
class EventMetadataPublisher(Publisher):
|
||||||
@ -21,12 +19,11 @@ class EventMetadataPublisher(Publisher):
|
|||||||
|
|
||||||
topic_base = "event_metadata/"
|
topic_base = "event_metadata/"
|
||||||
|
|
||||||
def __init__(self, topic: EventMetadataTypeEnum) -> None:
|
def __init__(self) -> None:
|
||||||
topic = topic.value
|
super().__init__()
|
||||||
super().__init__(topic)
|
|
||||||
|
|
||||||
def publish(self, payload: tuple[str, RegenerateDescriptionEnum]) -> None:
|
def publish(self, topic: EventMetadataTypeEnum, payload: any) -> None:
|
||||||
super().publish(payload)
|
super().publish(payload, topic.value)
|
||||||
|
|
||||||
|
|
||||||
class EventMetadataSubscriber(Subscriber):
|
class EventMetadataSubscriber(Subscriber):
|
||||||
@ -35,17 +32,14 @@ class EventMetadataSubscriber(Subscriber):
|
|||||||
topic_base = "event_metadata/"
|
topic_base = "event_metadata/"
|
||||||
|
|
||||||
def __init__(self, topic: EventMetadataTypeEnum) -> None:
|
def __init__(self, topic: EventMetadataTypeEnum) -> None:
|
||||||
topic = topic.value
|
super().__init__(topic.value)
|
||||||
super().__init__(topic)
|
|
||||||
|
|
||||||
def check_for_update(
|
def check_for_update(self, timeout: float = 1) -> tuple | None:
|
||||||
self, timeout: float = 1
|
|
||||||
) -> Optional[tuple[EventMetadataTypeEnum, str, RegenerateDescriptionEnum]]:
|
|
||||||
return super().check_for_update(timeout)
|
return super().check_for_update(timeout)
|
||||||
|
|
||||||
def _return_object(self, topic: str, payload: any) -> any:
|
def _return_object(self, topic: str, payload: tuple) -> tuple:
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return (None, None, None)
|
return (None, None)
|
||||||
|
|
||||||
topic = EventMetadataTypeEnum[topic[len(self.topic_base) :]]
|
topic = EventMetadataTypeEnum[topic[len(self.topic_base) :]]
|
||||||
event_id, source = payload
|
return (topic, payload)
|
||||||
return (topic, event_id, RegenerateDescriptionEnum(source))
|
|
||||||
|
|||||||
@ -8,12 +8,11 @@ from typing import List, Optional, Tuple
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import requests
|
|
||||||
from Levenshtein import distance
|
from Levenshtein import distance
|
||||||
from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset
|
from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
from frigate.const import FRIGATE_LOCALHOST
|
from frigate.comms.event_metadata_updater import EventMetadataTypeEnum
|
||||||
from frigate.util.image import area
|
from frigate.util.image import area
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -34,10 +33,10 @@ class LicensePlateProcessingMixin:
|
|||||||
self.batch_size = 6
|
self.batch_size = 6
|
||||||
|
|
||||||
# Detection specific parameters
|
# Detection specific parameters
|
||||||
self.min_size = 3
|
self.min_size = 8
|
||||||
self.max_size = 960
|
self.max_size = 960
|
||||||
self.box_thresh = 0.8
|
self.box_thresh = 0.6
|
||||||
self.mask_thresh = 0.8
|
self.mask_thresh = 0.6
|
||||||
|
|
||||||
def _detect(self, image: np.ndarray) -> List[np.ndarray]:
|
def _detect(self, image: np.ndarray) -> List[np.ndarray]:
|
||||||
"""
|
"""
|
||||||
@ -158,47 +157,40 @@ class LicensePlateProcessingMixin:
|
|||||||
logger.debug("Model runners not loaded")
|
logger.debug("Model runners not loaded")
|
||||||
return [], [], []
|
return [], [], []
|
||||||
|
|
||||||
plate_points = self._detect(image)
|
boxes = self._detect(image)
|
||||||
if len(plate_points) == 0:
|
if len(boxes) == 0:
|
||||||
logger.debug("No points found by OCR detector model")
|
logger.debug("No boxes found by OCR detector model")
|
||||||
return [], [], []
|
return [], [], []
|
||||||
|
|
||||||
plate_points = self._sort_polygon(list(plate_points))
|
boxes = self._sort_boxes(list(boxes))
|
||||||
plate_images = [self._crop_license_plate(image, x) for x in plate_points]
|
plate_images = [self._crop_license_plate(image, x) for x in boxes]
|
||||||
rotated_images, _ = self._classify(plate_images)
|
|
||||||
|
|
||||||
# debug rotated and classification result
|
|
||||||
if WRITE_DEBUG_IMAGES:
|
if WRITE_DEBUG_IMAGES:
|
||||||
current_time = int(datetime.datetime.now().timestamp())
|
current_time = int(datetime.datetime.now().timestamp())
|
||||||
for i, img in enumerate(plate_images):
|
for i, img in enumerate(plate_images):
|
||||||
cv2.imwrite(
|
cv2.imwrite(
|
||||||
f"debug/frames/license_plate_rotated_{current_time}_{i + 1}.jpg",
|
f"debug/frames/license_plate_cropped_{current_time}_{i + 1}.jpg",
|
||||||
img,
|
|
||||||
)
|
|
||||||
for i, img in enumerate(rotated_images):
|
|
||||||
cv2.imwrite(
|
|
||||||
f"debug/frames/license_plate_classified_{current_time}_{i + 1}.jpg",
|
|
||||||
img,
|
img,
|
||||||
)
|
)
|
||||||
|
|
||||||
# keep track of the index of each image for correct area calc later
|
# keep track of the index of each image for correct area calc later
|
||||||
sorted_indices = np.argsort([x.shape[1] / x.shape[0] for x in rotated_images])
|
sorted_indices = np.argsort([x.shape[1] / x.shape[0] for x in plate_images])
|
||||||
reverse_mapping = {
|
reverse_mapping = {
|
||||||
idx: original_idx for original_idx, idx in enumerate(sorted_indices)
|
idx: original_idx for original_idx, idx in enumerate(sorted_indices)
|
||||||
}
|
}
|
||||||
|
|
||||||
results, confidences = self._recognize(rotated_images)
|
results, confidences = self._recognize(plate_images)
|
||||||
|
|
||||||
if results:
|
if results:
|
||||||
license_plates = [""] * len(rotated_images)
|
license_plates = [""] * len(plate_images)
|
||||||
average_confidences = [[0.0]] * len(rotated_images)
|
average_confidences = [[0.0]] * len(plate_images)
|
||||||
areas = [0] * len(rotated_images)
|
areas = [0] * len(plate_images)
|
||||||
|
|
||||||
# map results back to original image order
|
# map results back to original image order
|
||||||
for i, (plate, conf) in enumerate(zip(results, confidences)):
|
for i, (plate, conf) in enumerate(zip(results, confidences)):
|
||||||
original_idx = reverse_mapping[i]
|
original_idx = reverse_mapping[i]
|
||||||
|
|
||||||
height, width = rotated_images[original_idx].shape[:2]
|
height, width = plate_images[original_idx].shape[:2]
|
||||||
area = height * width
|
area = height * width
|
||||||
|
|
||||||
average_confidence = conf
|
average_confidence = conf
|
||||||
@ -206,7 +198,7 @@ class LicensePlateProcessingMixin:
|
|||||||
# set to True to write each cropped image for debugging
|
# set to True to write each cropped image for debugging
|
||||||
if False:
|
if False:
|
||||||
save_image = cv2.cvtColor(
|
save_image = cv2.cvtColor(
|
||||||
rotated_images[original_idx], cv2.COLOR_RGB2BGR
|
plate_images[original_idx], cv2.COLOR_RGB2BGR
|
||||||
)
|
)
|
||||||
filename = f"debug/frames/plate_{original_idx}_{plate}_{area}.jpg"
|
filename = f"debug/frames/plate_{original_idx}_{plate}_{area}.jpg"
|
||||||
cv2.imwrite(filename, save_image)
|
cv2.imwrite(filename, save_image)
|
||||||
@ -328,7 +320,7 @@ class LicensePlateProcessingMixin:
|
|||||||
# Use pyclipper to shrink the polygon slightly based on the computed distance.
|
# Use pyclipper to shrink the polygon slightly based on the computed distance.
|
||||||
offset = PyclipperOffset()
|
offset = PyclipperOffset()
|
||||||
offset.AddPath(points, JT_ROUND, ET_CLOSEDPOLYGON)
|
offset.AddPath(points, JT_ROUND, ET_CLOSEDPOLYGON)
|
||||||
points = np.array(offset.Execute(distance * 1.5)).reshape((-1, 1, 2))
|
points = np.array(offset.Execute(distance * 1.75)).reshape((-1, 1, 2))
|
||||||
|
|
||||||
# get the minimum bounding box around the shrunken polygon.
|
# get the minimum bounding box around the shrunken polygon.
|
||||||
box, min_side = self._get_min_boxes(points)
|
box, min_side = self._get_min_boxes(points)
|
||||||
@ -453,46 +445,64 @@ class LicensePlateProcessingMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clockwise_order(point: np.ndarray) -> np.ndarray:
|
def _clockwise_order(pts: np.ndarray) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Arrange the points of a polygon in clockwise order based on their angular positions
|
Arrange the points of a polygon in order: top-left, top-right, bottom-right, bottom-left.
|
||||||
around the polygon's center.
|
taken from https://github.com/PyImageSearch/imutils/blob/master/imutils/perspective.py
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
point (np.ndarray): Array of points of the polygon.
|
pts (np.ndarray): Array of points of the polygon.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
np.ndarray: Points ordered in clockwise direction.
|
np.ndarray: Points ordered clockwise starting from top-left.
|
||||||
"""
|
"""
|
||||||
center = point.mean(axis=0)
|
# Sort the points based on their x-coordinates
|
||||||
return point[
|
x_sorted = pts[np.argsort(pts[:, 0]), :]
|
||||||
np.argsort(np.arctan2(point[:, 1] - center[1], point[:, 0] - center[0]))
|
|
||||||
]
|
# Separate the left-most and right-most points
|
||||||
|
left_most = x_sorted[:2, :]
|
||||||
|
right_most = x_sorted[2:, :]
|
||||||
|
|
||||||
|
# Sort the left-most coordinates by y-coordinates
|
||||||
|
left_most = left_most[np.argsort(left_most[:, 1]), :]
|
||||||
|
(tl, bl) = left_most # Top-left and bottom-left
|
||||||
|
|
||||||
|
# Use the top-left as an anchor to calculate distances to right points
|
||||||
|
# The further point will be the bottom-right
|
||||||
|
distances = np.sqrt(
|
||||||
|
((tl[0] - right_most[:, 0]) ** 2) + ((tl[1] - right_most[:, 1]) ** 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort right points by distance (descending)
|
||||||
|
right_idx = np.argsort(distances)[::-1]
|
||||||
|
(br, tr) = right_most[right_idx, :] # Bottom-right and top-right
|
||||||
|
|
||||||
|
return np.array([tl, tr, br, bl])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sort_polygon(points):
|
def _sort_boxes(boxes):
|
||||||
"""
|
"""
|
||||||
Sort polygons based on their position in the image. If polygons are close in vertical
|
Sort polygons based on their position in the image. If boxes are close in vertical
|
||||||
position (within 5 pixels), sort them by horizontal position.
|
position (within 5 pixels), sort them by horizontal position.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
points: List of polygons to sort.
|
points: detected text boxes with shape [4, 2]
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List: Sorted list of polygons.
|
List: sorted boxes(array) with shape [4, 2]
|
||||||
"""
|
"""
|
||||||
points.sort(key=lambda x: (x[0][1], x[0][0]))
|
boxes.sort(key=lambda x: (x[0][1], x[0][0]))
|
||||||
for i in range(len(points) - 1):
|
for i in range(len(boxes) - 1):
|
||||||
for j in range(i, -1, -1):
|
for j in range(i, -1, -1):
|
||||||
if abs(points[j + 1][0][1] - points[j][0][1]) < 5 and (
|
if abs(boxes[j + 1][0][1] - boxes[j][0][1]) < 5 and (
|
||||||
points[j + 1][0][0] < points[j][0][0]
|
boxes[j + 1][0][0] < boxes[j][0][0]
|
||||||
):
|
):
|
||||||
temp = points[j]
|
temp = boxes[j]
|
||||||
points[j] = points[j + 1]
|
boxes[j] = boxes[j + 1]
|
||||||
points[j + 1] = temp
|
boxes[j + 1] = temp
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
return points
|
return boxes
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _zero_pad(image: np.ndarray) -> np.ndarray:
|
def _zero_pad(image: np.ndarray) -> np.ndarray:
|
||||||
@ -583,9 +593,11 @@ class LicensePlateProcessingMixin:
|
|||||||
for j in range(len(outputs)):
|
for j in range(len(outputs)):
|
||||||
label, score = outputs[j]
|
label, score = outputs[j]
|
||||||
results[indices[i + j]] = [label, score]
|
results[indices[i + j]] = [label, score]
|
||||||
# make sure we have high confidence if we need to flip a box, this will be rare in lpr
|
# make sure we have high confidence if we need to flip a box
|
||||||
if "180" in label and score >= 0.9:
|
if "180" in label and score >= 0.7:
|
||||||
images[indices[i + j]] = cv2.rotate(images[indices[i + j]], 1)
|
images[indices[i + j]] = cv2.rotate(
|
||||||
|
images[indices[i + j]], cv2.ROTATE_180
|
||||||
|
)
|
||||||
|
|
||||||
return images, results
|
return images, results
|
||||||
|
|
||||||
@ -682,7 +694,7 @@ class LicensePlateProcessingMixin:
|
|||||||
)
|
)
|
||||||
height, width = image.shape[0:2]
|
height, width = image.shape[0:2]
|
||||||
if height * 1.0 / width >= 1.5:
|
if height * 1.0 / width >= 1.5:
|
||||||
image = np.rot90(image, k=3)
|
image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
|
||||||
return image
|
return image
|
||||||
|
|
||||||
def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]:
|
def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]:
|
||||||
@ -942,9 +954,23 @@ class LicensePlateProcessingMixin:
|
|||||||
return
|
return
|
||||||
|
|
||||||
license_plate_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
license_plate_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||||
|
|
||||||
|
# Expand the license_plate_box by 30%
|
||||||
|
box_array = np.array(license_plate_box)
|
||||||
|
expansion = (box_array[2:] - box_array[:2]) * 0.30
|
||||||
|
expanded_box = np.array(
|
||||||
|
[
|
||||||
|
license_plate_box[0] - expansion[0],
|
||||||
|
license_plate_box[1] - expansion[1],
|
||||||
|
license_plate_box[2] + expansion[0],
|
||||||
|
license_plate_box[3] + expansion[1],
|
||||||
|
]
|
||||||
|
).clip(0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2)
|
||||||
|
|
||||||
|
# Crop using the expanded box
|
||||||
license_plate_frame = license_plate_frame[
|
license_plate_frame = license_plate_frame[
|
||||||
license_plate_box[1] : license_plate_box[3],
|
int(expanded_box[1]) : int(expanded_box[3]),
|
||||||
license_plate_box[0] : license_plate_box[2],
|
int(expanded_box[0]) : int(expanded_box[2]),
|
||||||
]
|
]
|
||||||
|
|
||||||
# double the size of the license plate frame for better OCR
|
# double the size of the license plate frame for better OCR
|
||||||
@ -1032,22 +1058,15 @@ class LicensePlateProcessingMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send the result to the API
|
# Send the result to the API
|
||||||
resp = requests.post(
|
self.sub_label_publisher.publish(
|
||||||
f"{FRIGATE_LOCALHOST}/api/events/{id}/sub_label",
|
EventMetadataTypeEnum.sub_label, (id, sub_label, avg_confidence)
|
||||||
json={
|
|
||||||
"camera": obj_data.get("camera"),
|
|
||||||
"subLabel": sub_label,
|
|
||||||
"subLabelScore": avg_confidence,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
self.detected_license_plates[id] = {
|
||||||
if resp.status_code == 200:
|
"plate": top_plate,
|
||||||
self.detected_license_plates[id] = {
|
"char_confidences": top_char_confidences,
|
||||||
"plate": top_plate,
|
"area": top_area,
|
||||||
"char_confidences": top_char_confidences,
|
"obj_data": obj_data,
|
||||||
"area": top_area,
|
}
|
||||||
"obj_data": obj_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import numpy as np
|
|||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||||
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.data_processing.common.license_plate.mixin import (
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
WRITE_DEBUG_IMAGES,
|
WRITE_DEBUG_IMAGES,
|
||||||
@ -30,6 +31,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
|
sub_label_publisher: EventMetadataPublisher,
|
||||||
metrics: DataProcessorMetrics,
|
metrics: DataProcessorMetrics,
|
||||||
model_runner: LicensePlateModelRunner,
|
model_runner: LicensePlateModelRunner,
|
||||||
detected_license_plates: dict[str, dict[str, any]],
|
detected_license_plates: dict[str, dict[str, any]],
|
||||||
@ -38,6 +40,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
|||||||
self.model_runner = model_runner
|
self.model_runner = model_runner
|
||||||
self.lpr_config = config.lpr
|
self.lpr_config = config.lpr
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.sub_label_publisher = sub_label_publisher
|
||||||
super().__init__(config, metrics, model_runner)
|
super().__init__(config, metrics, model_runner)
|
||||||
|
|
||||||
def process_data(
|
def process_data(
|
||||||
|
|||||||
@ -5,10 +5,13 @@ import os
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import requests
|
|
||||||
|
|
||||||
|
from frigate.comms.event_metadata_updater import (
|
||||||
|
EventMetadataPublisher,
|
||||||
|
EventMetadataTypeEnum,
|
||||||
|
)
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import FRIGATE_LOCALHOST, MODEL_CACHE_DIR
|
from frigate.const import MODEL_CACHE_DIR
|
||||||
from frigate.util.object import calculate_region
|
from frigate.util.object import calculate_region
|
||||||
|
|
||||||
from ..types import DataProcessorMetrics
|
from ..types import DataProcessorMetrics
|
||||||
@ -23,9 +26,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class BirdRealTimeProcessor(RealTimeProcessorApi):
|
class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||||
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
sub_label_publisher: EventMetadataPublisher,
|
||||||
|
metrics: DataProcessorMetrics,
|
||||||
|
):
|
||||||
super().__init__(config, metrics)
|
super().__init__(config, metrics)
|
||||||
self.interpreter: Interpreter = None
|
self.interpreter: Interpreter = None
|
||||||
|
self.sub_label_publisher = sub_label_publisher
|
||||||
self.tensor_input_details: dict[str, any] = None
|
self.tensor_input_details: dict[str, any] = None
|
||||||
self.tensor_output_details: dict[str, any] = None
|
self.tensor_output_details: dict[str, any] = None
|
||||||
self.detected_birds: dict[str, float] = {}
|
self.detected_birds: dict[str, float] = {}
|
||||||
@ -134,17 +143,10 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
logger.debug(f"Score {score} is worse than previous score {previous_score}")
|
logger.debug(f"Score {score} is worse than previous score {previous_score}")
|
||||||
return
|
return
|
||||||
|
|
||||||
resp = requests.post(
|
self.sub_label_publisher.publish(
|
||||||
f"{FRIGATE_LOCALHOST}/api/events/{obj_data['id']}/sub_label",
|
EventMetadataTypeEnum.sub_label, (id, self.labelmap[best_id], score)
|
||||||
json={
|
|
||||||
"camera": obj_data.get("camera"),
|
|
||||||
"subLabel": self.labelmap[best_id],
|
|
||||||
"subLabelScore": score,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
self.detected_birds[obj_data["id"]] = score
|
||||||
if resp.status_code == 200:
|
|
||||||
self.detected_birds[obj_data["id"]] = score
|
|
||||||
|
|
||||||
def handle_request(self, topic, request_data):
|
def handle_request(self, topic, request_data):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -11,11 +11,14 @@ from typing import Optional
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import requests
|
|
||||||
|
|
||||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||||
|
from frigate.comms.event_metadata_updater import (
|
||||||
|
EventMetadataPublisher,
|
||||||
|
EventMetadataTypeEnum,
|
||||||
|
)
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import FACE_DIR, FRIGATE_LOCALHOST, MODEL_CACHE_DIR
|
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
||||||
from frigate.util.image import area
|
from frigate.util.image import area
|
||||||
|
|
||||||
from ..types import DataProcessorMetrics
|
from ..types import DataProcessorMetrics
|
||||||
@ -28,9 +31,15 @@ MIN_MATCHING_FACES = 2
|
|||||||
|
|
||||||
|
|
||||||
class FaceRealTimeProcessor(RealTimeProcessorApi):
|
class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||||
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
sub_label_publisher: EventMetadataPublisher,
|
||||||
|
metrics: DataProcessorMetrics,
|
||||||
|
):
|
||||||
super().__init__(config, metrics)
|
super().__init__(config, metrics)
|
||||||
self.face_config = config.face_recognition
|
self.face_config = config.face_recognition
|
||||||
|
self.sub_label_publisher = sub_label_publisher
|
||||||
self.face_detector: cv2.FaceDetectorYN = None
|
self.face_detector: cv2.FaceDetectorYN = None
|
||||||
self.landmark_detector: cv2.face.FacemarkLBF = None
|
self.landmark_detector: cv2.face.FacemarkLBF = None
|
||||||
self.recognizer: cv2.face.LBPHFaceRecognizer = None
|
self.recognizer: cv2.face.LBPHFaceRecognizer = None
|
||||||
@ -349,18 +358,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
||||||
return
|
return
|
||||||
|
|
||||||
resp = requests.post(
|
self.sub_label_publisher.publish(
|
||||||
f"{FRIGATE_LOCALHOST}/api/events/{id}/sub_label",
|
EventMetadataTypeEnum.sub_label, (id, sub_label, score)
|
||||||
json={
|
|
||||||
"camera": obj_data.get("camera"),
|
|
||||||
"subLabel": sub_label,
|
|
||||||
"subLabelScore": score,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
self.detected_faces[id] = face_score
|
||||||
if resp.status_code == 200:
|
|
||||||
self.detected_faces[id] = face_score
|
|
||||||
|
|
||||||
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
||||||
|
|
||||||
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import logging
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.data_processing.common.license_plate.mixin import (
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
LicensePlateProcessingMixin,
|
LicensePlateProcessingMixin,
|
||||||
@ -22,6 +23,7 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
|
sub_label_publisher: EventMetadataPublisher,
|
||||||
metrics: DataProcessorMetrics,
|
metrics: DataProcessorMetrics,
|
||||||
model_runner: LicensePlateModelRunner,
|
model_runner: LicensePlateModelRunner,
|
||||||
detected_license_plates: dict[str, dict[str, any]],
|
detected_license_plates: dict[str, dict[str, any]],
|
||||||
@ -30,6 +32,7 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
|||||||
self.model_runner = model_runner
|
self.model_runner = model_runner
|
||||||
self.lpr_config = config.lpr
|
self.lpr_config = config.lpr
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.sub_label_publisher = sub_label_publisher
|
||||||
super().__init__(config, metrics)
|
super().__init__(config, metrics)
|
||||||
|
|
||||||
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
|
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
|
|||||||
|
|
||||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsResponder
|
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsResponder
|
||||||
from frigate.comms.event_metadata_updater import (
|
from frigate.comms.event_metadata_updater import (
|
||||||
|
EventMetadataPublisher,
|
||||||
EventMetadataSubscriber,
|
EventMetadataSubscriber,
|
||||||
EventMetadataTypeEnum,
|
EventMetadataTypeEnum,
|
||||||
)
|
)
|
||||||
@ -43,7 +44,7 @@ from frigate.data_processing.real_time.license_plate import (
|
|||||||
LicensePlateRealTimeProcessor,
|
LicensePlateRealTimeProcessor,
|
||||||
)
|
)
|
||||||
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
|
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
|
||||||
from frigate.events.types import EventTypeEnum
|
from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
|
||||||
from frigate.genai import get_genai_client
|
from frigate.genai import get_genai_client
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||||
@ -89,6 +90,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
self.event_subscriber = EventUpdateSubscriber()
|
self.event_subscriber = EventUpdateSubscriber()
|
||||||
self.event_end_subscriber = EventEndSubscriber()
|
self.event_end_subscriber = EventEndSubscriber()
|
||||||
|
self.event_metadata_publisher = EventMetadataPublisher()
|
||||||
self.event_metadata_subscriber = EventMetadataSubscriber(
|
self.event_metadata_subscriber = EventMetadataSubscriber(
|
||||||
EventMetadataTypeEnum.regenerate_description
|
EventMetadataTypeEnum.regenerate_description
|
||||||
)
|
)
|
||||||
@ -108,15 +110,27 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.realtime_processors: list[RealTimeProcessorApi] = []
|
self.realtime_processors: list[RealTimeProcessorApi] = []
|
||||||
|
|
||||||
if self.config.face_recognition.enabled:
|
if self.config.face_recognition.enabled:
|
||||||
self.realtime_processors.append(FaceRealTimeProcessor(self.config, metrics))
|
self.realtime_processors.append(
|
||||||
|
FaceRealTimeProcessor(
|
||||||
|
self.config, self.event_metadata_publisher, metrics
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self.config.classification.bird.enabled:
|
if self.config.classification.bird.enabled:
|
||||||
self.realtime_processors.append(BirdRealTimeProcessor(self.config, metrics))
|
self.realtime_processors.append(
|
||||||
|
BirdRealTimeProcessor(
|
||||||
|
self.config, self.event_metadata_publisher, metrics
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self.config.lpr.enabled:
|
if self.config.lpr.enabled:
|
||||||
self.realtime_processors.append(
|
self.realtime_processors.append(
|
||||||
LicensePlateRealTimeProcessor(
|
LicensePlateRealTimeProcessor(
|
||||||
self.config, metrics, lpr_model_runner, self.detected_license_plates
|
self.config,
|
||||||
|
self.event_metadata_publisher,
|
||||||
|
metrics,
|
||||||
|
lpr_model_runner,
|
||||||
|
self.detected_license_plates,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -126,7 +140,11 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
if self.config.lpr.enabled:
|
if self.config.lpr.enabled:
|
||||||
self.post_processors.append(
|
self.post_processors.append(
|
||||||
LicensePlatePostProcessor(
|
LicensePlatePostProcessor(
|
||||||
self.config, metrics, lpr_model_runner, self.detected_license_plates
|
self.config,
|
||||||
|
self.event_metadata_publisher,
|
||||||
|
metrics,
|
||||||
|
lpr_model_runner,
|
||||||
|
self.detected_license_plates,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -150,6 +168,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.event_subscriber.stop()
|
self.event_subscriber.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
self.recordings_subscriber.stop()
|
self.recordings_subscriber.stop()
|
||||||
|
self.event_metadata_publisher.stop()
|
||||||
self.event_metadata_subscriber.stop()
|
self.event_metadata_subscriber.stop()
|
||||||
self.embeddings_responder.stop()
|
self.embeddings_responder.stop()
|
||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
@ -375,15 +394,17 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
def _process_event_metadata(self):
|
def _process_event_metadata(self):
|
||||||
# Check for regenerate description requests
|
# Check for regenerate description requests
|
||||||
(topic, event_id, source) = self.event_metadata_subscriber.check_for_update(
|
(topic, payload) = self.event_metadata_subscriber.check_for_update(timeout=0.01)
|
||||||
timeout=0.01
|
|
||||||
)
|
|
||||||
|
|
||||||
if topic is None:
|
if topic is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
event_id, source = payload
|
||||||
|
|
||||||
if event_id:
|
if event_id:
|
||||||
self.handle_regenerate_description(event_id, source)
|
self.handle_regenerate_description(
|
||||||
|
event_id, RegenerateDescriptionEnum(source)
|
||||||
|
)
|
||||||
|
|
||||||
def _create_thumbnail(self, yuv_frame, box, height=500) -> Optional[bytes]:
|
def _create_thumbnail(self, yuv_frame, box, height=500) -> Optional[bytes]:
|
||||||
"""Return jpg thumbnail of a region of the frame."""
|
"""Return jpg thumbnail of a region of the frame."""
|
||||||
|
|||||||
@ -351,7 +351,8 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
|
|
||||||
self.read_audio()
|
self.read_audio()
|
||||||
|
|
||||||
stop_ffmpeg(self.audio_listener, self.logger)
|
if self.audio_listener:
|
||||||
|
stop_ffmpeg(self.audio_listener, self.logger)
|
||||||
self.logpipe.close()
|
self.logpipe.close()
|
||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
self.config_subscriber.stop()
|
self.config_subscriber.stop()
|
||||||
|
|||||||
@ -119,7 +119,7 @@ class User(Model): # type: ignore[misc]
|
|||||||
username = CharField(null=False, primary_key=True, max_length=30)
|
username = CharField(null=False, primary_key=True, max_length=30)
|
||||||
role = CharField(
|
role = CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
default="viewer",
|
default="admin",
|
||||||
)
|
)
|
||||||
password_hash = CharField(null=False, max_length=120)
|
password_hash = CharField(null=False, max_length=120)
|
||||||
notification_tokens = JSONField()
|
notification_tokens = JSONField()
|
||||||
|
|||||||
@ -9,10 +9,15 @@ from typing import Callable, Optional
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
|
from frigate.comms.event_metadata_updater import (
|
||||||
|
EventMetadataSubscriber,
|
||||||
|
EventMetadataTypeEnum,
|
||||||
|
)
|
||||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import (
|
from frigate.config import (
|
||||||
@ -24,6 +29,7 @@ from frigate.config import (
|
|||||||
)
|
)
|
||||||
from frigate.const import UPDATE_CAMERA_ACTIVITY
|
from frigate.const import UPDATE_CAMERA_ACTIVITY
|
||||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||||
|
from frigate.models import Event, Timeline
|
||||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||||
from frigate.track.tracked_object import TrackedObject
|
from frigate.track.tracked_object import TrackedObject
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
@ -446,6 +452,9 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
||||||
self.event_sender = EventUpdatePublisher()
|
self.event_sender = EventUpdatePublisher()
|
||||||
self.event_end_subscriber = EventEndSubscriber()
|
self.event_end_subscriber = EventEndSubscriber()
|
||||||
|
self.sub_label_subscriber = EventMetadataSubscriber(
|
||||||
|
EventMetadataTypeEnum.sub_label
|
||||||
|
)
|
||||||
|
|
||||||
self.camera_activity: dict[str, dict[str, any]] = {}
|
self.camera_activity: dict[str, dict[str, any]] = {}
|
||||||
|
|
||||||
@ -684,6 +693,46 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
"""Returns the latest frame time for a given camera."""
|
"""Returns the latest frame time for a given camera."""
|
||||||
return self.camera_states[camera].current_frame_time
|
return self.camera_states[camera].current_frame_time
|
||||||
|
|
||||||
|
def set_sub_label(
|
||||||
|
self, event_id: str, sub_label: str | None, score: float | None
|
||||||
|
) -> None:
|
||||||
|
"""Update sub label for given event id."""
|
||||||
|
tracked_obj: TrackedObject = None
|
||||||
|
|
||||||
|
for state in self.camera_states.values():
|
||||||
|
tracked_obj = state.tracked_objects.get(event_id)
|
||||||
|
|
||||||
|
if tracked_obj is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
event: Event = Event.get(Event.id == event_id)
|
||||||
|
except DoesNotExist:
|
||||||
|
event = None
|
||||||
|
|
||||||
|
if not tracked_obj and not event:
|
||||||
|
return
|
||||||
|
|
||||||
|
if tracked_obj:
|
||||||
|
tracked_obj.obj_data["sub_label"] = (sub_label, score)
|
||||||
|
|
||||||
|
if event:
|
||||||
|
event.sub_label = sub_label
|
||||||
|
data = event.data
|
||||||
|
if sub_label is None:
|
||||||
|
data["sub_label_score"] = None
|
||||||
|
elif score is not None:
|
||||||
|
data["sub_label_score"] = score
|
||||||
|
event.data = data
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
# update timeline items
|
||||||
|
Timeline.update(
|
||||||
|
data=Timeline.data.update({"sub_label": (sub_label, score)})
|
||||||
|
).where(Timeline.source_id == event_id).execute()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def force_end_all_events(self, camera: str, camera_state: CameraState):
|
def force_end_all_events(self, camera: str, camera_state: CameraState):
|
||||||
"""Ends all active events on camera when disabling."""
|
"""Ends all active events on camera when disabling."""
|
||||||
last_frame_name = camera_state.previous_frame_id
|
last_frame_name = camera_state.previous_frame_id
|
||||||
@ -741,6 +790,18 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
if not current_enabled:
|
if not current_enabled:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# check for sub label updates
|
||||||
|
while True:
|
||||||
|
(topic, payload) = self.sub_label_subscriber.check_for_update(
|
||||||
|
timeout=0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not topic:
|
||||||
|
break
|
||||||
|
|
||||||
|
(event_id, sub_label, score) = payload
|
||||||
|
self.set_sub_label(event_id, sub_label, score)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
(
|
(
|
||||||
camera,
|
camera,
|
||||||
@ -799,6 +860,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.detection_publisher.stop()
|
self.detection_publisher.stop()
|
||||||
self.event_sender.stop()
|
self.event_sender.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
|
self.sub_label_subscriber.stop()
|
||||||
self.config_enabled_subscriber.stop()
|
self.config_enabled_subscriber.stop()
|
||||||
|
|
||||||
logger.info("Exiting object processor...")
|
logger.info("Exiting object processor...")
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from peewee_migrate import Router
|
from peewee_migrate import Router
|
||||||
@ -10,6 +11,7 @@ from playhouse.sqlite_ext import SqliteExtDatabase
|
|||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
|
||||||
from frigate.api.fastapi_app import create_fastapi_app
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import BASE_DIR, CACHE_DIR
|
from frigate.const import BASE_DIR, CACHE_DIR
|
||||||
from frigate.models import Event, Recordings, Timeline
|
from frigate.models import Event, Recordings, Timeline
|
||||||
@ -243,6 +245,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
|
|
||||||
def test_set_delete_sub_label(self):
|
def test_set_delete_sub_label(self):
|
||||||
|
mock_event_updater = Mock(spec=EventMetadataPublisher)
|
||||||
app = create_fastapi_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
@ -252,11 +255,18 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
mock_event_updater,
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
sub_label = "sub"
|
sub_label = "sub"
|
||||||
|
|
||||||
|
def update_event(topic, payload):
|
||||||
|
event = Event.get(id=id)
|
||||||
|
event.sub_label = payload[1]
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
mock_event_updater.publish.side_effect = update_event
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
new_sub_label_response = client.post(
|
new_sub_label_response = client.post(
|
||||||
@ -281,6 +291,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
assert event["sub_label"] == None
|
assert event["sub_label"] == None
|
||||||
|
|
||||||
def test_sub_label_list(self):
|
def test_sub_label_list(self):
|
||||||
|
mock_event_updater = Mock(spec=EventMetadataPublisher)
|
||||||
app = create_fastapi_app(
|
app = create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
@ -290,11 +301,18 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
mock_event_updater,
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
sub_label = "sub"
|
sub_label = "sub"
|
||||||
|
|
||||||
|
def update_event(topic, payload):
|
||||||
|
event = Event.get(id=id)
|
||||||
|
event.sub_label = payload[1]
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
mock_event_updater.publish.side_effect = update_event
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
client.post(
|
client.post(
|
||||||
|
|||||||
@ -87,7 +87,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
name="user"
|
name="user"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -141,6 +142,73 @@ export default function FaceLibrary() {
|
|||||||
[refreshFaces],
|
[refreshFaces],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// face multiselect
|
||||||
|
|
||||||
|
const [selectedFaces, setSelectedFaces] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const onClickFace = useCallback(
|
||||||
|
(imageId: string) => {
|
||||||
|
const index = selectedFaces.indexOf(imageId);
|
||||||
|
|
||||||
|
if (index != -1) {
|
||||||
|
if (selectedFaces.length == 1) {
|
||||||
|
setSelectedFaces([]);
|
||||||
|
} else {
|
||||||
|
const copy = [
|
||||||
|
...selectedFaces.slice(0, index),
|
||||||
|
...selectedFaces.slice(index + 1),
|
||||||
|
];
|
||||||
|
setSelectedFaces(copy);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const copy = [...selectedFaces];
|
||||||
|
copy.push(imageId);
|
||||||
|
setSelectedFaces(copy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedFaces, setSelectedFaces],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = useCallback(() => {
|
||||||
|
axios
|
||||||
|
.post(`/faces/train/delete`, { ids: selectedFaces })
|
||||||
|
.then((resp) => {
|
||||||
|
setSelectedFaces([]);
|
||||||
|
|
||||||
|
if (resp.status == 200) {
|
||||||
|
toast.success(`Successfully deleted face.`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
refreshFaces();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [selectedFaces, refreshFaces]);
|
||||||
|
|
||||||
|
// keyboard
|
||||||
|
|
||||||
|
useKeyboardListener(["a"], (key, modifiers) => {
|
||||||
|
if (modifiers.repeat || !modifiers.down) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "a":
|
||||||
|
if (modifiers.ctrl) {
|
||||||
|
setSelectedFaces([...trainImages]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
@ -210,16 +278,27 @@ export default function FaceLibrary() {
|
|||||||
<ScrollBar orientation="horizontal" className="h-0" />
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<div className="flex items-center justify-center gap-2">
|
{selectedFaces?.length > 0 ? (
|
||||||
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
|
<div className="flex items-center justify-center gap-2">
|
||||||
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
|
<Button className="flex gap-2" onClick={() => onDelete()}>
|
||||||
Add Face
|
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
</Button>
|
Delete Face Attempts
|
||||||
<Button className="flex gap-2" onClick={() => setUpload(true)}>
|
</Button>
|
||||||
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
|
</div>
|
||||||
Upload Image
|
) : (
|
||||||
</Button>
|
<div className="flex items-center justify-center gap-2">
|
||||||
</div>
|
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
|
||||||
|
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
Add Face
|
||||||
|
</Button>
|
||||||
|
{pageToggle != "train" && (
|
||||||
|
<Button className="flex gap-2" onClick={() => setUpload(true)}>
|
||||||
|
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
Upload Image
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pageToggle &&
|
{pageToggle &&
|
||||||
(pageToggle == "train" ? (
|
(pageToggle == "train" ? (
|
||||||
@ -227,6 +306,8 @@ export default function FaceLibrary() {
|
|||||||
config={config}
|
config={config}
|
||||||
attemptImages={trainImages}
|
attemptImages={trainImages}
|
||||||
faceNames={faces}
|
faceNames={faces}
|
||||||
|
selectedFaces={selectedFaces}
|
||||||
|
onClickFace={onClickFace}
|
||||||
onRefresh={refreshFaces}
|
onRefresh={refreshFaces}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -244,22 +325,28 @@ type TrainingGridProps = {
|
|||||||
config: FrigateConfig;
|
config: FrigateConfig;
|
||||||
attemptImages: string[];
|
attemptImages: string[];
|
||||||
faceNames: string[];
|
faceNames: string[];
|
||||||
|
selectedFaces: string[];
|
||||||
|
onClickFace: (image: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
function TrainingGrid({
|
function TrainingGrid({
|
||||||
config,
|
config,
|
||||||
attemptImages,
|
attemptImages,
|
||||||
faceNames,
|
faceNames,
|
||||||
|
selectedFaces,
|
||||||
|
onClickFace,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: TrainingGridProps) {
|
}: TrainingGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll">
|
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll p-1">
|
||||||
{attemptImages.map((image: string) => (
|
{attemptImages.map((image: string) => (
|
||||||
<FaceAttempt
|
<FaceAttempt
|
||||||
key={image}
|
key={image}
|
||||||
image={image}
|
image={image}
|
||||||
faceNames={faceNames}
|
faceNames={faceNames}
|
||||||
threshold={config.face_recognition.threshold}
|
threshold={config.face_recognition.threshold}
|
||||||
|
selected={selectedFaces.includes(image)}
|
||||||
|
onClick={() => onClickFace(image)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -271,12 +358,16 @@ type FaceAttemptProps = {
|
|||||||
image: string;
|
image: string;
|
||||||
faceNames: string[];
|
faceNames: string[];
|
||||||
threshold: number;
|
threshold: number;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
function FaceAttempt({
|
function FaceAttempt({
|
||||||
image,
|
image,
|
||||||
faceNames,
|
faceNames,
|
||||||
threshold,
|
threshold,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: FaceAttemptProps) {
|
}: FaceAttemptProps) {
|
||||||
const data = useMemo(() => {
|
const data = useMemo(() => {
|
||||||
@ -336,30 +427,16 @@ function FaceAttempt({
|
|||||||
});
|
});
|
||||||
}, [image, onRefresh]);
|
}, [image, onRefresh]);
|
||||||
|
|
||||||
const onDelete = useCallback(() => {
|
|
||||||
axios
|
|
||||||
.post(`/faces/train/delete`, { ids: [image] })
|
|
||||||
.then((resp) => {
|
|
||||||
if (resp.status == 200) {
|
|
||||||
toast.success(`Successfully deleted face.`, {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
onRefresh();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage =
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data?.detail ||
|
|
||||||
"Unknown error";
|
|
||||||
toast.error(`Failed to delete: ${errorMessage}`, {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [image, onRefresh]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col rounded-lg">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer flex-col rounded-lg outline outline-[3px]",
|
||||||
|
selected
|
||||||
|
? "shadow-selected outline-selected"
|
||||||
|
: "outline-transparent duration-500",
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
<div className="w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
|
<div className="w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
|
||||||
<img className="size-40" src={`${baseUrl}clips/faces/train/${image}`} />
|
<img className="size-40" src={`${baseUrl}clips/faces/train/${image}`} />
|
||||||
</div>
|
</div>
|
||||||
@ -409,15 +486,6 @@ function FaceAttempt({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Reprocess Face</TooltipContent>
|
<TooltipContent>Reprocess Face</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<LuTrash2
|
|
||||||
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
|
||||||
onClick={onDelete}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Delete Face Attempt</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -770,7 +770,12 @@ function DetectionReview({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-transparent duration-500"}`}
|
className={cn(
|
||||||
|
"review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px]",
|
||||||
|
selected
|
||||||
|
? `outline-severity_${value.severity} shadow-severity_${value.severity}`
|
||||||
|
: "outline-transparent duration-500",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user