手搓canvas 图片勾绘,获取勾绘坐标可缩放

起因

最近公司有个需求,需要对图片进行框选勾绘出目标物,生成数据集用于机器学习。在网上找了很多库都不是很满意(本人懒,没找几下,若有小伙伴有什么好的库,请务必告知我。跪谢.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>

效果图

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值