<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Konva 可疑区域编辑器(支持右键删除)</title>
<style>
body { margin: 0; padding: 20px; font-family: sans-serif; }
#container {
border: 1px solid #ccc;
position: relative;
overflow: auto;
max-width: 100vw;
max-height: 90vh;
}
#controls {
margin-top: 10px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
button {
padding: 8px 16px;
}
/* .form-popup, #deleteMenu {
position: fixed;
background: white;
padding: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
display: none;
} */
.form-popup label { display: block; margin: 8px 0 4px; }
.form-popup select, .form-popup button,
#deleteMenu button {
width: 100%; padding: 6px; margin: 4px 0;
}
.icon-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
border: 1px solid #ccc;
background: white;
}
.icon-btn.active {
background: #007bff;
color: white;
}
.form-popup {
position: fixed;
background: white;
padding: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
display: none;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
min-width: 250px;
}
#deleteMenu {
position: fixed;
background: white;
padding: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
display: none;
min-width: 120px;
}
</style>
</head>
<body>
<div id="container"></div>
<div id="controls">
<button id="editBtn">进入编辑模式</button>
<button id="undoBtn">撤销 (Ctrl+Z)</button>
<button id="zoomInBtn">放大</button>
<button id="zoomOutBtn">缩小</button>
<div id="zoomDisplay">100%</div>
<div class="icon-btn" id="handBtn" title="拖拽模式">✋</div>
</div>
<!-- 新增区域表单 -->
<div class="form-popup" id="popup">
<label>操作类型:</label>
<select id="opType" disabled>
<option value="add">新增</option>
</select>
<label>瑕疵类型:</label>
<select id="defectType">
<option value="scratch">划痕</option>
<option value="stain">污渍</option>
<option value="dent">凹陷</option>
<option value="irregular">不规则瑕疵</option>
</select>
<button onclick="saveRegion()">确定</button>
<button onclick="cancelRegion()">取消</button>
</div>
<!-- 右键删除菜单 -->
<div id="deleteMenu">
<button onclick="deleteSelectedRegion()">删除区域</button>
</div>
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
<script>
// ========== 模拟后端数据 ==========
const imageUrl = 'https://picsum.photos/1024/768';
// 数据转换函数
function convertPointsFormat(points) {
if (Array.isArray(points) && points.length > 0) {
// 判断是对象数组还是数值数组
if (typeof points[0] === 'object' && points[0] !== null) {
// 对象数组格式 [{x:100,y:200}, ...] 转换为 [100, 200, ...]
return points.flatMap(point => [point.x, point.y]);
} else {
// 已经是一维数组格式,直接返回
return points;
}
}
return [];
}
// 处理后端数据的示例
function processBackendData(backendData) {
return backendData.map(item => ({
id: item.id,
points: convertPointsFormat(item.points),
defectType: item.defectType
}));
}
function generateIrregularShape(cx, cy, outerR = 60, innerR = 25, spikes = 5) {
const points = [];
for (let i = 0; i < spikes * 2; i++) {
const radius = i % 2 === 0 ? outerR : innerR;
const angle = Math.PI / 2 + (i * Math.PI) / spikes;
const x = cx + radius * Math.cos(angle) + (Math.random() - 0.5) * 10;
const y = cy + radius * Math.sin(angle) + (Math.random() - 0.5) * 10;
points.push(x, y);
}
return points;
}
const backendResponse = [
{ id: 1, points: [100,100, 200,100, 200,200, 100,200], defectType: 'scratch' },
{ id: 2, points: [300,150, 350,120, 400,150, 375,200, 325,200], defectType: 'stain' },
{ id: 3, points: generateIrregularShape(600, 200), defectType: 'irregular' },
];
const suspiciousRegions = processBackendData(backendResponse);
console.log("后端返回的数据",suspiciousRegions)
// ========== 全局变量 ==========
let stage, layer;
let imageObj = new Image();
let imgWidth = 0, imgHeight = 0; // 始终为原始图片尺寸
let isEditing = false;
let isDragModeActive = false;
let regions = [];
let currentDrawingPoints = [];
let drawingLine = null;
let drawingAnchors = [];
let history = [];
let tempNewRegion = null;
let scale = 1;
const minScale = 0.1;
const maxScale = 5;
let offsetX = 0;
let offsetY = 0;
let isDraggingAnchor = false;
let currentSelectedRegion = null; // 用于右键删除
// ========== 历史管理 ==========
function addToHistory(operation) {
history.push(operation);
if (history.length > 50) history.shift();
}
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
undoLastOperation();
}
if (e.key === 'Escape' && isEditing) {
if (currentDrawingPoints.length > 0) {
resetDrawingState();
}
}
});
function undoLastOperation() {
if (currentDrawingPoints.length > 0) {
resetDrawingState();
return;
}
if (history.length === 0) {
alert('没有可撤销的操作');
return;
}
const operation = history.pop();
switch (operation.type) {
case 'addRegion':
const region = createRegion(operation.points, operation.regionId, operation.defectType);
regions.push(region);
layer.batchDraw();
break;
case 'modifyRegion':
const regionToRestore = regions.find(r => r.id === operation.regionId);
if (regionToRestore) {
const scaledOldPoints = operation.oldPoints.map(p => p * scale);
regionToRestore.shape.points(scaledOldPoints);
regionToRestore.points = [...operation.oldPoints];
for (let i = 0; i < regionToRestore.anchors.length; i++) {
const anchor = regionToRestore.anchors[i];
const pointIndex = i * 2;
anchor.x(scaledOldPoints[pointIndex]);
anchor.y(scaledOldPoints[pointIndex + 1]);
}
layer.batchDraw();
}
break;
}
}
// ========== 工具函数 ==========
function createAnchor(x, y, line, index, region, isReadOnly = false) {
const anchor = new Konva.Circle({
x: x,
y: y,
radius: isReadOnly ? 5 : 8,
fill: isReadOnly ? '#ff6600' : '#ff0000',
stroke: '#ffffff',
strokeWidth: isReadOnly ? 1 : 2,
draggable: !isReadOnly,
visible: true,
dragBoundFunc: isReadOnly ? undefined : function (pos) {
return {
x: Math.max(0, Math.min(imgWidth * scale, pos.x)),
y: Math.max(0, Math.min(imgHeight * scale, pos.y)),
};
}
});
if (!isReadOnly) {
let dragStartPoints = null;
anchor.on('dragstart', () => {
isDraggingAnchor = true;
dragStartPoints = line.points().map(p => p / scale);
});
anchor.on('dragmove', function () {
const currentPoints = line.points().slice();
currentPoints[index] = this.x();
currentPoints[index + 1] = this.y();
line.points(currentPoints);
const updatedOriginalPoints = currentPoints.map(p => p / scale);
region.points = [...updatedOriginalPoints];
layer.batchDraw();
});
anchor.on('dragend', () => {
isDraggingAnchor = false;
if (dragStartPoints) {
const dragEndPoints = line.points().map(p => p / scale);
if (JSON.stringify(dragStartPoints) !== JSON.stringify(dragEndPoints)) {
addToHistory({
type: 'modifyRegion',
regionId: region.id,
oldPoints: [...dragStartPoints],
newPoints: [...dragEndPoints]
});
}
}
});
}
return anchor;
}
function createRegion(points, id = Date.now(), defectType = 'scratch') {
const scaledPoints = points.map(p => p * scale);
const line = new Konva.Line({
points: scaledPoints,
closed: true,
stroke: '#ff0000',
strokeWidth: 3,
fill: 'rgba(255,0,0,0.1)',
listening: true,
hitStrokeWidth: 10,
});
const anchors = [];
// ✅ 只在编辑模式下创建锚点
if (isEditing) {
for (let i = 0; i < scaledPoints.length; i += 2) {
const anchor = createAnchor(scaledPoints[i], scaledPoints[i + 1], line, i, { points: [...points], id, defectType }, false);
anchors.push(anchor);
}
}
const region = { id, shape: line, anchors, points: [...points], defectType };
setupHover(region);
return region;
}
function setupHover(region) {
region.shape.on('mouseenter', () => {
region.shape.stroke('#00ff00').strokeWidth(4).fill('rgba(0,255,0,0.2)');
layer.batchDraw();
});
region.shape.on('mouseleave', () => {
region.shape.stroke('#ff0000').strokeWidth(3).fill('rgba(255,0,0,0.1)');
layer.batchDraw();
});
}
// ========== 缩放逻辑(关键修复) ==========
function updateScale(newScale, centerPoint) {
if (isDraggingAnchor) {
setTimeout(() => {
if (!isDraggingAnchor) {
updateScale(newScale, centerPoint);
}
}, 100);
return;
}
const oldScale = scale;
scale = Math.max(minScale, Math.min(maxScale, newScale));
document.getElementById('zoomDisplay').textContent = Math.round(scale * 100) + '%';
if (centerPoint) {
const mousePointTo = {
x: (centerPoint.x - offsetX) / oldScale,
y: (centerPoint.y - offsetY) / oldScale
};
offsetX = centerPoint.x - mousePointTo.x * scale;
offsetY = centerPoint.y - mousePointTo.y * scale;
const maxX = Math.max(0, imgWidth * scale - stage.width());
const maxY = Math.max(0, imgHeight * scale - stage.height());
offsetX = Math.max(-maxX, Math.min(0, offsetX));
offsetY = Math.max(-maxY, Math.min(0, offsetY));
stage.position({ x: offsetX, y: offsetY });
}
const imageNode = layer.findOne('Image');
if (imageNode) {
imageNode.width(imgWidth * scale);
imageNode.height(imgHeight * scale);
}
// 更新已保存区域
regions.forEach(region => {
const currentOriginalPoints = region.shape.points().map(p => p / oldScale);
const newScaledPoints = currentOriginalPoints.map(p => p * scale);
region.shape.points(newScaledPoints);
for (let i = 0; i < region.anchors.length; i++) {
const anchor = region.anchors[i];
const pointIndex = i * 2;
anchor.x(newScaledPoints[pointIndex]);
anchor.y(newScaledPoints[pointIndex + 1]);
}
region.points = [...currentOriginalPoints];
});
// 更新正在绘制中的临时图形
if (drawingLine && currentDrawingPoints.length > 0) {
const newScaledTempPoints = currentDrawingPoints.map(p => p * scale);
drawingLine.points(newScaledTempPoints);
if (drawingAnchors.length === currentDrawingPoints.length / 2) {
for (let i = 0; i < drawingAnchors.length; i++) {
drawingAnchors[i].position({
x: currentDrawingPoints[i * 2] * scale,
y: currentDrawingPoints[i * 2 + 1] * scale
});
}
}
}
layer.batchDraw();
// ✅ 关键:缩放后刷新预览线(防止漂移)
if (isEditing && currentDrawingPoints.length > 0) {
const pos = stage.getPointerPosition();
if (pos) {
const originalX = (pos.x - offsetX) / scale;
const originalY = (pos.y - offsetY) / scale;
const pts = [...currentDrawingPoints, originalX, originalY].map(p => p * scale);
drawingLine.points(pts);
layer.batchDraw();
}
}
}
// ========== 初始化 ==========
function initStage() {
const originalImgWidth = imageObj.naturalWidth;
const originalImgHeight = imageObj.naturalHeight;
const maxWidth = window.innerWidth * 0.9;
const maxHeight = window.innerHeight * 0.96;
let initialDisplayScale = Math.min(maxWidth / originalImgWidth, maxHeight / originalImgHeight);
if (initialDisplayScale > 1) initialDisplayScale = 1;
const displayWidth = originalImgWidth * initialDisplayScale;
const displayHeight = originalImgHeight * initialDisplayScale;
stage = new Konva.Stage({
container: 'container',
width: displayWidth,
height: displayHeight,
});
layer = new Konva.Layer();
stage.add(layer);
const image = new Konva.Image({
x: 0,
y: 0,
image: imageObj,
width: displayWidth,
height: displayHeight,
listening: false,
});
layer.add(image);
// ✅ 保存原始尺寸!
imgWidth = originalImgWidth;
imgHeight = originalImgHeight;
scale = initialDisplayScale;
document.getElementById('zoomDisplay').textContent = Math.round(scale * 100) + '%';
drawRegions(suspiciousRegions);
layer.draw();
bindGlobalEvents();
}
function drawRegions(regionData) {
regions = [];
regionData.forEach(data => {
const region = createRegion(data.points, data.id, data.defectType);
regions.push(region);
layer.add(region.shape);
region.anchors.forEach(a => layer.add(a));
});
}
// 用于检测点是否在多边形内
function isPointInPolygon(point, vs) {
var x = point.x, y = point.y;
var inside = false;
for (var i = 0, j = vs.length - 2; i < vs.length; i += 2) {
var xi = vs[i], yi = vs[i + 1];
var xj = vs[j];
var yj = vs[j + 1];
var intersect = ((yi > y) != (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
j = i;
}
return inside;
}
// ========== 事件绑定 ==========
function bindGlobalEvents() {
stage.on('wheel', (e) => {
e.evt.preventDefault();
const pointer = stage.getPointerPosition();
const direction = e.evt.deltaY > 0 ? -1 : 1;
const zoomIntensity = 0.1;
const newScale = scale * (1 + direction * zoomIntensity);
updateScale(newScale, pointer);
});
// ✅ 右键删除菜单
stage.on('contextmenu', (e) => {
e.evt.preventDefault();
const pos = stage.getPointerPosition();
if (!pos) return;
const clickedShape = stage.getIntersection(pos);
const clickedRegion = regions.find(r => r.shape === clickedShape);
if (clickedRegion) {
currentSelectedRegion = clickedRegion;
const menu = document.getElementById('deleteMenu');
const x = Math.min(pos.x, window.innerWidth - 120);
const y = Math.min(pos.y, window.innerHeight - 80);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
menu.style.display = 'block';
} else {
hideDeleteMenu();
}
});
stage.on('mousedown', () => hideDeleteMenu());
document.addEventListener('click', (e) => {
if (!e.target.closest('#deleteMenu')) hideDeleteMenu();
});
// 控制按钮
document.getElementById('editBtn').addEventListener('click', toggleEditMode);
document.getElementById('undoBtn').addEventListener('click', undoLastOperation);
document.getElementById('zoomInBtn').addEventListener('click', () => {
const pointer = stage.getPointerPosition() || { x: stage.width() / 2, y: stage.height() / 2 };
updateScale(scale * 1.2, pointer);
});
document.getElementById('zoomOutBtn').addEventListener('click', () => {
const pointer = stage.getPointerPosition() || { x: stage.width() / 2, y: stage.height() / 2 };
updateScale(scale / 1.2, pointer);
});
document.getElementById('handBtn').addEventListener('click', toggleDragMode);
}
// ========== 编辑与拖拽模式 ==========
function toggleEditMode() {
if (isDragModeActive) {
document.getElementById('handBtn').classList.remove('active');
disableDragMode();
}
isEditing = !isEditing;
document.getElementById('editBtn').textContent = isEditing ? '退出编辑' : '进入编辑模式';
const regionData = regions.map(r => ({ id: r.id, points: [...r.points], defectType: r.defectType }));
regions.forEach(r => {
r.shape.destroy();
r.anchors.forEach(a => a.destroy());
});
regions = [];
drawRegions(regionData);
if (isEditing) enableDrawingMode();
else disableDrawingMode();
layer.batchDraw();
}
function enableDragMode() {
isDragModeActive = true;
stage.container().style.cursor = 'grab';
let isDraggingStage = false;
let lastPointerPosition;
stage.on('mousedown touchstart', (e) => {
if (!isDragModeActive) return;
isDraggingStage = true;
lastPointerPosition = stage.getPointerPosition();
stage.container().style.cursor = 'grabbing';
});
stage.on('mousemove touchmove', (e) => {
if (!isDragModeActive || !isDraggingStage) return;
const pos = stage.getPointerPosition();
const dx = pos.x - lastPointerPosition.x;
const dy = pos.y - lastPointerPosition.y;
offsetX += dx;
offsetY += dy;
const maxX = Math.max(0, imgWidth * scale - stage.width());
const maxY = Math.max(0, imgHeight * scale - stage.height());
offsetX = Math.max(-maxX, Math.min(0, offsetX));
offsetY = Math.max(-maxY, Math.min(0, offsetY));
stage.position({ x: offsetX, y: offsetY });
lastPointerPosition = pos;
stage.batchDraw();
});
stage.on('mouseup touchend mouseleave', () => {
isDraggingStage = false;
stage.container().style.cursor = 'grab';
});
}
function disableDragMode() {
isDragModeActive = false;
stage.container().style.cursor = 'default';
stage.off('mousedown touchstart mousemove touchmove mouseup touchend mouseleave');
}
function toggleDragMode() {
if (isEditing) {
isEditing = false;
document.getElementById('editBtn').textContent = '进入编辑模式';
const regionData = regions.map(r => ({ id: r.id, points: [...r.points], defectType: r.defectType }));
regions.forEach(r => {
r.shape.destroy();
r.anchors.forEach(a => a.destroy());
});
regions = [];
drawRegions(regionData);
disableDrawingMode();
}
const handBtn = document.getElementById('handBtn');
if (isDragModeActive) {
handBtn.classList.remove('active');
disableDragMode();
} else {
handBtn.classList.add('active');
enableDragMode();
}
layer.batchDraw();
}
// ========== 绘制新区域 ==========
function enableDrawingMode() {
stage.on('click tap', handleStageClick);
stage.on('mousemove', handleMouseMove);
}
function disableDrawingMode() {
stage.off('click tap', handleStageClick);
stage.off('mousemove', handleMouseMove);
resetDrawingState();
}
function resetDrawingState() {
currentDrawingPoints = [];
if (drawingLine) drawingLine.destroy();
drawingLine = null;
drawingAnchors.forEach(anchor => anchor.destroy());
drawingAnchors = [];
layer.draw();
tempNewRegion = null;
}
function createDrawingAnchor(x, y, isStart = false) {
const anchor = new Konva.Circle({
x: x,
y: y,
radius: isStart ? 9 : 7,
fill: isStart ? '#ff6b6b' : '#4ecdc4',
stroke: '#ffffff',
strokeWidth: 2,
listening: true,
});
if (isStart) {
anchor.on('click', function (e) {
e.cancelBubble = true;
if (!isEditing || currentDrawingPoints.length < 6) return;
finishDrawing([...currentDrawingPoints]);
});
anchor.on('mouseenter', () => {
anchor.stroke('#ffff00').strokeWidth(3);
layer.batchDraw();
});
anchor.on('mouseleave', () => {
anchor.stroke('#ffffff').strokeWidth(2);
layer.batchDraw();
});
}
return anchor;
}
function handleStageClick(e) {
//✅ 只响应左键点击(button === 0)
if (e.evt.button !== 0) {
return; // 忽略右键、中键等
}
const target = e.target;
if (target && (target.className === 'Line' || target.className === 'Circle')) {
const isInExistingRegion = regions.some(r =>
r.shape === target || r.anchors.includes(target)
);
if (isInExistingRegion) return;
}
if (!isEditing) return;
const pos = stage.getPointerPosition();
const originalX = (pos.x - offsetX) / scale;
const originalY = (pos.y - offsetY) / scale;
if (originalX < 0 || originalX > imgWidth || originalY < 0 || originalY > imgHeight) return;
if (currentDrawingPoints.length === 0) {
currentDrawingPoints.push(originalX, originalY);
const scaledX = originalX * scale;
const scaledY = originalY * scale;
const startAnchor = createDrawingAnchor(scaledX, scaledY, true);
drawingAnchors.push(startAnchor);
layer.add(startAnchor);
layer.draw();
return;
}
currentDrawingPoints.push(originalX, originalY);
const scaledX = originalX * scale;
const scaledY = originalY * scale;
const anchor = createDrawingAnchor(scaledX, scaledY, false);
drawingAnchors.push(anchor);
layer.add(anchor);
if (!drawingLine) {
drawingLine = new Konva.Line({
points: currentDrawingPoints.map(p => p * scale),
stroke: '#ffff00',
strokeWidth: 2,
dash: [5, 5],
closed: false,
listening: false,
});
layer.add(drawingLine);
} else {
drawingLine.points(currentDrawingPoints.map(p => p * scale));
}
layer.batchDraw();
}
function handleMouseMove(e) {
if (!isEditing || !drawingLine || currentDrawingPoints.length === 0) return;
const pos = stage.getPointerPosition();
const originalX = (pos.x - offsetX) / scale;
const originalY = (pos.y - offsetY) / scale;
const pts = [...currentDrawingPoints, originalX, originalY].map(p => p * scale);
drawingLine.points(pts);
layer.batchDraw();
}
function finishDrawing(points) {
if (points.length < 6) {
alert('至少需要3个点才能形成区域');
resetDrawingState();
return;
}
const newRegion = createRegion(points, Date.now(), 'scratch');
regions.push(newRegion);
layer.add(newRegion.shape);
newRegion.anchors.forEach(a => layer.add(a));
tempNewRegion = newRegion;
// 不再调用resetDrawingState(),而是手动清理绘制状态但保留tempNewRegion
currentDrawingPoints = [];
if (drawingLine) drawingLine.destroy();
drawingLine = null;
drawingAnchors.forEach(anchor => anchor.destroy());
drawingAnchors = [];
layer.draw();
document.getElementById('popup').style.display = 'block';
}
// ========== 弹窗控制 ==========
function closePopup() {
document.getElementById('popup').style.display = 'none';
}
function saveRegion() {
const defectType = document.getElementById('defectType').value;
if (tempNewRegion) {
tempNewRegion.defectType = defectType;
addToHistory({
type: 'addRegion',
regionId: tempNewRegion.id,
points: [...tempNewRegion.points],
defectType: defectType
});
}
closePopup();
tempNewRegion = null;
layer.batchDraw();
}
function cancelRegion() {
// 清除已完成但未保存的区域(tempNewRegion)
if (tempNewRegion) {
tempNewRegion.shape.destroy();
tempNewRegion.anchors.forEach(anchor => anchor.destroy());
regions = regions.filter(r => r.id !== tempNewRegion.id);
}
// 清除绘制过程中的临时元素
if (drawingLine) {
drawingLine.destroy();
drawingLine = null;
}
drawingAnchors.forEach(anchor => anchor.destroy());
drawingAnchors = [];
currentDrawingPoints = [];
closePopup();
tempNewRegion = null;
layer.batchDraw();
}
// ========== 删除菜单控制 ==========
function hideDeleteMenu() {
document.getElementById('deleteMenu').style.display = 'none';
currentSelectedRegion = null;
}
function deleteSelectedRegion() {
if (!currentSelectedRegion) return;
addToHistory({
type: 'addRegion',
regionId: currentSelectedRegion.id,
points: [...currentSelectedRegion.points],
defectType: currentSelectedRegion.defectType
});
currentSelectedRegion.shape.destroy();
currentSelectedRegion.anchors.forEach(anchor => anchor.destroy());
regions = regions.filter(r => r.id !== currentSelectedRegion.id);
layer.batchDraw();
hideDeleteMenu();
}
// ========== 启动 ==========
imageObj.onload = initStage;
// imageObj.src = imageUrl;
imageObj.src = "http://192.168.77.249:7745/Data/6a8c44a4-b8cd-4e8f-9bf4-55bce793a1b6.jpg";
</script>
</body>
</html>