diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 99724d57e..0e16269e3 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -12,7 +12,7 @@ pandas == 2.2.* peewee == 3.17.* peewee_migrate == 1.12.* psutil == 5.9.* -pydantic == 2.6.* +pydantic == 2.7.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml PyYAML == 6.0.* pytz == 2024.1 diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 0591f9757..f603d33fb 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -257,6 +257,28 @@ objects: # Checks based on the bottom center of the bounding box of the object mask: 0,0,1000,0,1000,200,0,200 +# Optional: Review configuration +# NOTE: Can be overridden at the camera level +review: + # Optional: alerts configuration + alerts: + # Optional: labels that qualify as an alert (default: shown below) + labels: + - car + - person + # Optional: required zones for an object to be marked as an alert (default: none) + required_zones: + - driveway + # Optional: detections configuration + detections: + # Optional: labels that qualify as a detection (default: all labels that are tracked / listened to) + labels: + - car + - person + # Optional: required zones for an object to be marked as a detection (default: none) + required_zones: + - driveway + # Optional: Motion configuration # NOTE: Can be overridden at the camera level motion: @@ -345,8 +367,6 @@ record: # Optional: Objects to save recordings for. (default: all tracked objects) objects: - person - # Optional: Restrict recordings to objects that entered any of the listed zones (default: no required zones) - required_zones: [] # Optional: Retention settings for recordings of events retain: # Required: Default retention days (default: shown below) diff --git a/frigate/app.py b/frigate/app.py index 596abed72..a0722ecf2 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -63,6 +63,7 @@ from frigate.storage import StorageMaintainer from frigate.timeline import TimelineProcessor from frigate.types import CameraMetricsTypes, PTZMetricsTypes from frigate.util.builtin import save_default_config +from frigate.util.config import migrate_frigate_config from frigate.util.object import get_camera_regions_grid from frigate.version import VERSION from frigate.video import capture_camera, track_camera @@ -126,6 +127,9 @@ class FrigateApp: config_file = config_file_yaml save_default_config(config_file) + # check if the config file needs to be migrated + migrate_frigate_config(config_file) + user_config = FrigateConfig.parse_file(config_file) self.config = user_config.runtime_config(self.plus_api) diff --git a/frigate/config.py b/frigate/config.py index 5c4b890bc..ebb471028 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -245,10 +245,6 @@ class EventsConfig(FrigateBaseModel): default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE ) post_capture: int = Field(default=5, title="Seconds to retain after event ends.") - required_zones: List[str] = Field( - default_factory=list, - title="List of required zones to be entered in order to save the event.", - ) objects: Optional[List[str]] = Field( None, title="List of objects to be detected in order to save the event.", @@ -657,13 +653,45 @@ class ZoneConfig(BaseModel): class ObjectConfig(FrigateBaseModel): track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.") - alert: List[str] = Field( - default=DEFAULT_ALERT_OBJECTS, title="Objects to create alerts for." - ) filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.") mask: Union[str, List[str]] = Field(default="", title="Object mask.") +class AlertsConfig(FrigateBaseModel): + """Configure alerts""" + + labels: List[str] = Field( + default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for." + ) + required_zones: List[str] = Field( + default_factory=list, + title="List of required zones to be entered in order to save the event as an alert.", + ) + + +class DetectionsConfig(FrigateBaseModel): + """Configure detections""" + + labels: Optional[List[str]] = Field( + default=None, title="Labels to create detections for." + ) + required_zones: List[str] = Field( + default_factory=list, + title="List of required zones to be entered in order to save the event as a detection.", + ) + + +class ReviewConfig(FrigateBaseModel): + """Configure reviews""" + + alerts: AlertsConfig = Field( + default_factory=AlertsConfig, title="Review alerts config." + ) + detections: DetectionsConfig = Field( + default_factory=DetectionsConfig, title="Review detections config." + ) + + class AudioConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable audio events.") max_not_heard: int = Field( @@ -942,6 +970,9 @@ class CameraConfig(FrigateBaseModel): objects: ObjectConfig = Field( default_factory=ObjectConfig, title="Object configuration." ) + review: ReviewConfig = Field( + default_factory=ReviewConfig, title="Review configuration." + ) audio: AudioConfig = Field( default_factory=AudioConfig, title="Audio events configuration." ) @@ -1263,6 +1294,9 @@ class FrigateConfig(FrigateBaseModel): objects: ObjectConfig = Field( default_factory=ObjectConfig, title="Global object configuration." ) + review: ReviewConfig = Field( + default_factory=ReviewConfig, title="Review configuration." + ) audio: AudioConfig = Field( default_factory=AudioConfig, title="Global Audio events configuration." ) @@ -1310,6 +1344,7 @@ class FrigateConfig(FrigateBaseModel): "snapshots": ..., "live": ..., "objects": ..., + "review": ..., "motion": ..., "detect": ..., "ffmpeg": ..., diff --git a/frigate/object_processing.py b/frigate/object_processing.py index a244838d1..9da0e2b25 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -1007,7 +1007,7 @@ class TrackedObjectProcessor(threading.Thread): return True - def should_retain_recording(self, camera, obj: TrackedObject): + def should_retain_recording(self, camera: str, obj: TrackedObject): if obj.false_positive: return False @@ -1022,7 +1022,11 @@ class TrackedObjectProcessor(threading.Thread): return False # If there are required zones and there is no overlap - required_zones = record_config.events.required_zones + review_config = self.config.cameras[camera].review + required_zones = ( + review_config.alerts.required_zones + + review_config.detections.required_zones + ) if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): logger.debug( f"Not creating clip for {obj.obj_data['id']} because it did not enter required zones" diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 06fba8fdd..ebc03a35e 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -32,13 +32,11 @@ THUMB_WIDTH = 320 THRESHOLD_ALERT_ACTIVITY = 120 THRESHOLD_DETECTION_ACTIVITY = 30 -THRESHOLD_MOTION_ACTIVITY = 30 class SeverityEnum(str, Enum): alert = "alert" detection = "detection" - signification_motion = "significant_motion" class PendingReviewSegment: @@ -50,7 +48,6 @@ class PendingReviewSegment: detections: dict[str, str], zones: set[str] = set(), audio: set[str] = set(), - motion: list[int] = [], ): rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) self.id = f"{frame_time}-{rand_id}" @@ -60,7 +57,6 @@ class PendingReviewSegment: self.detections = detections self.zones = zones self.audio = audio - self.sig_motion_areas = motion self.last_update = frame_time # thumbnail @@ -117,7 +113,6 @@ class PendingReviewSegment: "objects": list(set(self.detections.values())), "zones": list(self.zones), "audio": list(self.audio), - "significant_motion_areas": self.sig_motion_areas, }, } @@ -170,7 +165,6 @@ class ReviewSegmentMaintainer(threading.Thread): segment: PendingReviewSegment, frame_time: float, objects: list[TrackedObject], - motion: list, ) -> None: """Validate if existing review segment should continue.""" camera_config = self.config.cameras[segment.camera] @@ -180,19 +174,6 @@ class ReviewSegmentMaintainer(threading.Thread): if frame_time > segment.last_update: segment.last_update = frame_time - # update type for this segment now that active objects are detected - if segment.severity == SeverityEnum.signification_motion: - segment.severity = SeverityEnum.detection - - if len(active_objects) > segment.frame_active_count: - frame_id = f"{camera_config.name}{frame_time}" - yuv_frame = self.frame_manager.get( - frame_id, camera_config.frame_shape_yuv - ) - segment.update_frame(camera_config, yuv_frame, active_objects) - self.frame_manager.close(frame_id) - self.update_segment(segment) - for object in active_objects: if not object["sub_label"]: segment.detections[object["id"]] = object["label"] @@ -201,24 +182,38 @@ class ReviewSegmentMaintainer(threading.Thread): else: segment.detections[object["id"]] = f'{object["label"]}-verified' - # if object is alert label and has qualified for recording + # if object is alert label + # and has entered required zones or required zones is not set # mark this review as alert if ( - segment.severity == SeverityEnum.detection - and object["has_clip"] - and object["label"] in camera_config.objects.alert + segment.severity != SeverityEnum.alert + and object["label"] in camera_config.review.alerts.labels + and ( + not camera_config.review.alerts.required_zones + or ( + len(object["current_zones"]) > 0 + and set(object["current_zones"]) + & set(camera_config.review.alerts.required_zones) + ) + ) ): segment.severity = SeverityEnum.alert # keep zones up to date if len(object["current_zones"]) > 0: segment.zones.update(object["current_zones"]) - elif ( - segment.severity == SeverityEnum.signification_motion - and len(motion) >= THRESHOLD_MOTION_ACTIVITY - ): - if frame_time > segment.last_update: - segment.last_update = frame_time + + if len(active_objects) > segment.frame_active_count: + try: + frame_id = f"{camera_config.name}{frame_time}" + yuv_frame = self.frame_manager.get( + frame_id, camera_config.frame_shape_yuv + ) + segment.update_frame(camera_config, yuv_frame, active_objects) + self.frame_manager.close(frame_id) + self.update_segment(segment) + except FileNotFoundError: + return else: if segment.severity == SeverityEnum.alert and frame_time > ( segment.last_update + THRESHOLD_ALERT_ACTIVITY @@ -232,7 +227,6 @@ class ReviewSegmentMaintainer(threading.Thread): camera: str, frame_time: float, objects: list[TrackedObject], - motion: list, ) -> None: """Check if a new review segment should be created.""" camera_config = self.config.cameras[camera] @@ -242,15 +236,9 @@ class ReviewSegmentMaintainer(threading.Thread): has_sig_object = False detections: dict[str, str] = {} zones: set = set() + severity = None for object in active_objects: - if ( - not has_sig_object - and object["has_clip"] - and object["label"] in camera_config.objects.alert - ): - has_sig_object = True - if not object["sub_label"]: detections[object["id"]] = object["label"] elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS: @@ -258,35 +246,68 @@ class ReviewSegmentMaintainer(threading.Thread): else: detections[object["id"]] = f'{object["label"]}-verified' + # if object is alert label + # and has entered required zones or required zones is not set + # mark this review as alert + if ( + severity != SeverityEnum.alert + and object["label"] in camera_config.review.alerts.labels + and ( + not camera_config.review.alerts.required_zones + or ( + len(object["current_zones"]) > 0 + and set(object["current_zones"]) + & set(camera_config.review.alerts.required_zones) + ) + ) + ): + severity = SeverityEnum.alert + + # if object is detection label + # and review is not already a detection or alert + # and has entered required zones or required zones is not set + # mark this review as alert + if ( + not severity + and ( + not camera_config.review.detections.labels + or object["label"] in (camera_config.review.detections.labels) + ) + and ( + not camera_config.review.detections.required_zones + or ( + len(object["current_zones"]) > 0 + and set(object["current_zones"]) + & set(camera_config.review.detections.required_zones) + ) + ) + ): + severity = SeverityEnum.detection + zones.update(object["current_zones"]) - self.active_review_segments[camera] = PendingReviewSegment( - camera, - frame_time, - SeverityEnum.alert if has_sig_object else SeverityEnum.detection, - detections, - audio=set(), - zones=zones, - motion=[], - ) + if severity: + self.active_review_segments[camera] = PendingReviewSegment( + camera, + frame_time, + SeverityEnum.alert if has_sig_object else SeverityEnum.detection, + detections, + audio=set(), + zones=zones, + ) - frame_id = f"{camera_config.name}{frame_time}" - yuv_frame = self.frame_manager.get(frame_id, camera_config.frame_shape_yuv) - self.active_review_segments[camera].update_frame( - camera_config, yuv_frame, active_objects - ) - self.frame_manager.close(frame_id) - self.update_segment(self.active_review_segments[camera]) - elif len(motion) >= 20: - self.active_review_segments[camera] = PendingReviewSegment( - camera, - frame_time, - SeverityEnum.signification_motion, - detections={}, - audio=set(), - motion=motion, - zones=set(), - ) + try: + frame_id = f"{camera_config.name}{frame_time}" + yuv_frame = self.frame_manager.get( + frame_id, camera_config.frame_shape_yuv + ) + self.active_review_segments[camera].update_frame( + camera_config, yuv_frame, active_objects + ) + self.frame_manager.close(frame_id) + self.update_segment(self.active_review_segments[camera]) + except FileNotFoundError: + return def run(self) -> None: while not self.stop_event.is_set(): @@ -344,13 +365,22 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment, frame_time, current_tracked_objects, - motion_boxes, ) elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: + camera_config = self.config.cameras[camera] + if frame_time > current_segment.last_update: current_segment.last_update = frame_time - current_segment.audio.update(audio_detections) + for audio in audio_detections: + if audio in camera_config.review.alerts.labels: + current_segment.audio.add(audio) + current_segment.severity = SeverityEnum.alert + elif ( + not camera_config.review.detections.labels + or audio in camera_config.review.detections.labels + ): + current_segment.audio.add(audio) elif topic == DetectionTypeEnum.api: if manual_info["state"] == ManualEventState.complete: current_segment.detections[manual_info["event_id"]] = ( @@ -378,18 +408,35 @@ class ReviewSegmentMaintainer(threading.Thread): camera, frame_time, current_tracked_objects, - motion_boxes, ) elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: - self.active_review_segments[camera] = PendingReviewSegment( - camera, - frame_time, - SeverityEnum.detection, - {}, - set(), - set(audio_detections), - [], - ) + severity = None + + camera_config = self.config.cameras[camera] + detections = set() + + for audio in audio_detections: + if audio in camera_config.review.alerts.labels: + detections.add(audio) + severity = SeverityEnum.alert + elif ( + not camera_config.review.detections.labels + or audio in camera_config.review.detections.labels + ): + detections.add(audio) + + if not severity: + severity = SeverityEnum.detection + + if severity: + self.active_review_segments[camera] = PendingReviewSegment( + camera, + frame_time, + severity, + {}, + set(), + detections, + ) elif topic == DetectionTypeEnum.api: self.active_review_segments[camera] = PendingReviewSegment( camera, @@ -398,7 +445,6 @@ class ReviewSegmentMaintainer(threading.Thread): {manual_info["event_id"]: manual_info["label"]}, set(), set(), - [], ) if manual_info["state"] == ManualEventState.start: @@ -425,8 +471,16 @@ def get_active_objects( return [ o for o in all_objects - if o["motionless_count"] < camera_config.detect.stationary.threshold - and o["position_changes"] > 0 - and o["frame_time"] == frame_time - and not o["false_positive"] + if o["motionless_count"] + < camera_config.detect.stationary.threshold # no stationary objects + and o["position_changes"] > 0 # object must have moved at least once + and o["frame_time"] == frame_time # object must be detected in this frame + and not o["false_positive"] # object must not be a false positive + and ( + o["label"] in camera_config.review.alerts.labels + or ( + not camera_config.review.detections.labels + or o["label"] in camera_config.review.detections.labels + ) + ) # object must be in the alerts or detections label list ] diff --git a/frigate/util/config.py b/frigate/util/config.py new file mode 100644 index 000000000..46a1ea941 --- /dev/null +++ b/frigate/util/config.py @@ -0,0 +1,127 @@ +"""configuration utils.""" + +import logging +import os +import shutil + +from ruamel.yaml import YAML + +from frigate.const import CONFIG_DIR + +logger = logging.getLogger(__name__) + +CURRENT_CONFIG_VERSION = 0.14 + + +def migrate_frigate_config(config_file: str): + """handle migrating the frigate config.""" + logger.info("Checking if frigate config needs migration...") + version_file = os.path.join(CONFIG_DIR, ".version") + + if not os.path.isfile(version_file): + previous_version = 0.13 + else: + with open(version_file) as f: + try: + previous_version = float(f.readline()) + except Exception: + previous_version = 0.13 + + if previous_version == CURRENT_CONFIG_VERSION: + logger.info("frigate config does not need migration...") + return + + logger.info("copying config as backup...") + shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml")) + + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + with open(config_file, "r") as f: + config: dict[str, dict[str, any]] = yaml.load(f) + + if previous_version < 0.14: + logger.info(f"Migrating frigate config from {previous_version} to 0.14...") + new_config = migrate_014(config) + with open(config_file, "w") as f: + yaml.dump(new_config, f) + previous_version = 0.14 + + with open(version_file, "w") as f: + f.write(str(CURRENT_CONFIG_VERSION)) + + logger.info("Finished frigate config migration...") + + +def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]: + """Handle migrating frigate config to 0.14""" + # migrate record.events.required_zones to review.alerts.required_zones + new_config = config.copy() + global_required_zones = ( + config.get("record", {}).get("events", {}).get("required_zones", []) + ) + + if global_required_zones: + # migrate to new review config + if not new_config.get("review"): + new_config["review"] = {} + + if not new_config["review"].get("alerts"): + new_config["review"]["alerts"] = {} + + if not new_config["review"]["alerts"].get("required_zones"): + new_config["review"]["alerts"]["required_zones"] = global_required_zones + + # remove record required zones config + del new_config["record"]["events"]["required_zones"] + + # remove record altogether if there is not other config + if not new_config["record"]["events"]: + del new_config["record"]["events"] + + if not new_config["record"]: + del new_config["record"] + + # remove rtmp + if new_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"): + del new_config["ffmpeg"]["output_args"]["rtmp"] + + if new_config.get("rtmp"): + del new_config["rtmp"] + + for name, camera in config.get("cameras", {}).items(): + camera_config: dict[str, dict[str, any]] = camera.copy() + required_zones = ( + camera_config.get("record", {}).get("events", {}).get("required_zones", []) + ) + + if required_zones: + # migrate to new review config + if not camera_config.get("review"): + camera_config["review"] = {} + + if not camera_config["review"].get("alerts"): + camera_config["review"]["alerts"] = {} + + if not camera_config["review"]["alerts"].get("required_zones"): + camera_config["review"]["alerts"]["required_zones"] = required_zones + + # remove record required zones config + del camera_config["record"]["events"]["required_zones"] + + # remove record altogether if there is not other config + if not camera_config["record"]["events"]: + del camera_config["record"]["events"] + + if not camera_config["record"]: + del camera_config["record"] + + # remove rtmp + if camera_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"): + del camera_config["ffmpeg"]["output_args"]["rtmp"] + + if camera_config.get("rtmp"): + del camera_config["rtmp"] + + new_config["cameras"][name] = camera_config + + return new_config