Canvas和Konva结合——实现手动标记图像特定位置目标功能,记录坐标,命名后保存
页面效果如下图:
功能需求简介:
对各种图像数据进行样本采集(标注),生成样本文件。客户端操作需要就是上传图像数据后,可以进行手动标注图像目标,同时对标注目标进行命名,和当前标注目标的坐标对应保存生成xml文件作为样本。本文介绍的就是如上图所示,运用canvas和konva来实现此功能,首先你需要安装Konva,然后main.js引入
import Konva from 'konva'
Vue.use(Konva)
vue代码如下:
<div class="canvasbox" ref="canvasbox">
<div class="el-image-viewer__canvas">
<canvas id="previewCanvas" :style="{
transform:'scale(' + scale + ',' + scale + ') ' + 'translate3d(' +x / scale + 'px,' + y / scale + 'px,' + '0)' + 'rotateZ(' + rotate * 90 + 'deg)',
}"
:width="trueWidth"
:height="trueHeight"></canvas>
<div class="occlusion">
<div class="img_box">
<div
id="markcontainer"
ref="markcontainer"
:style="{
transform: 'scale(' + scale + ',' + scale + ') ' + 'translate3d(' + x / scale + 'px,' + y / scale + 'px,' + '0)' + 'rotateZ(' + rotate * 90 + 'deg)',
cursor:crosshair
}"
></div>
</div>
</div>
</div>
</div>
data() {
return {
mouseMovement:false,//鼠标在画布上是否移动标注画框
// isDragDrop:false,//拖拽功能切换
crosshair:'',//鼠标显示形状,决定是否可以拖拽
previewCanvas:null,
w: 0,// 容器宽
h: 0,// 容器高
scale: 1,// 图片缩放比例
x: 0,// 图片偏移x轴
y: 0,// 图片偏移y轴
trueWidth: 0,// 图片真实宽度
trueHeight: 0,// 图片真实高度
move: true,
moveX: 0,// 移动的x
moveY: 0, // 移动的y
support: "",// 支持的滚动事件
touches: [],// 移动端手指缩放
touchNow: false,
rotate: 0,// 图片旋转
isIos: false,
orientation: 0,
coe: 0.2,// 图片缩放系数
scaling: false,// 是否正在多次缩放
scalingSet: "",
coeStatus: "",
isCanShow: true,// 控制emit触发频率
imgList:[],
/**/
width: 0,
height: 0,
deg: 0, //画布旋转
ratio: 1, //图片缩放比
stage: null,
layer: null,
graphNow: null, // 当前图形
flag: "rectH",
drawing: false, // 绘制中
pointStart: [], // 初始坐标
dialogInput: false,
imgTotal: 0,
selfCurrentRectInfo: [], //自己标注的坐标用来回显
coordinateObj: {}, //回显自己标注的坐标对象
restaurants: [],
state: '',
};
},
props: {
img: {
type: [String, Blob, null, File],
default: ""
},
// 输出图片压缩比
outputSize: {
type: Number,
default: 1
},
outputType: {
type: String,
default: "jpeg"
},
info: {
type: Boolean,
default: true
},
// 是否开启滚轮放大缩小
canScale: {
type: Boolean,
default: true
},
// 是否开启固定宽高比
fixed: {
type: Boolean,
default: false
},
// 宽高比 w/h
fixedNumber: {
type: Array,
default: () => {
return [1, 1];
}
},
// 是否可以拖动图片
canMove: {
type: Boolean,
default: true
},
// 上传图片按照原始比例显示
original: {
type: Boolean,
default: false
},
// 是否根据dpr输出高清图片
high: {
type: Boolean,
default: true
},
// 可以压缩图片宽高 默认不超过200
maxImgSize: {
type: [Number, String],
default: 2000
},
// 倍数 可渲染当前截图框的n倍 0 - 1000;
enlarge: {
type: [Number, String],
default: 1
},
// 自动预览的固定宽度
preW: {
type: [Number, String],
default: 0
},
mode: {
type: String,
default: "contain"
},
limitMinSize: {
type: [Number, Array, String],
default: () => {
return 10;
}
},
},
实现功能包含对图像进行标注、旋转、缩放、拖拽、复位等基本操作浏览功能,根据自己需要参考吧(以下代码只是记录实现思路和方法,如有错误的地方可自行补充)
beforeDestroy() {
//清除监听事件
window.removeEventListener("keydown", this.handleKey);
},
methods: {
//canvas实现将图像绘制到画布上
markInitCanvas: function (imgData, canvas, hasClear=true) {
let
ctx = canvas.getContext('2d'),
canvasW = this.trueWidth,
canvasH = this.trueHeight;
if (hasClear) {
ctx.clearRect(0, 0, canvasW, canvasH);
}
let img = new Image();
let self = this;
img.src = imgData.fileUrl;
img.onload = function () {
ctx.drawImage(img, 0, 0, self.trueWidth, self.trueHeight);
};
},
// 快捷键绑定以及鼠标状态切换同步实现是否可以拖拽图像
handleKey: function (event) {
let evt = window.event || event;
let keyCode = evt.keyCode;
if (keyCode == 87) {
this.stage.show()
this.crosshair === '' ? this.crosshair = 'crosshair' : this.crosshair = ''
this.currentOperation = 'mark'
}
if ( !this.dialogInput && !this.defaultFocus) {
if (keyCode == 68) {
this.next()
this.stage.show()
}
if (keyCode == 65) {
this.prev()
this.stage.show()
}
}
//ctrl + s
if (keyCode == 83 && (navigator.platform.match("Mac") ? evt.metaKey : evt.ctrlKey)) {
evt.preventDefault();
this.Operationsave()
}
},
// 缩放图片
scaleImg: function () {
if (this.canScale) {
window.addEventListener(this.support, this.changeSize, this.passive);
}
},
// 改变大小函数
changeSize: function (e) {
e.preventDefault();
let scale = this.scale;
var change = e.deltaY || e.wheelDelta;
// 根据图片本身大小 决定每次改变大小的系数, 图片越大系数越小
var isFirefox = navigator.userAgent.indexOf("Firefox");
change = isFirefox > 0 ? change * 30 : change;
// 修复ie的滚动缩放
if (this.isIE) {
change = -change;
}
// 1px - 0.2
var coe = this.coe;
coe =
coe / this.trueWidth > coe / this.trueHeight
? coe / this.trueHeight
: coe / this.trueWidth;
var num = coe * change;
num < 0
? (scale += Math.abs(num))
: scale > Math.abs(num)
? (scale -= Math.abs(num))
: scale;
// 延迟0.1s 每次放大大或者缩小的范围
let status = num < 0 ? "add" : "reduce";
if (status !== this.coeStatus) {
this.coeStatus = status;
this.coe = 0.2;
}
if (!this.scaling) {
this.scalingSet = setTimeout(() => {
this.scaling = false;
this.coe = this.coe += 0.01;
}, 50);
}
this.scaling = true;
this.scale = scale;
},
// 修改图片大小函数
changeScale: function (num) {
let scale = this.scale;
num = num || 1;
var coe = 20;
coe =
coe / this.trueWidth > coe / this.trueHeight
? coe / this.trueHeight
: coe / this.trueWidth;
num = num * coe;
num > 0
? (scale += Math.abs(num))
: scale > Math.abs(num)
? (scale -= Math.abs(num))
: scale;
this.scale = scale;
},
// 自动预览函数
showPreview: function () {
// 优化不要多次触发
if (this.isCanShow) {
this.isCanShow = false;
setTimeout(() => {
this.isCanShow = true;
}, 16);
} else {
return false;
}
},
mark: function (currentOperation) {
this.currentOperation = currentOperation
this.stage.show();
this.crosshair = 'crosshair'
},
// 监听鼠标滚轮事件
checkDivScroolTop: function () {
//绑定事件
let that = this
this.$refs.canvasbox.addEventListener("mousewheel", function() {
if ( event.deltaY > 0 ) {
that.changeScale(1)
}
if (event.deltaY < 0) {
that.changeScale(-1)
}
});
},
//图像左旋转
rotateLeft: function () {
this.rotate = this.rotate <= -3 ? 0 : this.rotate - 1;
if (this.deg <= 0) {
this.deg = 360;
}
this.deg -= 90;
},
//图像右旋转
rotateRight: function () {
this.rotate = this.rotate >= 3 ? 0 : this.rotate + 1;
this.deg += 90;
if (this.deg >= 360) {
this.deg = 0;
}
},
//图像复位
reset: function () {
this.scale = 1
this.rotate = 0
this.x = 0
this.y = 0
},
// 获取上传图片的宽高方法
loadImageAsync: function (url) {
return new Promise(function(resolve, reject) {
const image = new Image();
image.onload = function() {
resolve(image);
};
image.onerror = function() {
reject(new Error("Could not load image at " + url));
};
image.src = url;
});
},
}
konva实现在图像上画框等操作
//canvas画布
initCanvas: function(width, height, hasHide) {
this.stage = new Konva.Stage({
container: "markcontainer",
width: width,
height: height
// draggable: true,
});
this.layer = new Konva.Layer({
zIndex: 20
});
let self = this;
this.stage.add(self.layer);
this.stage.on("mousedown", self.mouseDown);
this.stage.on("mousemove", self.mouseMove);
this.stage.on("mouseup", self.mouseUp);
if (hasHide) this.stage.hide();
},
mouseDown: function(e) {
if( this.crosshair != 'crosshair') {
this.dialogInput = false
this.move = true
this.startMove()
return
}
if( !this.mouseMovement && this.state === "" && this.dialogInput === true) {
return
}
if (e.target === this.stage) {
let x = e.evt.offsetX,
y = e.evt.offsetY;
this.pointStart = [x, y];
this.drawRect(x, y, 0, 0);
this.drawing = true;
// 移除图形选择框
this.stage.find("Transformer").destroy();
this.layer.draw();
return;
}
// 如果没有匹配到就终止往下执行
if (
!e.target.hasName("line") &&
!e.target.hasName("ellipse") &&
!e.target.hasName("rect") &&
!e.target.hasName("circle")
) {
return;
}
// 移除图形选择框
this.stage.find("Transformer").destroy();
// 当前点击的对象赋值给graphNow
this.graphNow = e.target;
// 创建图形选框事件
const tr = new Konva.Transformer({
borderStroke: "#000", // 虚线颜色
borderStrokeWidth: 1, //虚线大小
borderDash: [5], // 虚线间距
keepRatio: false // 不等比缩放
});
this.layer.add(tr);
tr.attachTo(e.target);
this.layer.draw();
},
mouseMove: function(e) {
if( this.defaultNameChecked) this.state = this.defaultNameInput;
if( !this.state && this.defaultNameChecked ) return;
if (this.graphNow && this.flag && this.drawing) {
this.graphNow.setAttrs({
width: e.evt.offsetX - this.pointStart[0],
height: e.evt.offsetY - this.pointStart[1]
});
this.layer.draw();
this.mouseMovement = true
}
},
mouseUp: function(e) {
if( this.crosshair != 'crosshair') {
this.dialogInput = false
return
}
this.drawing = false;
// 弹窗写文字
if( this.defaultNameChecked ) {
this.dialogInput = false;
this.state = this.defaultNameInput
this.submitText()
return
}
if( !this.mouseMovement ) {
this.dialogInput = true;
this.$refs.selectfocus.focus()
return
}
this.mouseMovement = false
if ( !this.defaultNameChecked ) {
this.dialogInput = true;
// console.log(e.evt.offsetX,e.evt.offsetY)
this.tranLeft = (e.evt.offsetX + this.$refs.markcontainer.offsetLeft + this.x / this.scale ) + 'px'
this.tranTop = (e.evt.offsetY + this.$refs.markcontainer.offsetTop + this.y / this.scale ) + 'px'
this.focusTimer = setTimeout( () => {
this.state = ''
this.$refs.selectfocus.focus()
}, 50);
}
},
querySearch: function (queryString, cb) {
// var restaurants = this.restaurants;
// var results = queryString ? restaurants.filter(this.createFilter(queryString)) : restaurants;
// 调用 callback 返回建议列表的数据
cb(this.restaurants);
},
createFilter: function(queryString) {
return (restaurant) => {
return (restaurant.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0);
};
},
handleSelect: function (item) {
this.dialogInput = false
this.state = item.value
this.submitText()
},
parentboxBlur: function () {
if( !this.status && !this.mouseMovement ) {
this.dialogInput = true
this.focusTimer = setTimeout( () => {
this.$refs.selectfocus.focus()
}, 50);
return
}
this.mouseMovement = false
},
submitText: function() {
if (!this.state) {
this.$confirm('请输入标注名称', {
closeOnClickModal: false,
type: 'warning',
})
return
}
this.state = this.state.replace(/[^a-zA-Z]/g,'')
if (!this.typeTextList.includes(this.state)) {
this.typeTextList.push(this.state);
this.restaurants.push({value:this.state})
}
this.dialogInput = false;
let { attrs } = this.graphNow;
let rectInfo = {
labelName: this.state,
upperLeftCoorX: attrs.x,
upperLeftCoorY: attrs.y,
lowerRightCoorX: attrs.x + attrs.width,
lowerRightCoorY: attrs.y + attrs.height,
imageId:this.curData[this.curIndex].imageId,
ratio:this.ratio
};
this.currentRectInfo.push(rectInfo);
},
cancelSubmission: function() {
this.nameCheck = false
this.dialogInput = false;
},
drawRect: function(x, y, w, h) {
const rect = new Konva.Rect({
name: "rect",
x: x,
y: y,
width: w,
height: h,
fill: "rgba(255,0,0,0.3)",
stroke: "red",
strokeWidth: 2,
opacity: 1,
scale: 1,
draggable: true
// fillcolor:'rgba(0,255,0,0.5)'
});
this.graphNow = rect;
this.layer.add(rect);
this.layer.draw();
const self = this;
//双击标注框删除自己
rect.addEventListener("dblclick", () => {
if ( self.crosshair === 'crosshair' ) {
self.dialogInput = false
self.currentRectInfo.splice(self.graphNow.index,1)
rect.remove();
self.stage.find("Transformer").destroy();
self.layer.draw();
}
});
},
draw: function(url) {
var img = new Image();
let self = this;
img.onload = function() {
self.ctx.drawImage(img, 0, 0);
};
img.src = url;
},
实现图像拖拽
/**图像拖拽功能 */
// 当按下鼠标键
startMove:function(event) {
let e = window.event || event;
e.preventDefault();
// 如果move 为true 表示当前可以拖动
if (this.move) {
if (!this.canMove) {
return false;
}
// 开始移动
this.moveX = (e.clientX ? e.clientX : e.touches[0].clientX) - this.x;
this.moveY = (e.clientY ? e.clientY : e.touches[0].clientY) - this.y;
if (e.touches) {
window.addEventListener("touchmove", this.moveImg);
window.addEventListener("touchend", this.leaveImg);
} else {
window.addEventListener("mousemove", this.moveImg);
window.addEventListener("mouseup", this.leaveImg);
}
}
},
cancelTouchScale:function(e) {
window.removeEventListener("touchmove", this.touchScale);
},
// 移动图片
moveImg:function(e) {
e.preventDefault();
if (e.touches && e.touches.length === 2) {
this.touches = e.touches;
window.addEventListener("touchmove", this.touchScale);
window.addEventListener("touchend", this.cancelTouchScale);
window.removeEventListener("touchmove", this.moveImg);
return false;
}
let nowX = e.clientX ? e.clientX : e.touches[0].clientX;
let nowY = e.clientY ? e.clientY : e.touches[0].clientY;
let changeX, changeY;
changeX = nowX - this.moveX;
changeY = nowY - this.moveY;
this.$nextTick(() => {
this.x = changeX;
this.y = changeY;
});
},
// 移动图片结束
leaveImg:function(e) {
window.removeEventListener("mousemove", this.moveImg);
window.removeEventListener("touchmove", this.moveImg);
window.removeEventListener("mouseup", this.leaveImg);
window.removeEventListener("touchend", this.leaveImg);
},
touchmove:function() {
if (this.flags) {
let touch;
if (event.touches) {
touch = event.touches[0];
} else {
touch = event;
}
this.nx = touch.clientX - this.positions.x;
this.ny = touch.clientY - this.positions.y;
this.xPum = this.dx + this.nx;
this.yPum = this.dy + this.ny;
//添加限制:只允许在屏幕内拖动
const maxWidth = document.body.clientWidth - 54; //屏幕宽度减去悬浮框宽高
const maxHeight = document.body.clientHeight - 54;
if (this.xPum < 0) {
//屏幕x限制
this.xPum = 0;
} else if (this.xPum > maxWidth) {
this.xPum = maxWidth;
}
if (this.yPum < 0) {
//屏幕y限制
this.yPum = 0;
} else if (this.yPum > maxHeight) {
this.yPum = maxHeight;
}
this.$refs.cropper.style.left = this.xPum + "px";
this.$refs.cropper.style.top = this.yPum + "px";
//阻止页面的滑动默认事件
document.addEventListener(
"touchmove",
function() {
// 1.2 如果碰到滑动问题,请注意是否获取到 touchmove
event.preventDefault(); //jq 阻止冒泡事件
// event.stopPropagation(); // 如果没有引入jq 就用 stopPropagation()
},
false
);
}
},
代码至此完成
自己写的图片适应画布大小进行缩放的方法、有时间再来改进优化:
方法一
//判断当前显示图片宽高,自适应画布
changeImgSize: function (imgWidth,imgHeight) {
//固定盒子宽高
let fixedwidtn = 1300;
let fixedheight = 680;
if (imgWidth <= fixedwidtn && imgHeight <= fixedheight) {
// 当图片比图片框小时不做任何改变
this.trueWidth = imgWidth
this.trueHeight = imgHeight
this.ratio = 1
this.$refs.markcontainer.style.width = imgWidth+'px'
this.$refs.markcontainer.style.height = imgHeight+'px'
this.initCanvas(imgWidth, imgHeight, true);
} else if (imgWidth > fixedwidtn && imgHeight > fixedheight) {
// 当图片宽高都比图片框小大时
if (fixedwidtn / imgWidth >= fixedheight / imgHeight) {
//相比之下高大的更多,按照高来缩放
this.initCanvas((680 / imgHeight) * imgWidth, 680, true);
this.ratio = imgHeight / 680
this.trueWidth = (680 / imgHeight) * imgWidth
this.trueHeight = 680
this.$refs.markcontainer.style.width = (680 / imgHeight) * imgWidth + 'px'
this.$refs.markcontainer.style.height = 680 + 'px'
} else {
//根据宽来缩放
this.ratio = imgWidth / 1300
this.trueWidth = 1300
this.trueHeight = (1300 / imgWidth) * imgHeight
this.$refs.markcontainer.style.width = 1300 + 'px'
this.$refs.markcontainer.style.height = (1300 / imgWidth) * imgHeight + 'px'
this.initCanvas(1300, (1300 / imgWidth) * imgHeight, true);
}
} else if (imgWidth > fixedwidtn && imgHeight < fixedheight) {
//根据宽来缩放
this.ratio = imgWidth / 1300
this.trueWidth = 1300
this.trueHeight = (1300 / imgWidth) * imgHeight
this.$refs.markcontainer.style.width = 1300 + 'px'
this.$refs.markcontainer.style.height = (1300 / imgWidth) * imgHeight + 'px'
this.initCanvas(1300, (1300 / imgWidth) * imgHeight, true);
} else if (imgWidth < fixedwidtn && imgHeight > fixedheight) {
//根据高来缩放
if (imgWidth > imgHeight) {
}
if (imgWidth <= imgHeight) {
}
this.ratio = imgHeight / 680
this.trueWidth = (680 / imgHeight) * imgWidth
this.trueHeight = 680
this.$refs.markcontainer.style.width = (680 / imgHeight) * imgWidth + 'px'
this.$refs.markcontainer.style.height = 680 + 'px'
this.initCanvas((680 / imgHeight) * imgWidth, 680, true);
}
},
方法二
//判断当前显示图片宽高,自适应画布
changeImgSize: function (imgWidth,imgHeight) {
if (actImgW > canvasW || actImgH > canvasH) {
if (actImgW / actImgH >= canvasW / canvasH) {
imgW = canvasW;
rate = actImgW / canvasW;
imgH = actImgH / rate;
top = (canvasH - imgH) / 2;
} else {
imgH = canvasH;
rate = actImgH / canvasH;
imgW = actImgW / rate;
left = (canvasW - imgW) / 2;
}
} else {
left = (canvasW - imgW) / 2;
top = (canvasH - imgH) / 2;
}
},
总结:
1、实现思路及原理:
1)思路:操作区域由两部分组成(两层组合来实现),一是用canvas绘制的图像,二是konva生成画布进行手动画框操作。首先利用canvas将要进行标注的图像绘制到画布上,在页面中显示,这一过程中可添加对图像的上传格式、大小、等进行校验限制。在绘制的时候,需对图像进行缩放处理。页面将图像绘制完成后,通过点击操作按钮或者快捷键来切换鼠标样式stage.show来进行手动画框,标注。
2)原理:canvas绘制的图像,和konva生成的画布重叠在一起,绑定相同的旋转、缩放参数,同步赋值来实现客户端图像缩放,旋转的效果。坐标回显则是图像加载显示完成后,konva将获取到的该图像已有标注坐标绘制到画布上。
2、canvas默认宽高是300、150,为避免异常,最好使用显示属性添加而不通过css来添加宽高,canvas动态设置width和weight时需直接定义在标签,写在:style中动态赋值会不起作用
3、图像标注回显的时候,由于浏览器加载渲染的原因,将图像绘制到canvas画布中会慢于回显的坐标,造成坐标回显失败,添加定时器可解决。
4、在实现标注框可删除功能,给画框绑定监听事件时,需要注意要清除监听事件,不然会有你意想不到的bug。