diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json
index a9d9381de..f956a5e0e 100644
--- a/web/public/locales/en/views/classificationModel.json
+++ b/web/public/locales/en/views/classificationModel.json
@@ -52,7 +52,6 @@
"categorizeImage": "Classify Image",
"wizard": {
"title": "Create New Classification",
- "description": "Create a new state or object classification model.",
"steps": {
"nameAndDefine": "Name & Define",
"stateArea": "State Area",
@@ -60,6 +59,7 @@
"train": "Train"
},
"step1": {
+ "description": "Create a new state or object classification model.",
"name": "Name",
"namePlaceholder": "Enter model name...",
"type": "Type",
diff --git a/web/src/components/classification/ClassificationModelWizardDialog.tsx b/web/src/components/classification/ClassificationModelWizardDialog.tsx
index 9711fe246..9301ed5d4 100644
--- a/web/src/components/classification/ClassificationModelWizardDialog.tsx
+++ b/web/src/components/classification/ClassificationModelWizardDialog.tsx
@@ -150,8 +150,16 @@ export default function ClassificationModelWizardDialog({
{t("wizard.title")}
{wizardState.currentStep === 0 && (
- {t("wizard.description")}
+
+ {t("wizard.step1.description")}
+
)}
+ {wizardState.currentStep === 1 &&
+ wizardState.step1Data?.modelType === "state" && (
+
+ {t("wizard.step2.description")}
+
+ )}
diff --git a/web/src/components/classification/wizard/Step2StateArea.tsx b/web/src/components/classification/wizard/Step2StateArea.tsx
index 7ed089b92..ca2503956 100644
--- a/web/src/components/classification/wizard/Step2StateArea.tsx
+++ b/web/src/components/classification/wizard/Step2StateArea.tsx
@@ -15,6 +15,7 @@ import Konva from "konva";
import { useResizeObserver } from "@/hooks/resize-observer";
import { useApiHost } from "@/api";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
+import Heading from "@/components/ui/heading";
export type CameraAreaConfig = {
camera: string;
@@ -45,6 +46,7 @@ export default function Step2StateArea({
);
const [selectedCameraIndex, setSelectedCameraIndex] = useState
(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [imageLoaded, setImageLoaded] = useState(false);
const containerRef = useRef(null);
const imageRef = useRef(null);
@@ -108,15 +110,39 @@ export default function Step2StateArea({
const handleAddCamera = useCallback(
(cameraName: string) => {
+ // Calculate a square crop in pixel space
+ const camera = config?.cameras[cameraName];
+ if (!camera) return;
+
+ const cameraAspect = camera.detect.width / camera.detect.height;
+ const cropSize = 0.3;
+ let x1, y1, x2, y2;
+
+ if (cameraAspect >= 1) {
+ const pixelSize = cropSize * camera.detect.height;
+ const normalizedWidth = pixelSize / camera.detect.width;
+ x1 = (1 - normalizedWidth) / 2;
+ y1 = (1 - cropSize) / 2;
+ x2 = x1 + normalizedWidth;
+ y2 = y1 + cropSize;
+ } else {
+ const pixelSize = cropSize * camera.detect.width;
+ const normalizedHeight = pixelSize / camera.detect.height;
+ x1 = (1 - cropSize) / 2;
+ y1 = (1 - normalizedHeight) / 2;
+ x2 = x1 + cropSize;
+ y2 = y1 + normalizedHeight;
+ }
+
const newArea: CameraAreaConfig = {
camera: cameraName,
- crop: [0.385, 0.385, 0.535, 0.535],
+ crop: [x1, y1, x2, y2],
};
setCameraAreas([...cameraAreas, newArea]);
setSelectedCameraIndex(cameraAreas.length);
setIsPopoverOpen(false);
},
- [cameraAreas],
+ [cameraAreas, config],
);
const handleRemoveCamera = useCallback(
@@ -142,17 +168,27 @@ export default function Step2StateArea({
[cameraAreas, selectedCameraIndex],
);
+ useEffect(() => {
+ setImageLoaded(false);
+ }, [selectedCamera]);
+
useEffect(() => {
const rect = rectRef.current;
const transformer = transformerRef.current;
- if (rect && transformer) {
+ if (
+ rect &&
+ transformer &&
+ selectedCamera &&
+ imageSize.width > 0 &&
+ imageLoaded
+ ) {
rect.scaleX(1);
rect.scaleY(1);
transformer.nodes([rect]);
transformer.getLayer()?.batchDraw();
}
- }, [selectedCamera, imageSize]);
+ }, [selectedCamera, imageSize, imageLoaded]);
const handleRectChange = useCallback(() => {
const rect = rectRef.current;
@@ -186,10 +222,6 @@ export default function Step2StateArea({
return (
-
- {t("wizard.step2.description")}
-
-
@@ -205,7 +237,7 @@ export default function Step2StateArea({
type="button"
variant="ghost"
size="icon"
- className="h-6 w-6 p-0"
+ className="size-6 p-0"
aria-label="Add camera"
>
@@ -218,17 +250,17 @@ export default function Step2StateArea({
onOpenAutoFocus={(e) => e.preventDefault()}
>
-
+
{t("wizard.step2.selectCamera")}
-
-
+
+
{availableCameras.map((cam) => (
- {selectedCamera && selectedCameraConfig ? (
-
- {imageSize.width > 0 && (
-
+ {selectedCamera && selectedCameraConfig && imageSize.width > 0 ? (
+
+

setImageLoaded(true)}
+ />
+
-
-
-
- {
- const rect = rectRef.current;
- if (!rect) return pos;
+
+ {
+ 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),
- );
+ 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 maxSize = Math.min(
- imageSize.width,
- imageSize.height,
- );
+ return { x, y };
+ }}
+ onDragEnd={handleRectChange}
+ onTransformEnd={handleRectChange}
+ />
+ {
+ const minSize = 50;
+ const maxSize = Math.min(
+ imageSize.width,
+ imageSize.height,
+ );
- // Clamp dimensions to stage bounds first
- const clampedWidth = Math.max(
- minSize,
- Math.min(newBox.width, maxSize),
- );
- const clampedHeight = Math.max(
- minSize,
- Math.min(newBox.height, maxSize),
- );
+ // Clamp dimensions to stage bounds first
+ const clampedWidth = Math.max(
+ minSize,
+ Math.min(newBox.width, maxSize),
+ );
+ const clampedHeight = Math.max(
+ minSize,
+ Math.min(newBox.height, maxSize),
+ );
- // Enforce square using average
- const size = (clampedWidth + clampedHeight) / 2;
+ // Enforce square using average
+ const size = (clampedWidth + clampedHeight) / 2;
- // Clamp position to keep square within bounds
- const x = Math.max(
- 0,
- Math.min(newBox.x, imageSize.width - size),
- );
- const y = Math.max(
- 0,
- Math.min(newBox.y, imageSize.height - size),
- );
+ // Clamp position to keep square within bounds
+ const x = Math.max(
+ 0,
+ Math.min(newBox.x, imageSize.width - size),
+ );
+ const y = Math.max(
+ 0,
+ Math.min(newBox.y, imageSize.height - size),
+ );
- return {
- ...newBox,
- x,
- y,
- width: size,
- height: size,
- };
- }}
- />
-
-
-
- )}
-
- ) : (
-
- {t("wizard.step2.selectCameraPrompt")}
-
- )}
+ return {
+ ...newBox,
+ x,
+ y,
+ width: size,
+ height: size,
+ };
+ }}
+ />
+
+
+
+ ) : (
+
+ {t("wizard.step2.selectCameraPrompt")}
+
+ )}
+