Add zoom mode functionality and refactor video feed hooks

- Implemented zoom mode in RegionSelector for digital zooming.
- Updated VideoFeedGridPainter to handle zoom interactions.
- Refactored useGetVideoFeed to support target detection and video feed queries based on mode.
- Enhanced useCreateVideoSnapshot to manage snapshots during zoom mode.
This commit is contained in:
2025-12-09 12:39:03 +00:00
parent fa33b012cc
commit c6a336389b
4 changed files with 130 additions and 37 deletions

View File

@@ -174,6 +174,27 @@ const RegionSelector = ({
/> />
<span className="text-xl">Erase mode</span> <span className="text-xl">Erase mode</span>
</label> </label>
<label
htmlFor="zoomMode"
className={`p-4 border rounded-lg mb-2
${mode === "zoom" ? "border-gray-400 bg-[#202b36]" : "bg-[#253445] border-gray-700"}
hover:bg-[#202b36] hover:cursor-pointer`}
>
<input
id="zoomMode"
type="radio"
onChange={handleChange}
checked={mode === "zoom"}
value="zoom"
className="sr-only"
/>
<div className="flex flex-col space-y-3">
<span className="text-xl">Enlarge image</span>
{mode === "zoom" && (
<small className={`text-gray-400 italic`}>Use mouse to digitally zoom in and out</small>
)}
</div>
</label>
</div> </div>
</div> </div>

View File

@@ -23,6 +23,10 @@ const VideoFeedGridPainter = () => {
const { latestBitmapRef, isloading } = useCreateVideoSnapshot(); const { latestBitmapRef, isloading } = useCreateVideoSnapshot();
const [stageSize, setStageSize] = useState({ width: BACKEND_WIDTH, height: BACKEND_HEIGHT }); const [stageSize, setStageSize] = useState({ width: BACKEND_WIDTH, height: BACKEND_HEIGHT });
const isDrawingRef = useRef(false); const isDrawingRef = useRef(false);
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stageRef = useRef<any>(null);
const currentScale = stageSize.width / BACKEND_WIDTH; const currentScale = stageSize.width / BACKEND_WIDTH;
const size = BACKEND_CELL_SIZE * currentScale; const size = BACKEND_CELL_SIZE * currentScale;
@@ -71,14 +75,14 @@ const VideoFeedGridPainter = () => {
}; };
const handleStageMouseDown = (e: KonvaEventObject<MouseEvent>) => { const handleStageMouseDown = (e: KonvaEventObject<MouseEvent>) => {
if (!regions[selectedRegionIndex]) return; if (!regions[selectedRegionIndex] || mode === "zoom") return;
isDrawingRef.current = true; isDrawingRef.current = true;
const pos = e.target.getStage()?.getPointerPosition(); const pos = e.target.getStage()?.getPointerPosition();
if (pos) paintCell(pos.x, pos.y); if (pos) paintCell(pos.x, pos.y);
}; };
const handleStageMouseMove = (e: KonvaEventObject<MouseEvent>) => { const handleStageMouseMove = (e: KonvaEventObject<MouseEvent>) => {
if (!isDrawingRef.current) return; if (!isDrawingRef.current || mode === "zoom") return;
if (!regions[selectedRegionIndex]) return; if (!regions[selectedRegionIndex]) return;
const pos = e.target.getStage()?.getPointerPosition(); const pos = e.target.getStage()?.getPointerPosition();
if (pos) paintCell(pos.x, pos.y); if (pos) paintCell(pos.x, pos.y);
@@ -88,6 +92,38 @@ const VideoFeedGridPainter = () => {
isDrawingRef.current = false; isDrawingRef.current = false;
}; };
const handleMouseEnter = () => {
if (mode !== "zoom") return;
setScale(2);
};
const handleMouseLeave = () => {
document.body.style.cursor = "default";
setScale(1);
setPosition({ x: 0, y: 0 });
};
const handleMouseMove = (e: KonvaEventObject<MouseEvent>) => {
if (scale === 1) return;
const stage = e.target.getStage();
if (!stage) return;
const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) return;
const newX = stageSize.width / 2 - pointerPosition.x * scale;
const newY = stageSize.height / 2 - pointerPosition.y * scale;
const maxX = 0;
const minX = stageSize.width - stageSize.width * scale;
const maxY = 0;
const minY = stageSize.height - stageSize.height * scale;
setPosition({
x: Math.max(minX, Math.min(maxX, newX)),
y: Math.max(minY, Math.min(maxY, newY)),
});
};
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
const width = window.innerWidth; const width = window.innerWidth;
@@ -112,25 +148,33 @@ const VideoFeedGridPainter = () => {
if (image === null || isloading) return <span className="text-slate-500">Loading Video feed</span>; if (image === null || isloading) return <span className="text-slate-500">Loading Video feed</span>;
return ( return (
<div <div>
className={`w-full md:row-span-3 md:col-span-3 ${mode === "painter" ? "hover:cursor-crosshair" : ""} ${
mode === "eraser" ? "hover:cursor-pointer" : ""
}`}
>
<Stage <Stage
ref={stageRef}
width={stageSize.width} width={stageSize.width}
height={stageSize.height} height={stageSize.height}
onMouseDown={handleStageMouseDown} onMouseDown={handleStageMouseDown}
onMouseMove={handleStageMouseMove} onMouseMove={handleStageMouseMove}
onMouseUp={handleStageMouseUp} onMouseUp={handleStageMouseUp}
onMouseLeave={handleStageMouseUp} onMouseLeave={handleStageMouseUp}
className="max-w-[55%]" className={`max-w-[55%] md:row-span-3 md:col-span-3 ${mode === "painter" ? "hover:cursor-crosshair" : ""} ${
mode === "eraser" ? "hover:cursor-pointer" : ""
}`}
>
<Layer
scaleX={scale}
scaleY={scale}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
x={position.x}
y={position.y}
> >
<Layer>
<Image image={image} width={stageSize.width} height={stageSize.height} classname={"rounded-lg"} /> <Image image={image} width={stageSize.width} height={stageSize.height} classname={"rounded-lg"} />
</Layer> </Layer>
<Layer ref={paintLayerRef} opacity={0.6}> <Layer ref={paintLayerRef} opacity={0.6}>
{mode === "painter" || mode === "eraser" ? (
<Shape <Shape
sceneFunc={(ctx, shape) => { sceneFunc={(ctx, shape) => {
const cells = paintedCells; const cells = paintedCells;
@@ -154,6 +198,7 @@ const VideoFeedGridPainter = () => {
width={stageSize.width} width={stageSize.width}
height={stageSize.height} height={stageSize.height}
/> />
) : null}
</Layer> </Layer>
</Stage> </Stage>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { CAMBASE } from "../../../utils/config"; import { CAMBASE } from "../../../utils/config";
const getfeed = async (cameraFeedID: "A" | "B" | "C" | null) => { const targetDectionFeed = async (cameraFeedID: "A" | "B" | "C" | null) => {
const response = await fetch(`${CAMBASE}/TargetDetectionColour${cameraFeedID}-preview`, { const response = await fetch(`${CAMBASE}/TargetDetectionColour${cameraFeedID}-preview`, {
signal: AbortSignal.timeout(300000), signal: AbortSignal.timeout(300000),
cache: "no-store", cache: "no-store",
@@ -12,12 +12,31 @@ const getfeed = async (cameraFeedID: "A" | "B" | "C" | null) => {
return response.blob(); return response.blob();
}; };
export const useGetVideoFeed = (cameraFeedID: "A" | "B" | "C" | null) => { const getVideoFeed = async (cameraFeedID: "A" | "B" | "C" | null) => {
const videoQuery = useQuery({ const response = await fetch(`${CAMBASE}/Camera${cameraFeedID}-preview`, {
signal: AbortSignal.timeout(300000),
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Cannot reach endpoint (${response.status})`);
}
return response.blob();
};
export const useGetVideoFeed = (cameraFeedID: "A" | "B" | "C" | null, mode: string) => {
const targetDetectionQuery = useQuery({
queryKey: ["getfeed", cameraFeedID], queryKey: ["getfeed", cameraFeedID],
queryFn: () => getfeed(cameraFeedID), queryFn: () => targetDectionFeed(cameraFeedID),
refetchInterval: 500, refetchInterval: 500,
enabled: mode !== "zoom",
}); });
return { videoQuery }; const videoFeedQuery = useQuery({
queryKey: ["videoQuery", cameraFeedID, mode],
queryFn: () => getVideoFeed(cameraFeedID),
refetchInterval: 500,
enabled: mode === "zoom",
});
return { targetDetectionQuery, videoFeedQuery };
}; };

View File

@@ -5,11 +5,19 @@ import { useCameraFeedContext } from "../../../app/context/CameraFeedContext";
export const useCreateVideoSnapshot = () => { export const useCreateVideoSnapshot = () => {
const { state } = useCameraFeedContext(); const { state } = useCameraFeedContext();
const cameraFeedID = state?.cameraFeedID; const cameraFeedID = state?.cameraFeedID;
const mode = state.modeByCamera[cameraFeedID];
const latestBitmapRef = useRef<ImageBitmap | null>(null); const latestBitmapRef = useRef<ImageBitmap | null>(null);
const { videoQuery } = useGetVideoFeed(cameraFeedID); const { targetDetectionQuery, videoFeedQuery } = useGetVideoFeed(cameraFeedID, mode);
const snapShot = videoQuery?.data; let snapShot = targetDetectionQuery?.data;
const isloading = videoQuery.isPending; const isloading = targetDetectionQuery.isPending;
const videoSnapShot = videoFeedQuery?.data;
const isVideoLoading = videoFeedQuery.isPending;
if (isVideoLoading === false && videoSnapShot && mode === "zoom") {
snapShot = videoSnapShot;
}
useEffect(() => { useEffect(() => {
async function createBitmap() { async function createBitmap() {