一. 业务需求
需求背景是自研设备需要一个后台画线的功能, 在摄像头返回的画面上画线, 然后把坐标, 以及勾选的相关配置传给后端, 后端进行保存, 后续的机器识别基于这个配置, 对通过车辆进行车牌, 颜色等信息识别.
具体需求是:
1. 接收后台传递的图片、现有画线坐标和参数, 展示已有画线和勾选参数;
2.可重新划线进行修改并保存;
3. 点击测试按钮, 可以在下方列表展示测试结果,并展示更新后的划线图片.
本文主要介绍图片划线的模块.
二. 技术借鉴
基于本站fruge大佬的方案: vue2 canvas实现在图片上选点,画区域并将 坐标传递给后端
非常感谢fruge大佬, 您的方案是本文需求实现的基础.
原方案是绘制多边形区域的, 我做了相应的修改, 用于绘制线段.
扫描二维码关注公众号,回复:
17368136 查看本文章
三. 业务需求实现
1. 页面布局
画布:
<div class="canvas-wrap">
<canvas id="imgCanvas" ref="canvaxbox"></canvas>
<!--用来和鼠标进行交互操作的canvas-->
<canvas
id="drawCanvas"
ref="canvas"
:style="{ cursor: isDrawing ? 'crosshair' : 'default' }"
>
</canvas>
<!--存储已生成的点线,避免被清空-->
<canvas id="saveCanvas" ref="canvasSave"></canvas>
</div>
画三条线的按钮, 和清除标线的按钮:
<div class="button-group">
<a-button
class="upper-button"
:type="isDrawing ? 'warning' : 'primary'"
:disabled="submitData.length != 0 || drawedPoints.length != 0"
@click="startDraw"
>绘制左侧标线</a-button
>
<a-button
class="upper-button"
@click="startDraw"
:type="isDrawing ? 'warning' : 'primary'"
:disabled="drawedPoints.length != 1"
>绘制中间标线</a-button
>
<a-button
class="upper-button"
@click="startDraw"
:type="isDrawing ? 'warning' : 'primary'"
:disabled="drawedPoints.length != 2"
>绘制右侧标线</a-button
>
</div>
<div class="button-group">
<a-button
class="upper-button"
:disabled="isDrawing || drawedPoints.length == 0"
@click="clearAll"
type="danger"
>清除标线</a-button
>
</div>
2. 核心逻辑
<script>
// 我的接口引入
import { reqPic, reqPostConfig, reqTestConfig } from '@/api/camera'
export default {
data() {
return {
// 这里可以先网上找个图做测试
imgUrl: '',
isDrawing: false, // 是否正在绘制
ratio: 1,
// 配置画布的大小, 需要跟后台商量返回图片的大小和比例, 便于后续的坐标计算
imgWidth: '863px',
imgHeight: '360px',
wrapWidth: '863px',
wrapHeight: '360px',
canvasWidth: '863px',
canvasHeight: '360px',
drawingPoints: [],
drawedPoints: [],
imgCanvas: null,
imgCtx: null,
drawCanvas: null,
drawCtx: null,
saveCanvas: null,
saveCtx: null,
// 暂且写死的坐标用于测试效果
submitData: [
{
polygon: {
x1: 330,
y1: 200,
x2: 190,
y2: 350,
},
},
{
polygon: {
x1: 440,
y1: 200,
x2: 430,
y2: 350,
},
},
{
polygon: {
x1: 535,
y1: 200,
x2: 638,
y2: 350,
},
},
],
loadData: [],
loading: false,
picData: {},
submitPoints: {},
parameter: {},
}
},
mounted() {
this.$nextTick(() => {
this.initCanvas()
// this.initImgCanvas() // Call initImgCanvas here
this.getImage()
})
},
methods: {
initCanvas() {
// 初始化canvas画布
let canvasWrap = document.getElementsByClassName('canvas-wrap')
console.log(canvasWrap)
this.wrapWidth = canvasWrap[0].clientWidth
this.wrapHeight = canvasWrap[0].clientHeight
this.imgCanvas = document.getElementById('imgCanvas')
this.imgCtx = this.imgCanvas.getContext('2d')
// 绘制canvas
this.drawCanvas = document.getElementById('drawCanvas')
this.drawCtx = this.drawCanvas.getContext('2d')
// 保存绘制区域 saveCanvas
this.saveCanvas = document.getElementById('saveCanvas')
this.saveCtx = this.saveCanvas.getContext('2d')
// this.initImgCanvas()
},
initImgCanvas() {
// 计算宽高比
let ww = this.wrapWidth // 画布宽度
let wh = this.wrapHeight // 画布高度
let iw = this.imgWidth // 图片宽度
let ih = this.imgHeight // 图片高度
if (iw / ih < ww / wh) {
// 以高为主
this.ratio = ih / wh
this.canvasHeight = wh
this.canvasWidth = (wh * iw) / ih
} else {
// 以宽为主
this.ratio = iw / ww
this.canvasWidth = ww
this.canvasHeight = (ww * ih) / iw
}
// 初始化画布大小
this.imgCanvas.width = this.canvasWidth
this.imgCanvas.height = this.canvasHeight
this.drawCanvas.width = this.canvasWidth
this.drawCanvas.height = this.canvasHeight
this.saveCanvas.width = this.canvasWidth
this.saveCanvas.height = this.canvasHeight
// 图片加载绘制
let img = document.createElement('img')
img.src = this.imgUrl
img.onload = () => {
console.log('图片已加载')
this.imgCtx.drawImage(img, 0, 0, this.canvasWidth, this.canvasHeight)
this.renderDatas() // 渲染原有数据
}
},
startDraw() {
// 绘制区域
if (this.isDrawing) return
this.isDrawing = true
// 绘制逻辑
this.drawCanvas.addEventListener('click', this.drawImageClickFn)
this.drawCanvas.addEventListener('dblclick', this.drawImageDblClickFn)
this.drawCanvas.addEventListener('mousemove', this.drawImageMoveFn)
},
clearAll() {
// 清空所有绘制区域
this.saveCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.drawedPoints = []
this.submitData = []
},
// 从后台获取图片并整理坐标的逻辑
async getImage() {
const res = await reqPic()
console.log('pic data', res)
// 后台给我的是base64格式, 这里加上必要的前缀, 图片即可显示
this.imgUrl = 'data:image/png;base64,' + res.scribing_image
const reqPoints = res.coordinate
// 遍历 reqPoints 中的对象
for (let key in reqPoints) {
// 获取子对象或数组
let subObjectOrArray = reqPoints[key]
// 打印子对象或数组
// console.log(subObjectOrArray)
// 检查是否为数组
if (Array.isArray(subObjectOrArray) && subObjectOrArray.length === 4) {
// 将数组中的每个数字除以6
let modifiedArray = subObjectOrArray.map((num) =>
(Number(num) / 6).toFixed()
)
// 在 reqPoints 对象中更新数组
reqPoints[key] = modifiedArray
}
}
// 打印修改后的 reqPoints 对象
console.log('drawline points', reqPoints)
// 整理从后台获取的坐标进行绘制
this.submitData = [
{
polygon: {
x1: reqPoints.lineone[0],
y1: reqPoints.lineone[1],
x2: reqPoints.lineone[2],
y2: reqPoints.lineone[3],
},
},
{
polygon: {
x1: reqPoints.linetwo[0],
y1: reqPoints.linetwo[1],
x2: reqPoints.linetwo[2],
y2: reqPoints.linetwo[3],
},
},
{
polygon: {
x1: reqPoints.linethree[0],
y1: reqPoints.linethree[1],
x2: reqPoints.linethree[2],
y2: reqPoints.linethree[3],
},
},
]
// console.log('base64 file', this.imgUrl)
this.imgWidth = 683
this.imgHeight = 360
this.imgUrl && this.initImgCanvas()
},
// 这里对用户行为进行了限制, 只允许单击一次设置起点, 然后双击设置终点, 完成一条线的绘制
drawImageClickFn(e) {
let drawCtx = this.drawCtx
if (e.offsetX || e.layerX) {
let pointX = e.offsetX == undefined ? e.layerX : e.offsetX
let pointY = e.offsetY == undefined ? e.layerY : e.offsetY
// Check if there are fewer than 1 endpoints
if (this.drawingPoints.length < 1) {
let lastPoint =
this.drawingPoints[this.drawingPoints.length - 1] || []
// Check if the current point is different from the last recorded point
if (lastPoint[0] !== pointX || lastPoint[1] !== pointY) {
this.drawingPoints.push([pointX, pointY])
}
}
}
},
drawImageMoveFn(e) {
let drawCtx = this.drawCtx
if (e.offsetX || e.layerX) {
let pointX = e.offsetX == undefined ? e.layerX : e.offsetX
let pointY = e.offsetY == undefined ? e.layerY : e.offsetY
// 绘制
drawCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 绘制点
drawCtx.fillStyle = 'yellow'
this.drawingPoints.forEach((item, i) => {
drawCtx.beginPath()
drawCtx.arc(...item, 6, 0, 180)
drawCtx.fill() //填充
})
// 绘制动态区域
drawCtx.save()
drawCtx.beginPath()
this.drawingPoints.forEach((item, i) => {
drawCtx.lineTo(...item)
})
drawCtx.lineTo(pointX, pointY)
drawCtx.lineWidth = '3'
drawCtx.strokeStyle = 'yellow'
drawCtx.fillStyle = 'rgba(255, 0, 0, 0.3)'
drawCtx.stroke()
drawCtx.fill() //填充
drawCtx.restore()
}
},
drawImageDblClickFn(e) {
let drawCtx = this.drawCtx
let saveCtx = this.saveCtx
if (e.offsetX || e.layerX) {
let pointX = e.offsetX == undefined ? e.layerX : e.offsetX
let pointY = e.offsetY == undefined ? e.layerY : e.offsetY
let lastPoint = this.drawingPoints[this.drawingPoints.length - 1] || []
if (lastPoint[0] !== pointX || lastPoint[1] !== pointY) {
this.drawingPoints.push([pointX, pointY])
}
}
// 清空绘制图层
drawCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 绘制区域至保存图层
this.drawSaveArea(this.drawingPoints)
this.drawedPoints.push(this.drawingPoints)
this.drawingPoints = []
this.isDrawing = false
// 绘制结束逻辑
this.drawCanvas.removeEventListener('click', this.drawImageClickFn)
this.drawCanvas.removeEventListener('dblclick', this.drawImageDblClickFn)
this.drawCanvas.removeEventListener('mousemove', this.drawImageMoveFn)
},
drawSaveArea(points) {
// console.log(points, "points");
if (points.length === 0) return
this.saveCtx.save()
this.saveCtx.beginPath()
points.forEach((item, i) => {
this.saveCtx.lineTo(...item)
})
this.saveCtx.closePath()
this.saveCtx.lineWidth = '5'
this.saveCtx.fillStyle = 'rgba(255,0, 255, 0.3)'
this.saveCtx.strokeStyle = 'yellow'
this.saveCtx.stroke()
this.saveCtx.fill() //填充
this.saveCtx.restore()
},
savePoints() {
// 将画布坐标数据转换成提交数据
let objectPoints = []
objectPoints = this.drawedPoints.map((area) => {
let polygon = {}
area.forEach((point, i) => {
polygon[`x${i + 1}`] = Math.round(point[0] * this.ratio)
polygon[`y${i + 1}`] = Math.round(point[1] * this.ratio)
})
return {
polygon: polygon,
}
})
const coordinates = {
lineone: [],
linetwo: [],
linethree: [],
}
// 与后台商议, 把原图缩放6倍, 所以提交给后台之前, 也要相应地乘以6
objectPoints.forEach((data, index) => {
const { x1, y1, x2, y2 } = data.polygon
coordinates[
`${
index + 1 === 1
? 'lineone'
: index + 1 === 2
? 'linetwo'
: 'linethree'
}`
] = [x1 * 6, y1 * 6, x2 * 6, y2 * 6]
})
console.log('converted points', coordinates)
this.submitPoints = coordinates
console.log('最终提交坐标', this.submitPoints)
// 我在这里做了一些对用户行为限制的判断, 包括上下颠倒, 三条线的顺序等, 各位可以根据实际需要进行配置
// 构建要发送到后台的数据
let dataToSend = {}
dataToSend = {
...dataToSend,
coordinates: coordinates,
}
console.log('combined value', dataToSend)
this.parameter = dataToSend
this.postConfig()
},
async postConfig() {
const res = await reqPostConfig(this.parameter)
if (res.result_code === 200) {
this.$message.success('保存配置成功')
} else {
this.$message.error('配置失败')
}
},
renderDatas() {
// 将提交数据数据转换成画布坐标
this.drawedPoints = this.submitData.map((item) => {
let polygon = item.polygon
let points = []
for (let i = 1; i < Object.keys(polygon).length / 2 + 1; i++) {
if (!isNaN(polygon[`x${i}`]) && !isNaN(polygon[`y${i}`])) {
points.push([
polygon[`x${i}`] / this.ratio,
polygon[`y${i}`] / this.ratio,
])
}
}
this.drawSaveArea(points)
return points
})
},
},
}
</script>
3. 样式
请参考即可, 为了使上述第一小节的模版布局更清晰, 在第一小节我删掉了不必要的父元素
<style lang="less" scoped>
.main {
margin: 0;
padding: 0;
}
.upper {
display: flex;
height: 400px;
width: 100%;
margin-bottom: -25px;
.upper-left {
background-color: skyblue;
width: 683px;
min-width: 683px;
height: 360px;
min-height: 360px;
position: relative;
}
.upper-right {
margin: 0 0 0 20px;
display: flex;
flex-direction: column;
.draw-buttons {
display: flex;
flex-direction: column;
.button-group {
margin-bottom: 10px;
.upper-button {
margin: 0 10px 5px 0;
height: 5.5vh;
width: 8.5vw;
font-size: 1vw;
font-weight: bold;
}
}
}
.function {
width: 27vw;
margin: -20px 0;
.submit-button {
height: 5.5vh;
width: 8vw;
font-size: 16px;
margin: 0 0 0 2.5vw;
font-weight: bold;
}
}
}
}
.canvas-wrap {
width: 683px;
height: 360px;
min-width: 683px;
min-height: 360px;
// margin: 0px auto;
background-color: skyblue;
position: relative;
}
#imgCanvas,
#drawCanvas,
#saveCanvas {
background: rgba(255, 0, 255, 0);
position: absolute;
// top: 50%;
// left: 50%;
// transform: translate(-50%, -50%);
width: 683px;
height: 360px;
}
#drawCanvas {
z-index: 2;
}
</style>
四. 总结
本项目的目标用户主要是本公司其他部门的调试人员, 加上后边还排着其他的项目(其他前端同事都离职了, 我必须都得顶起来哇), 所以没有过多优化, 更多的是对用户行为进行了限制, 一定程度上影响了用户体验. 划线的动作应该是有优化空间的.
最后祝各位开发顺利~~