无源标靶上位机
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

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;