效果图
如图所示,左侧的一排按钮为画板功能,中间为图片区域,右侧的则是标注框信息。
1. 项目简介
该项目是vue3 + element plus + ailabel.js实现的,主要功能有:
- 将图片绘制在canvas上作为底层画布
- 可在该图片上进行绘制点,圆,线段,多线段,矩形,多边形等图形
- 可对绘制的图形进行编辑,删除,移动等操作
- 点击右侧列表中的眼睛图标可对单个绘制的图形进行显示/隐藏遮罩层
- 点击左侧的功能按钮中的眼睛图标可对整个绘制的图形进行显示/隐藏遮罩层
- 图片支持拖动滚动以及滚动鼠标轮放大缩小等
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 }} {{ 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…