You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1279 lines
43 KiB
1279 lines
43 KiB
import React, { useRef, useEffect, useState, useCallback } from "react";
|
|
import { useWebSocket } from "../actions/websocket.jsx";
|
|
import { message } from "antd";
|
|
// import { useTargetStorage } from "../hooks/useTargetStorage.js";
|
|
|
|
const CameraView = ({
|
|
selectedTargetId,
|
|
onClearSelection,
|
|
onRectangleClick,
|
|
selectedTemplate,
|
|
targets = [],
|
|
targetsLoading = false,
|
|
onRefreshTargets,
|
|
}) => {
|
|
const imgRef = useRef(null);
|
|
const canvasRef = useRef(null);
|
|
const videoInnerRef = useRef(null);
|
|
const containerRef = useRef(null);
|
|
|
|
// 缩放和平移相关状态
|
|
const [scale, setScale] = useState(1.0);
|
|
const [translateX, setTranslateX] = useState(0);
|
|
const [translateY, setTranslateY] = useState(0);
|
|
const [videoNaturalWidth, setVideoNaturalWidth] = useState(0);
|
|
const [videoNaturalHeight, setVideoNaturalHeight] = useState(0);
|
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
|
|
|
// 交互状态
|
|
const [drawing, setDrawing] = useState(false);
|
|
const [dragging, setDragging] = useState(false);
|
|
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
|
|
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });
|
|
const [mouseDownTime, setMouseDownTime] = useState(0); // 记录鼠标按下的时间
|
|
const [mouseDownPos, setMouseDownPos] = useState({ x: 0, y: 0 }); // 记录鼠标按下的位置
|
|
|
|
// 矩形列表管理 - 最多5个矩形
|
|
const [rectangles, setRectangles] = useState([]);
|
|
const [selectedRectIndex, setSelectedRectIndex] = useState(-1);
|
|
const [isDraggingRect, setIsDraggingRect] = useState(false);
|
|
const [dragStartPos, setDragStartPos] = useState({ x: 0, y: 0 });
|
|
const [dragRectOffset, setDragRectOffset] = useState({ x: 0, y: 0 }); // 鼠标在矩形内的偏移
|
|
const [currentDrawingRect, setCurrentDrawingRect] = useState(null);
|
|
const [hoveredRectIndex, setHoveredRectIndex] = useState(-1);
|
|
const [isSaving, setIsSaving] = useState(false); // 保存状态管理
|
|
const [streamError, setStreamError] = useState(false); // 视频流断开状态
|
|
|
|
// 使用WebSocket连接
|
|
const { isConnected, sendMessage } = useWebSocket();
|
|
|
|
// 从 props 接收标靶数据,而不是调用 Hook
|
|
// const { targets, loading, refreshTargets } = useTargetStorage();
|
|
|
|
const minScale = 0.2;
|
|
const maxScale = 4.0;
|
|
const scaleStep = 0.1;
|
|
const maxRectangles = 10;
|
|
let streamUrl = `http://${window.location.hostname}:2240/video_flow`;
|
|
// 摄像头流地址
|
|
if (window.env && window.env.FS_FLAG === "localdev") {
|
|
streamUrl = `http://10.8.30.179:2240/video_flow`; //开发用
|
|
}
|
|
|
|
// 应用变换
|
|
const applyTransform = () => {
|
|
if (videoInnerRef.current) {
|
|
videoInnerRef.current.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
|
|
videoInnerRef.current.style.transformOrigin = "0 0";
|
|
}
|
|
|
|
// 重新绘制所有矩形
|
|
redrawAllRectangles();
|
|
};
|
|
|
|
// 调整画布大小
|
|
const resizeCanvas = () => {
|
|
if (canvasRef.current && containerRef.current) {
|
|
const container = containerRef.current;
|
|
const rect = container.getBoundingClientRect();
|
|
const canvas = canvasRef.current;
|
|
|
|
canvas.width = rect.width;
|
|
canvas.height = rect.height;
|
|
canvas.style.width = rect.width + "px";
|
|
canvas.style.height = rect.height + "px";
|
|
|
|
// console.log("resizeCanvas: 画布大小已调整", { width: rect.width, height: rect.height });
|
|
|
|
// 延迟调用applyTransform,确保画布已经完全设置好
|
|
setTimeout(() => {
|
|
applyTransform();
|
|
}, 10);
|
|
}
|
|
};
|
|
|
|
// 图片加载完成
|
|
const handleImageLoad = () => {
|
|
setStreamError(false); // 恢复正常
|
|
if (imgRef.current) {
|
|
const img = imgRef.current;
|
|
if (img.naturalWidth && img.naturalHeight) {
|
|
setVideoNaturalWidth(img.naturalWidth);
|
|
setVideoNaturalHeight(img.naturalHeight);
|
|
// console.log(`handleImageLoad: 视频原始分辨率: ${img.naturalWidth}x${img.naturalHeight}`);
|
|
}
|
|
resizeCanvas();
|
|
// 视频加载完成后,确保重新绘制矩形框
|
|
setTimeout(() => {
|
|
// console.log("handleImageLoad: 延迟重绘矩形框");
|
|
redrawAllRectangles();
|
|
}, 200);
|
|
}
|
|
};
|
|
|
|
// 图片加载失败
|
|
const handleImageError = () => {
|
|
setStreamError(true);
|
|
// 自动重连,3秒后尝试刷新图片src
|
|
setTimeout(() => {
|
|
setStreamError(false);
|
|
if (imgRef.current) {
|
|
imgRef.current.src = streamUrl + "?t=" + Date.now();
|
|
}
|
|
}, 3000);
|
|
};
|
|
|
|
// 鼠标按下
|
|
const handleMouseDown = (e) => {
|
|
const container = containerRef.current;
|
|
const rect = container.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
// 记录鼠标按下的时间和位置
|
|
setMouseDownTime(Date.now());
|
|
setMouseDownPos({ x: mouseX, y: mouseY });
|
|
|
|
if (e.altKey && e.button === 0) {
|
|
// Alt + 左键拖拽进行平移
|
|
setDragging(true);
|
|
setLastMousePos({ x: mouseX, y: mouseY });
|
|
e.preventDefault();
|
|
} else if (!e.altKey && e.button === 0) {
|
|
// 检查是否点击在已存在的矩形上
|
|
const rectIndex = findRectangleAtPoint(mouseX, mouseY);
|
|
|
|
if (rectIndex !== -1) {
|
|
// 点击在已存在的矩形上,开始拖拽
|
|
setSelectedRectIndex(rectIndex);
|
|
setIsDraggingRect(true);
|
|
setDragStartPos({ x: mouseX, y: mouseY });
|
|
|
|
// 计算鼠标在矩形内的相对位置
|
|
const rect = rectangles[rectIndex];
|
|
const rectScreenPos = videoToScreenCoordinates(
|
|
rect.x,
|
|
rect.y,
|
|
rect.width,
|
|
rect.height
|
|
);
|
|
if (rectScreenPos) {
|
|
setDragRectOffset({
|
|
x: mouseX - rectScreenPos.x,
|
|
y: mouseY - rectScreenPos.y,
|
|
});
|
|
}
|
|
|
|
redrawAllRectangles();
|
|
} else {
|
|
// 点击在空白区域,开始绘制新矩形(如果没有达到上限)
|
|
if (rectangles.length < maxRectangles) {
|
|
setSelectedRectIndex(-1);
|
|
setStartPos({ x: mouseX, y: mouseY });
|
|
setDrawing(true);
|
|
setCurrentDrawingRect(null);
|
|
} else {
|
|
message.warning(`已达到标靶数量上限(${maxRectangles}个)!`);
|
|
}
|
|
}
|
|
e.preventDefault();
|
|
}
|
|
}; // 鼠标移动
|
|
const handleMouseMove = (e) => {
|
|
const container = containerRef.current;
|
|
const rect = container.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
// 更新鼠标位置显示
|
|
if (!drawing && !dragging && !isDraggingRect) {
|
|
updateMousePosition(mouseX, mouseY);
|
|
// 检测鼠标是否悬停在矩形上
|
|
const rectIndex = findRectangleAtPoint(mouseX, mouseY);
|
|
setHoveredRectIndex(rectIndex);
|
|
}
|
|
|
|
if (dragging) {
|
|
// 拖拽平移
|
|
const deltaX = mouseX - lastMousePos.x;
|
|
const deltaY = mouseY - lastMousePos.y;
|
|
setTranslateX((prev) => prev + deltaX);
|
|
setTranslateY((prev) => prev + deltaY);
|
|
setLastMousePos({ x: mouseX, y: mouseY });
|
|
} else if (drawing) {
|
|
// 绘制新矩形预览
|
|
drawNewRectanglePreview(startPos.x, startPos.y, mouseX, mouseY);
|
|
} else if (isDraggingRect && selectedRectIndex !== -1) {
|
|
// 拖拽已存在的矩形
|
|
dragExistingRectangle(mouseX, mouseY);
|
|
}
|
|
};
|
|
|
|
// 鼠标松开
|
|
const handleMouseUp = (e) => {
|
|
const currentTime = Date.now();
|
|
const timeDiff = currentTime - mouseDownTime;
|
|
const container = containerRef.current;
|
|
const rect = container.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
// 计算鼠标移动距离
|
|
const mouseMoveDistance = Math.sqrt(
|
|
Math.pow(mouseX - mouseDownPos.x, 2) +
|
|
Math.pow(mouseY - mouseDownPos.y, 2)
|
|
);
|
|
|
|
// 检查是否是单击操作:时间短且移动距离小
|
|
const isClick = timeDiff < 300 && mouseMoveDistance < 5;
|
|
|
|
if (dragging) {
|
|
setDragging(false);
|
|
} else if (drawing) {
|
|
setDrawing(false);
|
|
|
|
// 完成新矩形的绘制
|
|
finishDrawingRectangle(startPos.x, startPos.y, mouseX, mouseY);
|
|
setCurrentDrawingRect(null);
|
|
} else if (isDraggingRect) {
|
|
if (isClick && onRectangleClick) {
|
|
// 找到被点击的矩形
|
|
const rectIndex = findRectangleAtPoint(mouseX, mouseY);
|
|
if (rectIndex !== -1) {
|
|
const clickedRectangle = rectangles[rectIndex];
|
|
// 通过矩形ID找到对应的标靶数据
|
|
const targetData = targets.find(
|
|
(target) => target.id === clickedRectangle.id
|
|
);
|
|
if (targetData) {
|
|
// console.log("点击矩形框,弹出标靶详情:", targetData);
|
|
onRectangleClick(targetData);
|
|
}
|
|
}
|
|
}
|
|
|
|
setIsDraggingRect(false);
|
|
setSelectedRectIndex(-1); // 取消选中状态
|
|
|
|
// 只有在非单击情况下才清除选中高亮状态
|
|
if (onClearSelection && !isClick) {
|
|
onClearSelection();
|
|
}
|
|
}
|
|
};
|
|
|
|
// 绘制新矩形预览
|
|
const drawNewRectanglePreview = (startX, startY, endX, endY) => {
|
|
const x = Math.min(startX, endX);
|
|
const y = Math.min(startY, endY);
|
|
const w = Math.abs(endX - startX);
|
|
const h = Math.abs(endY - startY);
|
|
|
|
if (w < 5 || h < 5) return; // 太小的矩形不显示
|
|
|
|
// 转换为视频坐标系的矩形
|
|
const videoRect = screenToVideoCoordinates(x, y, w, h);
|
|
if (videoRect) {
|
|
setCurrentDrawingRect(videoRect);
|
|
redrawAllRectangles();
|
|
}
|
|
};
|
|
|
|
// 完成新矩形的绘制
|
|
const finishDrawingRectangle = (startX, startY, endX, endY) => {
|
|
const x = Math.min(startX, endX);
|
|
const y = Math.min(startY, endY);
|
|
const w = Math.abs(endX - startX);
|
|
const h = Math.abs(endY - startY);
|
|
|
|
if (w < 10 || h < 10) return; // 太小的矩形不保存
|
|
|
|
const videoRect = screenToVideoCoordinates(x, y, w, h);
|
|
if (videoRect && rectangles.length < maxRectangles) {
|
|
const nextIndex = Number(rectangles[rectangles.length - 1]?.id)
|
|
? Number(rectangles[rectangles.length - 1]?.id) - 100 + 1
|
|
: 1;
|
|
const fixedId = `10${nextIndex}`;
|
|
// 根据选中的模板预设参数
|
|
const templateParams = selectedTemplate
|
|
? {
|
|
// 从模板中获取参数
|
|
name: `T_${nextIndex}` || selectedTemplate.name,
|
|
radius: selectedTemplate.physicalRadius || 40.0,
|
|
isReferencePoint: selectedTemplate.isBaseline || false,
|
|
gradientThreshold: selectedTemplate.gradientThresholdValue,
|
|
anchorThreshold: selectedTemplate.anchorThresholdValue,
|
|
gaussianBlurThreshold: selectedTemplate.gaussianBlur || 3,
|
|
binaryThreshold: selectedTemplate.binaryThreshold || 120,
|
|
hasAdvancedConfig: false,
|
|
}
|
|
: {
|
|
// 默认参数
|
|
name: `T_${nextIndex}`,
|
|
radius: 40.0,
|
|
isReferencePoint: false,
|
|
gradientThreshold: 100,
|
|
anchorThreshold: 80,
|
|
gaussianBlurThreshold: 3,
|
|
binaryThreshold: 120,
|
|
hasAdvancedConfig: false,
|
|
};
|
|
|
|
const newRect = {
|
|
id: fixedId,
|
|
key: fixedId,
|
|
...videoRect,
|
|
...templateParams,
|
|
// 保存矩形区域信息用于服务端
|
|
rectangleArea: {
|
|
x: videoRect.x,
|
|
y: videoRect.y,
|
|
w: videoRect.width,
|
|
h: videoRect.height,
|
|
width: videoRect.width,
|
|
height: videoRect.height,
|
|
},
|
|
};
|
|
|
|
setRectangles((prev) => [...prev, newRect]);
|
|
|
|
// console.log("新建矩形:", newRect);
|
|
if (selectedTemplate) {
|
|
// console.log("使用模板参数:", selectedTemplate);
|
|
} else {
|
|
// console.log("使用默认参数");
|
|
}
|
|
// logRectangleData();
|
|
}
|
|
}; // 拖拽已存在的矩形
|
|
const dragExistingRectangle = (mouseX, mouseY) => {
|
|
if (selectedRectIndex === -1) return;
|
|
|
|
// 计算矩形应该在的屏幕位置(考虑鼠标在矩形内的偏移)
|
|
const targetScreenX = mouseX - dragRectOffset.x;
|
|
const targetScreenY = mouseY - dragRectOffset.y;
|
|
|
|
// 转换为视频坐标
|
|
const rect = rectangles[selectedRectIndex];
|
|
const videoCoords = screenToVideoCoordinates(
|
|
targetScreenX,
|
|
targetScreenY,
|
|
0,
|
|
0
|
|
);
|
|
|
|
if (videoCoords) {
|
|
setRectangles((prev) => {
|
|
const newRects = [...prev];
|
|
|
|
// 计算新位置,确保不超出视频边界
|
|
const newX = Math.max(
|
|
0,
|
|
Math.min(videoNaturalWidth - rect.width, videoCoords.x)
|
|
);
|
|
const newY = Math.max(
|
|
0,
|
|
Math.min(videoNaturalHeight - rect.height, videoCoords.y)
|
|
);
|
|
|
|
newRects[selectedRectIndex] = {
|
|
...rect,
|
|
x: newX,
|
|
y: newY,
|
|
};
|
|
|
|
return newRects;
|
|
});
|
|
}
|
|
};
|
|
|
|
// 屏幕坐标转换为视频坐标
|
|
const screenToVideoCoordinates = (screenX, screenY, screenW, screenH) => {
|
|
// 转换为视频内容坐标系(考虑当前的变换)
|
|
const videoX = (screenX - translateX) / scale;
|
|
const videoY = (screenY - translateY) / scale;
|
|
const videoW = screenW / scale;
|
|
const videoH = screenH / scale;
|
|
|
|
// 计算视频显示区域
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return null;
|
|
|
|
const displayW = canvas.width;
|
|
const displayH = canvas.height;
|
|
const videoAR = videoNaturalWidth / videoNaturalHeight;
|
|
const displayAR = displayW / displayH;
|
|
|
|
let drawW, drawH, offsetX, offsetY;
|
|
if (videoAR > displayAR) {
|
|
drawW = displayW;
|
|
drawH = displayW / videoAR;
|
|
offsetX = 0;
|
|
offsetY = (displayH - drawH) / 2;
|
|
} else {
|
|
drawH = displayH;
|
|
drawW = displayH * videoAR;
|
|
offsetX = (displayW - drawW) / 2;
|
|
offsetY = 0;
|
|
}
|
|
|
|
// 转换为基于原始视频分辨率的绝对坐标
|
|
const videoScaleX = videoNaturalWidth / drawW;
|
|
const videoScaleY = videoNaturalHeight / drawH;
|
|
|
|
const absX = Math.round((videoX - offsetX) * videoScaleX);
|
|
const absY = Math.round((videoY - offsetY) * videoScaleY);
|
|
const absW = Math.round(videoW * videoScaleX);
|
|
const absH = Math.round(videoH * videoScaleY);
|
|
|
|
// 限制在视频范围内
|
|
const finalRect = {
|
|
x: Math.max(0, Math.min(videoNaturalWidth - 1, absX)),
|
|
y: Math.max(0, Math.min(videoNaturalHeight - 1, absY)),
|
|
width: Math.max(1, Math.min(videoNaturalWidth - absX, absW)),
|
|
height: Math.max(1, Math.min(videoNaturalHeight - absY, absH)),
|
|
};
|
|
|
|
return finalRect;
|
|
};
|
|
|
|
// 视频坐标转换为屏幕坐标
|
|
const videoToScreenCoordinates = (videoX, videoY, videoW, videoH) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return null;
|
|
|
|
const displayW = canvas.width;
|
|
const displayH = canvas.height;
|
|
const videoAR = videoNaturalWidth / videoNaturalHeight;
|
|
const displayAR = displayW / displayH;
|
|
|
|
let drawW, drawH, offsetX, offsetY;
|
|
if (videoAR > displayAR) {
|
|
drawW = displayW;
|
|
drawH = displayW / videoAR;
|
|
offsetX = 0;
|
|
offsetY = (displayH - drawH) / 2;
|
|
} else {
|
|
drawH = displayH;
|
|
drawW = displayH * videoAR;
|
|
offsetX = (displayW - drawW) / 2;
|
|
offsetY = 0;
|
|
}
|
|
|
|
// 将视频坐标转换为显示坐标
|
|
const displayScaleX = drawW / videoNaturalWidth;
|
|
const displayScaleY = drawH / videoNaturalHeight;
|
|
|
|
const displayX = offsetX + videoX * displayScaleX;
|
|
const displayY = offsetY + videoY * displayScaleY;
|
|
const displayWidth = videoW * displayScaleX;
|
|
const displayHeight = videoH * displayScaleY;
|
|
|
|
// 应用当前的缩放和平移变换
|
|
const screenX = displayX * scale + translateX;
|
|
const screenY = displayY * scale + translateY;
|
|
const screenW = displayWidth * scale;
|
|
const screenH = displayHeight * scale;
|
|
|
|
return {
|
|
x: screenX,
|
|
y: screenY,
|
|
width: screenW,
|
|
height: screenH,
|
|
};
|
|
};
|
|
|
|
// 输出所有矩形数据到控制台
|
|
const logRectangleData = () => {
|
|
console.log(
|
|
"当前矩形列表:",
|
|
rectangles.map((rect, index) => ({
|
|
index,
|
|
id: rect.id,
|
|
leftTop: { x: rect.x, y: rect.y },
|
|
rightBottom: { x: rect.x + rect.width, y: rect.y + rect.height },
|
|
width: rect.width,
|
|
height: rect.height,
|
|
}))
|
|
);
|
|
};
|
|
|
|
// 检查点击是否在矩形内
|
|
const findRectangleAtPoint = (mouseX, mouseY) => {
|
|
if (!videoNaturalWidth || !videoNaturalHeight) return -1;
|
|
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return -1;
|
|
|
|
// 计算视频显示区域
|
|
const displayW = canvas.width;
|
|
const displayH = canvas.height;
|
|
const videoAR = videoNaturalWidth / videoNaturalHeight;
|
|
const displayAR = displayW / displayH;
|
|
|
|
let drawW, drawH, offsetX, offsetY;
|
|
if (videoAR > displayAR) {
|
|
drawW = displayW;
|
|
drawH = displayW / videoAR;
|
|
offsetX = 0;
|
|
offsetY = (displayH - drawH) / 2;
|
|
} else {
|
|
drawH = displayH;
|
|
drawW = displayH * videoAR;
|
|
offsetX = (displayW - drawW) / 2;
|
|
offsetY = 0;
|
|
}
|
|
|
|
// 从后往前检查(优先选择最上层的矩形)
|
|
for (let i = rectangles.length - 1; i >= 0; i--) {
|
|
const rect = rectangles[i];
|
|
|
|
// 将绝对坐标转换为显示坐标
|
|
const displayScaleX = drawW / videoNaturalWidth;
|
|
const displayScaleY = drawH / videoNaturalHeight;
|
|
|
|
const displayX = offsetX + rect.x * displayScaleX;
|
|
const displayY = offsetY + rect.y * displayScaleY;
|
|
const displayW_rect = rect.width * displayScaleX;
|
|
const displayH_rect = rect.height * displayScaleY;
|
|
|
|
// 转换鼠标坐标到视频显示坐标系
|
|
const videoMouseX = (mouseX - translateX) / scale;
|
|
const videoMouseY = (mouseY - translateY) / scale;
|
|
|
|
// 检查是否在矩形内
|
|
if (
|
|
videoMouseX >= displayX &&
|
|
videoMouseX <= displayX + displayW_rect &&
|
|
videoMouseY >= displayY &&
|
|
videoMouseY <= displayY + displayH_rect
|
|
) {
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
};
|
|
|
|
// 重新绘制所有矩形
|
|
const redrawAllRectangles = () => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {
|
|
console.log("redrawAllRectangles: canvas不存在");
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// 如果视频没有加载完成,直接返回
|
|
if (
|
|
!videoNaturalWidth ||
|
|
!videoNaturalHeight ||
|
|
videoNaturalWidth < 100 ||
|
|
videoNaturalHeight < 100
|
|
) {
|
|
// 清空画布,防止残影
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
return;
|
|
}
|
|
|
|
// 如果没有矩形框数据且没有正在绘制的预览矩形,也记录一下
|
|
if ((!rectangles || rectangles.length === 0) && !currentDrawingRect) {
|
|
// console.log("redrawAllRectangles: 没有矩形框数据且没有预览矩形");
|
|
return;
|
|
}
|
|
|
|
// 计算视频显示区域
|
|
const displayW = canvas.width;
|
|
const displayH = canvas.height;
|
|
const videoAR = videoNaturalWidth / videoNaturalHeight;
|
|
const displayAR = displayW / displayH;
|
|
|
|
let drawW, drawH, offsetX, offsetY;
|
|
if (videoAR > displayAR) {
|
|
drawW = displayW;
|
|
drawH = displayW / videoAR;
|
|
offsetX = 0;
|
|
offsetY = (displayH - drawH) / 2;
|
|
} else {
|
|
drawH = displayH;
|
|
drawW = displayH * videoAR;
|
|
offsetX = (displayW - drawW) / 2;
|
|
offsetY = 0;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.translate(translateX, translateY);
|
|
ctx.scale(scale, scale);
|
|
|
|
// 绘制所有已保存的矩形
|
|
if (rectangles && rectangles.length > 0) {
|
|
rectangles.forEach((rect, index) => {
|
|
// 将绝对坐标转换为显示坐标(在变换空间中)
|
|
const displayScaleX = drawW / videoNaturalWidth;
|
|
const displayScaleY = drawH / videoNaturalHeight;
|
|
|
|
const displayX = offsetX + rect.x * displayScaleX;
|
|
const displayY = offsetY + rect.y * displayScaleY;
|
|
const displayW_rect = rect.width * displayScaleX;
|
|
const displayH_rect = rect.height * displayScaleY;
|
|
|
|
// 检查是否为选中的标靶
|
|
const isSelected =
|
|
selectedTargetId &&
|
|
rect.id &&
|
|
rect.id.toString() === selectedTargetId.toString();
|
|
|
|
// 设置矩形样式 - 根据不同状态设置不同颜色和样式
|
|
if (index === selectedRectIndex && isDraggingRect) {
|
|
ctx.strokeStyle = "lime"; // 拖拽时显示为绿色
|
|
ctx.lineWidth = 2 / scale;
|
|
} else if (isSelected) {
|
|
ctx.strokeStyle = "lime"; // 选中状态显示为lime绿色
|
|
ctx.lineWidth = 2 / scale; // 选中时线条更粗
|
|
ctx.setLineDash([]); // 实线
|
|
} else {
|
|
ctx.strokeStyle = "blue"; // 正常状态为蓝色
|
|
ctx.lineWidth = 1 / scale;
|
|
ctx.setLineDash([]); // 实线
|
|
}
|
|
|
|
ctx.strokeRect(displayX, displayY, displayW_rect, displayH_rect);
|
|
|
|
// 显示矩形序号
|
|
ctx.fillStyle =
|
|
index === selectedRectIndex && isDraggingRect
|
|
? "lime"
|
|
: isSelected
|
|
? "lime"
|
|
: "blue";
|
|
ctx.font = `${12 / scale}px Arial`;
|
|
// ctx.fillText(`#${index + 1}`, displayX + 5 / scale, displayY + 15 / scale);
|
|
});
|
|
}
|
|
|
|
// 绘制当前正在绘制的矩形预览
|
|
if (currentDrawingRect) {
|
|
const displayScaleX = drawW / videoNaturalWidth;
|
|
const displayScaleY = drawH / videoNaturalHeight;
|
|
|
|
const displayX = offsetX + currentDrawingRect.x * displayScaleX;
|
|
const displayY = offsetY + currentDrawingRect.y * displayScaleY;
|
|
const displayW_rect = currentDrawingRect.width * displayScaleX;
|
|
const displayH_rect = currentDrawingRect.height * displayScaleY;
|
|
|
|
ctx.strokeStyle = "cyan";
|
|
ctx.lineWidth = 2 / scale;
|
|
ctx.setLineDash([6, 4]); // 预览时使用虚线
|
|
ctx.strokeRect(displayX, displayY, displayW_rect, displayH_rect);
|
|
}
|
|
|
|
ctx.restore();
|
|
}; // 更新鼠标位置显示
|
|
const updateMousePosition = (mouseX, mouseY) => {
|
|
// 计算鼠标在原始视频坐标系中的位置
|
|
const videoX = (mouseX - translateX) / scale;
|
|
const videoY = (mouseY - translateY) / scale;
|
|
|
|
// 转换为绝对像素坐标
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const displayW = canvas.width;
|
|
const displayH = canvas.height;
|
|
const videoAR = videoNaturalWidth / videoNaturalHeight;
|
|
const displayAR = displayW / displayH;
|
|
|
|
let drawW, drawH, offsetX, offsetY;
|
|
if (videoAR > displayAR) {
|
|
drawW = displayW;
|
|
drawH = displayW / videoAR;
|
|
offsetX = 0;
|
|
offsetY = (displayH - drawH) / 2;
|
|
} else {
|
|
drawH = displayH;
|
|
drawW = displayH * videoAR;
|
|
offsetX = (displayW - drawW) / 2;
|
|
offsetY = 0;
|
|
}
|
|
|
|
const videoScaleX = videoNaturalWidth / drawW;
|
|
const videoScaleY = videoNaturalHeight / drawH;
|
|
const absMouseX = Math.round((videoX - offsetX) * videoScaleX);
|
|
const absMouseY = Math.round((videoY - offsetY) * videoScaleY);
|
|
|
|
// 检查是否在视频区域内
|
|
if (
|
|
absMouseX >= 0 &&
|
|
absMouseX < videoNaturalWidth &&
|
|
absMouseY >= 0 &&
|
|
absMouseY < videoNaturalHeight
|
|
) {
|
|
setMousePos({ x: absMouseX, y: absMouseY });
|
|
}
|
|
};
|
|
|
|
// 重置视图
|
|
const handleReset = () => {
|
|
setScale(1.0);
|
|
setTranslateX(0);
|
|
setTranslateY(0);
|
|
// 不清除选择框,保留 selectionRect 和 rectInfo
|
|
};
|
|
|
|
// 保存矩形信息
|
|
const handleSave = useCallback(async () => {
|
|
// 防止重复保存
|
|
if (isSaving) {
|
|
// console.log("正在保存中,忽略重复请求");
|
|
return;
|
|
}
|
|
|
|
// console.log("=== 保存矩形信息 ===");
|
|
// console.log("矩形总数:", rectangles.length);
|
|
|
|
if (rectangles.length === 0) {
|
|
// console.log("没有创建任何矩形");
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
// 组装指定格式的数据
|
|
const targets = {};
|
|
// console.log(rectangles, "当前矩形数据");
|
|
|
|
rectangles.forEach((rect) => {
|
|
// 使用标靶的原始ID,而不是数组索引
|
|
const targetKey = rect.id
|
|
? rect.id.toString()
|
|
: Date.now().toString();
|
|
|
|
targets[targetKey] = {
|
|
info: {
|
|
rectangle_area: {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
w: rect.width,
|
|
h: rect.height,
|
|
},
|
|
threshold: {
|
|
binary: rect.binaryThreshold || 120,
|
|
gauss: rect.gaussianBlurThreshold || 1,
|
|
gradient: rect.gradientThreshold,
|
|
anchor: rect.anchorThreshold,
|
|
},
|
|
radius: rect.radius || 40.0,
|
|
id: rect.id || targetKey, // 使用原始ID
|
|
desc: rect.name || `target_${targetKey}`,
|
|
base: rect.isReferencePoint || false,
|
|
},
|
|
perspective: [
|
|
[1.0, 0, 0],
|
|
[0, 1.0, 0],
|
|
[0, 0, 1.0],
|
|
],
|
|
};
|
|
});
|
|
|
|
const outputData = {
|
|
_from: "setup",
|
|
cmd: "setPoints",
|
|
values: {
|
|
targets: targets,
|
|
},
|
|
};
|
|
|
|
// console.log("格式化数据:");
|
|
|
|
// 手动序列化以保持浮点数格式
|
|
const jsonString = JSON.stringify(outputData, null, 2).replace(
|
|
/"perspective":\s*\[\s*\[\s*1,\s*0,\s*0\s*\],\s*\[\s*0,\s*1,\s*0\s*\],\s*\[\s*0,\s*0,\s*1\s*\]\s*\]/g,
|
|
'"perspective": [\n [1.0, 0, 0],\n [0, 1.0, 0],\n [0, 0, 1.0]\n ]'
|
|
);
|
|
|
|
if (isConnected) {
|
|
const success = sendMessage(jsonString);
|
|
|
|
if (success) {
|
|
// console.log("数据已发送到服务器");
|
|
|
|
// 保存成功后刷新标靶列表数据
|
|
setTimeout(() => {
|
|
if (onRefreshTargets) {
|
|
onRefreshTargets();
|
|
}
|
|
}, 500); // 延迟500ms确保服务器处理完成
|
|
|
|
// 清除选中状态
|
|
if (onClearSelection) {
|
|
onClearSelection();
|
|
}
|
|
} else {
|
|
console.error("发送数据失败");
|
|
}
|
|
} else {
|
|
console.error("WebSocket未连接,无法发送数据");
|
|
}
|
|
} catch (error) {
|
|
console.error("保存过程中出现错误:", error);
|
|
} finally {
|
|
// 延迟重置保存状态,确保不会立即被重复触发
|
|
setTimeout(() => {
|
|
setIsSaving(false);
|
|
}, 1000);
|
|
}
|
|
}, [
|
|
rectangles,
|
|
isConnected,
|
|
sendMessage,
|
|
onRefreshTargets,
|
|
onClearSelection,
|
|
isSaving,
|
|
]);
|
|
|
|
// 全局滚轮事件处理,防止alt+滚轮触发页面滚动
|
|
useEffect(() => {
|
|
const handleGlobalWheel = (e) => {
|
|
if (e.altKey) {
|
|
const container = containerRef.current;
|
|
// 检查事件目标是否在摄像头容器内
|
|
if (container && container.contains(e.target)) {
|
|
// 如果在容器内,不拦截,让容器处理器处理
|
|
return;
|
|
}
|
|
|
|
// 只有在容器外才拦截
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// 在document上添加监听器
|
|
document.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
|
|
return () => {
|
|
document.removeEventListener("wheel", handleGlobalWheel);
|
|
};
|
|
}, []);
|
|
|
|
// 应用变换效果
|
|
useEffect(() => {
|
|
applyTransform();
|
|
}, [scale, translateX, translateY]);
|
|
|
|
// 监听视频尺寸变化,确保重新绘制
|
|
useEffect(() => {
|
|
if (videoNaturalWidth && videoNaturalHeight) {
|
|
// console.log("视频尺寸已更新,重新绘制矩形框");
|
|
redrawAllRectangles();
|
|
}
|
|
}, [videoNaturalWidth, videoNaturalHeight]);
|
|
|
|
// 监听矩形列表变化,自动重绘
|
|
useEffect(() => {
|
|
// console.log("矩形列表发生变化,重新绘制:", rectangles);
|
|
// 使用requestAnimationFrame来优化重绘性能
|
|
const rafId = requestAnimationFrame(() => {
|
|
redrawAllRectangles();
|
|
});
|
|
|
|
return () => cancelAnimationFrame(rafId);
|
|
}, [rectangles]);
|
|
|
|
// 从标靶数据初始化矩形框
|
|
useEffect(() => {
|
|
// console.log("targets 或 loading 状态变化:", {
|
|
// targets,
|
|
// targetsLoading,
|
|
// targetsLength: targets?.length,
|
|
// });
|
|
|
|
if (
|
|
!targetsLoading &&
|
|
targets &&
|
|
targets.length > 0 &&
|
|
videoNaturalWidth > 0 &&
|
|
videoNaturalHeight > 0
|
|
) {
|
|
// console.log("从标靶数据初始化矩形框:", targets);
|
|
|
|
const initialRectangles = targets
|
|
.map((target) => {
|
|
const rectangleArea = target.rectangleArea;
|
|
// console.log("处理标靶:", target.id, "矩形区域:", rectangleArea);
|
|
if (
|
|
rectangleArea &&
|
|
rectangleArea.x !== undefined &&
|
|
rectangleArea.y !== undefined
|
|
) {
|
|
return {
|
|
id: target.id,
|
|
key: target.key,
|
|
x: rectangleArea.x,
|
|
y: rectangleArea.y,
|
|
width: rectangleArea.w || rectangleArea.width || 100,
|
|
height: rectangleArea.h || rectangleArea.height || 100,
|
|
// 保留所有标靶属性,确保拖拽后不会丢失
|
|
name: target.name,
|
|
radius: target.radius,
|
|
isReferencePoint: target.isReferencePoint,
|
|
gradientThreshold: target.gradientThreshold,
|
|
anchorThreshold: target.anchorThreshold,
|
|
gaussianBlurThreshold: target.gaussianBlurThreshold,
|
|
binaryThreshold: target.binaryThreshold,
|
|
hasAdvancedConfig: target.hasAdvancedConfig,
|
|
};
|
|
}
|
|
return null;
|
|
})
|
|
.filter((rect) => rect !== null); // 过滤掉空值
|
|
|
|
// console.log("生成的矩形框数据:", initialRectangles);
|
|
// console.log("当前矩形框数据:", rectangles);
|
|
// console.log(
|
|
// "标靶数据详情:",
|
|
// targets.map((t) => ({ id: t.id, rectangleArea: t.rectangleArea }))
|
|
// );
|
|
|
|
// 总是根据targets数据更新rectangles,确保数据同步
|
|
// console.log("强制更新矩形框数据以确保与targets同步");
|
|
setRectangles(initialRectangles);
|
|
// console.log("已从标靶数据强制更新矩形框:", initialRectangles);
|
|
|
|
// 矩形框更新后,延迟一下确保重绘
|
|
const timeoutId = setTimeout(() => {
|
|
redrawAllRectangles();
|
|
}, 100);
|
|
return () => clearTimeout(timeoutId);
|
|
} else if (!targetsLoading && (!targets || targets.length === 0)) {
|
|
// 如果没有标靶数据,清空矩形框
|
|
// console.log(
|
|
// "标靶数据为空,准备清空矩形框,当前矩形框数量:",
|
|
// rectangles.length
|
|
// );
|
|
setRectangles([]);
|
|
// console.log("标靶数据为空,已清空矩形框");
|
|
}
|
|
}, [targets, targetsLoading, videoNaturalWidth, videoNaturalHeight]);
|
|
|
|
// 专门监听rectangles变化并重绘
|
|
useEffect(() => {
|
|
// console.log("rectangles状态变化:", rectangles);
|
|
// 矩形状态改变时立即重绘
|
|
if (videoNaturalWidth && videoNaturalHeight) {
|
|
// 如果矩形数组为空,强制清除画布
|
|
if (rectangles.length === 0) {
|
|
const canvas = canvasRef.current;
|
|
if (canvas) {
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
// console.log("强制清除画布,因为矩形数组为空");
|
|
}
|
|
} else {
|
|
redrawAllRectangles();
|
|
}
|
|
}
|
|
}, [rectangles, videoNaturalWidth, videoNaturalHeight]);
|
|
|
|
// 监听拖拽状态变化,自动重绘
|
|
useEffect(() => {
|
|
redrawAllRectangles();
|
|
}, [isDraggingRect, selectedRectIndex]);
|
|
|
|
// 确保在视频尺寸和矩形框数据都准备好时重绘
|
|
useEffect(() => {
|
|
if (videoNaturalWidth && videoNaturalHeight && rectangles.length > 0) {
|
|
// 立即尝试重绘一次
|
|
redrawAllRectangles();
|
|
}
|
|
}, [videoNaturalWidth, videoNaturalHeight, rectangles.length]);
|
|
|
|
// 监听选中标靶变化,重新绘制高亮效果
|
|
useEffect(() => {
|
|
// console.log("选中标靶ID变化:", selectedTargetId);
|
|
// 只有在有矩形框的时候才重绘,使用requestAnimationFrame优化性能
|
|
if (rectangles.length > 0) {
|
|
const rafId = requestAnimationFrame(() => {
|
|
redrawAllRectangles();
|
|
});
|
|
|
|
return () => cancelAnimationFrame(rafId);
|
|
}
|
|
}, [selectedTargetId]);
|
|
|
|
// 容器滚轮事件处理
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const handleContainerWheel = (e) => {
|
|
if (e.altKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// 执行缩放逻辑
|
|
const rect = container.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
const beforeX = (mouseX - translateX) / scale;
|
|
const beforeY = (mouseY - translateY) / scale;
|
|
|
|
const delta = e.deltaY > 0 ? -scaleStep : scaleStep;
|
|
const newScale = Math.max(
|
|
minScale,
|
|
Math.min(maxScale, scale + delta)
|
|
);
|
|
|
|
const newTranslateX = mouseX - beforeX * newScale;
|
|
const newTranslateY = mouseY - beforeY * newScale;
|
|
|
|
setScale(newScale);
|
|
setTranslateX(newTranslateX);
|
|
setTranslateY(newTranslateY);
|
|
}
|
|
};
|
|
|
|
// 使用原生addEventListener,更好的控制
|
|
container.addEventListener("wheel", handleContainerWheel, {
|
|
passive: false,
|
|
});
|
|
|
|
return () => {
|
|
container.removeEventListener("wheel", handleContainerWheel);
|
|
};
|
|
}, [scale, translateX, translateY, minScale, maxScale, scaleStep]);
|
|
|
|
// 窗口大小变化时调整画布
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
setTimeout(resizeCanvas, 100);
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
flex: 4,
|
|
backgroundColor: "#000",
|
|
marginRight: "16px",
|
|
borderRight: "2px solid #d9d9d9",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* 视频容器 */}
|
|
<div
|
|
ref={containerRef}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
position: "relative",
|
|
cursor: dragging
|
|
? "grabbing"
|
|
: drawing
|
|
? "crosshair"
|
|
: isDraggingRect
|
|
? "move"
|
|
: hoveredRectIndex !== -1
|
|
? "move"
|
|
: "default",
|
|
}}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
>
|
|
{/* 视频内容 */}
|
|
<div
|
|
ref={videoInnerRef}
|
|
style={{
|
|
position: "relative",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
<img
|
|
ref={imgRef}
|
|
src={streamUrl}
|
|
alt="MJPEG 视频流"
|
|
onLoad={handleImageLoad}
|
|
onError={handleImageError}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "contain",
|
|
userSelect: "none",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
{/* 视频流断开提示 */}
|
|
{streamError && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
background: "rgba(0,0,0,0.7)",
|
|
color: "#fff",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 24,
|
|
zIndex: 100,
|
|
}}
|
|
>
|
|
视频流断开,正在尝试重连...
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 绘制画布 */}
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 重置按钮 */}
|
|
<button
|
|
onClick={handleReset}
|
|
style={{
|
|
position: "absolute",
|
|
top: "10px",
|
|
right: "90px", // 为保存按钮让出空间
|
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
color: "white",
|
|
border: "1px solid rgba(255, 255, 255, 0.3)",
|
|
borderRadius: "4px",
|
|
padding: "6px 12px",
|
|
fontSize: "12px",
|
|
cursor: "pointer",
|
|
zIndex: 10,
|
|
transition: "all 0.2s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.target.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.target.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
|
|
}}
|
|
>
|
|
重置视图
|
|
</button>
|
|
|
|
{/* 保存按钮 */}
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={isSaving || rectangles.length === 0}
|
|
style={{
|
|
position: "absolute",
|
|
top: "10px",
|
|
right: "10px",
|
|
backgroundColor:
|
|
isSaving || rectangles.length === 0
|
|
? "rgba(128, 128, 128, 0.7)"
|
|
: "rgba(0, 100, 0, 0.7)",
|
|
color: "white",
|
|
border: "1px solid rgba(255, 255, 255, 0.3)",
|
|
borderRadius: "4px",
|
|
padding: "6px 12px",
|
|
fontSize: "12px",
|
|
cursor:
|
|
isSaving || rectangles.length === 0
|
|
? "not-allowed"
|
|
: "pointer",
|
|
zIndex: 10,
|
|
transition: "all 0.2s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isSaving && rectangles.length > 0) {
|
|
e.target.style.backgroundColor = "rgba(0, 150, 0, 0.8)";
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isSaving && rectangles.length > 0) {
|
|
e.target.style.backgroundColor = "rgba(0, 100, 0, 0.7)";
|
|
}
|
|
}}
|
|
>
|
|
{isSaving
|
|
? "下发中..."
|
|
: rectangles.length === 0
|
|
? "无标靶"
|
|
: "一键下发"}
|
|
</button>
|
|
|
|
{/* 信息显示 */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: "10px",
|
|
left: "10px",
|
|
color: "white",
|
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
padding: "8px 12px",
|
|
borderRadius: "4px",
|
|
fontSize: "12px",
|
|
fontFamily: "monospace",
|
|
pointerEvents: "none",
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<div>
|
|
摄像头分辨率: {videoNaturalWidth} × {videoNaturalHeight}
|
|
</div>
|
|
<div>缩放: {Math.round(scale * 100)}%</div>
|
|
<div>
|
|
鼠标: ({mousePos.x}, {mousePos.y})
|
|
</div>
|
|
<div>
|
|
标靶数量: {rectangles.length}/{maxRectangles}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 操作说明 */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
bottom: "10px",
|
|
right: "10px",
|
|
color: "white",
|
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
padding: "8px 12px",
|
|
borderRadius: "4px",
|
|
fontSize: "11px",
|
|
pointerEvents: "none",
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<div>Alt + 滚轮: 缩放</div>
|
|
<div>Alt + 拖拽: 平移</div>
|
|
<div>左键拖拽空白: 创建矩形</div>
|
|
<div>左键拖拽矩形: 移动矩形</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CameraView;
|
|
|