vue3使用ailabel.js实现图片标注,可支持修改,删除,移动标注的框

1,282 阅读9分钟

效果图

微信截图_20241231151240.png
如图所示,左侧的一排按钮为画板功能,中间为图片区域,右侧的则是标注框信息。

1. 项目简介

该项目是vue3 + element plus + ailabel.js实现的,主要功能有:

  1. 将图片绘制在canvas上作为底层画布
  2. 可在该图片上进行绘制点,圆,线段,多线段,矩形,多边形等图形
  3. 可对绘制的图形进行编辑,删除,移动等操作
  4. 点击右侧列表中的眼睛图标可对单个绘制的图形进行显示/隐藏遮罩层
  5. 点击左侧的功能按钮中的眼睛图标可对整个绘制的图形进行显示/隐藏遮罩层
  6. 图片支持拖动滚动以及滚动鼠标轮放大缩小等

2. 项目初始化

2.1 直接初始化一个vue3的项目即可
2.2 安装ailabel.js
pnpm install ailabel

3. 项目完整代码

完成代码如图


<template>
  <div class="page image_page">
    <div class="image_left">
      <!--开始标注-->
      <el-tooltip effect="dark" content="开始标注" placement="right-start">
        <i class="button_2 button_text" @click="setMode('RECT')" />
      </el-tooltip>
      <!--取消标注-->
      <el-tooltip effect="dark" content="取消标注" placement="right-start">
        <i class="button_3 button_text" @click="setMode('PAN')" />
      </el-tooltip>
      <!--显示遮罩层-->
      <el-tooltip effect="dark" content="显示遮罩层" placement="right-start">
        <i class="button_4 button_text" @click="maskLayer(true)" />
      </el-tooltip>
      <!--取消遮罩层-->
      <el-tooltip effect="dark" content="取消遮罩层" placement="right-start">
        <i class="button_5 button_text" @click="maskLayer(false)" />
      </el-tooltip>
      <!--移动-->
      <el-tooltip effect="dark" content="移动标注框" placement="right-start">
        <i class="button_8 button_text" @click="setMove('RECT', true)" />
      </el-tooltip>
      <!--保存-->
      <el-tooltip effect="dark" content="保存" placement="right-start">
        <i class="button_9 button_text" @click="onSave()" />
      </el-tooltip>
      <!--返回-->
      <el-tooltip effect="dark" content="返回" placement="right-start">
        <i class="button_1 button_text" @click="getSubmit()" />
      </el-tooltip>
      <!--删除当前图片-->
      <el-tooltip effect="dark" content="删除当前图片" placement="right-start">
        <i class="button_7 box_item_footer button_text" @click="onDeleteCurrentImage" />
      </el-tooltip>
      <!--选择弹窗-->
      <div v-show="dialogVisible" class="left_model">
        <div class="label_content">
          <span style="display: inline-block; margin-right: 20px">选择模型分类:</span>
          <el-select
            v-model="selectModel"
            filterable
            remote
            clearable
            placeholder="请搜索值选择模型分类"
            style="width: 240px"
            @change="onChangeModel"
          >
            <el-option v-for="item in options" :key="item.id" :label="item.class_name" :value="item.class_no" />
          </el-select>
        </div>
        <div class="dialog_footer">
          <el-button @click="onDialogVisibleClose" style="margin-right: 20px">关闭</el-button>
          <el-button type="primary" @click="onConfirm">确认</el-button>
        </div>
      </div>
    </div>
    <div ref="imageCenter" class="image_center" id="map">
      <h1>操作提示:</h1>
      <h1>
        1.
        新增标注框后,需点击“保存”按钮以保存结果。若未点击保存便尝试移动新增的未保存框,系统将提示您先保存完成后再移动标注框。
      </h1>
      <h1>2. 针对已保存的标注框,移动操作完成后,系统会自动保存结果。</h1>
      <div class="main_map" ref="main_map" />
      <el-image style="width: 0; height: 0" :src="state.imgUrl" @load="onLoadImage" />
    </div>
    <div class="image_right">
      <div v-if="imageDetails" class="image_title">图片名称:{{ imageDetails.file_name }}</div>
      <div v-if="imageDetails" class="image_title">图片大小:{{ imageDetails.width }} x {{ imageDetails.height }}</div>
      <div v-for="(item, index) in list" :key="index" class="label_list">
        <svg
          v-if="!isSelected(item.props.id)"
          t="1734420048840"
          class="icon"
          viewBox="0 0 1024 1024"
          version="1.1"
          xmlns="http://www.w3.org/2000/svg"
          p-id="34778"
          width="20"
          height="20"
          @click.stop="toggleSvgDisplay(item.props, index)"
        >
          <path
            d="M512 768c-183.466667 0-328.533333-85.333333-426.666667-256 98.133333-170.666667 243.2-256 426.666667-256s328.533333 85.333333 426.666667 256c-98.133333 170.666667-243.2 256-426.666667 256z m8.533333-426.666667c-128 0-256 55.466667-328.533333 170.666667 72.533333 115.2 200.533333 170.666667 328.533333 170.666667s238.933333-55.466667 311.466667-170.666667c-72.533333-115.2-183.466667-170.666667-311.466667-170.666667z m-8.533333 298.666667c-72.533333 0-128-55.466667-128-128s55.466667-128 128-128 128 55.466667 128 128-55.466667 128-128 128z m0-85.333333c25.6 0 42.666667-17.066667 42.666667-42.666667s-17.066667-42.666667-42.666667-42.666667-42.666667 17.066667-42.666667 42.666667 17.066667 42.666667 42.666667 42.666667z"
            :fill="item.props.selectModelColor"
            p-id="34779"
          ></path>
        </svg>
        <svg
          v-if="isSelected(item.props.id)"
          t="1734420234948"
          class="icon"
          viewBox="0 0 1024 1024"
          version="1.1"
          xmlns="http://www.w3.org/2000/svg"
          p-id="34988"
          width="20"
          height="20"
          @click.stop="toggleSvgDisplay(item.props, index)"
        >
          <path
            d="M422.4 776.533333l76.8-76.8h8.533333c145.066667 0 251.733333-55.466667 332.8-170.666666-25.6-34.133333-55.466667-64-85.333333-89.6L819.2 384c46.933333 38.4 85.333333 89.6 119.466667 145.066667-98.133333 170.666667-243.2 251.733333-426.666667 251.733333-29.866667 4.266667-59.733333 0-89.6-4.266667z m-238.933333-119.466666c-34.133333-34.133333-68.266667-76.8-98.133334-128 98.133333-170.666667 243.2-251.733333 426.666667-251.733334h46.933333l-85.333333 85.333334c-128 8.533333-226.133333 64-298.666667 166.4 17.066667 25.6 38.4 51.2 59.733334 68.266666l-51.2 59.733334zM755.2 213.333333l59.733333 59.733334L277.333333 810.666667l-59.733333-59.733334L755.2 213.333333z"
            fill="#2c2c2c"
            p-id="34989"
          ></path>
        </svg>
        <span class="label_item" :style="{ color: item.props.selectModelColor }"
          >{{ index + 1 }} &nbsp;&nbsp;{{ item.props.class_name }}</span
        >
        <el-button type="primary" :icon="Edit" circle size="small" @click.stop="onEdit(item)" />
        <el-button type="danger" :icon="Delete" circle size="small" @click.stop="onDelete(item)" />
      </div>
    </div>
    <!--编辑组件-->
    <EditAnnotationBox
      v-model:modelValue="parentDialogVisible"
      :options="options"
      :currentEditValue="currentEditValue"
      @update:selectModel="handleSelectModel"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, reactive, watch, onUnmounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import {
  getAiAnnotationModel,
  getAiAnnotationModelDelete,
  getAiAnnotationModelImage,
  getAiAnnotationModelMove,
  getALImageModelDelete,
  getClassApi
} from "@/api/mark"
import { BASE_PATH } from "@/utils/global.js"
import AILabel from "ailabel"
import { Delete, Edit } from "@element-plus/icons-vue"
import { ElLoading, ElMessage, ElMessageBox } from "element-plus"
import { type TagView, useTagsViewStore } from "@/store/modules/tags-view"
import EditAnnotationBox from "@/components/EditAnnotationBox/index.vue"

const parentDialogVisible = ref(false)
const options = ref([]) //  模型分类列表
const route = useRoute()
const router = useRouter()
const tagsViewStore = useTagsViewStore()
const sourceInfo: any = reactive({
  dataset_id: "",
  image_id: ""
})
//动态添加视图
const main_map = ref(null) // 画布
const imageCenter = ref(null) // 画布中心
const list: any = ref([]) // 原始数据集列表
const imageDetails = ref() // 图片详情信息
const originalWidth = ref() // 原始图片宽度
const originalHeight = ref() // 原始图片宽度
const frameWidth = ref() // 框的宽度
const frameHeight = ref() // 框的高度
const ratioWidth = ref() // 宽的比例
const ratioHeight = ref() // 高的比例
const selectionBox: any = ref() // 临时选择框的数据
const selectionBoxType: any = ref() // 临时选择框的数据
const annotation: any = ref([]) // 标注框的数据
const clickedSvgIds: any = ref([]) // 显示隐藏遮罩层数据
const dialogVisible = ref(false) // 预设标签
const selectModel = ref("") // 模型分类选择name
const selectModelCode = ref("") // 模型分类选择的code
const selectModelColorWare = ref("") // 模型分类选择的颜色
const isMovingMode = ref(false) // 新增,用于记录是否处于移动标注框的模式,初始为false,表示非移动模式
const currentEditValue: any = ref("") // 当前编辑的值
const state: any = reactive({
  imgUrl: "",
  divId: "map",
  drawingStyle: {},
  mode: "",
  itemName: "",
  editId: "", //待填充图形id
  deleteIconId: "delete01",
  gMap: null, //AILabel实例
  gFirstFeatureLayer: null, //矢量图层实例(矩形,多边形等矢量)
  allFeatures: null, //所有features
  gFirstImageLayer: null, //图层
  nHeight: "",
  nWidth: "",
  centerObj: {},
  gFirstTextLayer: null, //文本
  gFirstMaskLayer: null //涂抹层
})
const svg = `
        <path class="path" d="
          M 30 15
          L 28 17
          M 25.61 25.61
          A 15 15, 0, 0, 1, 15 30
          A 15 15, 0, 1, 1, 27.99 7.5
          L 15 15
        " style="stroke-width: 4px; fill: rgba(0, 0, 0, 0)"/>
      `
watch(
  () => state.mode,
  (newVal, oldVal) => {
    // console.log('新的值mode:', newVal, '旧的mode:', oldVal,);
    state.gMap.setMode(newVal)
    setDrawingStyle(newVal)
  }
)
onMounted(() => {
  sourceInfo.dataset_id = route.query.dataset_id || ""
  sourceInfo.image_id = route.query.image_id || ""
  getImageDetails()
  remoteMethod()
})
onUnmounted(() => {
  console.log("退出页面")
  const view: any = route.path
  tagsViewStore.delOthersVisitedViews(view)
  tagsViewStore.delOthersCachedViews(view)
  toLastView(tagsViewStore.visitedViews, view)
  router.replace(`/details?id=${sourceInfo.dataset_id}`)
})
const remoteMethod = () => {
  const data = {
    "class_name-like": ""
  }
  getClassApi(data).then((res: any) => {
    options.value = res.data.list
  })
}
// 图片详情
const getImageDetails = async () => {
  getAiAnnotationModelImage(sourceInfo.image_id).then((res: any) => {
    imageDetails.value = res.data
    state.imgUrl = BASE_PATH + imageDetails.value.path
    annotation.value = imageDetails.value.annotation
  })
}
// 计算属性,用于判断当前项的遮罩层是否需要隐藏
const isSelected = (id: any) => {
  return clickedSvgIds.value.includes(id)
}
// 点击显示隐藏按钮实现隐藏遮罩层
const toggleSvgDisplay = (item: any, index: number) => {
  const indexIds = clickedSvgIds.value.findIndex((id) => id === item.id)
  if (indexIds > -1) {
    // 如果 id 已存在,删除该 id
    clickedSvgIds.value.splice(indexIds, 1)
    updateShadowForAllFeatures(true, index)
  } else {
    // 如果 id 不存在,添加该 id
    clickedSvgIds.value.push(item.id)
    updateShadowForAllFeatures(false, index)
  }
}
// 隐藏遮罩层阴影方法
const updateShadowForAllFeatures = (flag: any, index: number) => {
  const allFeatures = state.gFirstFeatureLayer.getAllFeatures()
  allFeatures[index].style.fill = !!flag
  state.gMap.refresh()
}
// 图片加载完成
const onLoadImage = (event: any) => {
  const image = event.target
  originalWidth.value = image.naturalWidth
  originalHeight.value = image.naturalHeight
  // 获取图片原宽高
  getImageWH()
  // 初始化实例
  initMap()
  // 添加事件
  addEvent()
  // 图片层添加
  setGFirstImageLayer()
  // 添加矢量图层
  setGFirstFeatureLayer()
  // 初始化涂抹层
  setGFirstMaskLayer()
  // AILabel.Map设置绘制过程中十字丝关闭
  state.gMap.disableDrawingCrosshair()
  handleAnnotationDataBackfill() // 新增,调用回显处理函数
}
// 初始化实例
const initMap = () => {
  const gMap = new AILabel.Map(state.divId, {
    center: state.centerObj, // 为了让图片居中
    zoom: originalHeight.value * 1.2, //初始缩放级别
    mode: "PAN", // 绘制线段
    refreshDelayWhenZooming: true, // 缩放时是否允许刷新延时,性能更优
    zoomWhenDrawing: false, //绘制时可滑轮缩放
    panWhenDrawing: false, //绘制时可到边界外自动平移
    zoomWheelRatio: 5, // 控制滑轮缩放缩率[0, 10), 值越小,则缩放越快,反之越慢
    withHotKeys: true // 关闭快捷键
  })
  state.gMap = gMap
}
const convertBboxToArray = (bboxStr: any) => {
  const bboxArr = bboxStr.slice(1, -1).split(",")
  return bboxArr.map((item) => parseFloat(item.trim()))
}
// 数据回显、初始化数据
const handleAnnotationDataBackfill = () => {
  if (!state.gFirstFeatureLayer) {
    console.error("矢量图层state.gFirstFeatureLayer尚未初始化,无法添加标注特征")
    return
  }
  /***
   id: 标注框的id
   selectModelColor: 颜色
   data: 标注框的x1坐标以及宽高
   class_id: 没用
   class_name: 标注框的信息文字
   image_id: 图片id
   modelId: 标签id
   textId: 标签id
   */
  annotation.value.forEach((annItem: any) => {
    const { id, data, class_name, color, class_id } = annItem
    const selectModelColor = color
    const drawingStyle = state.drawingStyle
    const modelId = class_id
    const rectShape = {
      x: data[2],
      y: data[3],
      width: data[1],
      height: data[0]
    }
    const rectFeature = new AILabel.Feature.Rect(
      id, // id
      rectShape, // shape,这里假设shape结构符合Rect对应的要求
      { textId: class_id, modelId, class_name, selectModelColor, id }, // props
      drawingStyle // style
    )
    // rectFeature.shape = data
    rectFeature.style.strokeStyle = color
    rectFeature.style.fillStyle = hexToRGBA(color, 0.3)
    rectFeature.style.lineWidth = 2
    rectFeature.style.fill = true //是否填充
    rectFeature.style.stroke = true
    rectFeature.props.type = 3 // 标记为原始数据,之后不做处理
    state.gFirstFeatureLayer.addFeature(rectFeature)
    // 多边形 添加 文本
    getFeatures()
  })
  // 刷新地图,确保回显的标注能及时显示出来
  state.gMap.refresh()
}
// 颜色转换
function hexToRGBA(hex, alpha = 1) {
  hex = hex.replace("#", "")
  let r = parseInt(hex.substring(0, 2), 16)
  let g = parseInt(hex.substring(2, 4), 16)
  let b = parseInt(hex.substring(4, 6), 16)
  return `rgba(${r},${g},${b},${alpha})`
}
// 图片层添加
const setGFirstImageLayer = () => {
  const widthRatio = 0.5 // 宽度缩放比例,可根据需求调整
  const heightRatio = 0.5 // 高度缩放比例
  const newWidth = originalWidth.value * widthRatio
  const newHeight = originalHeight.value * heightRatio
  // 图片层添加
  const gFirstImageLayer = new AILabel.Layer.Image(
    "first-layer-image", // id
    {
      src: state.imgUrl,
      width: newWidth,
      height: newHeight,
      crossOrigin: false, // 如果跨域图片,需要设置为true
      position: {
        // 左上角相对中心点偏移量
        x: 0,
        y: 0
      }
    }, // imageInfo
    { name: "第一个图片图层" }, // props
    { zIndex: 5 } // style
  )
  // 添加到gMap对象
  state.gMap.addLayer(gFirstImageLayer)
  state.gFirstImageLayer = gFirstImageLayer
}
//获取原图宽度高度
const getImageWH = () => {
  // 页面宽高
  if (imageCenter.value) {
    frameWidth.value = imageCenter.value.offsetWidth
    frameHeight.value = imageCenter.value.offsetHeight
  }
  // 宽高比例
  ratioWidth.value = originalWidth.value / frameWidth.value // 宽的比例
  ratioHeight.value = originalHeight.value / frameHeight.value // 高的比例
  const pageCenterX = frameWidth.value / 2
  const pageCenterY = frameHeight.value / 2
  const centerObj = { x: originalWidth.value / 2, y: originalHeight.value / 2 }
  state.centerObj = {
    x: pageCenterX,
    y: pageCenterY
  }
  annotation.value.forEach((item: any) => {
    const bboxArray = convertBboxToArray(item.bbox)
    const h = (bboxArray[3] * originalHeight.value) / 2
    const w = (bboxArray[2] * originalWidth.value) / 2
    const left = (bboxArray[0] * originalWidth.value) / 2 - w / 2 // 调整水平方向的偏移量,使其基于页面中心
    const top = (bboxArray[1] * originalHeight.value) / 2 - h / 2 // 调整垂直方向的偏移量,使其基于页面中心
    item.data = [h, w, left, top]
  })
  return { centerObj }
}
// 初始样式
const setDrawingStyle = (mode: any) => {
  const drawingStyle = {}
  switch (mode) {
    case "PAN": {
      break
    }
    //矩形
    case "RECT": {
      state.drawingStyle = {
        strokeStyle: "#0099CC", //边框颜色
        fill: true, //是否填充
        fillStyle: "#FF6666", //填充色
        globalAlpha: 0.3,
        lineWidth: 3,
        stroke: true
      }
      state.gMap.setDrawingStyle(drawingStyle)
      break
    }
    default:
      break
  }
}
//添加 涂抹层
const setGFirstMaskLayer = () => {
  const gFirstMaskLayer = new AILabel.Layer.Mask(
    "first-layer-mask", // id
    { name: "第一个涂抹图层" }, // props
    { zIndex: 11, opacity: 0.5 } // style
  )
  state.gMap.addLayer(gFirstMaskLayer)
  state.gFirstMaskLayer = gFirstMaskLayer
}
//添加矢量图层
const setGFirstFeatureLayer = () => {
  // 添加矢量图层
  const gFirstFeatureLayer = new AILabel.Layer.Feature(
    "first-layer-feature", // id
    { name: "第一个矢量图层" }, // props
    { zIndex: 10 } // style
  )
  state.gFirstFeatureLayer = gFirstFeatureLayer
  state.gMap.addLayer(gFirstFeatureLayer)
}

// 增加事件
const addEvent = () => {
  const gMap: any = state.gMap
  gMap.events.on("drawDone", (type: any, data: any) => {
    if (isMovingMode.value) {
      return // 如果处于移动模式,直接返回,不执行绘制完成后的添加标注框等逻辑
    }
    selectionBox.value = data
    selectionBoxType.value = type
    addFeature(
      selectionBox.value,
      selectionBoxType.value,
      selectModelCode.value,
      selectModel.value,
      selectModelColorWare.value
    ) // 在onConfirm函数中直接调用addFeature事件
  })
  gMap.events.on("boundsChanged", (data: any) => {
    console.log("boundsChanged", data)
    // state.mode = "RECT"
  })
  // 双击编辑 在绘制模式下双击feature触发选中
  gMap.events.on("featureSelected", (feature: any) => {
    state.editId = feature.id
    //设置编辑feature
    gMap.setActiveFeature(feature)
  })
  // 单机空白取消编辑
  gMap.events.on("featureUnselected", () => {
    console.log("单机空白取消编辑")
    // 取消featureSelected
    state.mode = "PAN"
    state.editId = ""
    gMap.setActiveFeature(null)
  })
  // 更新完
  gMap.events.on("featureUpdated", (feature: any, shape: any) => {
    // 如果移动的是原始数据则赋值为2,如果移动的是新增的数据则赋值为1
    if (feature.props.type === 3 || feature.props.type === 2) {
      feature.props.type = 2
    } else {
      feature.props.type = 1
    }
    feature.updateShape(shape)
    // 判断是新增的框还是原始数据的框,移动新增的框需要保存之后再移动
    if (feature.props.type === 3 || feature.props.type === 2) {
      // 移动框需要修改之后立即保存
      const points: any = []
      const h = feature.shape.height / (originalHeight.value / 2)
      const w = feature.shape.width / (originalWidth.value / 2)
      points.push({
        id: feature.props.modelId,
        width: w,
        height: h,
        x: feature.shape.x / (originalWidth.value / 2) + w / 2,
        y: feature.shape.y / (originalHeight.value / 2) + h / 2
      })
      console.log(points)
      const result: any = points
        .map((item: any) => {
          return `${item.id} ${item.x} ${item.y} ${item.width} ${item.height}`
        })
        .join(" ")
      const data = {
        annotation: result
      }
      getAiAnnotationModelMove(data, feature.id)
        .then(async () => {
          getFeatures()
        })
        .catch(() => {})
        .finally(() => {})
    } else {
      ElMessage({
        message: "请先保存框之后再进行移动操作!",
        type: "warning"
      })
    }
  })
  // 删除
  gMap.events.on("FeatureDeleted", () => {
    console.log("删除")
  })
}
// 获取所有features
const getFeatures = () => {
  state.allFeatures = state.gFirstFeatureLayer.getAllFeatures()
  list.value = state.gFirstFeatureLayer.getAllFeatures()
  // console.log(list.value)
}

// 绘制图形
const setMode = (mode: any) => {
  // 如果是取消标注事件进来则不需要执行if下面的语句
  if (mode === "PAN") {
    state.mode = mode
    return
  }
  isMovingMode.value = false
  // 先判断是否需要使用预设标签
  if (selectModel.value) {
    ElMessageBox.confirm("是否需要使用预设标签?", "提示", {
      confirmButtonText: "确认使用",
      cancelButtonText: "取消使用",
      type: "warning"
    })
      .then(() => {
        // dialogVisible.value = false
        state.mode = mode
        getFeatures()
      })
      .catch(() => {
        dialogVisible.value = true
        selectModelCode.value = ""
        selectModel.value = ""
      })
  } else {
    dialogVisible.value = true
  }
}
// 移动图形
const setMove = (mode: any, flag: boolean) => {
  state.mode = mode
  isMovingMode.value = true // 设置为移动模式
}
// 添加图形
const addFeature = async (data: any, type: any, modelId: any, class_name: any, selectModelColor: any) => {
  // 使用得到模型分类里面的框的颜色
  /***
   id: 标注框的id
   selectModelColor: 颜色
   data: 标注框的x1坐标以及宽高
   class_id: 没用
   class_name: 标注框的信息文字
   image_id: 图片id
   modelId: 标签id
   textId: 标签id
   type: RECT类型
   */
  if (type === "RECT") {
    const id = `${modelId}${new Date().valueOf()}`
    selectModelColor = selectModelColorWare.value
    const drawingStyle = state.drawingStyle
    const rectFeature = new AILabel.Feature.Rect(
      id,
      data, // shape
      { textId: modelId, modelId, class_name, selectModelColor, id }, // props
      drawingStyle // style
    )
    // 设置边框颜色以及其他属性
    rectFeature.style.strokeStyle = selectModelColorWare.value
    rectFeature.style.fillStyle = hexToRGBA(selectModelColorWare.value, 0.4)
    rectFeature.style.lineWidth = 2
    rectFeature.style.fill = true //是否填充
    rectFeature.style.stroke = true
    console.log("rectFeature", rectFeature)
    if (rectFeature.props.type === 3) {
      rectFeature.props.type = 2 //新增
    } else {
      rectFeature.props.type = 1 //新增
    }
    state.gFirstFeatureLayer.addFeature(rectFeature)
    getFeatures()
  }
  state.gMap.refresh()
}
// 保存当前移动或者生成的标注框
const onSave = async () => {
  const type1Array: any = []
  const type2Array: any = []
  let type1Error = false
  let type2Error = false
  // 提取数据
  list.value.forEach((item: any) => {
    const typeValue = item.props.type
    if (typeValue === 1) {
      type1Array.push(item)
    } else if (typeValue === 2) {
      type2Array.push(item)
    }
  })
  // 如果type1Array和type2Array都为空,说明没有数据变化,直接退出方法
  if (type1Array.length === 0 && type2Array.length === 0) {
    ElMessage({
      message: "没有需要保存的数据!",
      type: "warning"
    })
    return
  }
  const loading = ElLoading.service({
    lock: true,
    text: "数据保存中,请稍候...",
    svg: svg,
    background: "rgba(0, 0, 0, 0.7)"
  })
  // 保存新增的数据
  if (type1Array.length) {
    try {
      const points: any = []
      type1Array.forEach((rectFeature: any) => {
        const h = rectFeature.shape.height / (originalHeight.value / 2)
        const w = rectFeature.shape.width / (originalWidth.value / 2)
        points.push({
          id: rectFeature.props.modelId,
          width: w,
          height: h,
          x: rectFeature.shape.x / (originalWidth.value / 2) + w / 2,
          y: rectFeature.shape.y / (originalHeight.value / 2) + h / 2
        })
      })
      const result: any = points.map((item: any) => {
        return `${item.id} ${item.x} ${item.y} ${item.width} ${item.height}`
      })
      const data1 = {
        dataset_id: sourceInfo.dataset_id,
        image_id: sourceInfo.image_id,
        annotation: result
      }
      await getAiAnnotationModel(data1)
        .then(async () => {
          getFeatures()
          const updatedAnnotations: any = await getAiAnnotationModelImage(sourceInfo.image_id)
          // 确保updatedAnnotations.data.annotation是一个数组,并且有数据,进行非空判断
          if (
            updatedAnnotations.data &&
            updatedAnnotations.data.annotation &&
            Array.isArray(updatedAnnotations.data.annotation)
          ) {
            const newAnnotationCount = type1Array.length
            if (newAnnotationCount > 0) {
              const reversedUpdatedAnnotations = updatedAnnotations.data.annotation
              for (let i = 0; i < newAnnotationCount; i++) {
                const listItem = type1Array[i]
                const updatedItem = reversedUpdatedAnnotations[i]
                // 将list.value中对应于type1Array新增元素的props.id替换为接口返回数据中对应位置(反转后对应)的id
                listItem.props.id = updatedItem.id
                listItem.props.type = 3
              }
            }
          }
        })
        .catch(() => {})
        .finally(() => {
          loading.close()
        })
    } catch (error) {
      type1Error = true
      console.error("保存新增数据时出错:", error)
    }
  }
  // 保存移动过的数据
  if (type2Array.length) {
    console.log("保存移动过的数据", type2Array)
    try {
      // 对当前框进行保存
      const points: any = []
      type2Array.forEach((rectFeature: any) => {
        const h = rectFeature.shape.height / (originalHeight.value / 2)
        const w = rectFeature.shape.width / (originalWidth.value / 2)
        points.push({
          id: rectFeature.props.modelId,
          width: w,
          height: h,
          x: rectFeature.shape.x / (originalWidth.value / 2) + w / 2,
          y: rectFeature.shape.y / (originalHeight.value / 2) + h / 2
        })
      })
      console.log("移动的数据", points, type2Array)
      const result: any = points
        .map((item: any) => {
          return `${item.id} ${item.x} ${item.y} ${item.width} ${item.height}`
        })
        .join(" ")
      const data = {
        annotation: result
      }
      console.log(type2Array[0].props.id)
      getAiAnnotationModelMove(data, type2Array[0].props.id)
        .then(async () => {
          getFeatures()
        })
        .catch(() => {})
        .finally(() => {
          loading.close()
        })
    } catch (error) {
      type2Error = true
      console.error("保存移动数据时出错:", error)
    }
  }
  if (type1Error && type2Error) {
    ElMessage({
      message: "新增数据和移动数据保存均失败,请检查网络或联系管理员",
      type: "error"
    })
  } else if (type1Error) {
    ElMessage({
      message: "新增数据保存失败,请检查网络或联系管理员",
      type: "error"
    })
  } else if (type2Error) {
    ElMessage({
      message: "移动数据保存失败,请检查网络或联系管理员",
      type: "error"
    })
  } else {
    ElMessage({
      message: "数据保存成功,包含新增数据和移动数据的保存!",
      type: "success"
    })
  }
  // window.history.go(0)
}
// 返回
const getSubmit = () => {
  const view: any = route.path
  tagsViewStore.delOthersVisitedViews(view)
  tagsViewStore.delOthersCachedViews(view)
  toLastView(tagsViewStore.visitedViews, view)
  router.replace(`/details?id=${sourceInfo.dataset_id}`)
}
/** 跳转到最后一个标签页 */
const toLastView = (visitedViews: TagView[], view: TagView) => {
  const latestView = visitedViews.slice(-1)[0]
  const fullPath = latestView?.fullPath
  if (fullPath !== undefined) {
    router.push(fullPath)
  } else {
    // 如果 TagsView 全部被关闭了,则默认重定向到主页
    if (view.name === "Dashboard") {
      // 重新加载主页
      router.push({ path: "/redirect" + view.path, query: view.query })
    } else {
      router.push("/")
    }
  }
}
// 删除已选中的数据框
const onDelete = (item: any) => {
  // 如果新增的框还未保存
  if (item.props.type === 1) {
    const featureToRemove = list.value.find((feature: any) => feature.props.id === item.props.id)
    ElMessageBox.confirm("是否删除当前标注框?", "删除", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning"
    })
      .then(() => {
        state.gFirstFeatureLayer.removeFeatureById(featureToRemove.id)
        list.value = list.value.filter((feature: any) => feature.props.id !== item.props.id)
        ElMessage({
          message: "删除成功!",
          type: "success"
        })
      })
      .catch(() => {
        ElMessage({
          message: "取消删除",
          type: "error"
        })
      })
    return
  }
  // 标注框的数据已经提交了
  const featureToRemove = list.value.find((feature: any) => feature.props.id === item.props.id)
  ElMessageBox.confirm("是否删除当前标注框?", "删除", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning"
  })
    .then(() => {
      getAiAnnotationModelDelete(item.props.id)
        .then(() => {
          state.gFirstFeatureLayer.removeFeatureById(featureToRemove.id)
          list.value = list.value.filter((feature: any) => feature.props.id !== item.props.id)
          ElMessage({
            message: "删除成功!",
            type: "success"
          })
        })
        .catch(() => {
          ElMessage({
            message: "删除失败,请稍后重试!",
            type: "error"
          })
        })
    })
    .catch(() => {})
}
// 删除当前图片
const onDeleteCurrentImage = () => {
  ElMessageBox.confirm("是否删除当前图片?", "删除", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning"
  })
    .then(() => {
      getALImageModelDelete(sourceInfo.image_id)
        .then(() => {
          const view: any = route.path
          tagsViewStore.delOthersVisitedViews(view)
          tagsViewStore.delOthersCachedViews(view)
          toLastView(tagsViewStore.visitedViews, view)
          router.replace(`/details?id=${sourceInfo.dataset_id}`)
        })
        .catch(() => {
          ElMessage({
            message: "删除失败,请稍后重试!",
            type: "error"
          })
        })
    })
    .catch(() => {})
}
// 取消遮罩层 / 显示遮罩层
const maskLayer = (flag: any) => {
  const allFeatures = state.gFirstFeatureLayer.getAllFeatures()
  if (flag) {
    allFeatures.forEach((item) => {
      item.style.fill = true
      // item.style.stroke = true
    })
  } else {
    allFeatures.forEach((item) => {
      item.style.fill = false
      // item.style.stroke = false
    })
  }
  state.gMap.refresh()
}
// 选择模型的分类
const onChangeModel = (event: any) => {
  selectModelCode.value = event
  options.value.forEach((item) => {
    if (item.class_no === selectModelCode.value) {
      selectModel.value = item.class_name
      selectModelColorWare.value = item.color
    }
  })
  state.mode = "RECT"
}
// 取消
const onDialogVisibleClose = () => {
  state.mode = "PAN"
  dialogVisible.value = false
}
// 确认
const onConfirm = () => {
  if (selectModel.value && selectModelColorWare.value) {
    // dialogVisible.value = false
    state.mode = "RECT"
  } else {
    ElMessage({
      message: "请选择好标签之后点击确认进行绘制!",
      type: "warning"
    })
  }
}
// 编辑标注框
const onEdit = (item: any) => {
  // 先清除在赋值
  currentEditValue.value = ""
  currentEditValue.value = item
  parentDialogVisible.value = true
}
// 保存值
const handleSelectModel = (name: string, color: string, id: string) => {
  console.log(name, color, id)
  console.log(currentEditValue.value)
  const loading = ElLoading.service({
    lock: true,
    text: "数据保存中,请稍候...",
    svg: svg,
    background: "rgba(0, 0, 0, 0.7)"
  })
  // 对当前框进行保存
  const points: any = []
  const h = currentEditValue.value.shape.height / (originalHeight.value / 2)
  const w = currentEditValue.value.shape.width / (originalWidth.value / 2)
  points.push({
    id: id,
    width: w,
    height: h,
    x: currentEditValue.value.shape.x / (originalWidth.value / 2) + w / 2,
    y: currentEditValue.value.shape.y / (originalHeight.value / 2) + h / 2
  })
  const result: any = points
    .map((item: any) => {
      return `${item.id} ${item.x} ${item.y} ${item.width} ${item.height}`
    })
    .join(" ")
  const data = {
    annotation: result
  }
  const currentEditIndex = list.value.findIndex((item: any) => item.id === currentEditValue.value.id)
  getAiAnnotationModelMove(data, currentEditValue.value.props.id)
    .then(async () => {
      getFeatures()
      list.value.forEach((item: any, index: number) => {
        if (currentEditIndex === index) {
          item.props.class_name = name
          item.props.selectModelColor = color
          item.props.textId = id
          item.props.type = 3
          item.style.fillStyle = hexToRGBA(color, 0.4)
          item.style.strokeStyle = color
          item.style.fill = true //是否填充
          item.style.stroke = true
        }
      })
      // 刷新地图显示,使颜色更改生效
      state.gMap.refresh()
      loading.close()
      ElMessage({
        message: "修改成功!",
        type: "success"
      })
    })
    .catch(() => {})
    .finally(() => {
      loading.close()
    })
}
</script>

<style scoped lang="scss">
.page {
  position: relative;
  display: flex;
  align-items: center;
  overflow: hidden;
}
.image_page {
  position: relative;
  overflow: hidden;
}
.image_center {
  flex: 1;
  width: 100%;
  height: 100%;
  background: #7c818c;
  overflow: hidden;
}
.image_left {
  flex: 0 0 50px;
  display: flex;
  flex-direction: column;
  position: relative;
  padding: 20px 0 0;
  align-items: center;
  z-index: 999;
  height: 100%;
  background: #4b5162;
  .button_text {
    display: inline-block;
    width: 20px;
    height: 20px;
    margin-bottom: 20px;
    cursor: pointer;
    background-position: center;
    background-size: cover;
    background-repeat: no-repeat;
  }
  .button_1 {
    background-image: url("@/assets/images/back_icon.png");
  }
  .button_2 {
    background-image: url("@/assets/images/frame_icon.png");
  }
  .button_3 {
    background-image: url("@/assets/images/frame_icon_no.png");
  }
  .button_4 {
    background-image: url("@/assets/images/eye_show.png");
  }
  .button_5 {
    background-image: url("@/assets/images/eye_close.png");
  }
  .button_6 {
    background-image: url("@/assets/images/modify_icon.png");
  }
  .button_7 {
    background-image: url("@/assets/images/delete_icon.png");
  }
  .button_8 {
    background-image: url("@/assets/images/move_icon.png");
  }
  .button_9 {
    background-image: url("@/assets/images/save_icon.png");
  }
  .box_item_footer {
    position: absolute;
    bottom: 10px;
  }
}
.image_right {
  position: relative;
  flex: 0 0 300px;
  z-index: 999;
  width: 300px;
  height: 100%;
  padding: 0 10px;
  background: #4b5162;
  overflow-x: hidden;
  overflow-y: auto;
  .image_title {
    width: 100%;
    padding: 10px 5px 5px;
    color: #fff;
    text-overflow: ellipsis;
    word-break: break-all;
    overflow: hidden;
  }
  .label_list {
    display: flex;
    align-items: center;
    padding: 10px 20px;
    margin-bottom: 5px;
    background: rgba(0, 0, 0, 0.2);
    width: 100%;
    border-radius: 5px;
    overflow: hidden;
    .label_item {
      flex: 1;
      display: inline-block;
      margin-left: 5px;
      margin-right: 20px;
      font-size: 14px;
      color: #fff;
      cursor: pointer;
      overflow: hidden;
    }
    .label_list_icon {
      display: inline-block;
      flex: 0 0 20px;
      margin-right: 10px;
      height: 20px;
      background-image: url("@/assets/images/eye_show.png");
      background-position: center;
      background-size: cover;
      background-repeat: no-repeat;
    }
    & svg {
      cursor: pointer;
    }
  }
}
.left_model {
  position: absolute;
  left: 50px;
  top: 0;
  width: 400px;
  height: 150px;
  padding: 10px;
  background: #fff;
  animation: scale-up-hor-left 0.4s cubic-bezier(0.39, 0.575, 0.565, 1) both;
  border-radius: 0 0 10px 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  z-index: 9;
}
.label_content {
  display: flex;
  align-items: center;
  margin-bottom: 20px;
}
.dialog_footer {
  display: flex;
  align-items: center;
  justify-content: flex-end;
}
.image_center {
  h1 {
    margin: 0;
    font-weight: normal;
    padding: 10px 5px 0;
    font-size: 18px;
    line-height: 20px;
    color: #c00;
  }
}
@keyframes scale-up-hor-left {
  0% {
    -webkit-transform: scaleX(0.4);
    transform: scaleX(0.4);
    -webkit-transform-origin: 0% 0%;
    transform-origin: 0% 0%;
  }
  100% {
    -webkit-transform: scaleX(1);
    transform: scaleX(1);
    -webkit-transform-origin: 0% 0%;
    transform-origin: 0% 0%;
  }
}
</style>

4. 项目中用到的一些数据举例和功能简单说明

注释: 本项目只实现矩形相关的功能,其他类型的大致相同

options = [
{
  class_name: "安全设备-防火墙-新华三-T9000"
  class_no: 265
  color: "#D28FDA"
  id: 292
},
{
  class_name: "安全设备-防火墙-新华三-T9000"
  class_no: 265
  color: "#D28FDA"
  id: 292
}
]
list = [
{
id: 38063
props: {
  class_name: "服务器-PC服务器-联想-SR650"
  id: 38063
  modelId: 80
  selectModelColor: "#7488DE"
  textId: 80
  type: 3
}
shape: {
  height: 68.1536254286766
  width: 347.20757603645325
  x: 80.32076060771942
  y: 141.28022703528404
}
style: {
  fill: true
  fillStyle: "rgba(116,136,222,0.3)"
  lineWidth: 2
  opacity: 1
  stroke: true
  strokeStyle: "#7488DE"
}
type: "RECT"
}
...
]
props里面存的是我需要的值,shape存的则是标注框的信息,style则是当前标注框的样式

一些功能的说明已在代码中说明,像页面上换算图片比例,数据回显,保存结果,以及点击开始标注按钮功能弹出弹窗这些代码是我这边的需求,和代码并不冲突,可自行判断删除

5.最后,

本项目使用的图标地址 www.iconfont.cn/
项目使用的图片地址来自百度
img2.fr-trading.com/0/5_542_242… 可自行更换
本项目功能参考地址 blog.csdn.net/Tanjc518/ar…
本项目样式参考地址 COCO Annotator github.com/jsbroks/coc…