重构:ROI选区界面改造 — 全图+自定义选区+可收起面板
RoiCanvas.vue: - 移除矩形绘制模式,保留多边形模式 - 添加鼠标跟随线、首尾闭合预览、绘制提示条 - 键盘事件:Esc取消、Ctrl+Z撤销上一顶点 - 支持 fullscreen 类型渲染和点击检测 roiConfig.vue: - 工具栏状态机:默认/绘制中两套按钮 - 全图按钮一键创建覆盖整张图的ROI - 初始Canvas全宽,点击ROI后右侧面板滑出 - 绘制完成/全图创建时弹出命名输入框 - 删除最后ROI时面板自动收起
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="roi-canvas-wrapper" ref="wrapper">
|
||||
<div class="roi-canvas-wrapper" ref="wrapper" tabindex="0" @keydown="onKeyDown">
|
||||
<img
|
||||
v-if="snapUrl"
|
||||
ref="bgImage"
|
||||
@@ -15,12 +15,16 @@
|
||||
<canvas
|
||||
ref="canvas"
|
||||
class="roi-overlay"
|
||||
:class="{ drawing: drawMode === 'polygon' }"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@contextmenu.prevent="onContextMenu"
|
||||
></canvas>
|
||||
<!-- 绘制中浮动提示条 -->
|
||||
<div v-if="drawMode === 'polygon'" class="draw-hint-bar">
|
||||
单击添加顶点 | 双击或右键完成选区 | Esc取消 | Ctrl+Z撤销
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,10 +42,8 @@ export default {
|
||||
ctx: null,
|
||||
canvasWidth: 0,
|
||||
canvasHeight: 0,
|
||||
isDrawing: false,
|
||||
startPoint: null,
|
||||
currentPoint: null,
|
||||
polygonPoints: [],
|
||||
mouseMovePoint: null,
|
||||
loading: true,
|
||||
errorMsg: '',
|
||||
resizeObserver: null
|
||||
@@ -50,10 +52,14 @@ export default {
|
||||
watch: {
|
||||
rois: { handler() { this.redraw() }, deep: true },
|
||||
selectedRoiId() { this.redraw() },
|
||||
drawMode() {
|
||||
drawMode(newVal) {
|
||||
this.polygonPoints = []
|
||||
this.isDrawing = false
|
||||
this.mouseMovePoint = null
|
||||
this.redraw()
|
||||
// 进入绘制模式时聚焦 wrapper 以接收键盘事件
|
||||
if (newVal && this.$refs.wrapper) {
|
||||
this.$refs.wrapper.focus()
|
||||
}
|
||||
},
|
||||
snapUrl() {
|
||||
this.loading = true
|
||||
@@ -64,7 +70,6 @@ export default {
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.initCanvas()
|
||||
// ResizeObserver 确保容器尺寸变化时重新初始化 canvas
|
||||
if (this.$refs.wrapper && typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) {
|
||||
@@ -86,14 +91,11 @@ export default {
|
||||
methods: {
|
||||
onImageLoad() {
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
this.initCanvas()
|
||||
})
|
||||
this.$nextTick(() => this.initCanvas())
|
||||
},
|
||||
onImageError() {
|
||||
this.loading = false
|
||||
this.errorMsg = '截图加载失败,请确认摄像头正在拉流'
|
||||
// 关键:截图失败也初始化 canvas,使 ROI 区域可见可操作
|
||||
this.$nextTick(() => this.initCanvas())
|
||||
},
|
||||
initCanvas() {
|
||||
@@ -117,74 +119,99 @@ export default {
|
||||
y: (e.clientY - rect.top) / this.canvasHeight
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 鼠标事件 ----
|
||||
onMouseDown(e) {
|
||||
if (e.button !== 0) return
|
||||
const pt = this.getCanvasPoint(e)
|
||||
if (this.drawMode === 'rectangle') {
|
||||
this.isDrawing = true
|
||||
this.startPoint = pt
|
||||
this.currentPoint = pt
|
||||
} else if (this.drawMode === 'polygon') {
|
||||
|
||||
if (this.drawMode === 'polygon') {
|
||||
// 多边形模式:添加顶点
|
||||
this.polygonPoints.push(pt)
|
||||
this.redraw()
|
||||
this.drawPolygonInProgress()
|
||||
} else {
|
||||
} else if (!this.drawMode) {
|
||||
// 非绘制模式:点击选中 ROI
|
||||
const clickedRoi = this.findRoiAtPoint(pt)
|
||||
this.$emit('roi-selected', clickedRoi ? clickedRoi.roiId || clickedRoi.roi_id : null)
|
||||
}
|
||||
},
|
||||
onMouseMove(e) {
|
||||
if (!this.isDrawing || this.drawMode !== 'rectangle') return
|
||||
this.currentPoint = this.getCanvasPoint(e)
|
||||
this.redraw()
|
||||
this.drawRectInProgress()
|
||||
},
|
||||
onMouseUp(e) {
|
||||
if (!this.isDrawing || this.drawMode !== 'rectangle') return
|
||||
this.isDrawing = false
|
||||
const endPoint = this.getCanvasPoint(e)
|
||||
const x = Math.min(this.startPoint.x, endPoint.x)
|
||||
const y = Math.min(this.startPoint.y, endPoint.y)
|
||||
const w = Math.abs(endPoint.x - this.startPoint.x)
|
||||
const h = Math.abs(endPoint.y - this.startPoint.y)
|
||||
if (w > 0.01 && h > 0.01) {
|
||||
this.$emit('roi-drawn', {
|
||||
roi_type: 'rectangle',
|
||||
coordinates: JSON.stringify({ x, y, w, h })
|
||||
})
|
||||
}
|
||||
this.startPoint = null
|
||||
this.currentPoint = null
|
||||
if (this.drawMode !== 'polygon' || this.polygonPoints.length === 0) return
|
||||
this.mouseMovePoint = this.getCanvasPoint(e)
|
||||
this.redraw()
|
||||
this.drawPolygonInProgress()
|
||||
},
|
||||
onDoubleClick() {
|
||||
if (this.drawMode === 'polygon' && this.polygonPoints.length >= 3) {
|
||||
this.$emit('roi-drawn', {
|
||||
roi_type: 'polygon',
|
||||
coordinates: JSON.stringify(this.polygonPoints.map(p => ({ x: p.x, y: p.y })))
|
||||
})
|
||||
this.polygonPoints = []
|
||||
this.redraw()
|
||||
// 双击完成:移除最后一个重复点(双击会触发两次 mousedown)
|
||||
this.finishPolygon()
|
||||
}
|
||||
},
|
||||
onContextMenu(e) {
|
||||
const pt = this.getCanvasPoint(e)
|
||||
const roi = this.findRoiAtPoint(pt)
|
||||
if (roi) {
|
||||
this.$emit('roi-deleted', roi.roiId || roi.roi_id)
|
||||
if (this.drawMode === 'polygon' && this.polygonPoints.length >= 3) {
|
||||
// 右键完成选区
|
||||
this.finishPolygon()
|
||||
return
|
||||
}
|
||||
// 非绘制模式:右键删除 ROI
|
||||
if (!this.drawMode) {
|
||||
const pt = this.getCanvasPoint(e)
|
||||
const roi = this.findRoiAtPoint(pt)
|
||||
if (roi) {
|
||||
this.$emit('roi-deleted', roi.roiId || roi.roi_id)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 键盘事件 ----
|
||||
onKeyDown(e) {
|
||||
if (this.drawMode === 'polygon') {
|
||||
if (e.key === 'Escape') {
|
||||
// Esc 取消绘制
|
||||
this.polygonPoints = []
|
||||
this.mouseMovePoint = null
|
||||
this.$emit('draw-cancelled')
|
||||
this.redraw()
|
||||
} else if ((e.ctrlKey && e.key === 'z') || e.key === 'Backspace') {
|
||||
// Ctrl+Z 或 Backspace 撤销上一个顶点
|
||||
e.preventDefault()
|
||||
if (this.polygonPoints.length > 0) {
|
||||
this.polygonPoints.pop()
|
||||
this.redraw()
|
||||
if (this.polygonPoints.length > 0) {
|
||||
this.drawPolygonInProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 多边形完成 ----
|
||||
finishPolygon() {
|
||||
const points = this.polygonPoints.map(p => ({ x: p.x, y: p.y }))
|
||||
this.$emit('roi-drawn', {
|
||||
roi_type: 'polygon',
|
||||
coordinates: JSON.stringify(points)
|
||||
})
|
||||
this.polygonPoints = []
|
||||
this.mouseMovePoint = null
|
||||
this.redraw()
|
||||
},
|
||||
|
||||
// ---- ROI 查找 ----
|
||||
findRoiAtPoint(pt) {
|
||||
for (let i = this.rois.length - 1; i >= 0; i--) {
|
||||
const roi = this.rois[i]
|
||||
try {
|
||||
const coords = typeof roi.coordinates === 'string' ? JSON.parse(roi.coordinates) : roi.coordinates
|
||||
if (roi.roiType === 'rectangle' || roi.roi_type === 'rectangle') {
|
||||
const type = roi.roiType || roi.roi_type
|
||||
if (type === 'rectangle' || type === 'fullscreen') {
|
||||
if (pt.x >= coords.x && pt.x <= coords.x + coords.w &&
|
||||
pt.y >= coords.y && pt.y <= coords.y + coords.h) {
|
||||
return roi
|
||||
}
|
||||
} else if (roi.roiType === 'polygon' || roi.roi_type === 'polygon') {
|
||||
} else if (type === 'polygon') {
|
||||
if (this.isPointInPolygon(pt, coords)) return roi
|
||||
}
|
||||
} catch (e) { /* skip */ }
|
||||
@@ -202,6 +229,8 @@ export default {
|
||||
}
|
||||
return inside
|
||||
},
|
||||
|
||||
// ---- 绘制 ----
|
||||
redraw() {
|
||||
if (!this.ctx) return
|
||||
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
|
||||
@@ -210,10 +239,13 @@ export default {
|
||||
const coords = typeof roi.coordinates === 'string' ? JSON.parse(roi.coordinates) : roi.coordinates
|
||||
const color = roi.color || '#FF0000'
|
||||
const isSelected = (roi.roiId || roi.roi_id) === this.selectedRoiId
|
||||
const type = roi.roiType || roi.roi_type
|
||||
|
||||
this.ctx.strokeStyle = color
|
||||
this.ctx.lineWidth = isSelected ? 3 : 2
|
||||
this.ctx.fillStyle = color + '33'
|
||||
if (roi.roiType === 'rectangle' || roi.roi_type === 'rectangle') {
|
||||
|
||||
if (type === 'rectangle' || type === 'fullscreen') {
|
||||
const rx = coords.x * this.canvasWidth
|
||||
const ry = coords.y * this.canvasHeight
|
||||
const rw = coords.w * this.canvasWidth
|
||||
@@ -225,7 +257,7 @@ export default {
|
||||
this.ctx.font = '12px Arial'
|
||||
this.ctx.fillText(roi.name, rx + 4, ry + 14)
|
||||
}
|
||||
} else if (roi.roiType === 'polygon' || roi.roi_type === 'polygon') {
|
||||
} else if (type === 'polygon') {
|
||||
this.ctx.beginPath()
|
||||
coords.forEach((p, idx) => {
|
||||
const px = p.x * this.canvasWidth
|
||||
@@ -244,18 +276,6 @@ export default {
|
||||
} catch (e) { /* skip */ }
|
||||
})
|
||||
},
|
||||
drawRectInProgress() {
|
||||
if (!this.startPoint || !this.currentPoint) return
|
||||
const x = Math.min(this.startPoint.x, this.currentPoint.x) * this.canvasWidth
|
||||
const y = Math.min(this.startPoint.y, this.currentPoint.y) * this.canvasHeight
|
||||
const w = Math.abs(this.currentPoint.x - this.startPoint.x) * this.canvasWidth
|
||||
const h = Math.abs(this.currentPoint.y - this.startPoint.y) * this.canvasHeight
|
||||
this.ctx.strokeStyle = '#00FF00'
|
||||
this.ctx.lineWidth = 2
|
||||
this.ctx.setLineDash([5, 5])
|
||||
this.ctx.strokeRect(x, y, w, h)
|
||||
this.ctx.setLineDash([])
|
||||
},
|
||||
drawPolygonInProgress() {
|
||||
if (this.polygonPoints.length < 1) return
|
||||
this.ctx.strokeStyle = '#00FF00'
|
||||
@@ -266,9 +286,24 @@ export default {
|
||||
const px = p.x * this.canvasWidth
|
||||
const py = p.y * this.canvasHeight
|
||||
idx === 0 ? this.ctx.moveTo(px, py) : this.ctx.lineTo(px, py)
|
||||
// 顶点圆点
|
||||
this.ctx.fillStyle = '#00FF00'
|
||||
this.ctx.fillRect(px - 3, py - 3, 6, 6)
|
||||
this.ctx.fillRect(px - 4, py - 4, 8, 8)
|
||||
})
|
||||
// 鼠标跟随线(到当前鼠标位置)
|
||||
if (this.mouseMovePoint) {
|
||||
this.ctx.lineTo(
|
||||
this.mouseMovePoint.x * this.canvasWidth,
|
||||
this.mouseMovePoint.y * this.canvasHeight
|
||||
)
|
||||
}
|
||||
// 首尾闭合预览线(淡色)
|
||||
if (this.polygonPoints.length >= 3) {
|
||||
const first = this.polygonPoints[0]
|
||||
const last = this.mouseMovePoint || this.polygonPoints[this.polygonPoints.length - 1]
|
||||
this.ctx.moveTo(last.x * this.canvasWidth, last.y * this.canvasHeight)
|
||||
this.ctx.lineTo(first.x * this.canvasWidth, first.y * this.canvasHeight)
|
||||
}
|
||||
this.ctx.stroke()
|
||||
this.ctx.setLineDash([])
|
||||
}
|
||||
@@ -283,6 +318,7 @@ export default {
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
background: #000;
|
||||
outline: none;
|
||||
}
|
||||
.bg-image {
|
||||
width: 100%;
|
||||
@@ -306,6 +342,22 @@ export default {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.roi-overlay.drawing {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.draw-hint-bar {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user