背景:我们有一个摄像的产品,拍照传统的水表盘面,我们需要框选水表读数,标注点传到后端,后端根据标注点自动去截取摄像表拍摄回来的图片,然后拿到大模型里面进行训练。由于同一只表拍摄的画面都是一样的,所以按此方法减少了人工标注的繁琐工作
可关注,参考另外一篇文章:利用fabricjs 实现图片的标注,把标注像素点传入到后端
解锁前端难题:亲手实现一个图片标注工具
《T 恤图案编辑器》
《T 恤图案编辑器》-源码
实现一个轻量 fabric.js 系列一(摸透 canvas)
遗留问题:
1、矩形框旋转后,鼠标悬浮在缩放标注点的位置上,鼠标的样式无法旋转角度
2、矩形框旋转后,拖动缩放的标准变了
备注:经测试,不管怎么变化,传入到后端的像素点是对的
一、效果图
二、问题分解
三、源代码
<template>
<div
:style="{
width: canvasProp.width + 'px',
height: canvasProp.height + 'px',
border: '1px solid #ccc'
}"
>
<canvas
ref="canvas"
:width="canvasProp.width"
:height="canvasProp.height"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
:style="{
width: canvasProp.width + 'px',
height: canvasProp.height + 'px'
}"
></canvas>
<div @click="saveData">保存数据</div>
<div @click="zoomBig">放大</div>
<div @click="zoomSmall">缩小</div>
</div>
</template>
<script>
export default {
name: "images-tags",
props: {
// 矩形标注的数据
tagsData: {
type: Array,
default: () => {
return [
{
label: "基表数据",
color: "#0000ff",
type: "rectangle",
width: 150,
height: 50,
rotate: 0,
isInit: true,
startX: 185,
startY: 235
}
];
}
},
// 图片路径
images: {
type: String,
default: "/img/yejing1.jpg"
}
},
data() {
return {
ctx: null,
cursorClass: "",
initCenterX: 0,
initCenterY: 0,
rotateImages: null, //旋转图标是否加载
bgImage: null, //背景图是否加载
canvasProp: {
width: 0, // canvas的宽度
height: 0, // canvas的高度
scale: 1, // canvas的缩放比例
scaleX: 0,
scaleY: 0,
translateX: 0,
translateY: 0
},
selectedTag: null, // 当前选中的矩形框
isResizing: false,
isDragging: false,
isRotating: false,
resizeHandle: null,
dragOffsetX: 0,
dragOffsetY: 0,
mouseDownX: 0,
mouseDownY: 0,
initialRotation: 0,
isCanvasDraging: false
};
},
mounted() {
this.loadImageAndSetCanvas();
window.addEventListener("keydown", this.handleKeyDown);
window.addEventListener("wheel", this.onWheel, { passive: false });
console.log("保存的数据===", this.tagsData);
},
beforeDestroy() {
window.removeEventListener("keydown", this.handleKeyDown);
window.removeEventListener("wheel", this.onWheel);
},
methods: {
zoomBig() {
this.zoom(true, this.initCenterX, this.initCenterY);
},
zoomSmall() {
this.zoom(false, this.initCenterX, this.initCenterY);
},
onWheel(event) {
if (event.ctrlKey) {
// detect pinch
event.preventDefault(); // prevent zoom
this.zoom(event.deltaY < 0, event.offsetX, event.offsetY);
}
},
zoom(iszoomBig, zoomCenterX, zoomCenterY) {
if (iszoomBig) {
console.log("Pinching 放大");
if (this.canvasProp.scale < 3) {
this.canvasProp.scaleX = zoomCenterX;
this.canvasProp.scaleY = zoomCenterY;
this.canvasProp.scale = Math.min(this.canvasProp.scale + 0.1, 3);
}
this.drawTags();
} else {
if (this.canvasProp.scale > 1) {
this.canvasProp.scaleX = zoomCenterX;
this.canvasProp.scaleY = zoomCenterY;
this.canvasProp.scale = Math.max(this.canvasProp.scale - 0.1, 1);
this.drawTags();
}
}
},
computexy(x, y) {
let { scaleX, scale, scaleY, translateX, translateY } = this.canvasProp;
const xy = {
// x: x / scale - translateX,
// y: y / scale - translateY,
offsetX: (x - scaleX * (1 - scale) - translateX * scale) / scale,
offsetY: (y - scaleY * (1 - scale) - translateY * scale) / scale
};
return xy;
},
computewh(width, height) {
return {
width: width / scale,
height: height / scale
};
},
handleKeyDown(event) {
console.log("event.key", event.key);
const step = 10; // 每次移动的步长
switch (event.key) {
case "ArrowUp":
this.canvasProp.translateY -= step;
break;
case "ArrowDown":
this.canvasProp.translateY += step;
break;
case "ArrowLeft":
this.canvasProp.translateX -= step;
break;
case "ArrowRight":
this.canvasProp.translateX += step;
break;
}
this.drawTags(); // 重新绘制画布
},
saveData() {
console.log("保存的数据", this.tagsData);
let pointData = this.getPointData();
console.log("pointData", pointData);
this.setPointData(pointData);
// this.$emit("saveData",this.setPointData(pointData));
},
getPointData() {
const result = this.tagsData.map(tag => {
const { startX, startY, width, height, rotate } = tag;
const centerX = startX + width / 2;
const centerY = startY + height / 2;
const points = [
{ x: startX, y: startY }, // Top-left
{ x: startX + width, y: startY }, // Top-right
{ x: startX + width, y: startY + height }, // Bottom-right
{ x: startX, y: startY + height } // Bottom-left
];
const rotatedPoints = points.map(point => {
const dx = point.x - centerX;
const dy = point.y - centerY;
const rotatedX =
centerX +
dx * Math.cos((rotate * Math.PI) / 180) -
dy * Math.sin((rotate * Math.PI) / 180);
const rotatedY =
centerY +
dy * Math.cos((rotate * Math.PI) / 180) +
dx * Math.sin((rotate * Math.PI) / 180);
return [Math.round(rotatedX), Math.round(rotatedY)];
});
return rotatedPoints;
});
return result;
},
setPointData(result) {
const newTagData = result.map(points => {
const [p1, p2, p3, p4] = points;
const centerX = (p1[0] + p3[0]) / 2;
const centerY = (p1[1] + p3[1]) / 2;
const width = Math.sqrt(
Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)
);
const height = Math.sqrt(
Math.pow(p4[0] - p1[0], 2) + Math.pow(p4[1] - p1[1], 2)
);
const rotate =
Math.atan2(p2[1] - p1[1], p2[0] - p1[0]) * (180 / Math.PI);
return {
label: "新矩形", // 可以根据需要更改标签
color: "#0000ff", // 可以根据需要更改颜色
type: "rectangle",
startX: centerX - width / 2,
startY: centerY - height / 2,
width: width,
height: height,
rotate: rotate,
isInit: false
};
});
console.log("newTagData", newTagData);
return newTagData;
//this.tagsData = newTagData;
//this.drawTags();
},
loadImageAndSetCanvas() {
const img = new Image();
img.src = this.images;
img.onload = () => {
this.bgImage = img;
this.canvasProp.width = img.width;
this.canvasProp.height = img.height;
this.initCenterX = img.width / 2;
this.initCenterY = img.height / 2;
this.ctx = this.$refs.canvas.getContext("2d");
this.$nextTick(() => {
this.drawTags();
});
};
},
drawTags() {
this.ctx.clearRect(0, 0, this.canvasProp.width, this.canvasProp.height);
this.ctx.save();
if (this.bgImage) {
//画布缩放
this.ctx.translate(this.canvasProp.scaleX, this.canvasProp.scaleY);
this.ctx.scale(this.canvasProp.scale, this.canvasProp.scale);
this.ctx.translate(-this.canvasProp.scaleX, -this.canvasProp.scaleY);
//画布平移
this.ctx.translate(
this.canvasProp.translateX,
this.canvasProp.translateY
);
this.ctx.drawImage(
this.bgImage,
0,
0,
this.bgImage.width,
this.bgImage.height
);
this.tagsData.forEach(tag => {
if (tag.type === "rectangle") {
this.drawRectangle(tag);
}
});
}
this.ctx.restore();
},
rotateExec(tag) {
let { startX, startY, width, height, rotate } = tag;
this.ctx.translate(startX + width / 2, startY + height / 2);
this.ctx.rotate((rotate * Math.PI) / 180);
this.ctx.translate(-(startX + width / 2), -(startY + height / 2));
},
//手动添加输入框的时候
drawRectangle(tag) {
const { label, color, width, height, rotate, isInit } = tag;
if (isInit) {
tag.startX = this.initCenterX - width / 2;
tag.startY = this.initCenterY - height / 2;
}
// 旋转矩形框,平移-旋转-平移到原来
this.rotateExec(tag);
this.ctx.save();
// Draw the rectangle
this.ctx.beginPath();
this.ctx.rect(tag.startX, tag.startY, width, height);
this.ctx.fillStyle = this.hexToRgba(color, 0.2);
this.ctx.fill();
this.ctx.lineWidth = 2;
this.ctx.strokeStyle = color;
this.ctx.stroke();
//旋转矩形框
// Draw the label text
this.ctx.font = "14px Arial";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
let textX = tag.startX + width / 2;
let textY = tag.startY + height / 2;
let displayText = label;
if (this.ctx.measureText(label).width > width) {
displayText = this.truncateText(label, width);
}
this.ctx.fillStyle = color;
this.ctx.strokeStyle = "white";
this.ctx.lineWidth = 1;
this.ctx.strokeText(displayText, textX, textY);
this.ctx.fillText(displayText, textX, textY);
this.drawResizeHandles(tag);
this.drawRotateHandle(tag);
this.ctx.restore();
tag.isInit = false;
},
drawResizeHandles(tag) {
const { startX, startY, width, height, color, rotate } = tag;
const handles = [
{ x: startX, y: startY },
{ x: startX + width / 2, y: startY },
{ x: startX + width, y: startY },
{ x: startX, y: startY + height / 2 },
{ x: startX + width, y: startY + height / 2 },
{ x: startX, y: startY + height },
{ x: startX + width / 2, y: startY + height },
{ x: startX + width, y: startY + height }
];
this.ctx.save();
//this.rotateExec(tag);
handles.forEach(handle => {
this.ctx.beginPath();
this.ctx.rect(handle.x - 2.5, handle.y - 2.5, 5, 5);
this.ctx.fillStyle = "white";
this.ctx.fill();
this.ctx.lineWidth = 1;
this.ctx.strokeStyle = color;
this.ctx.stroke();
//添加鼠标悬浮事件,如果鼠标悬浮在矩形框上,则鼠标样式显示为resize样式,否则显示为默认样式
});
this.ctx.restore();
},
drawRotateHandle(tag) {
const { startX, startY, width, height, color, rotate } = tag;
const handleX = startX + width;
const handleY = startY - 12 - 5;
this.ctx.save();
// this.rotateExec(tag);
this.ctx.beginPath();
if (!this.rotateImages) {
console.log("记载旋1转图片");
var img = new Image();
img.src = "/img/tagRotate.png";
img.onload = () => {
this.rotateImages = img;
this.ctx.drawImage(img, handleX, handleY, 24, 24);
this.ctx.restore();
};
} else {
this.ctx.drawImage(this.rotateImages, handleX, handleY, 24, 24);
this.ctx.restore();
}
},
truncateText(text, maxWidth) {
const ellipsis = "...";
let truncated = text;
while (this.ctx.measureText(truncated + ellipsis).width > maxWidth) {
truncated = truncated.slice(0, -1);
}
return truncated + ellipsis;
},
hexToRgba(hex, alpha) {
const bigint = parseInt(hex.replace("#", ""), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba(${r},${g},${b},${alpha})`;
},
onMouseDown(e) {
const { offsetX, offsetY } = this.computexy(e.offsetX, e.offsetY);
this.mouseDownX = offsetX;
this.mouseDownY = offsetY;
this.tagsData.forEach(tag => {
const handle = this.getHandleUnderMouse(tag, offsetX, offsetY);
if (handle) {
this.isResizing = true; //缩放
this.resizeHandle = handle;
this.selectedTag = tag;
return;
}
const rotateHandle = this.getRotateHandleUnderMouse(
tag,
offsetX,
offsetY
);
if (rotateHandle) {
this.isRotating = true; //旋转
this.selectedTag = tag;
this.initialRotation = this.selectedTag.rotate; // 保存初始旋转角度
return;
}
if (this.isMouseInsideRectangle(tag, offsetX, offsetY)) {
this.isDragging = true;
this.selectedTag = tag;
this.dragOffsetX = offsetX - tag.startX;
this.dragOffsetY = offsetY - tag.startY;
}
});
// if (!this.isDragging && !this.isResizing && !this.isRotating) {
// console.log("拖动canvas大小");
// this.$refs.canvas.style.cursor = "hand";
// this.isCanvasDraging = true;
// }
},
onMouseUp() {
this.isDragging = false;
this.isResizing = false;
this.isRotating = false;
this.selectedTag = null;
this.resizeHandle = null;
this.isCanvasDraging = false;
},
onMouseMove(e) {
// console.log("鼠标移动事件", e);
const { offsetX, offsetY } = this.computexy(e.offsetX, e.offsetY);
// if (this.isCanvasDraging) {
// this.canvasProp.translateX -= offsetX - this.mouseDownX;
// this.canvasProp.translateY -= offsetY - this.mouseDownY;
// this.drawTags();
// return;
// }
if (this.isDragging && this.selectedTag) {
//矩形框拖动
this.selectedTag.startX = offsetX - this.dragOffsetX;
this.selectedTag.startY = offsetY - this.dragOffsetY;
this.drawTags();
} else if (this.isResizing && this.selectedTag) {
//矩形框缩放
const handle = this.resizeHandle;
switch (handle.position) {
case "top-left":
this.selectedTag.width += this.selectedTag.startX - offsetX;
this.selectedTag.height += this.selectedTag.startY - offsetY;
this.selectedTag.startX = offsetX;
this.selectedTag.startY = offsetY;
break;
case "top":
this.selectedTag.height += this.selectedTag.startY - offsetY;
this.selectedTag.startY = offsetY;
break;
case "top-right":
this.selectedTag.width = offsetX - this.selectedTag.startX;
this.selectedTag.height += this.selectedTag.startY - offsetY;
this.selectedTag.startY = offsetY;
break;
case "left":
this.selectedTag.width += this.selectedTag.startX - offsetX;
this.selectedTag.startX = offsetX;
break;
case "right":
this.selectedTag.width = offsetX - this.selectedTag.startX;
break;
case "bottom-left":
this.selectedTag.width += this.selectedTag.startX - offsetX;
this.selectedTag.height = offsetY - this.selectedTag.startY;
this.selectedTag.startX = offsetX;
break;
case "bottom":
this.selectedTag.height = offsetY - this.selectedTag.startY;
break;
case "bottom-right":
this.selectedTag.width = offsetX - this.selectedTag.startX;
this.selectedTag.height = offsetY - this.selectedTag.startY;
break;
}
this.drawTags();
} else if (this.isRotating && this.selectedTag) {
//矩形旋转
const centerX = this.selectedTag.startX + this.selectedTag.width / 2;
const centerY = this.selectedTag.startY + this.selectedTag.height / 2;
const initDeg = Math.atan2(
this.mouseDownY - centerY,
this.mouseDownX - centerX
);
const currentDeg = Math.atan2(offsetY - centerY, offsetX - centerX);
// this.selectedTag.rotate = ((currentDeg - initDeg) * 180) / Math.PI;
const rotationChange = ((currentDeg - initDeg) * 180) / Math.PI;
this.selectedTag.rotate = this.initialRotation + rotationChange; // 根据初始旋转角度调整
this.drawTags();
} else {
let cursorSet = false;
this.tagsData.some(tag => {
const handle = this.getHandleUnderMouse(tag, offsetX, offsetY);
if (handle) {
let cursor = this.getCursorStyle(handle);
this.$refs.canvas.style.cursor = cursor;
cursorSet = true;
return true;
}
const rotateHandle = this.getRotateHandleUnderMouse(
tag,
offsetX,
offsetY
);
if (rotateHandle) {
this.$refs.canvas.style.cursor = "crosshair";
cursorSet = true;
return true;
}
if (this.isMouseInsideRectangle(tag, offsetX, offsetY)) {
this.$refs.canvas.style.cursor = "move";
cursorSet = true;
return true;
}
return false;
});
if (!cursorSet) {
this.$refs.canvas.style.cursor = "default";
}
}
},
getCursorCustomStyle(handle) {
if (handle.position == "left" || handle.position == "right") {
return `h-cursor`;
} else if (handle.position === "top" || handle.position == "bottom") {
return `s-cursor`;
} else if (
handle.position === "top-left" ||
handle.position == "top-right"
) {
return `lx-cursor`;
} else if (
handle.position === "bottom-left" ||
handle.position == "bottom-right"
) {
return `-cursor`;
}
},
getCursorStyle(handle) {
if (handle.position == "left") {
return `w-resize`;
} else if (handle.position === "top") {
return `n-resize`;
} else if (handle.position === "top-left") {
return `nw-resize`;
} else if (handle.position === "top-right") {
return `ne-resize`;
} else if (handle.position === "right") {
return `e-resize`;
} else if (handle.position === "bottom") {
return `s-resize`;
} else if (handle.position == "bottom-left") {
return `sw-resize`;
} else if (handle.position === "bottom-right") {
return `se-resize`;
}
},
getHandleUnderMouse(tag, x, y) {
const handles = [
{
x: tag.startX,
y: tag.startY,
position: "top-left"
},
{
x: tag.startX + tag.width / 2,
y: tag.startY,
position: "top"
},
{
x: tag.startX + tag.width,
y: tag.startY,
position: "top-right"
},
{
x: tag.startX,
y: tag.startY + tag.height / 2,
position: "left"
},
{
x: tag.startX + tag.width,
y: tag.startY + tag.height / 2,
position: "right"
},
{
x: tag.startX,
y: tag.startY + tag.height,
position: "bottom-left"
},
{
x: tag.startX + tag.width / 2,
y: tag.startY + tag.height,
position: "bottom"
},
{
x: tag.startX + tag.width,
y: tag.startY + tag.height,
position: "bottom-right"
}
];
return handles.find(handle => {
let { rotatedX, rotatedY } = this.rotateAfterPoint(tag, x, y);
return this.isMouseOverHandle(handle, rotatedX, rotatedY);
});
},
isMouseOverHandle(handle, x, y) {
return (
x >= handle.x - 2.5 &&
x <= handle.x + 2.5 &&
y >= handle.y - 2.5 &&
y <= handle.y + 2.5
);
},
getRotateHandleUnderMouse(tag, x, y) {
let { rotatedX, rotatedY } = this.rotateAfterPoint(tag, x, y);
const handleX = tag.startX + tag.width;
const handleY = tag.startY - 12 - 5;
if (
rotatedX > handleX &&
rotatedX <= handleX + 24 &&
rotatedY > handleY &&
rotatedY <= handleY + 24
) {
return true;
} else {
return false;
}
},
isMouseInsideRectangle(tag, x, y) {
const { startX, startY, width, height, rotate } = tag;
this.ctx.save();
//this.rotateExec(tag);
let { rotatedX, rotatedY } = this.rotateAfterPoint(tag, x, y);
const isInside =
rotatedX >= startX &&
rotatedX <= startX + width &&
rotatedY >= startY &&
rotatedY <= startY + height;
this.ctx.restore();
return isInside;
},
//解决这个问题有两个思路,一个是将旋转后矩形的四个点坐标计算出来,这种方法比较麻烦。另一个思路是逆向的,将要判断的点,以矩形的中点为中心,做逆向旋转,计算出其在 canvas 中的坐标,这个坐标,可以继续参与我们之前点在矩形内的计算
rotateAfterPoint(tag, x, y) {
const { startX, startY, width, height, rotate } = tag;
const centerX = startX + width / 2;
const centerY = startY + height / 2;
let dx = x - centerX;
let dy = y - centerY;
// // 将鼠标点旋转回矩形未旋转前的坐标
let rotatedX =
dx * Math.cos((-rotate * Math.PI) / 180) -
dy * Math.sin((-rotate * Math.PI) / 180) +
centerX;
let rotatedY =
dy * Math.cos((-rotate * Math.PI) / 180) +
dx * Math.sin((-rotate * Math.PI) / 180) +
centerY;
return { rotatedX: rotatedX, rotatedY: rotatedY };
}
}
};
</script>
<style scoped>
.h-cursor {
cursor: url("/img/h-custor"), auto !important;
}
.s-cursor {
cursor: url("/img/s-custor"), auto !important;
}
.lx-cursor {
cursor: url("/img/lx-custor"), auto !important;
}
.yx-cursor {
cursor: url("/img/yx-custor"), auto !important;
}
.rotate-cursor {
cursor: url("/img/yx-custor"), auto !important;
}
</style>