mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Motion search fixes (#23359)
* improve error parsing and increase skip default * improve motion search layout to match tracking details * implement draw and move mode on mobile * update motion search docs * language tweaks * improve tips * note actions menu
This commit is contained in:
parent
08be019bed
commit
407817a3b1
@ -153,7 +153,7 @@ Clicking a preview clip seeks the recording player to that timestamp so you can
|
|||||||
|
|
||||||
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
|
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
|
||||||
|
|
||||||
To start a search, click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
To start a search, open the Actions menu in History or click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
||||||
|
|
||||||
1. Pick the camera and time range to scan.
|
1. Pick the camera and time range to scan.
|
||||||
2. Draw a polygon on the camera frame to define the region of interest.
|
2. Draw a polygon on the camera frame to define the region of interest.
|
||||||
@ -170,3 +170,21 @@ To start a search, click the kebab menu on a camera in the <NavPath path="Review
|
|||||||
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
|
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
|
||||||
|
|
||||||
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
|
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
|
||||||
|
|
||||||
|
#### Common use cases
|
||||||
|
|
||||||
|
Frigate's main use case is to record and surface tracked objects, so Motion Search is most useful for the cases where object detection produced nothing — there is no object to find in Explore, but you suspect something happened.
|
||||||
|
|
||||||
|
- **Locating an unattributed change.** You know something appeared, disappeared, or moved in a window of footage — a package now gone, a gate left open — but no detection points to it. A search returns the candidate timestamps instead of scrubbing the timeline by hand.
|
||||||
|
- **An object that was never detected.** Something Frigate doesn't have a model label for, an object too small or distant to be detected, or movement in a region where detection isn't running. The activity left no tracked object but did change the pixels, so a search can still find it.
|
||||||
|
- **Activity while detection was effectively paused.** Changes that occurred while object detection was disabled, motion was suppressed by `skip_motion_threshold`, or inside an area covered by a motion mask, won't appear as review items or tracked objects but can be recovered by searching the recordings directly.
|
||||||
|
|
||||||
|
#### Expected performance
|
||||||
|
|
||||||
|
Motion Search analyzes the saved recordings on demand rather than reading a pre-built index, so a search over a long range takes longer than browsing Motion Previews. Cost scales mainly with how much footage has to be examined: segments with no recorded motion in your ROI are skipped using the stored motion heatmap (shown as "segments skipped" in the status panel), so a quiet range finishes quickly while a busy one takes longer.
|
||||||
|
|
||||||
|
To increase the speed of searches:
|
||||||
|
|
||||||
|
- Draw a tight ROI. Because **Minimum Change Area** is measured as a percentage of the region you draw, a tight ROI around where you expect the change makes the object fill a larger share of the area, so it clears the threshold more easily. A loose ROI makes the same object a small fraction of the region, so it can fall below the threshold and be missed — forcing you to lower Minimum Change Area, which lets in more noise.
|
||||||
|
- Keep Frame Skip high. A higher value samples fewer frames and speeds up the search considerably, while still landing within a few seconds of when the motion or object appeared — close enough to seek to in the recording. Only lower it when you need to pinpoint the exact frame something appears or disappears.
|
||||||
|
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher CPU usage while it runs.
|
||||||
|
|||||||
@ -42,9 +42,9 @@ class MotionSearchRequest(BaseModel):
|
|||||||
description="Minimum change area as a percentage of the ROI",
|
description="Minimum change area as a percentage of the ROI",
|
||||||
)
|
)
|
||||||
frame_skip: int = Field(
|
frame_skip: int = Field(
|
||||||
default=5,
|
default=30,
|
||||||
ge=1,
|
ge=1,
|
||||||
le=30,
|
le=120,
|
||||||
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
|
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
|
||||||
)
|
)
|
||||||
parallel: bool = Field(
|
parallel: bool = Field(
|
||||||
|
|||||||
@ -24,7 +24,9 @@
|
|||||||
"points_one": "{{count}} point",
|
"points_one": "{{count}} point",
|
||||||
"points_other": "{{count}} points",
|
"points_other": "{{count}} points",
|
||||||
"undo": "Undo last point",
|
"undo": "Undo last point",
|
||||||
"reset": "Reset polygon"
|
"reset": "Reset polygon",
|
||||||
|
"drawMode": "Draw",
|
||||||
|
"moveMode": "Move"
|
||||||
},
|
},
|
||||||
"motionHeatmapLabel": "Motion Heatmap",
|
"motionHeatmapLabel": "Motion Heatmap",
|
||||||
"dialog": {
|
"dialog": {
|
||||||
|
|||||||
@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||||
import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa";
|
import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa";
|
||||||
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
|
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
|
||||||
|
import { LuHand, LuPencil } from "react-icons/lu";
|
||||||
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
|
import { ASPECT_PORTRAIT_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -113,12 +115,34 @@ export default function MotionSearchDialog({
|
|||||||
const { t } = useTranslation(["views/motionSearch", "common"]);
|
const { t } = useTranslation(["views/motionSearch", "common"]);
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [panMode, setPanMode] = useState(false);
|
||||||
|
|
||||||
const cameraConfig = useMemo(() => {
|
const cameraConfig = useMemo(() => {
|
||||||
if (!selectedCamera) return undefined;
|
if (!selectedCamera) return undefined;
|
||||||
return config.cameras[selectedCamera];
|
return config.cameras[selectedCamera];
|
||||||
}, [config, selectedCamera]);
|
}, [config, selectedCamera]);
|
||||||
|
|
||||||
|
const aspectRatio = useMemo(() => {
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cameraConfig.detect.width / cameraConfig.detect.height;
|
||||||
|
}, [cameraConfig]);
|
||||||
|
|
||||||
|
// Determine camera aspect ratio category
|
||||||
|
const cameraAspect = useMemo(() => {
|
||||||
|
if (!aspectRatio) {
|
||||||
|
return "normal";
|
||||||
|
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||||
|
return "wide";
|
||||||
|
} else if (aspectRatio < ASPECT_PORTRAIT_LAYOUT) {
|
||||||
|
return "tall";
|
||||||
|
} else {
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
}, [aspectRatio]);
|
||||||
|
|
||||||
const polygonClosed = useMemo(
|
const polygonClosed = useMemo(
|
||||||
() => !isDrawingROI && polygonPoints.length >= 3,
|
() => !isDrawingROI && polygonPoints.length >= 3,
|
||||||
[isDrawingROI, polygonPoints.length],
|
[isDrawingROI, polygonPoints.length],
|
||||||
@ -144,6 +168,7 @@ export default function MotionSearchDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImageLoaded(false);
|
setImageLoaded(false);
|
||||||
|
setPanMode(false);
|
||||||
}, [selectedCamera]);
|
}, [selectedCamera]);
|
||||||
|
|
||||||
const Overlay = isDesktop ? Dialog : Drawer;
|
const Overlay = isDesktop ? Dialog : Drawer;
|
||||||
@ -218,7 +243,13 @@ export default function MotionSearchDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
<TransformWrapper
|
||||||
|
minScale={1.0}
|
||||||
|
wheel={{ smoothStep: 0.005 }}
|
||||||
|
panning={{ disabled: !isDesktop && !panMode }}
|
||||||
|
pinch={{ disabled: !isDesktop && !panMode }}
|
||||||
|
doubleClick={{ disabled: !isDesktop && !panMode }}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<TransformComponent
|
<TransformComponent
|
||||||
wrapperStyle={{
|
wrapperStyle={{
|
||||||
@ -231,7 +262,15 @@ export default function MotionSearchDialog({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative mx-auto flex items-center justify-center overflow-hidden rounded-lg border bg-secondary",
|
||||||
|
cameraAspect === "tall"
|
||||||
|
? "max-h-[50dvh] lg:max-h-[60dvh]"
|
||||||
|
: "w-full",
|
||||||
|
)}
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
>
|
||||||
{selectedCamera && cameraConfig ? (
|
{selectedCamera && cameraConfig ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<img
|
<img
|
||||||
@ -261,6 +300,7 @@ export default function MotionSearchDialog({
|
|||||||
isDrawing={isDrawingROI}
|
isDrawing={isDrawingROI}
|
||||||
setIsDrawing={setIsDrawingROI}
|
setIsDrawing={setIsDrawingROI}
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
|
panMode={panMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -282,11 +322,41 @@ export default function MotionSearchDialog({
|
|||||||
{polygonClosed && <FaCheckCircle className="ml-2 size-5" />}
|
{polygonClosed && <FaCheckCircle className="ml-2 size-5" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-center gap-2">
|
<div className="flex flex-row justify-center gap-2">
|
||||||
|
{!isDesktop && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={panMode ? "select" : "default"}
|
||||||
|
className="size-8 rounded-md p-1.5"
|
||||||
|
aria-label={
|
||||||
|
panMode
|
||||||
|
? t("polygonControls.moveMode")
|
||||||
|
: t("polygonControls.drawMode")
|
||||||
|
}
|
||||||
|
onClick={() => setPanMode((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{panMode ? (
|
||||||
|
<LuHand className="text-selected-foreground" />
|
||||||
|
) : (
|
||||||
|
<LuPencil className="text-secondary-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{panMode
|
||||||
|
? t("polygonControls.moveMode")
|
||||||
|
: t("polygonControls.drawMode")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
className="size-6 rounded-md p-1"
|
className={cn(
|
||||||
|
"rounded-md",
|
||||||
|
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
|
||||||
|
)}
|
||||||
aria-label={t("polygonControls.undo")}
|
aria-label={t("polygonControls.undo")}
|
||||||
disabled={polygonPoints.length === 0 || isSearching}
|
disabled={polygonPoints.length === 0 || isSearching}
|
||||||
onClick={undoPolygonPoint}
|
onClick={undoPolygonPoint}
|
||||||
@ -302,7 +372,10 @@ export default function MotionSearchDialog({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
className="size-6 rounded-md p-1"
|
className={cn(
|
||||||
|
"rounded-md",
|
||||||
|
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
|
||||||
|
)}
|
||||||
aria-label={t("polygonControls.reset")}
|
aria-label={t("polygonControls.reset")}
|
||||||
disabled={polygonPoints.length === 0 || isSearching}
|
disabled={polygonPoints.length === 0 || isSearching}
|
||||||
onClick={resetPolygon}
|
onClick={resetPolygon}
|
||||||
@ -370,7 +443,7 @@ export default function MotionSearchDialog({
|
|||||||
<Slider
|
<Slider
|
||||||
id="frameSkip"
|
id="frameSkip"
|
||||||
min={1}
|
min={1}
|
||||||
max={60}
|
max={120}
|
||||||
step={1}
|
step={1}
|
||||||
value={[frameSkip]}
|
value={[frameSkip]}
|
||||||
onValueChange={([value]) => setFrameSkip(value)}
|
onValueChange={([value]) => setFrameSkip(value)}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ type MotionSearchROICanvasProps = {
|
|||||||
isDrawing: boolean;
|
isDrawing: boolean;
|
||||||
setIsDrawing: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsDrawing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isInteractive?: boolean;
|
isInteractive?: boolean;
|
||||||
|
panMode?: boolean;
|
||||||
motionHeatmap?: Record<string, number> | null;
|
motionHeatmap?: Record<string, number> | null;
|
||||||
showMotionHeatmap?: boolean;
|
showMotionHeatmap?: boolean;
|
||||||
};
|
};
|
||||||
@ -26,6 +27,7 @@ export default function MotionSearchROICanvas({
|
|||||||
isDrawing,
|
isDrawing,
|
||||||
setIsDrawing,
|
setIsDrawing,
|
||||||
isInteractive = true,
|
isInteractive = true,
|
||||||
|
panMode = false,
|
||||||
motionHeatmap,
|
motionHeatmap,
|
||||||
showMotionHeatmap = false,
|
showMotionHeatmap = false,
|
||||||
}: MotionSearchROICanvasProps) {
|
}: MotionSearchROICanvasProps) {
|
||||||
@ -341,7 +343,9 @@ export default function MotionSearchROICanvas({
|
|||||||
ref={setContainerNode}
|
ref={setContainerNode}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 z-10",
|
"absolute inset-0 z-10",
|
||||||
isInteractive ? "pointer-events-auto" : "pointer-events-none",
|
isInteractive && !panMode
|
||||||
|
? "pointer-events-auto"
|
||||||
|
: "pointer-events-none",
|
||||||
)}
|
)}
|
||||||
style={{ cursor: isDrawing ? "crosshair" : "default" }}
|
style={{ cursor: isDrawing ? "crosshair" : "default" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export default function MotionSearchView({
|
|||||||
const [parallelMode, setParallelMode] = useState(false);
|
const [parallelMode, setParallelMode] = useState(false);
|
||||||
const [threshold, setThreshold] = useState(30);
|
const [threshold, setThreshold] = useState(30);
|
||||||
const [minArea, setMinArea] = useState(20);
|
const [minArea, setMinArea] = useState(20);
|
||||||
const [frameSkip, setFrameSkip] = useState(10);
|
const [frameSkip, setFrameSkip] = useState(30);
|
||||||
const [maxResults, setMaxResults] = useState(25);
|
const [maxResults, setMaxResults] = useState(25);
|
||||||
|
|
||||||
// Job state
|
// Job state
|
||||||
@ -846,7 +846,13 @@ export default function MotionSearchView({
|
|||||||
responseData.errors;
|
responseData.errors;
|
||||||
|
|
||||||
if (Array.isArray(apiMessage)) {
|
if (Array.isArray(apiMessage)) {
|
||||||
errorMessage = apiMessage.join(", ");
|
errorMessage = apiMessage
|
||||||
|
.map((item) =>
|
||||||
|
typeof item === "string"
|
||||||
|
? item
|
||||||
|
: ((item as { msg?: string })?.msg ?? JSON.stringify(item)),
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
} else if (typeof apiMessage === "string") {
|
} else if (typeof apiMessage === "string") {
|
||||||
errorMessage = apiMessage;
|
errorMessage = apiMessage;
|
||||||
} else if (apiMessage) {
|
} else if (apiMessage) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user