Design optimizations

This commit is contained in:
Nicolas Mowen 2025-10-22 15:37:43 -06:00
parent f3b1769dd8
commit 4f83f6a021
3 changed files with 171 additions and 132 deletions

View File

@ -52,7 +52,6 @@
"categorizeImage": "Classify Image", "categorizeImage": "Classify Image",
"wizard": { "wizard": {
"title": "Create New Classification", "title": "Create New Classification",
"description": "Create a new state or object classification model.",
"steps": { "steps": {
"nameAndDefine": "Name & Define", "nameAndDefine": "Name & Define",
"stateArea": "State Area", "stateArea": "State Area",
@ -60,6 +59,7 @@
"train": "Train" "train": "Train"
}, },
"step1": { "step1": {
"description": "Create a new state or object classification model.",
"name": "Name", "name": "Name",
"namePlaceholder": "Enter model name...", "namePlaceholder": "Enter model name...",
"type": "Type", "type": "Type",

View File

@ -150,8 +150,16 @@ export default function ClassificationModelWizardDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>{t("wizard.title")}</DialogTitle> <DialogTitle>{t("wizard.title")}</DialogTitle>
{wizardState.currentStep === 0 && ( {wizardState.currentStep === 0 && (
<DialogDescription>{t("wizard.description")}</DialogDescription> <DialogDescription>
{t("wizard.step1.description")}
</DialogDescription>
)} )}
{wizardState.currentStep === 1 &&
wizardState.step1Data?.modelType === "state" && (
<DialogDescription>
{t("wizard.step2.description")}
</DialogDescription>
)}
</DialogHeader> </DialogHeader>
<div className="pb-4"> <div className="pb-4">

View File

@ -15,6 +15,7 @@ import Konva from "konva";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import Heading from "@/components/ui/heading";
export type CameraAreaConfig = { export type CameraAreaConfig = {
camera: string; camera: string;
@ -45,6 +46,7 @@ export default function Step2StateArea({
); );
const [selectedCameraIndex, setSelectedCameraIndex] = useState<number>(0); const [selectedCameraIndex, setSelectedCameraIndex] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null); const imageRef = useRef<HTMLImageElement>(null);
@ -108,15 +110,39 @@ export default function Step2StateArea({
const handleAddCamera = useCallback( const handleAddCamera = useCallback(
(cameraName: string) => { (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 = { const newArea: CameraAreaConfig = {
camera: cameraName, camera: cameraName,
crop: [0.385, 0.385, 0.535, 0.535], crop: [x1, y1, x2, y2],
}; };
setCameraAreas([...cameraAreas, newArea]); setCameraAreas([...cameraAreas, newArea]);
setSelectedCameraIndex(cameraAreas.length); setSelectedCameraIndex(cameraAreas.length);
setIsPopoverOpen(false); setIsPopoverOpen(false);
}, },
[cameraAreas], [cameraAreas, config],
); );
const handleRemoveCamera = useCallback( const handleRemoveCamera = useCallback(
@ -142,17 +168,27 @@ export default function Step2StateArea({
[cameraAreas, selectedCameraIndex], [cameraAreas, selectedCameraIndex],
); );
useEffect(() => {
setImageLoaded(false);
}, [selectedCamera]);
useEffect(() => { useEffect(() => {
const rect = rectRef.current; const rect = rectRef.current;
const transformer = transformerRef.current; const transformer = transformerRef.current;
if (rect && transformer) { if (
rect &&
transformer &&
selectedCamera &&
imageSize.width > 0 &&
imageLoaded
) {
rect.scaleX(1); rect.scaleX(1);
rect.scaleY(1); rect.scaleY(1);
transformer.nodes([rect]); transformer.nodes([rect]);
transformer.getLayer()?.batchDraw(); transformer.getLayer()?.batchDraw();
} }
}, [selectedCamera, imageSize]); }, [selectedCamera, imageSize, imageLoaded]);
const handleRectChange = useCallback(() => { const handleRectChange = useCallback(() => {
const rect = rectRef.current; const rect = rectRef.current;
@ -186,10 +222,6 @@ export default function Step2StateArea({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="text-sm text-muted-foreground">
{t("wizard.step2.description")}
</div>
<div className="flex gap-4 overflow-hidden"> <div className="flex gap-4 overflow-hidden">
<div className="flex w-64 flex-shrink-0 flex-col gap-2 overflow-y-auto rounded-lg bg-secondary p-4"> <div className="flex w-64 flex-shrink-0 flex-col gap-2 overflow-y-auto rounded-lg bg-secondary p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -205,7 +237,7 @@ export default function Step2StateArea({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 p-0" className="size-6 p-0"
aria-label="Add camera" aria-label="Add camera"
> >
<MdAddBox className="size-6 text-primary" /> <MdAddBox className="size-6 text-primary" />
@ -218,17 +250,17 @@ export default function Step2StateArea({
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="text-sm font-medium"> <Heading as="h4" className="text-sm font-medium">
{t("wizard.step2.selectCamera")} {t("wizard.step2.selectCamera")}
</h4> </Heading>
<div className="scrollbar-container flex max-h-64 flex-col gap-1 overflow-y-auto"> <div className="scrollbar-container flex max-h-[30vh] flex-col gap-1 overflow-y-auto">
{availableCameras.map((cam) => ( {availableCameras.map((cam) => (
<Button <Button
key={cam.name} key={cam.name}
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="capit h-auto justify-start px-3 py-2 capitalize" className="h-auto justify-start p-2 capitalize text-primary"
onClick={() => { onClick={() => {
handleAddCamera(cam.name); handleAddCamera(cam.name);
}} }}
@ -286,130 +318,129 @@ export default function Step2StateArea({
</div> </div>
<div className="flex flex-1 items-center justify-center overflow-hidden rounded-lg p-4"> <div className="flex flex-1 items-center justify-center overflow-hidden rounded-lg p-4">
{selectedCamera && selectedCameraConfig ? ( <div
<div ref={containerRef}
ref={containerRef} className="flex items-center justify-center"
className="flex items-center justify-center" style={{
style={{ width: "100%",
width: "100%", aspectRatio: "16 / 9",
aspectRatio: "16 / 9", maxHeight: "100%",
maxHeight: "100%", }}
}} >
> {selectedCamera && selectedCameraConfig && imageSize.width > 0 ? (
{imageSize.width > 0 && ( <div
<div style={{
style={{ width: imageSize.width,
width: imageSize.width, height: imageSize.height,
height: imageSize.height, position: "relative",
position: "relative", }}
}} >
<img
ref={imageRef}
src={`${apiHost}api/${selectedCamera.camera}/latest.jpg?h=500`}
alt={resolveCameraName(config, selectedCamera.camera)}
className="h-full w-full object-contain"
onLoad={() => setImageLoaded(true)}
/>
<Stage
ref={stageRef}
width={imageSize.width}
height={imageSize.height}
className="absolute inset-0"
> >
<img <Layer>
ref={imageRef} <Rect
src={`${apiHost}api/${selectedCamera.camera}/latest.jpg?h=500`} ref={rectRef}
alt={resolveCameraName(config, selectedCamera.camera)} x={selectedCamera.crop[0] * imageSize.width}
className="h-full w-full object-contain" y={selectedCamera.crop[1] * imageSize.height}
/> width={
<Stage (selectedCamera.crop[2] - selectedCamera.crop[0]) *
ref={stageRef} imageSize.width
width={imageSize.width} }
height={imageSize.height} height={
className="absolute inset-0" (selectedCamera.crop[3] - selectedCamera.crop[1]) *
> imageSize.height
<Layer> }
<Rect stroke="#3b82f6"
ref={rectRef} strokeWidth={2}
x={selectedCamera.crop[0] * imageSize.width} fill="rgba(59, 130, 246, 0.1)"
y={selectedCamera.crop[1] * imageSize.height} draggable
width={ dragBoundFunc={(pos) => {
(selectedCamera.crop[2] - selectedCamera.crop[0]) * const rect = rectRef.current;
imageSize.width if (!rect) return pos;
}
height={
(selectedCamera.crop[3] - selectedCamera.crop[1]) *
imageSize.height
}
stroke="#3b82f6"
strokeWidth={2}
fill="rgba(59, 130, 246, 0.1)"
draggable
dragBoundFunc={(pos) => {
const rect = rectRef.current;
if (!rect) return pos;
const size = rect.width(); const size = rect.width();
const x = Math.max( const x = Math.max(
0, 0,
Math.min(pos.x, imageSize.width - size), Math.min(pos.x, imageSize.width - size),
); );
const y = Math.max( const y = Math.max(
0, 0,
Math.min(pos.y, imageSize.height - size), Math.min(pos.y, imageSize.height - size),
); );
return { x, y }; return { x, y };
}} }}
onDragEnd={handleRectChange} onDragEnd={handleRectChange}
onTransformEnd={handleRectChange} onTransformEnd={handleRectChange}
/> />
<Transformer <Transformer
ref={transformerRef} ref={transformerRef}
rotateEnabled={false} rotateEnabled={false}
enabledAnchors={[ enabledAnchors={[
"top-left", "top-left",
"top-right", "top-right",
"bottom-left", "bottom-left",
"bottom-right", "bottom-right",
]} ]}
boundBoxFunc={(_oldBox, newBox) => { boundBoxFunc={(_oldBox, newBox) => {
const minSize = 50; const minSize = 50;
const maxSize = Math.min( const maxSize = Math.min(
imageSize.width, imageSize.width,
imageSize.height, imageSize.height,
); );
// Clamp dimensions to stage bounds first // Clamp dimensions to stage bounds first
const clampedWidth = Math.max( const clampedWidth = Math.max(
minSize, minSize,
Math.min(newBox.width, maxSize), Math.min(newBox.width, maxSize),
); );
const clampedHeight = Math.max( const clampedHeight = Math.max(
minSize, minSize,
Math.min(newBox.height, maxSize), Math.min(newBox.height, maxSize),
); );
// Enforce square using average // Enforce square using average
const size = (clampedWidth + clampedHeight) / 2; const size = (clampedWidth + clampedHeight) / 2;
// Clamp position to keep square within bounds // Clamp position to keep square within bounds
const x = Math.max( const x = Math.max(
0, 0,
Math.min(newBox.x, imageSize.width - size), Math.min(newBox.x, imageSize.width - size),
); );
const y = Math.max( const y = Math.max(
0, 0,
Math.min(newBox.y, imageSize.height - size), Math.min(newBox.y, imageSize.height - size),
); );
return { return {
...newBox, ...newBox,
x, x,
y, y,
width: size, width: size,
height: size, height: size,
}; };
}} }}
/> />
</Layer> </Layer>
</Stage> </Stage>
</div> </div>
)} ) : (
</div> <div className="flex items-center justify-center text-muted-foreground">
) : ( {t("wizard.step2.selectCameraPrompt")}
<div className="flex h-full items-center justify-center text-muted-foreground"> </div>
{t("wizard.step2.selectCameraPrompt")} )}
</div> </div>
)}
</div> </div>
</div> </div>