mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Snap points to edges and create object mask from bounding box (#16488)
This commit is contained in:
parent
b594f198a9
commit
a3ede3cf8a
@ -45,6 +45,13 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
|
import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type ObjectLifecycleProps = {
|
type ObjectLifecycleProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -68,6 +75,7 @@ export default function ObjectLifecycle({
|
|||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [imgLoaded, setImgLoaded] = useState(false);
|
const [imgLoaded, setImgLoaded] = useState(false);
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
@ -293,62 +301,83 @@ export default function ObjectLifecycle({
|
|||||||
imgLoaded ? "visible" : "invisible",
|
imgLoaded ? "visible" : "invisible",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<ContextMenu>
|
||||||
key={event.id}
|
<ContextMenuTrigger>
|
||||||
ref={imgRef}
|
<img
|
||||||
className={cn(
|
key={event.id}
|
||||||
"max-h-[50dvh] max-w-full select-none rounded-lg object-contain",
|
ref={imgRef}
|
||||||
)}
|
className={cn(
|
||||||
loading={isSafari ? "eager" : "lazy"}
|
"max-h-[50dvh] max-w-full select-none rounded-lg object-contain",
|
||||||
style={
|
)}
|
||||||
isIOS
|
loading={isSafari ? "eager" : "lazy"}
|
||||||
? {
|
style={
|
||||||
WebkitUserSelect: "none",
|
isIOS
|
||||||
WebkitTouchCallout: "none",
|
? {
|
||||||
}
|
WebkitUserSelect: "none",
|
||||||
: undefined
|
WebkitTouchCallout: "none",
|
||||||
}
|
}
|
||||||
draggable={false}
|
: undefined
|
||||||
src={src}
|
}
|
||||||
onLoad={() => setImgLoaded(true)}
|
draggable={false}
|
||||||
onError={() => setHasError(true)}
|
src={src}
|
||||||
/>
|
onLoad={() => setImgLoaded(true)}
|
||||||
|
onError={() => setHasError(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{showZones &&
|
{showZones &&
|
||||||
lifecycleZones?.map((zone) => (
|
lifecycleZones?.map((zone) => (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
style={{
|
|
||||||
width: imgRef.current?.clientWidth,
|
|
||||||
height: imgRef.current?.clientHeight,
|
|
||||||
}}
|
|
||||||
key={zone}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
|
|
||||||
className="absolute inset-0"
|
|
||||||
>
|
|
||||||
<polygon
|
|
||||||
points={getZonePolygon(zone)}
|
|
||||||
className="fill-none stroke-2"
|
|
||||||
style={{
|
style={{
|
||||||
stroke: `rgb(${getZoneColor(zone)?.join(",")})`,
|
width: imgRef.current?.clientWidth,
|
||||||
fill:
|
height: imgRef.current?.clientHeight,
|
||||||
selectedZone == zone
|
|
||||||
? `rgba(${getZoneColor(zone)?.join(",")}, 0.5)`
|
|
||||||
: `rgba(${getZoneColor(zone)?.join(",")}, 0.3)`,
|
|
||||||
strokeWidth: selectedZone == zone ? 4 : 2,
|
|
||||||
}}
|
}}
|
||||||
/>
|
key={zone}
|
||||||
</svg>
|
>
|
||||||
</div>
|
<svg
|
||||||
))}
|
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
|
||||||
|
className="absolute inset-0"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points={getZonePolygon(zone)}
|
||||||
|
className="fill-none stroke-2"
|
||||||
|
style={{
|
||||||
|
stroke: `rgb(${getZoneColor(zone)?.join(",")})`,
|
||||||
|
fill:
|
||||||
|
selectedZone == zone
|
||||||
|
? `rgba(${getZoneColor(zone)?.join(",")}, 0.5)`
|
||||||
|
: `rgba(${getZoneColor(zone)?.join(",")}, 0.3)`,
|
||||||
|
strokeWidth: selectedZone == zone ? 4 : 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{boxStyle && (
|
{boxStyle && (
|
||||||
<div className="absolute border-2 border-red-600" style={boxStyle}>
|
<div
|
||||||
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
|
className="absolute border-2 border-red-600"
|
||||||
</div>
|
style={boxStyle}
|
||||||
)}
|
>
|
||||||
|
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem>
|
||||||
|
<div
|
||||||
|
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/settings?page=masks%20/%20zones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-primary">Create Object Mask</div>
|
||||||
|
</div>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -33,6 +33,8 @@ type MotionMaskEditPaneProps = {
|
|||||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
snapPoints: boolean;
|
||||||
|
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MotionMaskEditPane({
|
export default function MotionMaskEditPane({
|
||||||
@ -45,6 +47,8 @@ export default function MotionMaskEditPane({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
snapPoints,
|
||||||
|
setSnapPoints,
|
||||||
}: MotionMaskEditPaneProps) {
|
}: MotionMaskEditPaneProps) {
|
||||||
const { data: config, mutate: updateConfig } =
|
const { data: config, mutate: updateConfig } =
|
||||||
useSWR<FrigateConfig>("config");
|
useSWR<FrigateConfig>("config");
|
||||||
@ -252,6 +256,8 @@ export default function MotionMaskEditPane({
|
|||||||
polygons={polygons}
|
polygons={polygons}
|
||||||
setPolygons={setPolygons}
|
setPolygons={setPolygons}
|
||||||
activePolygonIndex={activePolygonIndex}
|
activePolygonIndex={activePolygonIndex}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
setSnapPoints={setSnapPoints}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -49,6 +49,8 @@ type ObjectMaskEditPaneProps = {
|
|||||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
snapPoints: boolean;
|
||||||
|
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ObjectMaskEditPane({
|
export default function ObjectMaskEditPane({
|
||||||
@ -61,6 +63,8 @@ export default function ObjectMaskEditPane({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
snapPoints,
|
||||||
|
setSnapPoints,
|
||||||
}: ObjectMaskEditPaneProps) {
|
}: ObjectMaskEditPaneProps) {
|
||||||
const { data: config, mutate: updateConfig } =
|
const { data: config, mutate: updateConfig } =
|
||||||
useSWR<FrigateConfig>("config");
|
useSWR<FrigateConfig>("config");
|
||||||
@ -272,6 +276,8 @@ export default function ObjectMaskEditPane({
|
|||||||
polygons={polygons}
|
polygons={polygons}
|
||||||
setPolygons={setPolygons}
|
setPolygons={setPolygons}
|
||||||
activePolygonIndex={activePolygonIndex}
|
activePolygonIndex={activePolygonIndex}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
setSnapPoints={setSnapPoints}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -6,6 +6,7 @@ import type { KonvaEventObject } from "konva/lib/Node";
|
|||||||
import { Polygon, PolygonType } from "@/types/canvas";
|
import { Polygon, PolygonType } from "@/types/canvas";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { snapPointToLines } from "@/utils/canvasUtil";
|
||||||
|
|
||||||
type PolygonCanvasProps = {
|
type PolygonCanvasProps = {
|
||||||
containerRef: RefObject<HTMLDivElement>;
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
@ -18,6 +19,7 @@ type PolygonCanvasProps = {
|
|||||||
hoveredPolygonIndex: number | null;
|
hoveredPolygonIndex: number | null;
|
||||||
selectedZoneMask: PolygonType[] | undefined;
|
selectedZoneMask: PolygonType[] | undefined;
|
||||||
activeLine?: number;
|
activeLine?: number;
|
||||||
|
snapPoints: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PolygonCanvas({
|
export function PolygonCanvas({
|
||||||
@ -31,6 +33,7 @@ export function PolygonCanvas({
|
|||||||
hoveredPolygonIndex,
|
hoveredPolygonIndex,
|
||||||
selectedZoneMask,
|
selectedZoneMask,
|
||||||
activeLine,
|
activeLine,
|
||||||
|
snapPoints,
|
||||||
}: PolygonCanvasProps) {
|
}: PolygonCanvasProps) {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [image, setImage] = useState<HTMLImageElement | undefined>();
|
const [image, setImage] = useState<HTMLImageElement | undefined>();
|
||||||
@ -156,9 +159,23 @@ export function PolygonCanvas({
|
|||||||
intersection?.getClassName() !== "Circle") ||
|
intersection?.getClassName() !== "Circle") ||
|
||||||
(activePolygon.isFinished && intersection?.name() == "unfilled-line")
|
(activePolygon.isFinished && intersection?.name() == "unfilled-line")
|
||||||
) {
|
) {
|
||||||
|
let newPoint = [mousePos.x, mousePos.y];
|
||||||
|
|
||||||
|
if (snapPoints) {
|
||||||
|
// Snap to other polygons' edges
|
||||||
|
const otherPolygons = polygons.filter(
|
||||||
|
(_, i) => i !== activePolygonIndex,
|
||||||
|
);
|
||||||
|
const snappedPos = snapPointToLines(newPoint, otherPolygons, 10);
|
||||||
|
|
||||||
|
if (snappedPos) {
|
||||||
|
newPoint = snappedPos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { updatedPoints, updatedPointsOrder } = addPointToPolygon(
|
const { updatedPoints, updatedPointsOrder } = addPointToPolygon(
|
||||||
activePolygon,
|
activePolygon,
|
||||||
[mousePos.x, mousePos.y],
|
newPoint,
|
||||||
);
|
);
|
||||||
|
|
||||||
updatedPolygons[activePolygonIndex] = {
|
updatedPolygons[activePolygonIndex] = {
|
||||||
@ -184,11 +201,24 @@ export function PolygonCanvas({
|
|||||||
if (stage) {
|
if (stage) {
|
||||||
// we add an unfilled line for adding points when finished
|
// we add an unfilled line for adding points when finished
|
||||||
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
|
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
|
||||||
const pos = [e.target._lastPos!.x, e.target._lastPos!.y];
|
let pos = [e.target._lastPos!.x, e.target._lastPos!.y];
|
||||||
if (pos[0] < 0) pos[0] = 0;
|
|
||||||
if (pos[1] < 0) pos[1] = 0;
|
if (snapPoints) {
|
||||||
if (pos[0] > stage.width()) pos[0] = stage.width();
|
// Snap to other polygons' edges
|
||||||
if (pos[1] > stage.height()) pos[1] = stage.height();
|
const otherPolygons = polygons.filter(
|
||||||
|
(_, i) => i !== activePolygonIndex,
|
||||||
|
);
|
||||||
|
const snappedPos = snapPointToLines(pos, otherPolygons, 10); // 10 is the snap threshold
|
||||||
|
|
||||||
|
if (snappedPos) {
|
||||||
|
pos = snappedPos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constrain to stage boundaries
|
||||||
|
pos[0] = Math.max(0, Math.min(pos[0], stage.width()));
|
||||||
|
pos[1] = Math.max(0, Math.min(pos[1], stage.height()));
|
||||||
|
|
||||||
updatedPolygons[activePolygonIndex] = {
|
updatedPolygons[activePolygonIndex] = {
|
||||||
...activePolygon,
|
...activePolygon,
|
||||||
points: [
|
points: [
|
||||||
@ -291,6 +321,16 @@ export function PolygonCanvas({
|
|||||||
handlePointDragMove={handlePointDragMove}
|
handlePointDragMove={handlePointDragMove}
|
||||||
handleGroupDragEnd={handleGroupDragEnd}
|
handleGroupDragEnd={handleGroupDragEnd}
|
||||||
activeLine={activeLine}
|
activeLine={activeLine}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
snapToLines={(point) =>
|
||||||
|
snapPoints
|
||||||
|
? snapPointToLines(
|
||||||
|
point,
|
||||||
|
polygons.filter((_, i) => i !== index),
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@ -310,6 +350,16 @@ export function PolygonCanvas({
|
|||||||
handlePointDragMove={handlePointDragMove}
|
handlePointDragMove={handlePointDragMove}
|
||||||
handleGroupDragEnd={handleGroupDragEnd}
|
handleGroupDragEnd={handleGroupDragEnd}
|
||||||
activeLine={activeLine}
|
activeLine={activeLine}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
snapToLines={(point) =>
|
||||||
|
snapPoints
|
||||||
|
? snapPointToLines(
|
||||||
|
point,
|
||||||
|
polygons.filter((_, i) => i !== activePolygonIndex),
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Layer>
|
</Layer>
|
||||||
|
@ -28,6 +28,8 @@ type PolygonDrawerProps = {
|
|||||||
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
|
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
|
||||||
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
|
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
|
||||||
activeLine?: number;
|
activeLine?: number;
|
||||||
|
snapToLines: (point: number[]) => number[] | null;
|
||||||
|
snapPoints: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PolygonDrawer({
|
export default function PolygonDrawer({
|
||||||
@ -41,6 +43,8 @@ export default function PolygonDrawer({
|
|||||||
handlePointDragMove,
|
handlePointDragMove,
|
||||||
handleGroupDragEnd,
|
handleGroupDragEnd,
|
||||||
activeLine,
|
activeLine,
|
||||||
|
snapToLines,
|
||||||
|
snapPoints,
|
||||||
}: PolygonDrawerProps) {
|
}: PolygonDrawerProps) {
|
||||||
const vertexRadius = 6;
|
const vertexRadius = 6;
|
||||||
const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
|
const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
|
||||||
@ -218,15 +222,32 @@ export default function PolygonDrawer({
|
|||||||
onMouseOver={handleMouseOverPoint}
|
onMouseOver={handleMouseOverPoint}
|
||||||
onMouseOut={handleMouseOutPoint}
|
onMouseOut={handleMouseOutPoint}
|
||||||
draggable={isActive}
|
draggable={isActive}
|
||||||
onDragMove={isActive ? handlePointDragMove : undefined}
|
onDragMove={(e) => {
|
||||||
|
if (isActive) {
|
||||||
|
if (snapPoints) {
|
||||||
|
const snappedPos = snapToLines([e.target.x(), e.target.y()]);
|
||||||
|
if (snappedPos) {
|
||||||
|
e.target.position({ x: snappedPos[0], y: snappedPos[1] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handlePointDragMove(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
dragBoundFunc={(pos) => {
|
dragBoundFunc={(pos) => {
|
||||||
if (stageRef.current) {
|
if (stageRef.current) {
|
||||||
return dragBoundFunc(
|
const boundPos = dragBoundFunc(
|
||||||
stageRef.current.width(),
|
stageRef.current.width(),
|
||||||
stageRef.current.height(),
|
stageRef.current.height(),
|
||||||
vertexRadius,
|
vertexRadius,
|
||||||
pos,
|
pos,
|
||||||
);
|
);
|
||||||
|
if (snapPoints) {
|
||||||
|
const snappedPos = snapToLines([boundPos.x, boundPos.y]);
|
||||||
|
return snappedPos
|
||||||
|
? { x: snappedPos[0], y: snappedPos[1] }
|
||||||
|
: boundPos;
|
||||||
|
}
|
||||||
|
return boundPos;
|
||||||
} else {
|
} else {
|
||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
|
@ -2,17 +2,23 @@ import { Polygon } from "@/types/canvas";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
|
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
import { TbPolygon, TbPolygonOff } from "react-icons/tb";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PolygonEditControlsProps = {
|
type PolygonEditControlsProps = {
|
||||||
polygons: Polygon[];
|
polygons: Polygon[];
|
||||||
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
|
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
|
||||||
activePolygonIndex: number | undefined;
|
activePolygonIndex: number | undefined;
|
||||||
|
snapPoints: boolean;
|
||||||
|
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PolygonEditControls({
|
export default function PolygonEditControls({
|
||||||
polygons,
|
polygons,
|
||||||
setPolygons,
|
setPolygons,
|
||||||
activePolygonIndex,
|
activePolygonIndex,
|
||||||
|
snapPoints,
|
||||||
|
setSnapPoints,
|
||||||
}: PolygonEditControlsProps) {
|
}: PolygonEditControlsProps) {
|
||||||
const undo = () => {
|
const undo = () => {
|
||||||
if (activePolygonIndex === undefined || !polygons) {
|
if (activePolygonIndex === undefined || !polygons) {
|
||||||
@ -97,6 +103,25 @@ export default function PolygonEditControls({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Reset</TooltipContent>
|
<TooltipContent>Reset</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={snapPoints ? "select" : "default"}
|
||||||
|
className={cn("size-6 rounded-md p-1")}
|
||||||
|
aria-label="Snap points"
|
||||||
|
onClick={() => setSnapPoints((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{snapPoints ? (
|
||||||
|
<TbPolygon className="text-primary" />
|
||||||
|
) : (
|
||||||
|
<TbPolygonOff className="text-secondary-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{snapPoints ? "Don't snap points" : "Snap points"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,8 @@ type ZoneEditPaneProps = {
|
|||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
setActiveLine: React.Dispatch<React.SetStateAction<number | undefined>>;
|
setActiveLine: React.Dispatch<React.SetStateAction<number | undefined>>;
|
||||||
|
snapPoints: boolean;
|
||||||
|
setSnapPoints: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ZoneEditPane({
|
export default function ZoneEditPane({
|
||||||
@ -54,6 +56,8 @@ export default function ZoneEditPane({
|
|||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
setActiveLine,
|
setActiveLine,
|
||||||
|
snapPoints,
|
||||||
|
setSnapPoints,
|
||||||
}: ZoneEditPaneProps) {
|
}: ZoneEditPaneProps) {
|
||||||
const { data: config, mutate: updateConfig } =
|
const { data: config, mutate: updateConfig } =
|
||||||
useSWR<FrigateConfig>("config");
|
useSWR<FrigateConfig>("config");
|
||||||
@ -483,6 +487,8 @@ export default function ZoneEditPane({
|
|||||||
polygons={polygons}
|
polygons={polygons}
|
||||||
setPolygons={setPolygons}
|
setPolygons={setPolygons}
|
||||||
activePolygonIndex={activePolygonIndex}
|
activePolygonIndex={activePolygonIndex}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
setSnapPoints={setSnapPoints}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -124,7 +124,7 @@ export default function Settings() {
|
|||||||
if (allSettingsViews.includes(page as SettingsType)) {
|
if (allSettingsViews.includes(page as SettingsType)) {
|
||||||
setPage(page as SettingsType);
|
setPage(page as SettingsType);
|
||||||
}
|
}
|
||||||
return true;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
useSearchEffect("camera", (camera: string) => {
|
useSearchEffect("camera", (camera: string) => {
|
||||||
@ -132,7 +132,7 @@ export default function Settings() {
|
|||||||
if (cameraNames.includes(camera)) {
|
if (cameraNames.includes(camera)) {
|
||||||
setSelectedCamera(camera);
|
setSelectedCamera(camera);
|
||||||
}
|
}
|
||||||
return true;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Vector2d } from "konva/lib/types";
|
import { Vector2d } from "konva/lib/types";
|
||||||
|
import { Polygon } from "@/types/canvas";
|
||||||
|
|
||||||
export const getAveragePoint = (points: number[]): Vector2d => {
|
export const getAveragePoint = (points: number[]): Vector2d => {
|
||||||
let totalX = 0;
|
let totalX = 0;
|
||||||
@ -100,3 +101,72 @@ export const masksAreIdentical = (arr1: string[], arr2: string[]): boolean => {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function snapPointToLines(
|
||||||
|
point: number[],
|
||||||
|
polygons: Polygon[],
|
||||||
|
threshold: number,
|
||||||
|
): number[] | null {
|
||||||
|
for (const polygon of polygons) {
|
||||||
|
if (!polygon.isFinished) continue;
|
||||||
|
|
||||||
|
for (let i = 0; i < polygon.points.length; i++) {
|
||||||
|
const start = polygon.points[i];
|
||||||
|
const end = polygon.points[(i + 1) % polygon.points.length];
|
||||||
|
|
||||||
|
const snappedPoint = snapPointToLine(point, start, end, threshold);
|
||||||
|
if (snappedPoint) {
|
||||||
|
return snappedPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapPointToLine(
|
||||||
|
point: number[],
|
||||||
|
lineStart: number[],
|
||||||
|
lineEnd: number[],
|
||||||
|
threshold: number,
|
||||||
|
): number[] | null {
|
||||||
|
const [x, y] = point;
|
||||||
|
const [x1, y1] = lineStart;
|
||||||
|
const [x2, y2] = lineEnd;
|
||||||
|
|
||||||
|
const A = x - x1;
|
||||||
|
const B = y - y1;
|
||||||
|
const C = x2 - x1;
|
||||||
|
const D = y2 - y1;
|
||||||
|
|
||||||
|
const dot = A * C + B * D;
|
||||||
|
const lenSq = C * C + D * D;
|
||||||
|
let param = -1;
|
||||||
|
|
||||||
|
if (lenSq !== 0) {
|
||||||
|
param = dot / lenSq;
|
||||||
|
}
|
||||||
|
|
||||||
|
let xx, yy;
|
||||||
|
|
||||||
|
if (param < 0) {
|
||||||
|
xx = x1;
|
||||||
|
yy = y1;
|
||||||
|
} else if (param > 1) {
|
||||||
|
xx = x2;
|
||||||
|
yy = y2;
|
||||||
|
} else {
|
||||||
|
xx = x1 + param * C;
|
||||||
|
yy = y1 + param * D;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = x - xx;
|
||||||
|
const dy = y - yy;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance <= threshold) {
|
||||||
|
return [xx, yy];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
@ -37,6 +37,7 @@ import PolygonItem from "@/components/settings/PolygonItem";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
|
|
||||||
type MasksAndZoneViewProps = {
|
type MasksAndZoneViewProps = {
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
@ -62,6 +63,7 @@ export default function MasksAndZonesView({
|
|||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
|
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
|
||||||
const [activeLine, setActiveLine] = useState<number | undefined>();
|
const [activeLine, setActiveLine] = useState<number | undefined>();
|
||||||
|
const [snapPoints, setSnapPoints] = useState(false);
|
||||||
|
|
||||||
const { addMessage } = useContext(StatusBarMessagesContext)!;
|
const { addMessage } = useContext(StatusBarMessagesContext)!;
|
||||||
|
|
||||||
@ -142,7 +144,7 @@ export default function MasksAndZonesView({
|
|||||||
}
|
}
|
||||||
}, [scaledHeight, aspectRatio]);
|
}, [scaledHeight, aspectRatio]);
|
||||||
|
|
||||||
const handleNewPolygon = (type: PolygonType) => {
|
const handleNewPolygon = (type: PolygonType, coordinates?: number[][]) => {
|
||||||
if (!cameraConfig) {
|
if (!cameraConfig) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -161,9 +163,9 @@ export default function MasksAndZonesView({
|
|||||||
setEditingPolygons([
|
setEditingPolygons([
|
||||||
...(allPolygons || []),
|
...(allPolygons || []),
|
||||||
{
|
{
|
||||||
points: [],
|
points: coordinates ?? [],
|
||||||
distances: [],
|
distances: [],
|
||||||
isFinished: false,
|
isFinished: coordinates ? true : false,
|
||||||
type,
|
type,
|
||||||
typeIndex: 9999,
|
typeIndex: 9999,
|
||||||
name: "",
|
name: "",
|
||||||
@ -373,6 +375,48 @@ export default function MasksAndZonesView({
|
|||||||
}
|
}
|
||||||
}, [selectedCamera]);
|
}, [selectedCamera]);
|
||||||
|
|
||||||
|
useSearchEffect("object_mask", (coordinates: string) => {
|
||||||
|
if (!scaledWidth || !scaledHeight || isLoading) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// convert box points string to points array
|
||||||
|
const points = coordinates.split(",").map((p) => parseFloat(p));
|
||||||
|
|
||||||
|
const [x1, y1, w, h] = points;
|
||||||
|
|
||||||
|
// bottom center
|
||||||
|
const centerX = x1 + w / 2;
|
||||||
|
const bottomY = y1 + h;
|
||||||
|
|
||||||
|
const centerXAbs = centerX * scaledWidth;
|
||||||
|
const bottomYAbs = bottomY * scaledHeight;
|
||||||
|
|
||||||
|
// padding and clamp
|
||||||
|
const minPadding = 0.1 * w * scaledWidth;
|
||||||
|
const maxPadding = 0.3 * w * scaledWidth;
|
||||||
|
const padding = Math.min(
|
||||||
|
Math.max(minPadding, 0.15 * w * scaledWidth),
|
||||||
|
maxPadding,
|
||||||
|
);
|
||||||
|
|
||||||
|
const top = Math.max(0, bottomYAbs - padding);
|
||||||
|
const bottom = Math.min(scaledHeight, bottomYAbs + padding);
|
||||||
|
const left = Math.max(0, centerXAbs - padding);
|
||||||
|
const right = Math.min(scaledWidth, centerXAbs + padding);
|
||||||
|
|
||||||
|
const paddedBox = [
|
||||||
|
[left, top],
|
||||||
|
[right, top],
|
||||||
|
[right, bottom],
|
||||||
|
[left, bottom],
|
||||||
|
];
|
||||||
|
|
||||||
|
setEditPane("object_mask");
|
||||||
|
setActivePolygonIndex(undefined);
|
||||||
|
handleNewPolygon("object_mask", paddedBox);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "Mask and Zone Editor - Frigate";
|
document.title = "Mask and Zone Editor - Frigate";
|
||||||
}, []);
|
}, []);
|
||||||
@ -399,6 +443,8 @@ export default function MasksAndZonesView({
|
|||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
setActiveLine={setActiveLine}
|
setActiveLine={setActiveLine}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
setSnapPoints={setSnapPoints}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{editPane == "motion_mask" && (
|
{editPane == "motion_mask" && (
|
||||||
@ -412,6 +458,8 @@ export default function MasksAndZonesView({
|
|||||||
setIsLoading={setIsLoading}
|
setIsLoading={setIsLoading}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
setSnapPoints={setSnapPoints}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{editPane == "object_mask" && (
|
{editPane == "object_mask" && (
|
||||||
@ -425,6 +473,8 @@ export default function MasksAndZonesView({
|
|||||||
setIsLoading={setIsLoading}
|
setIsLoading={setIsLoading}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
setSnapPoints={setSnapPoints}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{editPane === undefined && (
|
{editPane === undefined && (
|
||||||
@ -662,6 +712,7 @@ export default function MasksAndZonesView({
|
|||||||
hoveredPolygonIndex={hoveredPolygonIndex}
|
hoveredPolygonIndex={hoveredPolygonIndex}
|
||||||
selectedZoneMask={selectedZoneMask}
|
selectedZoneMask={selectedZoneMask}
|
||||||
activeLine={activeLine}
|
activeLine={activeLine}
|
||||||
|
snapPoints={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="size-full" />
|
<Skeleton className="size-full" />
|
||||||
|
Loading…
Reference in New Issue
Block a user