This commit is contained in:
ZhaiSoul 2025-03-15 01:32:01 +08:00
commit b9c6dfeeff
24 changed files with 996 additions and 516 deletions

View File

@ -3,19 +3,55 @@ id: face_recognition
title: Face Recognition title: Face Recognition
--- ---
Face recognition allows people to be assigned names and when their face is recognized Frigate will assign the person's name as a sub label. This information is included in the UI, filters, as well as in notifications. Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known person is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
## Model Requirements
Frigate has support for CV2 Local Binary Pattern Face Recognizer to recognize faces, which runs locally. A lightweight face landmark detection model is also used to align faces before running them through the face recognizer. Frigate has support for CV2 Local Binary Pattern Face Recognizer to recognize faces, which runs locally. A lightweight face landmark detection model is also used to align faces before running them through the face recognizer.
Users running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
Users without a model that detects faces can still run face recognition. Frigate uses a lightweight DNN face detection model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track.
:::note
Frigate needs to first detect a `face` before it can recognize a face.
:::
## Minimum System Requirements
Face recognition is lightweight and runs on the CPU, there are no significantly different system requirements than running Frigate itself.
## Configuration ## Configuration
Face recognition is disabled by default, face recognition must be enabled in your config file before it can be used. Face recognition is a global configuration setting. Face recognition is disabled by default, face recognition must be enabled in the UI or in your config file before it can be used. Face recognition is a global configuration setting.
```yaml ```yaml
face_recognition: face_recognition:
enabled: true enabled: true
``` ```
## Advanced Configuration
Fine-tune face recognition with these optional parameters:
### Detection
- `detection_threshold`: Face detection confidence score required before recognition runs:
- Default: `0.7`
- Note: This is field only applies to the standalone face detection model, `min_score` should be used to filter for models that have face detection built in.
- `min_area`: Defines the minimum size (in pixels) a face must be before recognition runs.
- Default: `500` pixels.
- Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant faces.
### Recognition
- `recognition_threshold`: Recognition confidence score required to add the face to the object as a sub label.
- Default: `0.9`.
- `blur_confidence_filter`: Enables a filter that calculates how blurry the face is and adjusts the confidence based on this.
- Default: `True`.
## Dataset ## Dataset
The number of images needed for a sufficient training set for face recognition varies depending on several factors: The number of images needed for a sufficient training set for face recognition varies depending on several factors:

View File

@ -51,16 +51,16 @@ Fine-tune the LPR feature using these optional parameters:
- **`detection_threshold`**: License plate object detection confidence score required before recognition runs. - **`detection_threshold`**: License plate object detection confidence score required before recognition runs.
- Default: `0.7` - Default: `0.7`
- 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`. - Note: This is field only applies to the standalone license plate detection model, `min_score` should be used to filter for models that have license plate detection built in.
- **`min_area`**: Defines the minimum size (in pixels) a license plate must be before recognition runs. - **`min_area`**: Defines the minimum size (in pixels) a license plate must be before recognition runs.
- Default: `1000` pixels. - Default: `1000` pixels.
- Depending on the resolution of your camera's `detect` stream, 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 ### Recognition
- **`recognition_threshold`**: Recognition confidence score required to add the plate to the object as a sub label. - **`recognition_threshold`**: Recognition confidence score required to add the plate to the object as a `recognized_license_plate` and/or `sub_label`.
- Default: `0.9`. - Default: `0.9`.
- **`min_plate_length`**: Specifies the minimum number of characters a detected license plate must have to be added as a sub label to an object. - **`min_plate_length`**: Specifies the minimum number of characters a detected license plate must have to be added as a `recognized_license_plate` and/or `sub_label` to an object.
- Use this to filter out short, incomplete, or incorrect detections. - Use this to filter out short, incomplete, or incorrect detections.
- **`format`**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. - **`format`**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded.
- `"^[A-Z]{1,3} [A-Z]{1,2} [0-9]{1,4}$"` matches plates like "B AB 1234" or "M X 7" - `"^[A-Z]{1,3} [A-Z]{1,2} [0-9]{1,4}$"` matches plates like "B AB 1234" or "M X 7"

View File

@ -189,21 +189,15 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec
async def get_current_user(request: Request): async def get_current_user(request: Request):
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name username = request.headers.get("remote-user")
encoded_token = request.cookies.get(JWT_COOKIE_NAME) role = request.headers.get("remote-role")
if not encoded_token:
return JSONResponse(content={"message": "No JWT token found"}, status_code=401)
try: if not username or not role:
token = jwt.decode(encoded_token, request.app.jwt_token) return JSONResponse(
if "sub" not in token.claims or "role" not in token.claims: content={"message": "No authorization headers."}, status_code=401
return JSONResponse( )
content={"message": "Invalid JWT token"}, status_code=401
) return {"username": username, "role": role}
return {"username": token.claims["sub"], "role": token.claims["role"]}
except Exception as e:
logger.error(f"Error parsing JWT: {e}")
return JSONResponse(content={"message": "Invalid JWT token"}, status_code=401)
def require_role(required_roles: List[str]): def require_role(required_roles: List[str]):

View File

@ -27,6 +27,7 @@ from frigate.api.defs.query.media_query_parameters import (
MediaRecordingsSummaryQueryParams, MediaRecordingsSummaryQueryParams,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.camera.state import CameraState
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import ( from frigate.const import (
CACHE_DIR, CACHE_DIR,
@ -106,10 +107,10 @@ def imagestream(
@router.get("/{camera_name}/ptz/info") @router.get("/{camera_name}/ptz/info")
def camera_ptz_info(request: Request, camera_name: str): async def camera_ptz_info(request: Request, camera_name: str):
if camera_name in request.app.frigate_config.cameras: if camera_name in request.app.frigate_config.cameras:
return JSONResponse( return JSONResponse(
content=request.app.onvif.get_camera_info(camera_name), content=await request.app.onvif.get_camera_info(camera_name),
) )
else: else:
return JSONResponse( return JSONResponse(
@ -765,12 +766,15 @@ def event_snapshot(
except DoesNotExist: except DoesNotExist:
# see if the object is currently being tracked # see if the object is currently being tracked
try: try:
camera_states = request.app.detected_frames_processor.camera_states.values() camera_states: list[CameraState] = (
request.app.detected_frames_processor.camera_states.values()
)
for camera_state in camera_states: for camera_state in camera_states:
if event_id in camera_state.tracked_objects: if event_id in camera_state.tracked_objects:
tracked_obj = camera_state.tracked_objects.get(event_id) tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None: if tracked_obj is not None:
jpg_bytes = tracked_obj.get_jpg_bytes( jpg_bytes = tracked_obj.get_img_bytes(
ext="jpg",
timestamp=params.timestamp, timestamp=params.timestamp,
bounding_box=params.bbox, bounding_box=params.bbox,
crop=params.crop, crop=params.crop,
@ -779,17 +783,19 @@ def event_snapshot(
) )
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Event not found"}, content={"success": False, "message": "Ongoing event not found"},
status_code=404, status_code=404,
) )
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Event not found"}, status_code=404 content={"success": False, "message": "Unknown error occurred"},
status_code=404,
) )
if jpg_bytes is None: if jpg_bytes is None:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Event not found"}, status_code=404 content={"success": False, "message": "Live frame not available"},
status_code=404,
) )
headers = { headers = {

View File

@ -9,10 +9,10 @@ import pandas as pd
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, fn, operator from peewee import Case, DoesNotExist, IntegrityError, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role from frigate.api.auth import get_current_user, require_role
from frigate.api.defs.query.review_query_parameters import ( from frigate.api.defs.query.review_query_parameters import (
ReviewActivityMotionQueryParams, ReviewActivityMotionQueryParams,
ReviewQueryParams, ReviewQueryParams,
@ -26,7 +26,7 @@ from frigate.api.defs.response.review_response import (
ReviewSummaryResponse, ReviewSummaryResponse,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.models import Recordings, ReviewSegment from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.util.builtin import get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
@ -36,7 +36,15 @@ router = APIRouter(tags=[Tags.review])
@router.get("/review", response_model=list[ReviewSegmentResponse]) @router.get("/review", response_model=list[ReviewSegmentResponse])
def review(params: ReviewQueryParams = Depends()): async def review(
params: ReviewQueryParams = Depends(),
current_user: dict = Depends(get_current_user),
):
if isinstance(current_user, JSONResponse):
return current_user
user_id = current_user["username"]
cameras = params.cameras cameras = params.cameras
labels = params.labels labels = params.labels
zones = params.zones zones = params.zones
@ -74,9 +82,7 @@ def review(params: ReviewQueryParams = Depends()):
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
) )
clauses.append(reduce(operator.or_, label_clauses))
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
if zones != "all": if zones != "all":
# use matching so segments with multiple zones # use matching so segments with multiple zones
@ -88,27 +94,52 @@ def review(params: ReviewQueryParams = Depends()):
zone_clauses.append( zone_clauses.append(
(ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*') (ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*')
) )
clauses.append(reduce(operator.or_, zone_clauses))
zone_clause = reduce(operator.or_, zone_clauses)
clauses.append((zone_clause))
if reviewed == 0:
clauses.append((ReviewSegment.has_been_reviewed == False))
if severity: if severity:
clauses.append((ReviewSegment.severity == severity)) clauses.append((ReviewSegment.severity == severity))
review = ( # Join with UserReviewStatus to get per-user review status
ReviewSegment.select() review_query = (
ReviewSegment.select(
ReviewSegment.id,
ReviewSegment.camera,
ReviewSegment.start_time,
ReviewSegment.end_time,
ReviewSegment.severity,
ReviewSegment.thumb_path,
ReviewSegment.data,
fn.COALESCE(UserReviewStatus.has_been_reviewed, False).alias(
"has_been_reviewed"
),
)
.left_outer_join(
UserReviewStatus,
on=(
(ReviewSegment.id == UserReviewStatus.review_segment)
& (UserReviewStatus.user_id == user_id)
),
)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.order_by(ReviewSegment.severity.asc()) )
# Filter unreviewed items without subquery
if reviewed == 0:
review_query = review_query.where(
(UserReviewStatus.has_been_reviewed == False)
| (UserReviewStatus.has_been_reviewed.is_null())
)
# Apply ordering and limit
review_query = (
review_query.order_by(ReviewSegment.severity.asc())
.order_by(ReviewSegment.start_time.desc()) .order_by(ReviewSegment.start_time.desc())
.limit(limit) .limit(limit)
.dicts() .dicts()
.iterator() .iterator()
) )
return JSONResponse(content=[r for r in review]) return JSONResponse(content=[r for r in review_query])
@router.get("/review_ids", response_model=list[ReviewSegmentResponse]) @router.get("/review_ids", response_model=list[ReviewSegmentResponse])
@ -134,7 +165,15 @@ def review_ids(ids: str):
@router.get("/review/summary", response_model=ReviewSummaryResponse) @router.get("/review/summary", response_model=ReviewSummaryResponse)
def review_summary(params: ReviewSummaryQueryParams = Depends()): async def review_summary(
params: ReviewSummaryQueryParams = Depends(),
current_user: dict = Depends(get_current_user),
):
if isinstance(current_user, JSONResponse):
return current_user
user_id = current_user["username"]
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp() day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp()
month_ago = (datetime.datetime.now() - datetime.timedelta(days=30)).timestamp() month_ago = (datetime.datetime.now() - datetime.timedelta(days=30)).timestamp()
@ -160,10 +199,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
) )
clauses.append(reduce(operator.or_, label_clauses))
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
if zones != "all": if zones != "all":
# use matching so segments with multiple zones # use matching so segments with multiple zones
# still match on a search where any zone matches # still match on a search where any zone matches
@ -172,21 +208,20 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
for zone in filtered_zones: for zone in filtered_zones:
zone_clauses.append( zone_clauses.append(
(ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*') ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*'
) )
clauses.append(reduce(operator.or_, zone_clauses))
zone_clause = reduce(operator.or_, zone_clauses) last_24_query = (
clauses.append((zone_clause))
last_24 = (
ReviewSegment.select( ReviewSegment.select(
fn.SUM( fn.SUM(
Case( Case(
None, None,
[ [
( (
(ReviewSegment.severity == SeverityEnum.alert), (ReviewSegment.severity == SeverityEnum.alert)
ReviewSegment.has_been_reviewed, & (UserReviewStatus.has_been_reviewed == True),
1,
) )
], ],
0, 0,
@ -197,8 +232,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == SeverityEnum.detection), (ReviewSegment.severity == SeverityEnum.detection)
ReviewSegment.has_been_reviewed, & (UserReviewStatus.has_been_reviewed == True),
1,
) )
], ],
0, 0,
@ -229,6 +265,13 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
) )
).alias("total_detection"), ).alias("total_detection"),
) )
.left_outer_join(
UserReviewStatus,
on=(
(ReviewSegment.id == UserReviewStatus.review_segment)
& (UserReviewStatus.user_id == user_id)
),
)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.dicts() .dicts()
.get() .get()
@ -248,14 +291,12 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
for label in filtered_labels: for label in filtered_labels:
label_clauses.append( label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
) )
clauses.append(reduce(operator.or_, label_clauses))
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
day_in_seconds = 60 * 60 * 24 day_in_seconds = 60 * 60 * 24
last_month = ( last_month_query = (
ReviewSegment.select( ReviewSegment.select(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
@ -271,8 +312,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == SeverityEnum.alert), (ReviewSegment.severity == SeverityEnum.alert)
ReviewSegment.has_been_reviewed, & (UserReviewStatus.has_been_reviewed == True),
1,
) )
], ],
0, 0,
@ -283,8 +325,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == SeverityEnum.detection), (ReviewSegment.severity == SeverityEnum.detection)
ReviewSegment.has_been_reviewed, & (UserReviewStatus.has_been_reviewed == True),
1,
) )
], ],
0, 0,
@ -315,28 +358,59 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
) )
).alias("total_detection"), ).alias("total_detection"),
) )
.left_outer_join(
UserReviewStatus,
on=(
(ReviewSegment.id == UserReviewStatus.review_segment)
& (UserReviewStatus.user_id == user_id)
),
)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.group_by( .group_by(
(ReviewSegment.start_time + seconds_offset).cast("int") / day_in_seconds, (ReviewSegment.start_time + seconds_offset).cast("int") / day_in_seconds
) )
.order_by(ReviewSegment.start_time.desc()) .order_by(ReviewSegment.start_time.desc())
) )
data = { data = {
"last24Hours": last_24, "last24Hours": last_24_query,
} }
for e in last_month.dicts().iterator(): for e in last_month_query.dicts().iterator():
data[e["day"]] = e data[e["day"]] = e
return JSONResponse(content=data) return JSONResponse(content=data)
@router.post("/reviews/viewed", response_model=GenericResponse) @router.post("/reviews/viewed", response_model=GenericResponse)
def set_multiple_reviewed(body: ReviewModifyMultipleBody): async def set_multiple_reviewed(
ReviewSegment.update(has_been_reviewed=True).where( body: ReviewModifyMultipleBody,
ReviewSegment.id << body.ids current_user: dict = Depends(get_current_user),
).execute() ):
if isinstance(current_user, JSONResponse):
return current_user
user_id = current_user["username"]
for review_id in body.ids:
try:
review_status = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id,
)
# If it exists and isnt reviewed, update it
if not review_status.has_been_reviewed:
review_status.has_been_reviewed = True
review_status.save()
except DoesNotExist:
try:
UserReviewStatus.create(
user_id=user_id,
review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=True,
)
except (DoesNotExist, IntegrityError):
pass
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Reviewed multiple items"}), content=({"success": True, "message": "Reviewed multiple items"}),
@ -389,6 +463,9 @@ def delete_reviews(body: ReviewModifyMultipleBody):
# delete recordings and review segments # delete recordings and review segments
Recordings.delete().where(Recordings.id << recording_ids).execute() Recordings.delete().where(Recordings.id << recording_ids).execute()
ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute() ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
UserReviewStatus.delete().where(
UserReviewStatus.review_segment << list_of_ids
).execute()
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Deleted review items."}), status_code=200 content=({"success": True, "message": "Deleted review items."}), status_code=200
@ -502,7 +579,15 @@ def get_review(review_id: str):
@router.delete("/review/{review_id}/viewed", response_model=GenericResponse) @router.delete("/review/{review_id}/viewed", response_model=GenericResponse)
def set_not_reviewed(review_id: str): async def set_not_reviewed(
review_id: str,
current_user: dict = Depends(get_current_user),
):
if isinstance(current_user, JSONResponse):
return current_user
user_id = current_user["username"]
try: try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id) review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id)
except DoesNotExist: except DoesNotExist:
@ -513,8 +598,15 @@ def set_not_reviewed(review_id: str):
status_code=404, status_code=404,
) )
review.has_been_reviewed = False try:
review.save() user_review = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review,
)
# we could update here instead of delete if we need
user_review.delete_instance()
except DoesNotExist:
pass # Already effectively "not reviewed"
return JSONResponse( return JSONResponse(
content=({"success": True, "message": f"Set Review {review_id} as not viewed"}), content=({"success": True, "message": f"Set Review {review_id} as not viewed"}),

View File

@ -55,7 +55,13 @@ class FaceRecognitionConfig(FrigateBaseModel):
gt=0.0, gt=0.0,
le=1.0, le=1.0,
) )
threshold: float = Field( detection_threshold: float = Field(
default=0.7,
title="Minimum face detection score required to be considered a face.",
gt=0.0,
le=1.0,
)
recognition_threshold: float = Field(
default=0.9, default=0.9,
title="Minimum face distance score required to be considered a match.", title="Minimum face distance score required to be considered a match.",
gt=0.0, gt=0.0,

View File

@ -937,12 +937,6 @@ class LicensePlateProcessingMixin:
if not license_plate: if not license_plate:
return return
if license_plate.get("score") < self.lpr_config.detection_threshold:
logger.debug(
f"Plate detection score is less than the threshold ({license_plate['score']:0.2f} < {self.lpr_config.detection_threshold})"
)
return
license_plate_box = license_plate.get("box") license_plate_box = license_plate.get("box")
# check that license plate is valid # check that license plate is valid

View File

@ -115,10 +115,10 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
x:x2, x:x2,
] ]
cv2.imwrite("/media/frigate/test_class.png", input) if input.shape != (224, 224):
input = cv2.resize(input, (224, 224))
input = np.expand_dims(input, axis=0) input = np.expand_dims(input, axis=0)
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input) self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input)
self.interpreter.invoke() self.interpreter.invoke()
res: np.ndarray = self.interpreter.get_tensor( res: np.ndarray = self.interpreter.get_tensor(

View File

@ -88,7 +88,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"), os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"),
config="", config="",
input_size=(320, 320), input_size=(320, 320),
score_threshold=0.8, score_threshold=self.face_config.detection_threshold,
nms_threshold=0.3, nms_threshold=0.3,
) )
self.landmark_detector = cv2.face.createFacemarkLBF() self.landmark_detector = cv2.face.createFacemarkLBF()
@ -367,9 +367,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
cv2.imwrite(file, face_frame) cv2.imwrite(file, face_frame)
if score < self.config.face_recognition.threshold: if score < self.config.face_recognition.recognition_threshold:
logger.debug( logger.debug(
f"Recognized face distance {score} is less than threshold {self.config.face_recognition.threshold}" f"Recognized face distance {score} is less than threshold {self.config.face_recognition.recognition_threshold}"
) )
self.__update_metrics(datetime.datetime.now().timestamp() - start) self.__update_metrics(datetime.datetime.now().timestamp() - start)
return return

View File

@ -3,6 +3,7 @@ from peewee import (
CharField, CharField,
DateTimeField, DateTimeField,
FloatField, FloatField,
ForeignKeyField,
IntegerField, IntegerField,
Model, Model,
TextField, TextField,
@ -92,12 +93,20 @@ class ReviewSegment(Model): # type: ignore[misc]
camera = CharField(index=True, max_length=20) camera = CharField(index=True, max_length=20)
start_time = DateTimeField() start_time = DateTimeField()
end_time = DateTimeField() end_time = DateTimeField()
has_been_reviewed = BooleanField(default=False)
severity = CharField(max_length=30) # alert, detection severity = CharField(max_length=30) # alert, detection
thumb_path = CharField(unique=True) thumb_path = CharField(unique=True)
data = JSONField() # additional data about detection like list of labels, zone, areas of significant motion data = JSONField() # additional data about detection like list of labels, zone, areas of significant motion
class UserReviewStatus(Model): # type: ignore[misc]
user_id = CharField(max_length=30)
review_segment = ForeignKeyField(ReviewSegment, backref="user_reviews")
has_been_reviewed = BooleanField(default=False)
class Meta:
indexes = ((("user_id", "review_segment"), True),)
class Previews(Model): # type: ignore[misc] class Previews(Model): # type: ignore[misc]
id = CharField(null=False, primary_key=True, max_length=30) id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20) camera = CharField(index=True, max_length=20)

View File

@ -584,19 +584,31 @@ class PtzAutoTracker:
# Extract areas and calculate weighted average # Extract areas and calculate weighted average
# grab the largest dimension of the bounding box and create a square from that # grab the largest dimension of the bounding box and create a square from that
# Filter out the initial frame and use a recent time window
current_time = obj.obj_data["frame_time"]
time_window = 1.5 # seconds
history = [
entry
for entry in self.tracked_object_history[camera]
if not entry.get("is_initial_frame", False)
and current_time - entry["frame_time"] <= time_window
]
if not history: # Fallback to latest if no recent entries
history = [self.tracked_object_history[camera][-1]]
areas = [ areas = [
{ {
"frame_time": obj["frame_time"], "frame_time": entry["frame_time"],
"box": obj["box"], "box": entry["box"],
"area": max( "area": max(
obj["box"][2] - obj["box"][0], obj["box"][3] - obj["box"][1] entry["box"][2] - entry["box"][0], entry["box"][3] - entry["box"][1]
) )
** 2, ** 2,
} }
for obj in self.tracked_object_history[camera] for entry in history
] ]
filtered_areas = remove_outliers(areas) if len(areas) >= 2 else areas filtered_areas = remove_outliers(areas) if len(areas) > 3 else areas
# Filter entries that are not touching the frame edge # Filter entries that are not touching the frame edge
filtered_areas_not_touching_edge = [ filtered_areas_not_touching_edge = [

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
import time
from enum import Enum from enum import Enum
from importlib.util import find_spec from importlib.util import find_spec
from pathlib import Path from pathlib import Path
@ -39,6 +40,10 @@ class OnvifController:
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics] self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics]
) -> None: ) -> None:
self.cams: dict[str, ONVIFCamera] = {} self.cams: dict[str, ONVIFCamera] = {}
self.failed_cams: dict[str, dict] = {}
self.max_retries = 5
self.reset_timeout = 900 # 15 minutes
self.config = config self.config = config
self.ptz_metrics = ptz_metrics self.ptz_metrics = ptz_metrics
@ -47,26 +52,37 @@ class OnvifController:
continue continue
if cam.onvif.host: if cam.onvif.host:
try: result = self._create_onvif_camera(cam_name, cam)
self.cams[cam_name] = { if result:
"onvif": ONVIFCamera( self.cams[cam_name] = result
cam.onvif.host,
cam.onvif.port, def _create_onvif_camera(self, cam_name: str, cam) -> dict | None:
cam.onvif.user, """Create an ONVIF camera instance and handle failures."""
cam.onvif.password, try:
wsdl_dir=str( return {
Path(find_spec("onvif").origin).parent / "wsdl" "onvif": ONVIFCamera(
), cam.onvif.host,
adjust_time=cam.onvif.ignore_time_mismatch, cam.onvif.port,
encrypt=not cam.onvif.tls_insecure, cam.onvif.user,
), cam.onvif.password,
"init": False, wsdl_dir=str(Path(find_spec("onvif").origin).parent / "wsdl"),
"active": False, adjust_time=cam.onvif.ignore_time_mismatch,
"features": [], encrypt=not cam.onvif.tls_insecure,
"presets": {}, ),
} "init": False,
except ONVIFError as e: "active": False,
logger.error(f"Onvif connection to {cam.name} failed: {e}") "features": [],
"presets": {},
}
except ONVIFError as e:
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
# track initial failures
self.failed_cams[cam_name] = {
"retry_attempts": 0,
"last_error": str(e),
"last_attempt": time.time(),
}
return None
async def _init_onvif(self, camera_name: str) -> bool: async def _init_onvif(self, camera_name: str) -> bool:
onvif: ONVIFCamera = self.cams[camera_name]["onvif"] onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
@ -548,7 +564,7 @@ class OnvifController:
self, camera_name: str, command: OnvifCommandEnum, param: str = "" self, camera_name: str, command: OnvifCommandEnum, param: str = ""
) -> None: ) -> None:
if camera_name not in self.cams.keys(): if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}") logger.error(f"ONVIF is not configured for {camera_name}")
return return
if not self.cams[camera_name]["init"]: if not self.cams[camera_name]["init"]:
@ -576,23 +592,94 @@ class OnvifController:
except ONVIFError as e: except ONVIFError as e:
logger.error(f"Unable to handle onvif command: {e}") logger.error(f"Unable to handle onvif command: {e}")
def get_camera_info(self, camera_name: str) -> dict[str, any]: async def get_camera_info(self, camera_name: str) -> dict[str, any]:
if camera_name not in self.cams.keys(): """
logger.debug(f"Onvif is not setup for {camera_name}") Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
but not initialized.
Returns camera details including features and presets if available.
"""
if not self.config.cameras[camera_name].enabled:
logger.debug(
f"Camera {camera_name} disabled, won't try to initialize ONVIF"
)
return {} return {}
if not self.cams[camera_name]["init"]: if camera_name not in self.cams and (
asyncio.run(self._init_onvif(camera_name)) camera_name not in self.config.cameras
or not self.config.cameras[camera_name].onvif.host
):
logger.debug(f"ONVIF is not configured for {camera_name}")
return {}
return { if camera_name in self.cams and self.cams[camera_name]["init"]:
"name": camera_name, return {
"features": self.cams[camera_name]["features"], "name": camera_name,
"presets": list(self.cams[camera_name]["presets"].keys()), "features": self.cams[camera_name]["features"],
} "presets": list(self.cams[camera_name]["presets"].keys()),
}
if camera_name not in self.cams and camera_name in self.config.cameras:
cam = self.config.cameras[camera_name]
result = self._create_onvif_camera(camera_name, cam)
if result:
self.cams[camera_name] = result
else:
return {}
# Reset retry count after timeout
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
logger.debug(f"Resetting retry count for {camera_name} after timeout")
attempts = 0
self.failed_cams[camera_name]["retry_attempts"] = 0
# Attempt initialization/reconnection
if attempts < self.max_retries:
logger.info(
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
)
try:
if await self._init_onvif(camera_name):
if camera_name in self.failed_cams:
del self.failed_cams[camera_name]
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
}
else:
logger.warning(f"ONVIF initialization failed for {camera_name}")
except Exception as e:
logger.error(
f"Error during ONVIF initialization for {camera_name}: {e}"
)
if camera_name not in self.failed_cams:
self.failed_cams[camera_name] = {"retry_attempts": 0}
self.failed_cams[camera_name].update(
{
"retry_attempts": attempts + 1,
"last_error": str(e),
"last_attempt": time.time(),
}
)
if attempts >= self.max_retries:
remaining_time = max(
0, int((self.reset_timeout - (time.time() - last_attempt)) / 60)
)
logger.error(
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
)
logger.debug(f"Could not initialize ONVIF for {camera_name}")
return {}
def get_service_capabilities(self, camera_name: str) -> None: def get_service_capabilities(self, camera_name: str) -> None:
if camera_name not in self.cams.keys(): if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}") logger.error(f"ONVIF is not configured for {camera_name}")
return {} return {}
if not self.cams[camera_name]["init"]: if not self.cams[camera_name]["init"]:
@ -622,7 +709,7 @@ class OnvifController:
def get_camera_status(self, camera_name: str) -> None: def get_camera_status(self, camera_name: str) -> None:
if camera_name not in self.cams.keys(): if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}") logger.error(f"ONVIF is not configured for {camera_name}")
return {} return {}
if not self.cams[camera_name]["init"]: if not self.cams[camera_name]["init"]:

View File

@ -12,7 +12,7 @@ from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR
from frigate.models import Previews, Recordings, ReviewSegment from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
from frigate.record.util import remove_empty_directories, sync_recordings from frigate.record.util import remove_empty_directories, sync_recordings
from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
@ -90,6 +90,10 @@ class RecordingCleanup(threading.Thread):
ReviewSegment.delete().where( ReviewSegment.delete().where(
ReviewSegment.id << deleted_reviews_list[i : i + max_deletes] ReviewSegment.id << deleted_reviews_list[i : i + max_deletes]
).execute() ).execute()
UserReviewStatus.delete().where(
UserReviewStatus.review_segment
<< deleted_reviews_list[i : i + max_deletes]
).execute()
def expire_existing_camera_recordings( def expire_existing_camera_recordings(
self, expire_date: float, config: CameraConfig, reviews: ReviewSegment self, expire_date: float, config: CameraConfig, reviews: ReviewSegment

View File

@ -157,16 +157,14 @@ class BaseTestHttp(unittest.TestCase):
start_time: float = datetime.datetime.now().timestamp(), start_time: float = datetime.datetime.now().timestamp(),
end_time: float = datetime.datetime.now().timestamp() + 20, end_time: float = datetime.datetime.now().timestamp() + 20,
severity: SeverityEnum = SeverityEnum.alert, severity: SeverityEnum = SeverityEnum.alert,
has_been_reviewed: bool = False,
data: Json = {}, data: Json = {},
) -> Event: ) -> ReviewSegment:
"""Inserts a review segment model with a given id.""" """Inserts a review segment model with a given id."""
return ReviewSegment.insert( return ReviewSegment.insert(
id=id, id=id,
camera="front_door", camera="front_door",
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
has_been_reviewed=has_been_reviewed,
severity=severity, severity=severity,
thumb_path=False, thumb_path=False,
data=data, data=data,

View File

@ -1,16 +1,29 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from peewee import DoesNotExist
from frigate.models import Event, Recordings, ReviewSegment from frigate.api.auth import get_current_user
from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.test.http_api.base_http_test import BaseTestHttp from frigate.test.http_api.base_http_test import BaseTestHttp
class TestHttpReview(BaseTestHttp): class TestHttpReview(BaseTestHttp):
def setUp(self): def setUp(self):
super().setUp([Event, Recordings, ReviewSegment]) super().setUp([Event, Recordings, ReviewSegment, UserReviewStatus])
self.app = super().create_app() self.app = super().create_app()
self.user_id = "admin"
# Mock get_current_user for all tests
async def mock_get_current_user():
return {"username": self.user_id, "role": "admin"}
self.app.dependency_overrides[get_current_user] = mock_get_current_user
def tearDown(self):
self.app.dependency_overrides.clear()
super().tearDown()
def _get_reviews(self, ids: list[str]): def _get_reviews(self, ids: list[str]):
return list( return list(
@ -24,6 +37,13 @@ class TestHttpReview(BaseTestHttp):
Recordings.select(Recordings.id).where(Recordings.id.in_(ids)).execute() Recordings.select(Recordings.id).where(Recordings.id.in_(ids)).execute()
) )
def _insert_user_review_status(self, review_id: str, reviewed: bool = True):
UserReviewStatus.create(
user_id=self.user_id,
review_segment=ReviewSegment.get(ReviewSegment.id == review_id),
has_been_reviewed=reviewed,
)
#################################################################################################################### ####################################################################################################################
################################### GET /review Endpoint ######################################################## ################################### GET /review Endpoint ########################################################
#################################################################################################################### ####################################################################################################################
@ -43,11 +63,14 @@ class TestHttpReview(BaseTestHttp):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now - 2, now - 1) id = "123456.random"
super().insert_mock_review_segment(id, now - 2, now - 1)
response = client.get("/review") response = client.get("/review")
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
assert len(response_json) == 1 assert len(response_json) == 1
assert response_json[0]["id"] == id
assert response_json[0]["has_been_reviewed"] == False
def test_get_review_with_time_filter_no_matches(self): def test_get_review_with_time_filter_no_matches(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
@ -391,37 +414,27 @@ class TestHttpReview(BaseTestHttp):
with TestClient(self.app) as client: with TestClient(self.app) as client:
five_days_ago_ts = five_days_ago.timestamp() five_days_ago_ts = five_days_ago.timestamp()
for i in range(10): for i in range(10):
id = f"123456_{i}.random_alert_not_reviewed"
super().insert_mock_review_segment( super().insert_mock_review_segment(
f"123456_{i}.random_alert_not_reviewed", id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.alert
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.alert,
False,
) )
for i in range(10): for i in range(10):
id = f"123456_{i}.random_alert_reviewed"
super().insert_mock_review_segment( super().insert_mock_review_segment(
f"123456_{i}.random_alert_reviewed", id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.alert
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.alert,
True,
) )
self._insert_user_review_status(id, reviewed=True)
for i in range(10): for i in range(10):
id = f"123456_{i}.random_detection_not_reviewed"
super().insert_mock_review_segment( super().insert_mock_review_segment(
f"123456_{i}.random_detection_not_reviewed", id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.detection
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.detection,
False,
) )
for i in range(5): for i in range(5):
id = f"123456_{i}.random_detection_reviewed"
super().insert_mock_review_segment( super().insert_mock_review_segment(
f"123456_{i}.random_detection_reviewed", id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.detection
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.detection,
True,
) )
self._insert_user_review_status(id, reviewed=True)
response = client.get("/review/summary") response = client.get("/review/summary")
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
@ -447,6 +460,7 @@ class TestHttpReview(BaseTestHttp):
#################################################################################################################### ####################################################################################################################
################################### POST reviews/viewed Endpoint ################################################ ################################### POST reviews/viewed Endpoint ################################################
#################################################################################################################### ####################################################################################################################
def test_post_reviews_viewed_no_body(self): def test_post_reviews_viewed_no_body(self):
with TestClient(self.app) as client: with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
@ -473,12 +487,11 @@ class TestHttpReview(BaseTestHttp):
assert response["success"] == True assert response["success"] == True
assert response["message"] == "Reviewed multiple items" assert response["message"] == "Reviewed multiple items"
# Verify that in DB the review segment was not changed # Verify that in DB the review segment was not changed
review_segment_in_db = ( with self.assertRaises(DoesNotExist):
ReviewSegment.select(ReviewSegment.has_been_reviewed) UserReviewStatus.get(
.where(ReviewSegment.id == id) UserReviewStatus.user_id == self.user_id,
.get() UserReviewStatus.review_segment == "1",
) )
assert review_segment_in_db.has_been_reviewed == False
def test_post_reviews_viewed(self): def test_post_reviews_viewed(self):
with TestClient(self.app) as client: with TestClient(self.app) as client:
@ -487,16 +500,15 @@ class TestHttpReview(BaseTestHttp):
body = {"ids": [id]} body = {"ids": [id]}
response = client.post("/reviews/viewed", json=body) response = client.post("/reviews/viewed", json=body)
assert response.status_code == 200 assert response.status_code == 200
response = response.json() response_json = response.json()
assert response["success"] == True assert response_json["success"] == True
assert response["message"] == "Reviewed multiple items" assert response_json["message"] == "Reviewed multiple items"
# Verify that in DB the review segment was changed # Verify UserReviewStatus was created
review_segment_in_db = ( user_review = UserReviewStatus.get(
ReviewSegment.select(ReviewSegment.has_been_reviewed) UserReviewStatus.user_id == self.user_id,
.where(ReviewSegment.id == id) UserReviewStatus.review_segment == id,
.get()
) )
assert review_segment_in_db.has_been_reviewed == True assert user_review.has_been_reviewed == True
#################################################################################################################### ####################################################################################################################
################################### POST reviews/delete Endpoint ################################################ ################################### POST reviews/delete Endpoint ################################################
@ -672,8 +684,7 @@ class TestHttpReview(BaseTestHttp):
"camera": "front_door", "camera": "front_door",
"start_time": now + 1, "start_time": now + 1,
"end_time": now + 2, "end_time": now + 2,
"has_been_reviewed": False, "severity": "alert",
"severity": SeverityEnum.alert,
"thumb_path": "False", "thumb_path": "False",
"data": {"detections": {"event_id": event_id}}, "data": {"detections": {"event_id": event_id}},
}, },
@ -708,8 +719,7 @@ class TestHttpReview(BaseTestHttp):
"camera": "front_door", "camera": "front_door",
"start_time": now + 1, "start_time": now + 1,
"end_time": now + 2, "end_time": now + 2,
"has_been_reviewed": False, "severity": "alert",
"severity": SeverityEnum.alert,
"thumb_path": "False", "thumb_path": "False",
"data": {}, "data": {},
}, },
@ -719,6 +729,7 @@ class TestHttpReview(BaseTestHttp):
#################################################################################################################### ####################################################################################################################
################################### DELETE /review/{review_id}/viewed Endpoint ################################## ################################### DELETE /review/{review_id}/viewed Endpoint ##################################
#################################################################################################################### ####################################################################################################################
def test_delete_review_viewed_review_not_found(self): def test_delete_review_viewed_review_not_found(self):
with TestClient(self.app) as client: with TestClient(self.app) as client:
review_id = "123456.random" review_id = "123456.random"
@ -735,11 +746,10 @@ class TestHttpReview(BaseTestHttp):
with TestClient(self.app) as client: with TestClient(self.app) as client:
review_id = "123456.review.random" review_id = "123456.review.random"
super().insert_mock_review_segment( super().insert_mock_review_segment(review_id, now + 1, now + 2)
review_id, now + 1, now + 2, has_been_reviewed=True self._insert_user_review_status(review_id, reviewed=True)
) # Verify its reviewed before
review_before = ReviewSegment.get(ReviewSegment.id == review_id) response = client.get(f"/review/{review_id}")
assert review_before.has_been_reviewed == True
response = client.delete(f"/review/{review_id}/viewed") response = client.delete(f"/review/{review_id}/viewed")
assert response.status_code == 200 assert response.status_code == 200
@ -749,5 +759,9 @@ class TestHttpReview(BaseTestHttp):
response_json, response_json,
) )
review_after = ReviewSegment.get(ReviewSegment.id == review_id) # Verify its unreviewed after
assert review_after.has_been_reviewed == False with self.assertRaises(DoesNotExist):
UserReviewStatus.get(
UserReviewStatus.user_id == self.user_id,
UserReviewStatus.review_segment == review_id,
)

View File

@ -0,0 +1,85 @@
"""Peewee migrations -- 030_create_user_review_status.py.
This migration creates the UserReviewStatus table to track per-user review states,
migrates existing has_been_reviewed data from ReviewSegment to all users in the user table,
and drops the has_been_reviewed column. Rollback drops UserReviewStatus and restores the column.
Some examples (model - class or model_name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
from frigate.models import User, UserReviewStatus
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
User._meta.database = database
UserReviewStatus._meta.database = database
migrator.sql(
"""
CREATE TABLE IF NOT EXISTS "userreviewstatus" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"user_id" VARCHAR(30) NOT NULL,
"review_segment_id" VARCHAR(30) NOT NULL,
"has_been_reviewed" INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY ("review_segment_id") REFERENCES "reviewsegment" ("id") ON DELETE CASCADE
)
"""
)
# Add unique index on (user_id, review_segment_id)
migrator.sql(
'CREATE UNIQUE INDEX IF NOT EXISTS "userreviewstatus_user_segment" ON "userreviewstatus" ("user_id", "review_segment_id")'
)
# Migrate existing has_been_reviewed data to UserReviewStatus for all users
def migrate_data():
all_users = list(User.select())
if not all_users:
return
cursor = database.execute_sql(
'SELECT "id" FROM "reviewsegment" WHERE "has_been_reviewed" = 1'
)
reviewed_segment_ids = [row[0] for row in cursor.fetchall()]
for segment_id in reviewed_segment_ids:
for user in all_users:
UserReviewStatus.create(
user_id=user.username,
review_segment=segment_id,
has_been_reviewed=True,
)
if not fake: # Only run data migration if not faking
migrator.python(migrate_data)
migrator.sql('ALTER TABLE "reviewsegment" DROP COLUMN "has_been_reviewed"')
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP TABLE IF EXISTS "userreviewstatus"')
# Restore has_been_reviewed column to reviewsegment (no data restoration)
migrator.sql(
'ALTER TABLE "reviewsegment" ADD COLUMN "has_been_reviewed" INTEGER NOT NULL DEFAULT 0'
)

View File

@ -78,7 +78,10 @@ export default function AutoUpdatingCameraImage({
let baseParam = ""; let baseParam = "";
if (periodicCache && !isCached) { if (periodicCache && !isCached) {
baseParam = "store=1"; const date = new Date(key);
date.setMinutes(date.getMinutes() - (date.getMinutes() % 10), 0, 0);
baseParam = `store=1&cache=${date.getTime() / 1000}`;
} else { } else {
baseParam = `cache=${key}`; baseParam = `cache=${key}`;
} }

View File

@ -275,6 +275,19 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
</Link> </Link>
</> </>
)} )}
{isAdmin && isMobile && (
<>
<Link to="/faces">
<MenuItem
className="flex w-full items-center p-2 text-sm"
aria-label="Face Library"
>
<LuSquarePen className="mr-2 size-4" />
<span>Configuration editor</span>
</MenuItem>
</Link>
</>
)}
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}> <DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
{t("menu.appearance")} {t("menu.appearance")}

View File

@ -256,11 +256,13 @@ function TimeRangeFilterContent({
const [endOpen, setEndOpen] = useState(false); const [endOpen, setEndOpen] = useState(false);
const [afterHour, beforeHour] = useMemo(() => { const [afterHour, beforeHour] = useMemo(() => {
if (!timeRange || !timeRange.includes(",")) { if (Array.isArray(timeRange) && timeRange.length === 2) {
return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE]; return timeRange;
} }
if (typeof timeRange === "string" && timeRange.includes(",")) {
return timeRange.split(","); return timeRange.split(",");
}
return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE];
}, [timeRange]); }, [timeRange]);
const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour); const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour);

View File

@ -347,7 +347,7 @@ function TrainingGrid({
key={image} key={image}
image={image} image={image}
faceNames={faceNames} faceNames={faceNames}
threshold={config.face_recognition.threshold} threshold={config.face_recognition.recognition_threshold}
selected={selectedFaces.includes(image)} selected={selectedFaces.includes(image)}
onClick={() => onClickFace(image)} onClick={() => onClickFace(image)}
onRefresh={onRefresh} onRefresh={onRefresh}

View File

@ -35,7 +35,7 @@ import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView"; import AuthenticationView from "@/views/settings/AuthenticationView";
import NotificationView from "@/views/settings/NotificationsSettingsView"; import NotificationView from "@/views/settings/NotificationsSettingsView";
import SearchSettingsView from "@/views/settings/SearchSettingsView"; import ClassificationSettingsView from "@/views/settings/ClassificationSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
@ -47,7 +47,7 @@ import { useTranslation } from "react-i18next";
const allSettingsViews = [ const allSettingsViews = [
"uiSettings", "uiSettings",
"exploreSettings", "classificationSettings",
"cameraSettings", "cameraSettings",
"masksAndZones", "masksAndZones",
"motionTuner", "motionTuner",
@ -247,8 +247,8 @@ export default function Settings() {
</div> </div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24"> <div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "uiSettings" && <UiSettingsView />} {page == "uiSettings" && <UiSettingsView />}
{page == "exploreSettings" && ( {page == "classificationSettings" && (
<SearchSettingsView setUnsavedChanges={setUnsavedChanges} /> <ClassificationSettingsView setUnsavedChanges={setUnsavedChanges} />
)} )}
{page == "debug" && ( {page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} /> <ObjectSettingsView selectedCamera={selectedCamera} />

View File

@ -333,7 +333,8 @@ export interface FrigateConfig {
face_recognition: { face_recognition: {
enabled: boolean; enabled: boolean;
threshold: number; detection_threshold: number;
recognition_threshold: number;
}; };
ffmpeg: { ffmpeg: {

View File

@ -0,0 +1,449 @@
import Heading from "@/components/ui/heading";
import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig";
import useSWR from "swr";
import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useContext, useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Separator } from "@/components/ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
type ClassificationSettings = {
search: {
enabled?: boolean;
reindex?: boolean;
model_size?: SearchModelSize;
};
face: {
enabled?: boolean;
};
lpr: {
enabled?: boolean;
};
};
type ClassificationSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function ClassificationSettingsView({
setUnsavedChanges,
}: ClassificationSettingsViewProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const [classificationSettings, setClassificationSettings] =
useState<ClassificationSettings>({
search: {
enabled: undefined,
reindex: undefined,
model_size: undefined,
},
face: {
enabled: undefined,
},
lpr: {
enabled: undefined,
},
});
const [origSearchSettings, setOrigSearchSettings] =
useState<ClassificationSettings>({
search: {
enabled: undefined,
reindex: undefined,
model_size: undefined,
},
face: {
enabled: undefined,
},
lpr: {
enabled: undefined,
},
});
useEffect(() => {
if (config) {
if (classificationSettings?.search.enabled == undefined) {
setClassificationSettings({
search: {
enabled: config.semantic_search.enabled,
reindex: config.semantic_search.reindex,
model_size: config.semantic_search.model_size,
},
face: {
enabled: config.face_recognition.enabled,
},
lpr: {
enabled: config.lpr.enabled,
},
});
}
setOrigSearchSettings({
search: {
enabled: config.semantic_search.enabled,
reindex: config.semantic_search.reindex,
model_size: config.semantic_search.model_size,
},
face: {
enabled: config.face_recognition.enabled,
},
lpr: {
enabled: config.lpr.enabled,
},
});
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const handleClassificationConfigChange = (
newConfig: Partial<ClassificationSettings>,
) => {
setClassificationSettings((prevConfig) => ({
search: {
...prevConfig.search,
...newConfig.search,
},
face: { ...prevConfig.face, ...newConfig.face },
lpr: { ...prevConfig.lpr, ...newConfig.lpr },
}));
setUnsavedChanges(true);
setChangedValue(true);
};
const saveToConfig = useCallback(async () => {
setIsLoading(true);
axios
.put(
`config/set?semantic_search.enabled=${classificationSettings.search.enabled ? "True" : "False"}&semantic_search.reindex=${classificationSettings.search.reindex ? "True" : "False"}&semantic_search.model_size=${classificationSettings.search.model_size}`,
{
requires_restart: 0,
},
)
.then((res) => {
if (res.status === 200) {
toast.success("Classification settings have been saved.", {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
})
.finally(() => {
setIsLoading(false);
});
}, [updateConfig, classificationSettings.search]);
const onCancel = useCallback(() => {
setClassificationSettings(origSearchSettings);
setChangedValue(false);
removeMessage("search_settings", "search_settings");
}, [origSearchSettings, removeMessage]);
useEffect(() => {
if (changedValue) {
addMessage(
"search_settings",
`Unsaved Classification settings changes`,
undefined,
"search_settings",
);
} else {
removeMessage("search_settings", "search_settings");
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue]);
useEffect(() => {
document.title = "Classification Settings - Frigate";
}, []);
if (!config) {
return <ActivityIndicator />;
}
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
Classification Settings
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Semantic Search
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Semantic Search in Frigate allows you to find tracked objects
within your review items using either the image itself, a
user-defined text description, or an automatically generated one.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/semantic_search"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={classificationSettings.search.enabled === undefined}
checked={classificationSettings.search.enabled === true}
onCheckedChange={(isChecked) => {
handleClassificationConfigChange({
search: { enabled: isChecked },
});
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="reindex"
className="mr-3"
disabled={classificationSettings.search.reindex === undefined}
checked={classificationSettings.search.reindex === true}
onCheckedChange={(isChecked) => {
handleClassificationConfigChange({
search: { reindex: isChecked },
});
}}
/>
<div className="space-y-0.5">
<Label htmlFor="reindex">Re-Index On Startup</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Re-indexing will reprocess all thumbnails and descriptions (if
enabled) and apply the embeddings on each startup.{" "}
<em>Don't forget to disable the option after restarting!</em>
</div>
</div>
<div className="mt-2 flex flex-col space-y-6">
<div className="space-y-0.5">
<div className="text-md">Model Size</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
The size of the model used for Semantic Search embeddings.
</p>
<ul className="list-disc pl-5 text-sm">
<li>
Using <em>small</em> employs a quantized version of the
model that uses less RAM and runs faster on CPU with a very
negligible difference in embedding quality.
</li>
<li>
Using <em>large</em> employs the full Jina model and will
automatically run on the GPU if applicable.
</li>
</ul>
</div>
</div>
<Select
value={classificationSettings.search.model_size}
onValueChange={(value) =>
handleClassificationConfigChange({
search: {
model_size: value as SearchModelSize,
},
})
}
>
<SelectTrigger className="w-20">
{classificationSettings.search.model_size}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["small", "large"].map((size) => (
<SelectItem
key={size}
className="cursor-pointer"
value={size}
>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="my-2 space-y-6">
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Face Recognition
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Face recognition allows people to be assigned names and when
their face is recognized Frigate will assign the person's name
as a sub label. This information is included in the UI, filters,
as well as in notifications.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/face_recognition"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={classificationSettings.face.enabled === undefined}
checked={classificationSettings.face.enabled === true}
onCheckedChange={(isChecked) => {
handleClassificationConfigChange({
face: { enabled: isChecked },
});
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
</div>
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
License Plate Recognition
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
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
objects that are of type car. A common use case may be to read
the license plates of cars pulling into a driveway or cars
passing by on a street.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/license_plate_recognition"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={classificationSettings.lpr.enabled === undefined}
checked={classificationSettings.lpr.enabled === true}
onCheckedChange={(isChecked) => {
handleClassificationConfigChange({
lpr: { enabled: isChecked },
});
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
</div>
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label="Reset"
onClick={onCancel}
>
Reset
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
aria-label="Save"
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,325 +0,0 @@
import Heading from "@/components/ui/heading";
import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig";
import useSWR from "swr";
import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useContext, useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Separator } from "@/components/ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Trans, useTranslation } from "react-i18next";
type ExploreSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
type ExploreSettings = {
enabled?: boolean;
reindex?: boolean;
model_size?: SearchModelSize;
};
export default function ExploreSettingsView({
setUnsavedChanges,
}: ExploreSettingsViewProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const [exploreSettings, setExploreSettings] = useState<ExploreSettings>({
enabled: undefined,
reindex: undefined,
model_size: undefined,
});
const [origExploreSettings, setOrigExploreSettings] =
useState<ExploreSettings>({
enabled: undefined,
reindex: undefined,
model_size: undefined,
});
useEffect(() => {
if (config) {
if (exploreSettings?.enabled == undefined) {
setExploreSettings({
enabled: config.semantic_search.enabled,
reindex: config.semantic_search.reindex,
model_size: config.semantic_search.model_size,
});
}
setOrigExploreSettings({
enabled: config.semantic_search.enabled,
reindex: config.semantic_search.reindex,
model_size: config.semantic_search.model_size,
});
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const handleSearchConfigChange = (newConfig: Partial<ExploreSettings>) => {
setExploreSettings((prevConfig) => ({ ...prevConfig, ...newConfig }));
setUnsavedChanges(true);
setChangedValue(true);
};
const saveToConfig = useCallback(async () => {
setIsLoading(true);
axios
.put(
`config/set?semantic_search.enabled=${exploreSettings.enabled ? "True" : "False"}&semantic_search.reindex=${exploreSettings.reindex ? "True" : "False"}&semantic_search.model_size=${exploreSettings.model_size}`,
{
requires_restart: 0,
},
)
.then((res) => {
if (res.status === 200) {
toast.success(t("explore.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(
t("toast.save.error", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
}, [
updateConfig,
exploreSettings.enabled,
exploreSettings.reindex,
exploreSettings.model_size,
t,
]);
const onCancel = useCallback(() => {
setExploreSettings(origExploreSettings);
setChangedValue(false);
removeMessage("search_settings", "search_settings");
}, [origExploreSettings, removeMessage]);
useEffect(() => {
if (changedValue) {
addMessage(
"search_settings",
`Unsaved Explore settings changes`,
undefined,
"search_settings",
);
} else {
removeMessage("search_settings", "search_settings");
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue]);
useEffect(() => {
document.title = "Explore Settings - Frigate";
}, []);
if (!config) {
return <ActivityIndicator />;
}
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
{t("explore.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("explore.semanticSearch.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans ns="views/settings">explore.semanticSearch.desc</Trans>
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/semantic_search"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("explore.semanticSearch.readTheDocumentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={exploreSettings.enabled === undefined}
checked={exploreSettings.enabled === true}
onCheckedChange={(isChecked) => {
handleSearchConfigChange({ enabled: isChecked });
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="reindex"
className="mr-3"
disabled={exploreSettings.reindex === undefined}
checked={exploreSettings.reindex === true}
onCheckedChange={(isChecked) => {
handleSearchConfigChange({ reindex: isChecked });
}}
/>
<div className="space-y-0.5">
<Label htmlFor="reindex">
{t("explore.semanticSearch.reindexOnStartup.label")}
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
explore.semanticSearch.reindexOnStartup.desc
</Trans>
</div>
</div>
<div className="mt-2 flex flex-col space-y-6">
<div className="space-y-0.5">
<div className="text-md">
{t("explore.semanticSearch.modelSize.label")}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">
explore.semanticSearch.modelSize.desc
</Trans>
</p>
<ul className="list-disc pl-5 text-sm">
<li>
<Trans ns="views/settings">
explore.semanticSearch.modelSize.small.desc
</Trans>
</li>
<li>
<Trans ns="views/settings">
explore.semanticSearch.modelSize.large.desc
</Trans>
</li>
</ul>
</div>
</div>
<Select
value={exploreSettings.model_size}
onValueChange={(value) =>
handleSearchConfigChange({
model_size: value as SearchModelSize,
})
}
>
<SelectTrigger className="w-20">
{t(
"explore.semanticSearch.modelSize." +
exploreSettings.model_size,
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["small", "large"].map((size) => (
<SelectItem
key={size}
className="cursor-pointer"
value={size}
>
{t("explore.semanticSearch.modelSize." + size)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
{t("button.reset", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</div>
);
}