Motion timeline data (#10245)

* Refactor activity api to send motion and audio data

* Prepare for using motion data timeline

* Get working

* reduce to 0

* fix

* Formatting

* fix typing

* add motion data to timelines and allow motion cameas to be selectable

* Fix tests

* cleanup

* Fix not loading preview when changing hours
This commit is contained in:
Nicolas Mowen 2024-03-05 12:55:44 -07:00 committed by GitHub
parent a174d82eb9
commit 9e8a42ca0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 269 additions and 197 deletions

View File

@ -5,12 +5,9 @@ import json
import logging
import os
import traceback
from collections import defaultdict
from datetime import datetime, timedelta
from functools import reduce
import numpy as np
import pandas as pd
import requests
from flask import (
Blueprint,
@ -31,7 +28,7 @@ from frigate.api.review import ReviewBp
from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR
from frigate.events.external import ExternalEventProcessor
from frigate.models import Event, Recordings, Timeline
from frigate.models import Event, Timeline
from frigate.plus import PlusApi
from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter
@ -632,102 +629,3 @@ def hourly_timeline():
"hours": hours,
}
)
@bp.route("/<camera_name>/recording/hourly/activity")
def hourly_timeline_activity(camera_name: str):
"""Get hourly summary for timeline."""
if camera_name not in current_app.frigate_config.cameras:
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
before = request.args.get("before", type=float, default=datetime.now())
after = request.args.get(
"after", type=float, default=datetime.now() - timedelta(hours=1)
)
tz_name = request.args.get("timezone", default="utc", type=str)
_, minute_modifier, _ = get_tz_modifiers(tz_name)
minute_offset = int(minute_modifier.split(" ")[0])
all_recordings: list[Recordings] = (
Recordings.select(
Recordings.start_time,
Recordings.duration,
Recordings.objects,
Recordings.motion,
)
.where(Recordings.camera == camera_name)
.where(Recordings.motion > 0)
.where((Recordings.start_time > after) & (Recordings.end_time < before))
.order_by(Recordings.start_time.asc())
.iterator()
)
# data format is ex:
# {timestamp: [{ date: 1, count: 1, type: motion }]}] }}
hours: dict[int, list[dict[str, any]]] = defaultdict(list)
key = datetime.fromtimestamp(after).replace(second=0, microsecond=0) + timedelta(
minutes=minute_offset
)
check = (key + timedelta(hours=1)).timestamp()
# set initial start so data is representative of full hour
hours[int(key.timestamp())].append(
[
key.timestamp(),
0,
False,
]
)
for recording in all_recordings:
if recording.start_time > check:
hours[int(key.timestamp())].append(
[
(key + timedelta(minutes=59, seconds=59)).timestamp(),
0,
False,
]
)
key = key + timedelta(hours=1)
check = (key + timedelta(hours=1)).timestamp()
hours[int(key.timestamp())].append(
[
key.timestamp(),
0,
False,
]
)
data_type = recording.objects > 0
count = recording.motion + recording.objects
hours[int(key.timestamp())].append(
[
recording.start_time + (recording.duration / 2),
0 if count == 0 else np.log2(count),
data_type,
]
)
# resample data using pandas to get activity on minute to minute basis
for key, data in hours.items():
df = pd.DataFrame(data, columns=["date", "count", "hasObjects"])
# set date as datetime index
df["date"] = pd.to_datetime(df["date"], unit="s")
df.set_index(["date"], inplace=True)
# normalize data
df = df.resample("T").mean().fillna(0)
# change types for output
df.index = df.index.astype(int) // (10**9)
df["count"] = df["count"].astype(int)
df["hasObjects"] = df["hasObjects"].astype(bool)
hours[key] = df.reset_index().to_dict("records")
return jsonify(hours)

View File

@ -4,6 +4,7 @@ import logging
from datetime import datetime, timedelta
from functools import reduce
import pandas as pd
from flask import (
Blueprint,
jsonify,
@ -12,7 +13,7 @@ from flask import (
)
from peewee import Case, DoesNotExist, fn, operator
from frigate.models import ReviewSegment
from frigate.models import Recordings, ReviewSegment
from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__)
@ -258,3 +259,66 @@ def delete_reviews(ids: str):
ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
return make_response(jsonify({"success": True, "message": "Delete reviews"}), 200)
@ReviewBp.route("/review/activity")
def review_activity():
"""Get motion and audio activity."""
before = request.args.get("before", type=float, default=datetime.now().timestamp())
after = request.args.get(
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
)
# get scale in seconds
scale = request.args.get("scale", type=int, default=30)
all_recordings: list[Recordings] = (
Recordings.select(
Recordings.start_time,
Recordings.duration,
Recordings.objects,
Recordings.motion,
Recordings.dBFS,
)
.where((Recordings.start_time > after) & (Recordings.end_time < before))
.order_by(Recordings.start_time.asc())
.iterator()
)
# format is: { timestamp: segment_start_ts, motion: [0-100], audio: [0 - -100] }
# periods where active objects / audio was detected will cause motion / audio to be scaled down
data: list[dict[str, float]] = []
for rec in all_recordings:
data.append(
{
"start_time": rec.start_time,
"motion": rec.motion if rec.objects == 0 else 0,
"audio": rec.dBFS if rec.objects == 0 else 0,
}
)
# resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "motion", "audio"])
# set date as datetime index
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
df.set_index(["start_time"], inplace=True)
# normalize data
df = df.resample(f"{scale}S").mean().fillna(0.0)
df["motion"] = (
(df["motion"] - df["motion"].min())
/ (df["motion"].max() - df["motion"].min())
* 100
)
df["audio"] = (
(df["audio"] - df["audio"].max())
/ (df["audio"].min() - df["audio"].max())
* -100
)
# change types for output
df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records")
return jsonify(normalized)

View File

@ -85,6 +85,16 @@ export default function DynamicVideoPlayer({
);
}, [camera, config, previewOnly]);
useEffect(() => {
if (!controller) {
return;
}
if (onControllerReady) {
onControllerReady(controller);
}
}, [controller, onControllerReady]);
const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true);
// keyboard control
@ -215,6 +225,10 @@ export default function DynamicVideoPlayer({
);
setCurrentPreview(preview);
if (preview && previewRef.current) {
previewRef.current.load();
}
controller.newPlayback({
recordings: recordings ?? [],
playbackUri,
@ -283,27 +297,20 @@ export default function DynamicVideoPlayer({
)}
<video
ref={previewRef}
className={`size-full rounded-2xl ${currentPreview != undefined && isScrubbing ? "visible" : "hidden"} ${tallVideo ? "aspect-tall" : ""} bg-black`}
className={`size-full rounded-2xl ${currentPreview != undefined && (previewOnly || isScrubbing) ? "visible" : "hidden"} ${tallVideo ? "aspect-tall" : ""} bg-black`}
preload="auto"
autoPlay
playsInline
muted
onSeeked={onPreviewSeeked}
onLoadedData={() => controller.previewReady()}
onLoadStart={
previewOnly && onControllerReady
? () => {
onControllerReady(controller);
}
: undefined
}
>
{currentPreview != undefined && (
<source src={currentPreview.src} type={currentPreview.type} />
)}
</video>
{onClick && !hasRecordingAtTime && (
<div className="absolute inset-0 z-10 bg-black bg-opacity-60" />
<div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-2xl" />
)}
</div>
);

View File

@ -9,9 +9,8 @@ import {
} from "react";
import MotionSegment from "./MotionSegment";
import { useEventUtils } from "@/hooks/use-event-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
import ReviewTimeline from "./ReviewTimeline";
import { MockMotionData } from "@/pages/UIPlayground";
export type MotionReviewTimelineProps = {
segmentDuration: number;
@ -25,7 +24,7 @@ export type MotionReviewTimelineProps = {
minimapStartTime?: number;
minimapEndTime?: number;
events: ReviewSegment[];
motion_events: MockMotionData[];
motion_events: MotionData[];
severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void;

View File

@ -1,6 +1,6 @@
import { useEventUtils } from "@/hooks/use-event-utils";
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { ReviewSegment } from "@/types/review";
import { MotionData, ReviewSegment } from "@/types/review";
import React, {
RefObject,
useCallback,
@ -10,13 +10,12 @@ import React, {
} from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import { MockMotionData } from "@/pages/UIPlayground";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import { isMobile } from "react-device-detect";
type MotionSegmentProps = {
events: ReviewSegment[];
motion_events: MockMotionData[];
motion_events: MotionData[];
segmentTime: number;
segmentDuration: number;
timestampSpread: number;

View File

@ -1,9 +1,9 @@
import { useCallback, useMemo } from "react";
import { MockMotionData } from "@/pages/UIPlayground";
import { MotionData } from "@/types/review";
export const useMotionSegmentUtils = (
segmentDuration: number,
motion_events: MockMotionData[],
motion_events: MotionData[],
) => {
const halfSegmentDuration = useMemo(
() => segmentDuration / 2,
@ -47,7 +47,7 @@ export const useMotionSegmentUtils = (
);
});
return matchingEvent?.motionValue ?? 0;
return matchingEvent?.motion ?? 0;
},
[motion_events, getSegmentStart, getSegmentEnd],
);
@ -61,7 +61,7 @@ export const useMotionSegmentUtils = (
);
});
return matchingEvent?.audioValue ?? 0;
return matchingEvent?.audio ?? 0;
},
[motion_events, getSegmentStart, getSegmentEnd],
);

View File

@ -240,22 +240,37 @@ export default function Events() {
// selected items
const selectedData = useMemo(() => {
const selectedReviewData = useMemo(() => {
if (!config) {
return undefined;
}
if (!selectedReviewId) {
return undefined;
}
if (!reviewPages) {
return undefined;
}
if (!selectedReviewId) {
return undefined;
}
const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras);
const allReviews = reviewPages.flat();
if (selectedReviewId.startsWith("motion")) {
const motionData = selectedReviewId.split(",");
// format is motion,camera,start_time
return {
camera: motionData[1],
severity: "significant_motion" as ReviewSeverity,
start_time: parseFloat(motionData[2]),
allCameras: allCameras,
cameraSegments: allReviews.filter((seg) =>
allCameras.includes(seg.camera),
),
};
}
const selectedReview = allReviews.find(
(item) => item.id == selectedReviewId,
);
@ -265,7 +280,9 @@ export default function Events() {
}
return {
selected: selectedReview,
camera: selectedReview.camera,
severity: selectedReview.severity,
start_time: selectedReview.start_time,
allCameras: allCameras,
cameraSegments: allReviews.filter((seg) =>
allCameras.includes(seg.camera),
@ -280,12 +297,14 @@ export default function Events() {
return <ActivityIndicator />;
}
if (selectedData) {
if (selectedReviewData) {
if (isMobile) {
return (
<MobileRecordingView
reviewItems={selectedData.cameraSegments}
selectedReview={selectedData.selected}
reviewItems={selectedReviewData.cameraSegments}
startCamera={selectedReviewData.camera}
startTime={selectedReviewData.start_time}
severity={selectedReviewData.severity}
relevantPreviews={allPreviews}
/>
);
@ -293,11 +312,11 @@ export default function Events() {
return (
<DesktopRecordingView
startCamera={selectedData.selected.camera}
startTime={selectedData.selected.start_time}
allCameras={selectedData.allCameras}
severity={selectedData.selected.severity}
reviewItems={selectedData.cameraSegments}
startCamera={selectedReviewData.camera}
startTime={selectedReviewData.start_time}
allCameras={selectedReviewData.allCameras}
severity={selectedReviewData.severity}
reviewItems={selectedReviewData.cameraSegments}
allPreviews={allPreviews}
/>
);

View File

@ -4,7 +4,12 @@ import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import { ReviewData, ReviewSegment, ReviewSeverity } from "@/types/review";
import {
MotionData,
ReviewData,
ReviewSegment,
ReviewSeverity,
} from "@/types/review";
import { Button } from "@/components/ui/button";
import CameraActivityIndicator from "@/components/indicators/CameraActivityIndicator";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
@ -53,14 +58,7 @@ function ColorSwatch({ name, value }: { name: string; value: string }) {
);
}
export type MockMotionData = {
start_time: number;
end_time: number;
motionValue: number;
audioValue: number;
};
function generateRandomMotionAudioData(): MockMotionData[] {
function generateRandomMotionAudioData(): MotionData[] {
const now = new Date();
const endTime = now.getTime() / 1000;
const startTime = endTime - 24 * 60 * 60; // 24 hours ago
@ -72,14 +70,12 @@ function generateRandomMotionAudioData(): MockMotionData[] {
startTimestamp < endTime;
startTimestamp += interval
) {
const endTimestamp = startTimestamp + interval;
const motionValue = Math.floor(Math.random() * 101); // Random number between 0 and 100
const audioValue = Math.random() * -100; // Random negative value between -100 and 0
const motion = Math.floor(Math.random() * 101); // Random number between 0 and 100
const audio = Math.random() * -100; // Random negative value between -100 and 0
data.push({
start_time: startTimestamp,
end_time: endTimestamp,
motionValue,
audioValue,
motion,
audio,
});
}
@ -126,7 +122,7 @@ function UIPlayground() {
const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement>(null);
const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]);
const [mockMotionData, setMockMotionData] = useState<MockMotionData[]>([]);
const [mockMotionData, setMockMotionData] = useState<MotionData[]>([]);
const [handlebarTime, setHandlebarTime] = useState(
Math.floor(Date.now() / 1000) - 15 * 60,
);

View File

@ -37,3 +37,9 @@ export type ReviewSummary = {
total_detection: number;
total_motion: number;
};
export type MotionData = {
start_time: number;
motion: number;
audio: number;
};

View File

@ -14,6 +14,7 @@ import { useScrollLockout } from "@/hooks/use-mouse-listener";
import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import {
MotionData,
ReviewFilter,
ReviewSegment,
ReviewSeverity,
@ -33,6 +34,7 @@ import { isDesktop, isMobile } from "react-device-detect";
import { LuFolderCheck } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
type EventViewProps = {
reviewPages?: ReviewSegment[][];
@ -284,6 +286,7 @@ export default function EventView({
relevantPreviews={relevantPreviews}
timeRange={timeRange}
filter={filter}
onSelectReview={onSelectReview}
/>
)}
</div>
@ -526,6 +529,7 @@ type MotionReviewProps = {
relevantPreviews?: Preview[];
timeRange: { before: number; after: number };
filter?: ReviewFilter;
onSelectReview: (data: string, ctrl: boolean) => void;
};
function MotionReview({
contentRef,
@ -533,6 +537,7 @@ function MotionReview({
relevantPreviews,
timeRange,
filter,
onSelectReview,
}: MotionReviewProps) {
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
@ -561,6 +566,17 @@ function MotionReview({
{},
);
// motion data
const { data: motionData } = useSWR<MotionData[]>([
"review/activity",
{
before: timeRange.before,
after: timeRange.after,
scale: segmentDuration / 2,
},
]);
// timeline time
const lastFullHour = useMemo(() => {
@ -580,6 +596,7 @@ function MotionReview({
);
// move to next clip
useEffect(() => {
if (
!videoPlayersRef.current &&
@ -638,12 +655,15 @@ function MotionReview({
videoPlayersRef.current[camera.name] = controller;
setPlayerReady(true);
}}
onClick={() =>
onSelectReview(`motion,${camera.name},${currentTime}`, false)
}
/>
);
})}
</div>
<div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
<EventReviewTimeline
<MotionReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}
timelineStart={timeRangeSegments.end}
@ -652,6 +672,7 @@ function MotionReview({
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems.all}
motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef}
/>

View File

@ -2,13 +2,17 @@ import DynamicVideoPlayer, {
DynamicVideoController,
} from "@/components/player/DynamicVideoPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button";
import { Preview } from "@/types/preview";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoMdArrowRoundBack } from "react-icons/io";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
const SEGMENT_DURATION = 30;
type DesktopRecordingViewProps = {
startCamera: string;
@ -116,6 +120,21 @@ export function DesktopRecordingView({
[allCameras, currentTime, mainCamera],
);
// motion timeline data
const { data: motionData } = useSWR<MotionData[]>(
severity == "significant_motion"
? [
"review/activity",
{
before: timeRange.end,
after: timeRange.start,
scale: SEGMENT_DURATION / 2,
},
]
: null,
);
return (
<div ref={contentRef} className="relative size-full">
<Button
@ -181,6 +200,7 @@ export function DesktopRecordingView({
</div>
<div className="absolute overflow-hidden w-56 inset-y-0 right-0">
{severity != "significant_motion" ? (
<EventReviewTimeline
segmentDuration={30}
timestampSpread={15}
@ -194,18 +214,38 @@ export function DesktopRecordingView({
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
) : (
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineStart={timeRange.end}
timelineEnd={timeRange.start}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems}
motion_events={motionData ?? []}
severityType={severity}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
)}
</div>
</div>
);
}
type MobileRecordingViewProps = {
selectedReview: ReviewSegment;
startCamera: string;
startTime: number;
severity: ReviewSeverity;
reviewItems: ReviewSegment[];
relevantPreviews?: Preview[];
};
export function MobileRecordingView({
selectedReview,
startCamera,
startTime,
severity,
reviewItems,
relevantPreviews,
}: MobileRecordingViewProps) {
@ -219,16 +259,10 @@ export function MobileRecordingView({
// timeline time
const timeRange = useMemo(
() => getChunkedTimeDay(selectedReview.start_time),
[selectedReview],
);
const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]);
const [selectedRangeIdx, setSelectedRangeIdx] = useState(
timeRange.ranges.findIndex((chunk) => {
return (
chunk.start <= selectedReview.start_time &&
chunk.end >= selectedReview.start_time
);
return chunk.start <= startTime && chunk.end >= startTime;
}),
);
@ -251,7 +285,7 @@ export function MobileRecordingView({
const [scrubbing, setScrubbing] = useState(false);
const [currentTime, setCurrentTime] = useState<number>(
selectedReview?.start_time || Date.now() / 1000,
startTime || Date.now() / 1000,
);
useEffect(() => {
@ -269,6 +303,21 @@ export function MobileRecordingView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrubbing]);
// motion timeline data
const { data: motionData } = useSWR<MotionData[]>(
severity == "significant_motion"
? [
"review/activity",
{
before: timeRange.end,
after: timeRange.start,
scale: SEGMENT_DURATION / 2,
},
]
: null,
);
return (
<div ref={contentRef} className="flex flex-col relative w-full h-full">
<Button className="rounded-lg" onClick={() => navigate(-1)}>
@ -278,7 +327,7 @@ export function MobileRecordingView({
<div>
<DynamicVideoPlayer
camera={selectedReview.camera}
camera={startCamera}
timeRange={timeRange.ranges[selectedRangeIdx]}
cameraPreviews={relevantPreviews || []}
onControllerReady={(controller) => {
@ -288,15 +337,13 @@ export function MobileRecordingView({
setCurrentTime(timestamp);
});
controllerRef.current?.seekToTimestamp(
selectedReview.start_time,
true,
);
controllerRef.current?.seekToTimestamp(startTime, true);
}}
/>
</div>
<div className="flex-grow overflow-hidden">
{severity != "significant_motion" ? (
<EventReviewTimeline
segmentDuration={30}
timestampSpread={15}
@ -306,10 +353,26 @@ export function MobileRecordingView({
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems}
severityType={selectedReview.severity}
severityType={severity}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
) : (
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineStart={timeRange.end}
timelineEnd={timeRange.start}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems}
motion_events={motionData ?? []}
severityType={severity}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
)}
</div>
</div>
);