diff --git a/docs/docs/configuration/index.md b/docs/docs/configuration/index.md index 80eb3fef8..e334d88ee 100644 --- a/docs/docs/configuration/index.md +++ b/docs/docs/configuration/index.md @@ -159,11 +159,23 @@ detect: enabled: True # Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate) max_disappeared: 25 - # Optional: Frequency for running detection on stationary objects (default: shown below) - # When set to 0, object detection will never be run on stationary objects. If set to 10, it will be run on every 10th frame. - stationary_interval: 0 - # Optional: Number of frames without a position change for an object to be considered stationary (default: shown below) - stationary_threshold: 10 + # Optional: Configuration for stationary object tracking + stationary: + # Optional: Frequency for running detection on stationary objects (default: shown below) + # When set to 0, object detection will never be run on stationary objects. If set to 10, it will be run on every 10th frame. + interval: 0 + # Optional: Number of frames without a position change for an object to be considered stationary (default: 10x the frame rate or 10s) + threshold: 50 + # Optional: Define a maximum number of frames for tracking a stationary object (default: not set, track forever) + # This can help with false positives for objects that should only be stationary for a limited amount of time. + # It can also be used to disable stationary object tracking. For example, you may want to set a value for person, but leave + # car at the default. + max_frames: + # Optional: Default for all object types (default: not set, track forever) + default: 3000 + # Optional: Object specific values + objects: + person: 1000 # Optional: Object configuration # NOTE: Can be overridden at the camera level diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index c4af845ae..5fe51cb00 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -167,13 +167,17 @@ cameras: roles: - detect - rtmp - - record # <----- Add role + - path: rtsp://10.0.10.10:554/high_res_stream # <----- Add high res stream + roles: + - record detect: ... record: # <----- Enable recording enabled: True motion: ... ``` +If you don't have separate streams for detect and record, you would just add the record role to the list on the first input. + By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](/configuration/index#full-configuration-reference). ### Step 8: Enable snapshots (optional) diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index c868d7e3c..ef6c07a42 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -56,8 +56,9 @@ Message published for each changed event. The first message is published when th "thumbnail": null, "has_snapshot": false, "has_clip": false, + "stationary": false, // whether or not the object is considered stationary "motionless_count": 0, // number of frames the object has been motionless - "position_changes": 2 // number of times the object has changed position + "position_changes": 2 // number of times the object has moved from a stationary position }, "after": { "id": "1607123955.475377-mxklsc", @@ -78,6 +79,7 @@ Message published for each changed event. The first message is published when th "thumbnail": null, "has_snapshot": false, "has_clip": false, + "stationary": false, // whether or not the object is considered stationary "motionless_count": 0, // number of frames the object has been motionless "position_changes": 2 // number of times the object has changed position } diff --git a/frigate/config.py b/frigate/config.py index 8af7b1721..3826731ab 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -170,6 +170,29 @@ class RuntimeMotionConfig(MotionConfig): extra = Extra.ignore +class StationaryMaxFramesConfig(FrigateBaseModel): + default: Optional[int] = Field(title="Default max frames.", ge=1) + objects: Dict[str, int] = Field( + default_factory=dict, title="Object specific max frames." + ) + + +class StationaryConfig(FrigateBaseModel): + interval: Optional[int] = Field( + default=0, + title="Frame interval for checking stationary objects.", + ge=0, + ) + threshold: Optional[int] = Field( + title="Number of frames without a position change for an object to be considered stationary", + ge=1, + ) + max_frames: StationaryMaxFramesConfig = Field( + default_factory=StationaryMaxFramesConfig, + title="Max frames for stationary objects.", + ) + + class DetectConfig(FrigateBaseModel): height: int = Field(default=720, title="Height of the stream for the detect role.") width: int = Field(default=1280, title="Width of the stream for the detect role.") @@ -180,15 +203,9 @@ class DetectConfig(FrigateBaseModel): max_disappeared: Optional[int] = Field( title="Maximum number of frames the object can dissapear before detection ends." ) - stationary_interval: Optional[int] = Field( - default=0, - title="Frame interval for checking stationary objects.", - ge=0, - ) - stationary_threshold: Optional[int] = Field( - default=10, - title="Number of frames without a position change for an object to be considered stationary", - ge=1, + stationary: StationaryConfig = Field( + default_factory=StationaryConfig, + title="Stationary objects config.", ) @@ -933,6 +950,11 @@ class FrigateConfig(FrigateBaseModel): if camera_config.detect.max_disappeared is None: camera_config.detect.max_disappeared = max_disappeared + # Default stationary_threshold configuration + stationary_threshold = camera_config.detect.fps * 10 + if camera_config.detect.stationary.threshold is None: + camera_config.detect.stationary.threshold = stationary_threshold + # FFMPEG input substitution if "ffmpeg" in camera_config: for input in camera_config.ffmpeg.inputs: diff --git a/frigate/http.py b/frigate/http.py index 0dcdabdbd..80fce55e3 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -249,7 +249,10 @@ def event_clip(id): clip_path = os.path.join(CLIPS_DIR, file_name) if not os.path.isfile(clip_path): - return recording_clip(event.camera, event.start_time, event.end_time) + end_ts = ( + datetime.now().timestamp() if event.end_time is None else event.end_time + ) + return recording_clip(event.camera, event.start_time, end_ts) response = make_response() response.headers["Content-Description"] = "File Transfer" diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 1ad6433a3..37d45e127 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -158,10 +158,10 @@ class TrackedObject: if self.obj_data["position_changes"] != obj_data["position_changes"]: significant_change = True - # if the motionless_count crosses the stationary threshold + # if the motionless_count reaches the stationary threshold if ( self.obj_data["motionless_count"] - > self.camera_config.detect.stationary_threshold + == self.camera_config.detect.stationary.threshold ): significant_change = True @@ -193,6 +193,8 @@ class TrackedObject: "box": self.obj_data["box"], "area": self.obj_data["area"], "region": self.obj_data["region"], + "stationary": self.obj_data["motionless_count"] + > self.camera_config.detect.stationary.threshold, "motionless_count": self.obj_data["motionless_count"], "position_changes": self.obj_data["position_changes"], "current_zones": self.current_zones.copy(), diff --git a/frigate/objects.py b/frigate/objects.py index 640130043..7b66536c2 100644 --- a/frigate/objects.py +++ b/frigate/objects.py @@ -78,9 +78,9 @@ class ObjectTracker: } return False - # if there are less than stationary_threshold entries for the position, add the bounding box + # if there are less than 10 entries for the position, add the bounding box # and recompute the position box - if len(position["xmins"]) < self.detect_config.stationary_threshold: + if len(position["xmins"]) < 10: position["xmins"].append(xmin) position["ymins"].append(ymin) position["xmaxs"].append(xmax) @@ -93,20 +93,52 @@ class ObjectTracker: return True + def is_expired(self, id): + obj = self.tracked_objects[id] + # get the max frames for this label type or the default + max_frames = self.detect_config.stationary.max_frames.objects.get( + obj["label"], self.detect_config.stationary.max_frames.default + ) + + # if there is no max_frames for this label type, continue + if max_frames is None: + return False + + # if the object has exceeded the max_frames setting, deregister + if ( + obj["motionless_count"] - self.detect_config.stationary.threshold + > max_frames + ): + print(f"expired: {obj['motionless_count']}") + return True + def update(self, id, new_obj): self.disappeared[id] = 0 # update the motionless count if the object has not moved to a new position if self.update_position(id, new_obj["box"]): self.tracked_objects[id]["motionless_count"] += 1 + if self.is_expired(id): + self.deregister(id) + return else: + # register the first position change and then only increment if + # the object was previously stationary + if ( + self.tracked_objects[id]["position_changes"] == 0 + or self.tracked_objects[id]["motionless_count"] + >= self.detect_config.stationary.threshold + ): + self.tracked_objects[id]["position_changes"] += 1 self.tracked_objects[id]["motionless_count"] = 0 - self.tracked_objects[id]["position_changes"] += 1 + self.tracked_objects[id].update(new_obj) def update_frame_times(self, frame_time): - for id in self.tracked_objects.keys(): + for id in list(self.tracked_objects.keys()): self.tracked_objects[id]["frame_time"] = frame_time self.tracked_objects[id]["motionless_count"] += 1 + if self.is_expired(id): + self.deregister(id) def match_and_update(self, frame_time, new_objects): # group by name diff --git a/frigate/output.py b/frigate/output.py index a0fcd6aa4..f36860587 100644 --- a/frigate/output.py +++ b/frigate/output.py @@ -192,10 +192,7 @@ class BirdsEyeFrameManager: if self.mode == BirdseyeModeEnum.continuous: return True - if ( - self.mode == BirdseyeModeEnum.motion - and object_box_count + motion_box_count > 0 - ): + if self.mode == BirdseyeModeEnum.motion and motion_box_count > 0: return True if self.mode == BirdseyeModeEnum.objects and object_box_count > 0: @@ -426,7 +423,7 @@ def output_frames(config: FrigateConfig, video_output_queue): ): if birdseye_manager.update( camera, - len(current_tracked_objects), + len([o for o in current_tracked_objects if not o["stationary"]]), len(motion_boxes), frame_time, frame, diff --git a/frigate/record.py b/frigate/record.py index ad9cb949c..ebd9659de 100644 --- a/frigate/record.py +++ b/frigate/record.py @@ -56,7 +56,6 @@ class RecordingMaintainer(threading.Thread): self.config = config self.recordings_info_queue = recordings_info_queue self.stop_event = stop_event - self.first_pass = True self.recordings_info = defaultdict(list) self.end_time_cache = {} @@ -346,12 +345,6 @@ class RecordingMaintainer(threading.Thread): logger.error(e) duration = datetime.datetime.now().timestamp() - run_start wait_time = max(0, 5 - duration) - if wait_time == 0 and not self.first_pass: - logger.warning( - "Cache is taking longer than 5 seconds to clear. Your recordings disk may be too slow." - ) - if self.first_pass: - self.first_pass = False logger.info(f"Exiting recording maintenance...") diff --git a/frigate/video.py b/frigate/video.py index 50af51bb2..9d322e633 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -506,12 +506,12 @@ def process_frames( stationary_object_ids = [ obj["id"] for obj in object_tracker.tracked_objects.values() - # if there hasn't been motion for N frames - if obj["motionless_count"] >= detect_config.stationary_threshold + # if there hasn't been motion for 10 frames + if obj["motionless_count"] >= 10 # and it isn't due for a periodic check and ( - detect_config.stationary_interval == 0 - or obj["motionless_count"] % detect_config.stationary_interval != 0 + detect_config.stationary.interval == 0 + or obj["motionless_count"] % detect_config.stationary.interval != 0 ) # and it hasn't disappeared and object_tracker.disappeared[obj["id"]] == 0 diff --git a/web/src/components/RecordingPlaylist.jsx b/web/src/components/RecordingPlaylist.jsx index 29f1cac22..3b3ff1568 100644 --- a/web/src/components/RecordingPlaylist.jsx +++ b/web/src/components/RecordingPlaylist.jsx @@ -1,6 +1,14 @@ import { h } from 'preact'; import { useState } from 'preact/hooks'; -import { addSeconds, differenceInSeconds, fromUnixTime, format, parseISO, startOfHour } from 'date-fns'; +import { + differenceInSeconds, + fromUnixTime, + format, + parseISO, + startOfHour, + differenceInMinutes, + differenceInHours, +} from 'date-fns'; import ArrowDropdown from '../icons/ArrowDropdown'; import ArrowDropup from '../icons/ArrowDropup'; import Link from '../components/Link'; @@ -89,9 +97,16 @@ export function ExpandableList({ title, events = 0, children, selected = false } export function EventCard({ camera, event, delay }) { const apiHost = useApiHost(); const start = fromUnixTime(event.start_time); - let duration = 0; + let duration = 'In Progress'; if (event.end_time) { - duration = addSeconds(new Date(0), differenceInSeconds(fromUnixTime(event.end_time), start)); + const end = fromUnixTime(event.end_time); + const hours = differenceInHours(end, start); + const minutes = differenceInMinutes(end, start) - hours * 60; + const seconds = differenceInSeconds(end, start) - hours * 60 - minutes * 60; + duration = ''; + if (hours) duration += `${hours}h `; + if (minutes) duration += `${minutes}m `; + duration += `${seconds}s`; } const position = differenceInSeconds(start, startOfHour(start)); const offset = Object.entries(delay) @@ -110,9 +125,7 @@ export function EventCard({ camera, event, delay }) {
{event.label}
Start: {format(start, 'HH:mm:ss')}
-
- Duration: {duration ? format(duration, 'mm:ss') : 'In Progress'} -
+
Duration: {duration}
{(event.top_score * 100).toFixed(1)}%
diff --git a/web/src/routes/Cameras.jsx b/web/src/routes/Cameras.jsx index 94b7cb7f1..3bb5647bd 100644 --- a/web/src/routes/Cameras.jsx +++ b/web/src/routes/Cameras.jsx @@ -29,12 +29,8 @@ function Camera({ name, conf }) { const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name); const href = `/cameras/${name}`; const buttons = useMemo(() => { - const result = [{ name: 'Events', href: `/events?camera=${name}` }]; - if (conf.record.enabled) { - result.push({ name: 'Recordings', href: `/recording/${name}` }); - } - return result; - }, [name, conf.record.enabled]); + return [{ name: 'Events', href: `/events?camera=${name}` }, { name: 'Recordings', href: `/recording/${name}` }]; + }, [name]); const icons = useMemo( () => [ { diff --git a/web/src/routes/Recording.jsx b/web/src/routes/Recording.jsx index 3dba365b0..888872437 100644 --- a/web/src/routes/Recording.jsx +++ b/web/src/routes/Recording.jsx @@ -64,11 +64,11 @@ export default function Recording({ camera, date, hour, seconds }) { this.player.playlist.currentItem(selectedHour); if (seconds !== undefined) { this.player.currentTime(seconds); - // Force playback rate to be correct - const playbackRate = this.player.playbackRate(); - this.player.defaultPlaybackRate(playbackRate); } } + // Force playback rate to be correct + const playbackRate = this.player.playbackRate(); + this.player.defaultPlaybackRate(playbackRate); } return ( diff --git a/web/src/routes/__tests__/Cameras.test.jsx b/web/src/routes/__tests__/Cameras.test.jsx index 68d0d0a80..cdb0c400d 100644 --- a/web/src/routes/__tests__/Cameras.test.jsx +++ b/web/src/routes/__tests__/Cameras.test.jsx @@ -46,7 +46,7 @@ describe('Cameras Route', () => { expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument(); - expect(screen.queryAllByText('Recordings')).toHaveLength(1); + expect(screen.queryAllByText('Recordings')).toHaveLength(2); }); test('buttons toggle detect, clips, and snapshots', async () => {