RoiCanvas.vue: - 移除矩形绘制模式,保留多边形模式 - 添加鼠标跟随线、首尾闭合预览、绘制提示条 - 键盘事件:Esc取消、Ctrl+Z撤销上一顶点 - 支持 fullscreen 类型渲染和点击检测 roiConfig.vue: - 工具栏状态机:默认/绘制中两套按钮 - 全图按钮一键创建覆盖整张图的ROI - 初始Canvas全宽,点击ROI后右侧面板滑出 - 绘制完成/全图创建时弹出命名输入框 - 删除最后ROI时面板自动收起
364 lines
11 KiB
Vue
364 lines
11 KiB
Vue
<template>
|
||
<div class="roi-canvas-wrapper" ref="wrapper" tabindex="0" @keydown="onKeyDown">
|
||
<img
|
||
v-if="snapUrl"
|
||
ref="bgImage"
|
||
:src="snapUrl"
|
||
class="bg-image"
|
||
@load="onImageLoad"
|
||
@error="onImageError"
|
||
/>
|
||
<div v-else class="video-placeholder">
|
||
<span v-if="loading">正在截取画面...</span>
|
||
<span v-else>{{ errorMsg || '暂无画面' }}</span>
|
||
</div>
|
||
<canvas
|
||
ref="canvas"
|
||
class="roi-overlay"
|
||
:class="{ drawing: drawMode === 'polygon' }"
|
||
@mousedown="onMouseDown"
|
||
@mousemove="onMouseMove"
|
||
@dblclick="onDoubleClick"
|
||
@contextmenu.prevent="onContextMenu"
|
||
></canvas>
|
||
<!-- 绘制中浮动提示条 -->
|
||
<div v-if="drawMode === 'polygon'" class="draw-hint-bar">
|
||
单击添加顶点 | 双击或右键完成选区 | Esc取消 | Ctrl+Z撤销
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
name: 'RoiCanvas',
|
||
props: {
|
||
rois: { type: Array, default: () => [] },
|
||
drawMode: { type: String, default: null },
|
||
selectedRoiId: { type: String, default: null },
|
||
snapUrl: { type: String, default: '' }
|
||
},
|
||
data() {
|
||
return {
|
||
ctx: null,
|
||
canvasWidth: 0,
|
||
canvasHeight: 0,
|
||
polygonPoints: [],
|
||
mouseMovePoint: null,
|
||
loading: true,
|
||
errorMsg: '',
|
||
resizeObserver: null
|
||
}
|
||
},
|
||
watch: {
|
||
rois: { handler() { this.redraw() }, deep: true },
|
||
selectedRoiId() { this.redraw() },
|
||
drawMode(newVal) {
|
||
this.polygonPoints = []
|
||
this.mouseMovePoint = null
|
||
this.redraw()
|
||
// 进入绘制模式时聚焦 wrapper 以接收键盘事件
|
||
if (newVal && this.$refs.wrapper) {
|
||
this.$refs.wrapper.focus()
|
||
}
|
||
},
|
||
snapUrl() {
|
||
this.loading = true
|
||
this.errorMsg = ''
|
||
this.$nextTick(() => this.initCanvas())
|
||
}
|
||
},
|
||
mounted() {
|
||
this.$nextTick(() => {
|
||
this.initCanvas()
|
||
if (this.$refs.wrapper && typeof ResizeObserver !== 'undefined') {
|
||
this.resizeObserver = new ResizeObserver(() => {
|
||
if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) {
|
||
this.initCanvas()
|
||
}
|
||
})
|
||
this.resizeObserver.observe(this.$refs.wrapper)
|
||
}
|
||
window.addEventListener('resize', this.handleResize)
|
||
})
|
||
},
|
||
beforeDestroy() {
|
||
window.removeEventListener('resize', this.handleResize)
|
||
if (this.resizeObserver) {
|
||
this.resizeObserver.disconnect()
|
||
this.resizeObserver = null
|
||
}
|
||
},
|
||
methods: {
|
||
onImageLoad() {
|
||
this.loading = false
|
||
this.$nextTick(() => this.initCanvas())
|
||
},
|
||
onImageError() {
|
||
this.loading = false
|
||
this.errorMsg = '截图加载失败,请确认摄像头正在拉流'
|
||
this.$nextTick(() => this.initCanvas())
|
||
},
|
||
initCanvas() {
|
||
const canvas = this.$refs.canvas
|
||
const wrapper = this.$refs.wrapper
|
||
if (!canvas || !wrapper) return
|
||
this.canvasWidth = wrapper.clientWidth
|
||
this.canvasHeight = wrapper.clientHeight
|
||
canvas.width = this.canvasWidth
|
||
canvas.height = this.canvasHeight
|
||
this.ctx = canvas.getContext('2d')
|
||
this.redraw()
|
||
},
|
||
handleResize() {
|
||
this.initCanvas()
|
||
},
|
||
getCanvasPoint(e) {
|
||
const rect = this.$refs.canvas.getBoundingClientRect()
|
||
return {
|
||
x: (e.clientX - rect.left) / this.canvasWidth,
|
||
y: (e.clientY - rect.top) / this.canvasHeight
|
||
}
|
||
},
|
||
|
||
// ---- 鼠标事件 ----
|
||
onMouseDown(e) {
|
||
if (e.button !== 0) return
|
||
const pt = this.getCanvasPoint(e)
|
||
|
||
if (this.drawMode === 'polygon') {
|
||
// 多边形模式:添加顶点
|
||
this.polygonPoints.push(pt)
|
||
this.redraw()
|
||
this.drawPolygonInProgress()
|
||
} 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.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) {
|
||
// 双击完成:移除最后一个重复点(双击会触发两次 mousedown)
|
||
this.finishPolygon()
|
||
}
|
||
},
|
||
onContextMenu(e) {
|
||
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
|
||
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 (type === 'polygon') {
|
||
if (this.isPointInPolygon(pt, coords)) return roi
|
||
}
|
||
} catch (e) { /* skip */ }
|
||
}
|
||
return null
|
||
},
|
||
isPointInPolygon(pt, polygon) {
|
||
let inside = false
|
||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||
const xi = polygon[i].x, yi = polygon[i].y
|
||
const xj = polygon[j].x, yj = polygon[j].y
|
||
const intersect = ((yi > pt.y) !== (yj > pt.y)) &&
|
||
(pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi)
|
||
if (intersect) inside = !inside
|
||
}
|
||
return inside
|
||
},
|
||
|
||
// ---- 绘制 ----
|
||
redraw() {
|
||
if (!this.ctx) return
|
||
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
|
||
this.rois.forEach(roi => {
|
||
try {
|
||
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 (type === 'rectangle' || type === 'fullscreen') {
|
||
const rx = coords.x * this.canvasWidth
|
||
const ry = coords.y * this.canvasHeight
|
||
const rw = coords.w * this.canvasWidth
|
||
const rh = coords.h * this.canvasHeight
|
||
this.ctx.fillRect(rx, ry, rw, rh)
|
||
this.ctx.strokeRect(rx, ry, rw, rh)
|
||
if (roi.name) {
|
||
this.ctx.fillStyle = color
|
||
this.ctx.font = '12px Arial'
|
||
this.ctx.fillText(roi.name, rx + 4, ry + 14)
|
||
}
|
||
} else if (type === 'polygon') {
|
||
this.ctx.beginPath()
|
||
coords.forEach((p, idx) => {
|
||
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.closePath()
|
||
this.ctx.fill()
|
||
this.ctx.stroke()
|
||
if (roi.name && coords.length > 0) {
|
||
this.ctx.fillStyle = color
|
||
this.ctx.font = '12px Arial'
|
||
this.ctx.fillText(roi.name, coords[0].x * this.canvasWidth + 4, coords[0].y * this.canvasHeight + 14)
|
||
}
|
||
}
|
||
} catch (e) { /* skip */ }
|
||
})
|
||
},
|
||
drawPolygonInProgress() {
|
||
if (this.polygonPoints.length < 1) return
|
||
this.ctx.strokeStyle = '#00FF00'
|
||
this.ctx.lineWidth = 2
|
||
this.ctx.setLineDash([5, 5])
|
||
this.ctx.beginPath()
|
||
this.polygonPoints.forEach((p, idx) => {
|
||
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 - 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([])
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.roi-canvas-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 400px;
|
||
background: #000;
|
||
outline: none;
|
||
}
|
||
.bg-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
display: block;
|
||
}
|
||
.video-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #666;
|
||
font-size: 18px;
|
||
background: #1a1a2e;
|
||
}
|
||
.roi-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
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>
|