diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json index 9b86d2df5..3a578c5d6 100644 --- a/web/public/locales/en/views/classificationModel.json +++ b/web/public/locales/en/views/classificationModel.json @@ -79,6 +79,13 @@ "stateRequiresTwoClasses": "State models require at least 2 classes", "objectTypeRequired": "Please select a classification type" } + }, + "step2": { + "description": "Select cameras and define the area to monitor for each camera. The model will classify the state of these areas.", + "cameras": "Cameras", + "selectCamera": "Select Camera", + "noCameras": "Click + to add cameras", + "selectCameraPrompt": "Select a camera from the list to define its monitoring area" } } } diff --git a/web/src/components/classification/ClassificationModelWizardDialog.tsx b/web/src/components/classification/ClassificationModelWizardDialog.tsx index 5aded4a58..5da96e9a4 100644 --- a/web/src/components/classification/ClassificationModelWizardDialog.tsx +++ b/web/src/components/classification/ClassificationModelWizardDialog.tsx @@ -9,6 +9,9 @@ import { } from "../ui/dialog"; import { useReducer, useMemo } from "react"; import Step1NameAndDefine, { Step1FormData } from "./wizard/Step1NameAndDefine"; +import Step2StateArea, { Step2FormData } from "./wizard/Step2StateArea"; +import { cn } from "@/lib/utils"; +import { isDesktop } from "react-device-detect"; const OBJECT_STEPS = [ "wizard.steps.nameAndDefine", @@ -31,8 +34,8 @@ type ClassificationModelWizardDialogProps = { type WizardState = { currentStep: number; step1Data?: Step1FormData; + step2Data?: Step2FormData; // Future steps can be added here - // step2Data?: Step2FormData; // step3Data?: Step3FormData; }; @@ -40,6 +43,7 @@ type WizardAction = | { type: "NEXT_STEP"; payload?: Partial } | { type: "PREVIOUS_STEP" } | { type: "SET_STEP_1"; payload: Step1FormData } + | { type: "SET_STEP_2"; payload: Step2FormData } | { type: "RESET" }; const initialState: WizardState = { @@ -54,6 +58,12 @@ function wizardReducer(state: WizardState, action: WizardAction): WizardState { step1Data: action.payload, currentStep: 1, }; + case "SET_STEP_2": + return { + ...state, + step2Data: action.payload, + currentStep: 2, + }; case "NEXT_STEP": return { ...state, @@ -93,6 +103,14 @@ export default function ClassificationModelWizardDialog({ dispatch({ type: "SET_STEP_1", payload: data }); }; + const handleStep2Next = (data: Step2FormData) => { + dispatch({ type: "SET_STEP_2", payload: data }); + }; + + const handleBack = () => { + dispatch({ type: "PREVIOUS_STEP" }); + }; + const handleCancel = () => { dispatch({ type: "RESET" }); onClose(); @@ -108,7 +126,10 @@ export default function ClassificationModelWizardDialog({ }} > { e.preventDefault(); }} @@ -134,6 +155,14 @@ export default function ClassificationModelWizardDialog({ onCancel={handleCancel} /> )} + {wizardState.currentStep === 1 && + wizardState.step1Data?.modelType === "state" && ( + + )} diff --git a/web/src/components/classification/wizard/Step2StateArea.tsx b/web/src/components/classification/wizard/Step2StateArea.tsx new file mode 100644 index 000000000..876521fc3 --- /dev/null +++ b/web/src/components/classification/wizard/Step2StateArea.tsx @@ -0,0 +1,431 @@ +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; +import { useState, useMemo, useRef, useCallback, useEffect } from "react"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { MdAddBox } from "react-icons/md"; +import { LuX } from "react-icons/lu"; +import { Stage, Layer, Rect, Transformer } from "react-konva"; +import Konva from "konva"; +import { useResizeObserver } from "@/hooks/resize-observer"; +import { useApiHost } from "@/api"; +import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; + +export type CameraAreaConfig = { + camera: string; + crop: [number, number, number, number]; // [x, y, width, height] normalized 0-1 +}; + +export type Step2FormData = { + cameraAreas: CameraAreaConfig[]; +}; + +type Step2StateAreaProps = { + initialData?: Partial; + onNext: (data: Step2FormData) => void; + onBack: () => void; +}; + +export default function Step2StateArea({ + initialData, + onNext, + onBack, +}: Step2StateAreaProps) { + const { t } = useTranslation(["views/classificationModel"]); + const { data: config } = useSWR("config"); + const apiHost = useApiHost(); + + const [cameraAreas, setCameraAreas] = useState( + initialData?.cameraAreas || [], + ); + const [selectedCameraIndex, setSelectedCameraIndex] = useState(0); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const containerRef = useRef(null); + const imageRef = useRef(null); + const stageRef = useRef(null); + const rectRef = useRef(null); + const transformerRef = useRef(null); + + const [{ width: containerWidth }] = useResizeObserver(containerRef); + + const availableCameras = useMemo(() => { + if (!config) return []; + + const selectedCameraNames = cameraAreas.map((ca) => ca.camera); + return Object.entries(config.cameras) + .sort() + .filter( + ([name, cam]) => + cam.enabled && + cam.enabled_in_config && + !selectedCameraNames.includes(name), + ) + .map(([name]) => ({ + name, + displayName: resolveCameraName(config, name), + })); + }, [config, cameraAreas]); + + const selectedCamera = useMemo(() => { + if (cameraAreas.length === 0) return null; + return cameraAreas[selectedCameraIndex]; + }, [cameraAreas, selectedCameraIndex]); + + const selectedCameraConfig = useMemo(() => { + if (!config || !selectedCamera) return null; + return config.cameras[selectedCamera.camera]; + }, [config, selectedCamera]); + + const imageSize = useMemo(() => { + if (!containerWidth || !selectedCameraConfig) { + return { width: 0, height: 0 }; + } + + const containerAspectRatio = 16 / 9; + const containerHeight = containerWidth / containerAspectRatio; + + const cameraAspectRatio = + selectedCameraConfig.detect.width / selectedCameraConfig.detect.height; + + // Fit camera within 16:9 container + let imageWidth, imageHeight; + if (cameraAspectRatio > containerAspectRatio) { + imageWidth = containerWidth; + imageHeight = imageWidth / cameraAspectRatio; + } else { + imageHeight = containerHeight; + imageWidth = imageHeight * cameraAspectRatio; + } + + return { width: imageWidth, height: imageHeight }; + }, [containerWidth, selectedCameraConfig]); + + const handleAddCamera = useCallback( + (cameraName: string) => { + const newArea: CameraAreaConfig = { + camera: cameraName, + crop: [0.385, 0.385, 0.15, 0.15], + }; + setCameraAreas([...cameraAreas, newArea]); + setSelectedCameraIndex(cameraAreas.length); + setIsPopoverOpen(false); + }, + [cameraAreas], + ); + + const handleRemoveCamera = useCallback( + (index: number) => { + const newAreas = cameraAreas.filter((_, i) => i !== index); + setCameraAreas(newAreas); + if (selectedCameraIndex >= newAreas.length) { + setSelectedCameraIndex(Math.max(0, newAreas.length - 1)); + } + }, + [cameraAreas, selectedCameraIndex], + ); + + const handleCropChange = useCallback( + (crop: [number, number, number, number]) => { + const newAreas = [...cameraAreas]; + newAreas[selectedCameraIndex] = { + ...newAreas[selectedCameraIndex], + crop, + }; + setCameraAreas(newAreas); + }, + [cameraAreas, selectedCameraIndex], + ); + + useEffect(() => { + const rect = rectRef.current; + const transformer = transformerRef.current; + + if (rect && transformer) { + rect.scaleX(1); + rect.scaleY(1); + transformer.nodes([rect]); + transformer.getLayer()?.batchDraw(); + } + }, [selectedCamera, imageSize]); + + const handleRectChange = useCallback(() => { + const rect = rectRef.current; + + if (rect && imageSize.width > 0) { + const actualWidth = rect.width() * rect.scaleX(); + const actualHeight = rect.height() * rect.scaleY(); + + // Average dimensions to maintain perfect square + const size = (actualWidth + actualHeight) / 2; + + rect.width(size); + rect.height(size); + rect.scaleX(1); + rect.scaleY(1); + + // Normalize to 0-1 range for storage + const x = rect.x() / imageSize.width; + const y = rect.y() / imageSize.height; + const width = size / imageSize.width; + const height = size / imageSize.height; + + handleCropChange([x, y, width, height]); + } + }, [imageSize, handleCropChange]); + + const handleContinue = useCallback(() => { + onNext({ cameraAreas }); + }, [cameraAreas, onNext]); + + const canContinue = cameraAreas.length > 0; + + return ( +
+
+ {t("wizard.step2.description")} +
+ +
+
+
+

{t("wizard.step2.cameras")}

+ {availableCameras.length > 0 ? ( + + + + + e.preventDefault()} + > +
+

+ {t("wizard.step2.selectCamera")} +

+
+ {availableCameras.map((cam) => ( + + ))} +
+
+
+
+ ) : ( + + )} +
+ +
+ {cameraAreas.map((area, index) => { + const isSelected = index === selectedCameraIndex; + const displayName = resolveCameraName(config, area.camera); + + return ( +
setSelectedCameraIndex(index)} + > + {displayName} + +
+ ); + })} +
+ + {cameraAreas.length === 0 && ( +
+ {t("wizard.step2.noCameras")} +
+ )} +
+ +
+ {selectedCamera && selectedCameraConfig ? ( +
+ {imageSize.width > 0 && ( +
+ {resolveCameraName(config, + + + { + const rect = rectRef.current; + if (!rect) return pos; + + const size = rect.width(); + const x = Math.max( + 0, + Math.min(pos.x, imageSize.width - size), + ); + const y = Math.max( + 0, + Math.min(pos.y, imageSize.height - size), + ); + + return { x, y }; + }} + onDragEnd={handleRectChange} + onTransformEnd={handleRectChange} + /> + { + const minSize = 50; + const avgSize = (newBox.width + newBox.height) / 2; + const size = Math.max(minSize, avgSize); + + const maxX = imageSize.width - size; + const maxY = imageSize.height - size; + + if ( + newBox.x < 0 || + newBox.y < 0 || + newBox.x > maxX || + newBox.y > maxY + ) { + const maxSizeFromPos = Math.min( + imageSize.width - newBox.x, + imageSize.height - newBox.y, + newBox.x + oldBox.width, + newBox.y + oldBox.height, + ); + + if (maxSizeFromPos < minSize) { + return oldBox; + } + + const constrainedSize = Math.min( + size, + maxSizeFromPos, + ); + return { + ...newBox, + width: constrainedSize, + height: constrainedSize, + }; + } + + return { + ...newBox, + width: size, + height: size, + }; + }} + /> + + +
+ )} +
+ ) : ( +
+ {t("wizard.step2.selectCameraPrompt")} +
+ )} +
+
+ +
+ + +
+
+ ); +}