起因
最近公司有个需求,需要对图片进行框选勾绘出目标物,生成数据集用于机器学习。在网上找了很多库都不是很满意(本人懒,没找几下,若有小伙伴有什么好的库,请务必告知我。跪谢.jpg),于是就手搓了一个,非常简单的小组件。
技术栈
用到了unocss vue3 ant-design-vue
实现
直接上代码吧
imageDraw
<template>
<div class="h-100% flex flex-col items-center p-4">
<div id="image-container" class="relative h-800px h-840px w-100% overflow-hidden border border-gray-300 rounded-lg">
<canvas
ref="canvasRef"
class="cursor-crosshair"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@wheel="handleWheel"
></canvas>
</div>
<div class="mt-30px flex">
<div class="flex items-center">
<span>画笔:</span>
<Select v-model:value="label" class="mr-20px w-160px" @change="handleLabelChange">
<SelectOption v-for="item in labels" :key="item.id" :value="item.id">
<div class="flex items-center">
<div class="mr-10px h-20px w-30px" :style="`background-color: ${item.color};`"></div>
<div class="flex-1 truncate" :title="item.name">{{ item.name }}</div>
</div>
</SelectOption>
</Select>
</div>
<button class="mr-20px" @click="handleReset">回正</button>
<div class="flex items-center space-x-2">
<button class="border rounded p-2 hover:bg-gray-100" @click="handleZoomOut">
<!-- <Minus /> -->
<MinusOutlined class="h-4 w-4" />
</button>
<input v-model="scale" type="range" min="0.5" max="3" step="0.1" class="w-[200px]" />
<button class="border rounded p-2 hover:bg-gray-100" @click="handleZoomIn">
<!-- <Plus class="h-4 w-4" /> -->
<PlusOutlined class="h-4 w-4" />
</button>
</div>
<div class="ml-20px flex items-center justify-center text-sm text-gray-500">
Zoom: {{ (scale * 100).toFixed(0) }}%
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, defineProps, toRefs, computed, nextTick } from 'vue';
import { PlusOutlined, MinusOutlined } from '@ant-design/icons-vue';
import { message, Select, SelectOption } from 'ant-design-vue';
const props = defineProps({
data: {
type: Object,
default: () => {
return {};
},
},
labels: {
type: Array,
default: () => [],
},
modelValue: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:modelValue', 'change']);
const { labels } = toRefs(props);
// from ref
const label = ref();
const labelRef = computed(() => labels.value?.find((item) => item.id == label.value));
// Canvas ref
const canvasRef = ref(null);
const isSelecting = ref(false);
// 当前勾绘集合
const selections = ref([]);
// 撤销栈,用于记录
const selectionsUndo = ref([]);
// 当前鼠标选择区域
const currentSelection = ref(null);
const scale = ref(1);
const offset = ref({ x: 0, y: 0 });
const image = ref(null);
const margin = ref(0);
const drawColor = computed(() => {
return labels.value?.find((item) => item.id == label.value)?.color || 'red';
});
// 已有勾绘集合
const annotation = computed(() => props.data?.annotation || []);
// 勾绘集合(未提交)
const toCorrectSelections = computed(() =>
selections.value.map((item) => {
return {
...item,
startX: item.startX - margin.value,
endX: item.endX - margin.value,
};
}),
);
// 画笔颜色改变时,重新设置画笔颜色
const handleLabelChange = () => {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.strokeStyle = drawColor.value;
};
const loadImage = (url) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
img.alt = '示例图片';
});
};
const handleReset = () => {
offset.value = { x: 0, y: 0 };
scale.value = 1;
drawCanvas();
};
// 绘制画布
const drawCanvas = () => {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let container = document.querySelector('#image-container');
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply scaling and translation
ctx.save();
ctx.translate(offset.value.x, offset.value.y);
ctx.scale(scale.value, scale.value);
// 绘制图片
if (image.value) {
//这里获取的坐标数据相对于图片都是缩放过后的,所以要想获取真实坐标,需要在获取到的坐标上除以缩放比例
const { width, height } = image.value;
const drawWidth = (width / height) * container.clientHeight;
const drawHeight = container.clientHeight;
const offset = container.clientWidth - drawWidth;
// 矩形框具体坐标需要减去margin
margin.value = offset > 0 ? offset / 2 : 0;
ctx.drawImage(image.value, margin.value, 0, drawWidth, drawHeight);
}
// 绘制所有选择区域
ctx.strokeStyle = drawColor.value;
ctx.lineWidth = 2 / scale.value;
selections.value.forEach((selection) => {
ctx.strokeStyle = selection.color;
ctx.strokeRect(
selection.startX,
selection.startY,
selection.endX - selection.startX,
selection.endY - selection.startY,
);
});
// 绘制当前选择区域
if (currentSelection.value) {
ctx.strokeRect(
currentSelection.value.startX,
currentSelection.value.startY,
currentSelection.value.endX - currentSelection.value.startX,
currentSelection.value.endY - currentSelection.value.startY,
);
}
ctx.restore();
};
const handleMouseDown = (e) => {
if (!labels.value.length) {
emit('change', {
type: 'labels',
data: {
message: '',
status: null,
},
});
return message.warning('请新建分类');
}
const canvas = canvasRef.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - offset.value.x) / scale.value;
const y = (e.clientY - rect.top - offset.value.y) / scale.value;
isSelecting.value = true;
currentSelection.value = { startX: x, startY: y, endX: x, endY: y };
};
const handleMouseMove = (e) => {
if (!isSelecting.value || !currentSelection.value) return;
const canvas = canvasRef.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - offset.value.x) / scale.value;
const y = (e.clientY - rect.top - offset.value.y) / scale.value;
currentSelection.value = { ...currentSelection.value, endX: x, endY: y };
};
const handleMouseUp = () => {
if (isSelecting.value && currentSelection.value) {
const { height } = image.value;
let container = document.querySelector('#image-container');
const drawHeight = container.clientHeight;
selections.value.push({
...currentSelection.value,
...labelRef.value,
scale: drawHeight / height,
margin: margin.value,
status: '新增', // 状态用来判断是否为新增数据
});
currentSelection.value = null;
}
isSelecting.value = false;
};
const handleZoomIn = () => {
scale.value = Math.min(scale.value * 1.1, 3);
};
const handleZoomOut = () => {
scale.value = Math.max(scale.value / 1.1, 0.5);
};
const handleWheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.5, Math.min(scale.value * delta, 3));
if (newScale !== scale.value) {
const rect = canvasRef.value.getBoundingClientRect();
const x = (e.clientX - rect.left - offset.value.x) / scale.value;
const y = (e.clientY - rect.top - offset.value.y) / scale.value;
const newOffsetX = offset.value.x - x * (newScale - scale.value);
const newOffsetY = offset.value.y - y * (newScale - scale.value);
scale.value = newScale;
offset.value = { x: newOffsetX, y: newOffsetY };
}
};
const handleUndo = () => {
if (selections.value.length > 0) {
selectionsUndo.value.push(selections.value.pop());
drawCanvas(); // 立即重新绘制画布
}
};
const handleForward = () => {
if (selectionsUndo.value.length > 0) {
selections.value.push(selectionsUndo.value.pop());
drawCanvas(); // 立即重新绘制画布
}
};
const handleKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
handleUndo();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault();
handleForward();
}
};
const resize = () => {
if (image.value) {
let container = document.querySelector('#image-container');
const { width, height } = image.value;
const drawWidth = (width / height) * container.clientHeight;
const drawHeight = container.clientHeight;
const offset = container.clientWidth - drawWidth;
// 矩形框具体坐标需要减去margin
margin.value = offset > 0 ? offset / 2 : 0;
const scaleW = drawWidth / width;
const scaleH = drawHeight / height;
// 若图片存在已经勾绘的矩形,则绘制于图片上
selections.value = annotation.value.map((item) => {
let { bbox_height, bbox_width, x_center, y_center } = item;
let label = labels.value.find((el) => el.id == item.classid) || {};
let startX = (x_center - bbox_width / 2) * scaleW + margin.value;
let startY = (y_center - bbox_height / 2) * scaleH;
let x = (x_center + bbox_width / 2) * scaleW + margin.value;
let y = (y_center + bbox_height / 2) * scaleH;
return {
color: label.color,
startX: startX,
startY,
endX: x,
endY: y,
id: label.id,
scale: drawHeight / height,
status: '原有标签',
margin: margin.value,
};
});
}
};
onMounted(async () => {
try {
label.value = labels.value?.[0]?.id;
image.value = await loadImage(props.data?.url);
drawCanvas();
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('resize', resize);
} catch (error) {
console.error('Failed to load image:', error);
}
});
watch([selections, currentSelection, scale, offset], () => {
drawCanvas();
});
// 检测图像变化
watch(
() => image,
() => {
try {
nextTick(() => {
// Draw the image
resize();
});
} catch (error) {
console.error('Failed to load image:', error);
}
},
{
deep: true,
},
);
watch(
() => props.data,
async () => {
try {
isSelecting.value = false;
selections.value = [];
selectionsUndo.value = [];
currentSelection.value = [];
scale.value = 1;
offset.value = { x: 0, y: 0 };
margin.value = 0;
image.value = await loadImage(props.data?.url);
drawCanvas();
} catch (error) {
console.error('Failed to load image:', error);
}
},
{
deep: true,
},
);
// 分类
watch(
() => props.labels,
async () => {
try {
selections.value = selections.value.map((item) => {
const { id } = item;
let lab = props.labels.find((el) => el.id == id);
return {
...item,
color: lab?.color || item.color,
};
});
selectionsUndo.value = selectionsUndo.value.map((item) => {
const { id } = item;
let lab = props.labels.find((el) => el.id == id);
return {
...item,
color: lab?.color || item.color,
};
});
} catch (error) {
console.error('Failed to load image:', error);
}
},
{
deep: true,
},
);
//勾绘数据
watch(
() => toCorrectSelections.value,
() => {
emit('update:modelValue', toCorrectSelections.value);
},
{
deep: true,
},
);
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', resize);
});
</script>
<style scoped lang="scss">
/* 可以添加任何组件特定的样式 */
.add {
cursor: pointer;
}
.labels-box {
overflow: auto;
scrollbar-width: thin;
}
canvas {
background-color: #eee;
}
</style>
父组件使用
<template>
<ImageDraw
v-model="drawData"
:labels="labels"
:data="info"
@change="handleImgChange"
></ImageDraw>
</template>
<script setup>
import ImageDraw from './imageDraw.vue';
const info = ref({
url:'xxxx', // 必须要 图片地址
annotation:[{
bbox_height:0,
bbox_width:0, // 矩形盒子宽
x_center:0, // 中心点x
y_center:0, // 中心点y
classid:0, // 勾绘类别
}] // 已存在的勾绘 可回显
})
const labels = ref([{
id:1, //唯一标识
name:'熊猫' // 名称
}]);
const drawData = ref([]);
const handleImgChange =(val) =>{
// 内部改变返回的事件
}
</script>