diff --git a/app/label/page.tsx b/app/label/page.tsx
new file mode 100644
index 0000000..1eaa356
--- /dev/null
+++ b/app/label/page.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import LabelContent from "@/components/label"
+import { Paper } from "@mantine/core"
+import { useSearchParams } from "next/navigation"
+import { useMemo } from "react"
+
+export default function LabelPage() {
+ const searchParams = useSearchParams()
+ const projectId = useMemo(() => {
+ const value = Number(searchParams.get("project_id"))
+ return Number.isFinite(value) ? value : -1
+ }, [searchParams])
+ const taskId = useMemo(() => {
+ const value = Number(searchParams.get("task_id"))
+ return Number.isFinite(value) ? value : -1
+ }, [searchParams])
+
+ return (
+
+
+
+ )
+}
diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx
new file mode 100644
index 0000000..f246eab
--- /dev/null
+++ b/components/label/LabelNossr.tsx
@@ -0,0 +1,1910 @@
+"use client"
+
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+} from "react"
+import ScaleComponent from "./components/ScaleComponent"
+
+import { Box, Button, Flex, LoadingOverlay, Stack } from "@mantine/core"
+import { notifications, showNotification } from "@mantine/notifications"
+import { getLabelResult, getServerImage } from "./api/label"
+import {
+ Comment,
+ ImageObjects,
+ LabelResult,
+ WorkLoad,
+} from "./api/label/typing"
+import { getProjectDetail } from "./api/project"
+import { Project } from "./api/project/typing"
+import { getTaskList, getTaskWorkFlow } from "./api/task"
+import { Task } from "./api/task/typing"
+import BottomTools from "./components/BottomTools"
+import ConfirmModal from "./components/ConfirmModal"
+import { splitWord } from "./components/EditorContainer"
+import PaperContainer from "./components/PaperContainer"
+import RightDescTools from "./components/RightDescTools"
+import RightGroupTools from "./components/RightGroupTools"
+import RightObjectTools from "./components/RightObjectTools"
+import RightQATools from "./components/RightQATools"
+import RightTaskTools from "./components/RightTaskTools"
+import ScaleToolContainer from "./components/ScaleToolContainer"
+import TopTools from "./components/TopTools"
+import { processVideo } from "./ffmpeg/processVideo"
+import {
+ useImagesStore,
+ useKeyEventStore,
+ useLabelStore,
+ useObjectStore,
+ useVideoFrameStore,
+} from "./store"
+import { usePermissionStore } from "./store/auth"
+import { useBottomToolsStore } from "./useBottomToolsStore"
+import { useDescToolsStore } from "./useDescToolsStore"
+import { useKeyboardStore } from "./useKeyBoardStore"
+import { usePaperStore } from "./usePaperStore"
+import { useRightToolsStore } from "./useRightToolsStore"
+import {
+ useIntervalStore,
+ useLabelTimeStore,
+ useTimerStore,
+} from "./useTimerStore"
+import { useTopToolsStore } from "./useTopToolsStore"
+import { findGroupKey } from "./util"
+import { safeClone } from "./utils/clone"
+import { labelTypeMap } from "./utils/constants"
+
+// Splitter component - will need custom implementation or alternative
+
+// 定义状态的类型
+export type LabelState = Map
+
+// 定义操作的类型
+type Action =
+ | {
+ type: "INIT_IMAGES"
+ imageIds: string[]
+ objectOperations: Project.LabelSchemaList[]
+ }
+ | { type: "REMOVE_IMAGE"; imageId: string }
+
+// 定义初始状态
+const initialState: LabelState = new Map()
+
+const operationTypeList = [101, 102, 103, 104]
+let latestProjectDetailRequestSeq = 0
+let latestTaskDetailRequestSeq = 0
+
+// utils
+const getRectPoints = ([sx, sy, w, h]: number[]) => {
+ return [
+ [sx, sy + h],
+ [sx, sy],
+ [sx + w, sy],
+ [sx + w, sy + h],
+ ]
+}
+
+const getSegmentPoints = (arr: Array) => {
+ return arr.map((item) => [item[0], item[1]])
+}
+
+const videoExtPattern = /\.(mp4|mov|avi|mkv|webm|h264|264|ts|m4v)$/i
+const GLOBAL_LOADING_Z_INDEX = 900
+const videoMimeByExt: Record = {
+ mp4: "video/mp4",
+ mov: "video/quicktime",
+ webm: "video/webm",
+ mkv: "video/x-matroska",
+ avi: "video/x-msvideo",
+ h264: "video/h264",
+ "264": "video/h264",
+ ts: "video/mp2t",
+ m4v: "video/x-m4v",
+}
+const streamingFrameArgs = [
+ "-vf",
+ "fps=1,scale='min(640,iw)':-2:flags=bilinear,format=yuv420p",
+ "-q:v",
+ "5",
+ "frame_%03d.jpg",
+]
+
+const getFileExt = (fileName: string) => {
+ const segments = fileName.split(".")
+ if (segments.length <= 1) return ""
+ return segments[segments.length - 1].toLowerCase()
+}
+
+const normalizeBase64 = (text: string) => {
+ if (!text) return ""
+ const raw = text.includes(",") ? text.split(",").slice(1).join(",") : text
+ return raw.replace(/\s+/g, "")
+}
+
+const normalizeNamePrefix = (name: string) => {
+ const value = name
+ .replace(/[^a-zA-Z0-9_-]+/g, "_")
+ .replace(/_+/g, "_")
+ .replace(/^_|_$/g, "")
+ return value || "video"
+}
+
+const isVideoTaskDetail = (detail: Task.DataProps) => {
+ const sourceNames = detail.data_name
+ .map((item) => item?.name?.[0] || "")
+ .filter((name) => !!name)
+ if (!sourceNames.length) return false
+ const taskDataType = Number(
+ (detail as { [key: string]: any })?.data_type ??
+ (detail as { [key: string]: any })?.task_data_type ??
+ -1
+ )
+ return (
+ taskDataType === 2 ||
+ taskDataType === 7 ||
+ sourceNames.some((name) => videoExtPattern.test(name))
+ )
+}
+
+interface ResultImageEntry {
+ frameName: string
+ imageObjects: ImageObjects
+}
+
+const expandLabelResultImages = (item: LabelResult): ResultImageEntry[] => {
+ if (item.image_objects) {
+ return [
+ {
+ frameName: item.data_name,
+ imageObjects: item.image_objects,
+ },
+ ]
+ }
+ if (!item.video_objects) return []
+ return Object.entries(item.video_objects).map(([frameName, imageObjects]) => {
+ return {
+ frameName,
+ imageObjects,
+ }
+ })
+}
+
+interface LabelProps {
+ headerHeight: number
+ leftWidth: number
+ project_id: number
+ task_id: number
+}
+
+const LabelPage = ({
+ headerHeight = 0,
+ leftWidth = 0,
+ project_id = -1,
+ task_id = -1,
+}: LabelProps) => {
+ const projectId = useMemo(() => {
+ return project_id
+ }, [project_id])
+ const taskId = useMemo(() => {
+ return task_id
+ }, [task_id])
+ // const searchParams = useSearchParams()
+ // const id1 = searchParams.get("project_id")
+ // const projectId =
+ // typeof id1 === "string" && !isNaN(Number(id1)) ? Number(id1) : 1
+ // const id2 = searchParams.get("task_id")
+ // const taskId =
+ // typeof id2 === "string" && !isNaN(Number(id2)) ? Number(id2) : 2
+
+ const user_id = usePermissionStore.getState().user_id
+ const [projectDetail, setProjectDetail] = useState()
+ const [taskDetail, setTaskDetail] = useState()
+ const [oldWorkLoad, setOldWorkLoad] = useState(null)
+ // const [objectOperations, setObjectOperations] = useState<
+ // Project.LabelSchemaList[]
+ // >([]);
+ const topRef = useRef(null)
+ const autoSaveTimerRef = useRef | null>(null)
+ const autoSaveInProgressRef = useRef(false)
+ const paperContainerRef = useRef(null)
+ const qaToolContainerRef = useRef(null)
+ const [isConfirmSave, setIsConfirmSave] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [sizes, setSizes] = useState([])
+ const [videoFetchPendingCount, setVideoFetchPendingCount] = useState(0)
+ const [hasReadyVideoFrame, setHasReadyVideoFrame] = useState(false)
+ const [sourceVideoNames, setSourceVideoNames] = useState([])
+ const [downloadingVideoName, setDownloadingVideoName] = useState<
+ string | null
+ >(null)
+
+ const labelData = useLabelStore((state) => state.label)
+ const setLabel = useLabelStore((state) => state.setLabel)
+ const setStateStack = useLabelStore((state) => state.setStateStack)
+ const setLabelTime = useLabelTimeStore((state) => state.setLabelTime)
+ const isAutoSave = useIntervalStore((state) => state.isAutoSave)
+ const autoSaveGap = useIntervalStore((state) => state.autoSaveGap)
+
+ const {
+ editMode,
+ setEditMode,
+ setPressA,
+ activeOperation,
+ setActiveOperation,
+ showObjectList,
+ showGroupList,
+ showTaskList,
+ showDescList,
+ showMultiFrame,
+ setShowMultiFrame,
+ isView,
+ setIsView,
+ scale,
+ setScale,
+ objectOperations,
+ setObjectOperations,
+ } = useTopToolsStore()
+ const { activeImage } = useBottomToolsStore()
+ const {
+ setDescOperations,
+ metaOperation,
+ setMetaOperation,
+ setQaOperations,
+ setQaData,
+ resetData,
+ } = useDescToolsStore()
+
+ const asyncGetProjectDetail = useCallback(async () => {
+ if (projectId) {
+ const requestSeq = ++latestProjectDetailRequestSeq
+ try {
+ const res = await getProjectDetail(projectId)
+ if (requestSeq !== latestProjectDetailRequestSeq) {
+ console.warn("[LabelNossr] ignore stale project detail response", {
+ projectId,
+ requestSeq,
+ latestRequestSeq: latestProjectDetailRequestSeq,
+ })
+ return
+ }
+ setProjectDetail(res)
+ let operations: Project.LabelSchemaList[] = []
+ let textOperations: Project.LabelSchemaList[] = []
+ let metaOperation: Project.LabelSchemaList | null = null
+ res.label_schema_list?.forEach((schema) => {
+ if (operationTypeList.includes(schema.label_type)) {
+ operations.push(schema)
+ } else if ([201].includes(schema.label_type)) {
+ if (schema.label_class === "元操作序列") metaOperation = schema
+ else textOperations.push(schema)
+ }
+ })
+ setObjectOperations(operations)
+ if (res.label_type === 5) {
+ setDescOperations(textOperations)
+ if (metaOperation) setMetaOperation(metaOperation)
+ } else {
+ setDescOperations([])
+ setMetaOperation(null)
+ }
+ if (res.label_type === 6) {
+ let obj: any = {}
+ textOperations.forEach((item) => {
+ let arr = item.sub_attributes_describe.map((sub) => {
+ return {
+ [sub.chinese_name]: {
+ value: "",
+ id: sub.round_id,
+ is_pre: true,
+ tag: 0,
+ uid: 0,
+ create_timestamp: 0,
+ modify_uid: 0,
+ modify_timestamp: 0,
+ comment: "",
+ flag: 0,
+ },
+ }
+ })
+ obj[item.label_class] = arr
+ })
+ let map = new Map()
+ map.set("init", obj)
+ console.log(map)
+ setQaData(map)
+ setQaOperations(textOperations)
+ } else {
+ setQaOperations([])
+ }
+ resetData()
+ } catch {}
+ }
+ }, [
+ projectId,
+ resetData,
+ setDescOperations,
+ setMetaOperation,
+ setObjectOperations,
+ setQaData,
+ setQaOperations,
+ ])
+
+ const normalizeVideoTaskData = useCallback(
+ async (detail: Task.DataProps, requestSeq: number) => {
+ const isStaleRequest = () => requestSeq !== latestTaskDetailRequestSeq
+
+ // 后端任务可能是视频(单条 data_name),这里统一展开为可渲染的“帧列表任务”。
+ const sourceNames = detail.data_name
+ .map((item) => item?.name?.[0] || "")
+ .filter((name) => !!name)
+ if (!sourceNames.length || !projectId || !taskId) return detail
+
+ const isVideoTask = isVideoTaskDetail(detail)
+ if (!isVideoTask) {
+ setSourceVideoNames([])
+ setHasReadyVideoFrame(true)
+ return detail
+ }
+ setSourceVideoNames(sourceNames)
+
+ const appendFramesToPage = (frameNames: string[]) => {
+ if (isStaleRequest() || !frameNames.length) return
+
+ const bottomStore = useBottomToolsStore.getState()
+ const nextAllItems = [...bottomStore.allItems]
+ const existedItems = new Set(nextAllItems)
+ const appendNames: string[] = []
+
+ frameNames.forEach((name) => {
+ if (!name || existedItems.has(name)) return
+ existedItems.add(name)
+ nextAllItems.push(name)
+ appendNames.push(name)
+ })
+ if (!appendNames.length) return
+
+ bottomStore.setAllItems(nextAllItems)
+ setTaskDetail((prev) => {
+ const baseDetail = prev ?? { ...detail, data_name: [] }
+ const existedFrameNames = new Set(
+ baseDetail.data_name.map((item) => item?.name?.[0] || "")
+ )
+ const additions = appendNames
+ .filter((name) => !existedFrameNames.has(name))
+ .map((name) => ({
+ name: [name] as [string],
+ related_images: [] as [],
+ }))
+ if (!additions.length) {
+ return baseDetail === prev ? prev : baseDetail
+ }
+ return {
+ ...baseDetail,
+ data_name: [...baseDetail.data_name, ...additions],
+ }
+ })
+ }
+
+ const imagesMap = new Map(useImagesStore.getState().images)
+ const frameMapUpdates = new Map()
+ let imageMapUpdated = false
+ const normalizedDataName: Task.DataProps["data_name"] = []
+
+ for (const videoName of sourceNames) {
+ // 任务级缓存键:相同 project/task/video 可复用历史抽帧结果,避免重复解码。
+ const cacheKey = `${projectId}:${taskId}:${videoName}`
+ let frameNames =
+ useVideoFrameStore.getState().videoFrames.get(cacheKey) ?? []
+
+ const hasAllCachedFrames =
+ frameNames.length > 0 &&
+ frameNames.every((frameName) => {
+ const cache = imagesMap.get(frameName)
+ return typeof cache === "string" && cache.length > 0
+ })
+
+ if (!hasAllCachedFrames) {
+ let frameData: Awaited> = []
+ const noticeId = `video-process-${cacheKey}`
+ const fetchStartedAt = Date.now()
+ let decodeStartedAt: number | null = null
+ let fetchNoticeTicker: ReturnType | null = null
+ let progress = 0
+ let firstFrameDelaySec: string | null = null
+ const streamFrameNames: string[] = []
+ const streamFrameNameSet = new Set()
+ let lastNoticeUpdateAt = 0
+ let noticeTicker: ReturnType | null = null
+ const getFetchSec = (anchorTs?: number) => {
+ const endTs = decodeStartedAt ?? anchorTs ?? Date.now()
+ return ((endTs - fetchStartedAt) / 1000).toFixed(1)
+ }
+
+ const updateProgressNotice = (force = false) => {
+ if (isStaleRequest()) return
+ const now = Date.now()
+ if (!force && now - lastNoticeUpdateAt < 500) return
+ lastNoticeUpdateAt = now
+ const decodeElapsedSec = decodeStartedAt
+ ? ((now - decodeStartedAt) / 1000).toFixed(1)
+ : "0.0"
+ const fetchSec = getFetchSec(now)
+ const progressPct = Math.max(0, Math.min(100, progress * 100))
+ const progressText =
+ progressPct < 1
+ ? `${progressPct.toFixed(1)}%`
+ : `${Math.round(progressPct)}%`
+ const firstFrameText = firstFrameDelaySec
+ ? `,首帧解码 ${firstFrameDelaySec}s`
+ : decodeStartedAt && now - decodeStartedAt >= 8000
+ ? ",首帧生成中"
+ : ""
+ notifications.update({
+ id: noticeId,
+ loading: true,
+ autoClose: false,
+ withCloseButton: false,
+ title: "视频抽帧中",
+ message: `${videoName} ${progressText},已生成 ${streamFrameNames.length} 帧,解码用时 ${decodeElapsedSec}s,拉取 ${fetchSec}s${firstFrameText}`,
+ })
+ }
+
+ const applyFrameBatch = (
+ batchFrames: Awaited>
+ ) => {
+ if (isStaleRequest() || !batchFrames.length) return
+ const appendNames: string[] = []
+ batchFrames.forEach((frame) => {
+ if (!frame?.name || streamFrameNameSet.has(frame.name)) return
+ streamFrameNameSet.add(frame.name)
+ streamFrameNames.push(frame.name)
+ appendNames.push(frame.name)
+ imagesMap.set(frame.name, frame.dataUrl)
+ })
+ if (!appendNames.length) return
+
+ if (!firstFrameDelaySec) {
+ const anchor = decodeStartedAt ?? Date.now()
+ const firstDelayMs = Date.now() - anchor
+ firstFrameDelaySec = (firstDelayMs / 1000).toFixed(1)
+ progress = Math.max(progress, 0.01)
+ setHasReadyVideoFrame(true)
+ }
+ imageMapUpdated = true
+ useImagesStore.getState().setImages(new Map(imagesMap))
+ useVideoFrameStore
+ .getState()
+ .setVideoFrames(cacheKey, [...streamFrameNames])
+ appendFramesToPage(appendNames)
+ updateProgressNotice(true)
+ }
+
+ // 用计数器驱动全局 LoadingOverlay,仅在首帧未到达前阻塞页面。
+ setVideoFetchPendingCount((prev) => prev + 1)
+ notifications.show({
+ id: noticeId,
+ loading: true,
+ autoClose: false,
+ withCloseButton: false,
+ title: "视频加载中",
+ message: `正在获取 ${videoName},拉取用时 0.0s`,
+ })
+ fetchNoticeTicker = setInterval(() => {
+ if (isStaleRequest()) return
+ notifications.update({
+ id: noticeId,
+ loading: true,
+ autoClose: false,
+ withCloseButton: false,
+ title: "视频加载中",
+ message: `正在获取 ${videoName},拉取用时 ${getFetchSec()}s`,
+ })
+ }, 1000)
+ try {
+ const base64Video = await getServerImage({
+ data_names: [videoName],
+ data_type: 2,
+ project_id: projectId,
+ })
+ if (isStaleRequest()) return detail
+ decodeStartedAt = Date.now()
+ const fetchSecAtDecode = getFetchSec(decodeStartedAt)
+ if (fetchNoticeTicker) {
+ clearInterval(fetchNoticeTicker)
+ fetchNoticeTicker = null
+ }
+
+ notifications.update({
+ id: noticeId,
+ loading: true,
+ autoClose: false,
+ withCloseButton: false,
+ title: "视频抽帧中",
+ message: `正在快速提取并持续抽帧 ${videoName},拉取 ${fetchSecAtDecode}s`,
+ })
+ noticeTicker = setInterval(() => {
+ updateProgressNotice(true)
+ }, 1000)
+
+ frameData = await processVideo({
+ base64Data: String(base64Video || ""),
+ fileName: videoName,
+ // 输出帧名带 project/task 前缀,降低跨任务重名覆盖风险。
+ namePrefix: normalizeNamePrefix(
+ `${projectId}_${taskId}_${videoName.replace(/\.[^.]+$/, "")}`
+ ),
+ args: streamingFrameArgs,
+ quickFirstFrame: true,
+ continueAfterQuickFirstFrame: true,
+ onProgress: (value) => {
+ if (isStaleRequest()) return
+ progress = Math.max(0, Math.min(1, Number(value) || 0))
+ updateProgressNotice(false)
+ },
+ onFrames: (batchFrames) => {
+ applyFrameBatch(batchFrames)
+ },
+ })
+ if (isStaleRequest()) return detail
+
+ const totalSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1)
+ const fetchSecFinal = decodeStartedAt
+ ? ((decodeStartedAt - fetchStartedAt) / 1000).toFixed(1)
+ : "未统计"
+ notifications.update({
+ id: noticeId,
+ loading: false,
+ color: "teal",
+ title: "视频处理完成",
+ message: `${videoName} 已生成 ${frameData.length} 帧(首帧解码 ${firstFrameDelaySec || "未统计"}s,总耗时 ${totalSec}s,拉取 ${fetchSecFinal}s)`,
+ autoClose: 2600,
+ withCloseButton: true,
+ })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "未知错误"
+ const fetchSec = getFetchSec()
+ notifications.update({
+ id: noticeId,
+ loading: false,
+ color: "red",
+ title: "视频处理失败",
+ message: `${videoName}(${message},拉取 ${fetchSec}s)`,
+ autoClose: 5000,
+ withCloseButton: true,
+ })
+ throw new Error(`视频帧生成失败:${videoName}(${message})`)
+ } finally {
+ if (fetchNoticeTicker) {
+ clearInterval(fetchNoticeTicker)
+ fetchNoticeTicker = null
+ }
+ if (noticeTicker) {
+ clearInterval(noticeTicker)
+ noticeTicker = null
+ }
+ setVideoFetchPendingCount((prev) => Math.max(prev - 1, 0))
+ }
+ frameNames = frameData.map((frame) => frame.name)
+ if (!frameNames.length) {
+ throw new Error(`视频帧生成失败:${videoName}`)
+ }
+ frameData.forEach((frame) => {
+ if (!imagesMap.has(frame.name)) {
+ imagesMap.set(frame.name, frame.dataUrl)
+ }
+ })
+ imageMapUpdated = true
+ frameMapUpdates.set(cacheKey, frameNames)
+ appendFramesToPage(frameNames)
+ } else {
+ // 已命中缓存时也立即解锁页面并展示帧列表。
+ setHasReadyVideoFrame(true)
+ appendFramesToPage(frameNames)
+ }
+
+ if (!frameNames.length) {
+ throw new Error(`视频帧映射为空:${videoName}`)
+ }
+
+ frameNames.forEach((frameName) => {
+ // 将视频帧映射回 Task.DataProps 结构,复用现有图片标注渲染逻辑。
+ normalizedDataName.push({
+ name: [frameName] as [string],
+ related_images: [],
+ })
+ })
+ }
+
+ if (!normalizedDataName.length) {
+ throw new Error("未生成可用视频帧")
+ }
+
+ if (imageMapUpdated) {
+ // 批量写入,避免每帧 setState 触发频繁重渲染。
+ useImagesStore.getState().setImages(imagesMap)
+ }
+ frameMapUpdates.forEach((frameNames, cacheKey) => {
+ useVideoFrameStore.getState().setVideoFrames(cacheKey, frameNames)
+ })
+ setHasReadyVideoFrame(true)
+
+ return {
+ ...detail,
+ data_name: normalizedDataName,
+ }
+ },
+ [projectId, taskId]
+ )
+
+ const downloadVideoToLocal = useCallback(
+ (base64Video: string, name: string) => {
+ // 调试入口:把服务端 base64 视频还原成本地文件,便于和抽帧结果对比。
+ const payload = normalizeBase64(String(base64Video || ""))
+ if (!payload) throw new Error("视频数据为空")
+ const binary = atob(payload)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ const ext = getFileExt(name)
+ const blob = new Blob([bytes], {
+ type: videoMimeByExt[ext] || "video/mp4",
+ })
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement("a")
+ anchor.href = url
+ anchor.download = name
+ document.body.appendChild(anchor)
+ anchor.click()
+ document.body.removeChild(anchor)
+ URL.revokeObjectURL(url)
+ },
+ []
+ )
+
+ const handleDownloadSourceVideo = useCallback(async () => {
+ const videoName = sourceVideoNames[0]
+ if (!videoName || !projectId) return
+ const noticeId = `video-download-${projectId}-${taskId || 0}-${videoName}`
+ const fetchStartedAt = Date.now()
+ let fetchNoticeTicker: ReturnType | null = null
+ setDownloadingVideoName(videoName)
+ setVideoFetchPendingCount((prev) => prev + 1)
+ notifications.show({
+ id: noticeId,
+ loading: true,
+ autoClose: false,
+ withCloseButton: false,
+ title: "准备下载视频",
+ message: `正在获取 ${videoName},拉取用时 0.0s`,
+ })
+ fetchNoticeTicker = setInterval(() => {
+ const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1)
+ notifications.update({
+ id: noticeId,
+ loading: true,
+ autoClose: false,
+ withCloseButton: false,
+ title: "准备下载视频",
+ message: `正在获取 ${videoName},拉取用时 ${fetchSec}s`,
+ })
+ }, 1000)
+ try {
+ const base64Video = await getServerImage({
+ data_names: [videoName],
+ data_type: 2,
+ project_id: projectId,
+ })
+ if (fetchNoticeTicker) {
+ clearInterval(fetchNoticeTicker)
+ fetchNoticeTicker = null
+ }
+ const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1)
+ downloadVideoToLocal(String(base64Video || ""), videoName)
+ notifications.update({
+ id: noticeId,
+ loading: false,
+ color: "teal",
+ title: "下载已开始",
+ message:
+ sourceVideoNames.length > 1
+ ? `${videoName}(当前任务共 ${sourceVideoNames.length} 个视频,拉取 ${fetchSec}s)`
+ : `${videoName}(拉取 ${fetchSec}s)`,
+ autoClose: 2500,
+ withCloseButton: true,
+ })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "未知错误"
+ const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1)
+ notifications.update({
+ id: noticeId,
+ loading: false,
+ color: "red",
+ title: "下载失败",
+ message: `${videoName}(${message},拉取 ${fetchSec}s)`,
+ autoClose: 5000,
+ withCloseButton: true,
+ })
+ } finally {
+ if (fetchNoticeTicker) {
+ clearInterval(fetchNoticeTicker)
+ fetchNoticeTicker = null
+ }
+ setDownloadingVideoName(null)
+ setVideoFetchPendingCount((prev) => Math.max(prev - 1, 0))
+ }
+ }, [downloadVideoToLocal, projectId, sourceVideoNames, taskId])
+
+ const asyncGetTaskDetail = useCallback(async () => {
+ if (taskId) {
+ const requestSeq = ++latestTaskDetailRequestSeq
+ try {
+ usePaperStore.getState().setLoadingData(true)
+ setHasReadyVideoFrame(false)
+ setSourceVideoNames([])
+ // 获取当前任务全量数据
+ const res = await getTaskList({
+ id: [taskId],
+ get_data: true,
+ page_number: 1,
+ page_size: 1,
+ })
+ if (requestSeq !== latestTaskDetailRequestSeq) {
+ console.warn("[LabelNossr] ignore stale task list response", {
+ taskId,
+ requestSeq,
+ latestRequestSeq: latestTaskDetailRequestSeq,
+ })
+ return
+ }
+ let detail = res.task_list[0]
+ if (isVideoTaskDetail(detail)) {
+ // 视频任务先清空帧列表占位,首帧到达后再逐步追加并可立即开始操作。
+ setTaskDetail({
+ ...detail,
+ data_name: [],
+ })
+ useBottomToolsStore.getState().setAllItems([])
+ } else {
+ setHasReadyVideoFrame(true)
+ }
+ detail = await normalizeVideoTaskData(detail, requestSeq)
+ if (requestSeq !== latestTaskDetailRequestSeq) {
+ console.warn("[LabelNossr] ignore stale normalized task response", {
+ taskId,
+ requestSeq,
+ latestRequestSeq: latestTaskDetailRequestSeq,
+ })
+ return
+ }
+ setTaskDetail(detail)
+ // 帧列表
+ useBottomToolsStore
+ .getState()
+ .setAllItems(detail.data_name.map((item) => item.name[0] || ""))
+ // 获取当前任务流转记录
+ let flowCommentArr: Comment[] = []
+ let turn1time = 1
+ let turn2time = 1
+ const flowRes = await getTaskWorkFlow(taskId)
+ if (requestSeq !== latestTaskDetailRequestSeq) {
+ console.warn("[LabelNossr] ignore stale workflow response", {
+ taskId,
+ requestSeq,
+ latestRequestSeq: latestTaskDetailRequestSeq,
+ })
+ return
+ }
+ flowRes.forEach((item) => {
+ if (item.task_status_src !== 4 && item.task_status_dst === 4) {
+ flowCommentArr.push({
+ uid: item.new_uid,
+ turn: 1,
+ times: turn1time,
+ check_box_comments: [],
+ text_comments: [],
+ comment_type: 0,
+ date: 0,
+ scene_info: {
+ type: 0,
+ },
+ })
+ turn1time++
+ } else if (item.task_status_src !== 6 && item.task_status_dst === 6) {
+ flowCommentArr.push({
+ uid: item.new_uid,
+ turn: 2,
+ times: turn2time,
+ check_box_comments: [],
+ text_comments: [],
+ comment_type: 0,
+ date: 0,
+ scene_info: {
+ type: 0,
+ },
+ })
+ turn2time++
+ }
+ })
+ console.log(flowCommentArr)
+ useLabelStore.getState().setLabelDefaultComments(flowCommentArr)
+ const { work_time, label_status } = detail
+ setLabelTime({
+ label: work_time.label_work_time,
+ review1: work_time.first_review_work_time,
+ review2: work_time.second_review_work_time,
+ })
+ // 获取并处理当前任务标注结果数据
+ const dataRes = await getLabelResult(taskId)
+ if (requestSeq !== latestTaskDetailRequestSeq) {
+ console.warn("[LabelNossr] ignore stale label result response", {
+ taskId,
+ requestSeq,
+ latestRequestSeq: latestTaskDetailRequestSeq,
+ })
+ return
+ }
+ const { results } = dataRes
+ const resultImageEntries = results.flatMap((item) =>
+ expandLabelResultImages(item)
+ )
+
+ let currentWorkTime = 0
+ if (label_status === 2) {
+ currentWorkTime = work_time.label_work_time
+ } else if (label_status === 4) {
+ currentWorkTime = work_time.first_review_work_time
+ } else if (label_status === 6) {
+ currentWorkTime = work_time.second_review_work_time
+ }
+
+ // 生成当前workload
+ const workLoad: WorkLoad = {
+ object_size: {},
+ prelabel_modify_size: {},
+ review_size: 0,
+ comment_size: 0,
+ work_time: currentWorkTime,
+ dynamic_size: {},
+ static_size: {},
+ review_dynamic_size: {},
+ review_static_size: {},
+ review_question_size: {},
+ key_frame_size: {},
+ review_key_frame_size: {},
+ question_size: {},
+ }
+ resultImageEntries.forEach(({ frameName, imageObjects }) => {
+ const { annotations, images, key_frame, desc } = imageObjects
+ const rasterName = frameName || images.file_name
+ if (rasterName) {
+ usePaperStore
+ .getState()
+ .setRasterSize(rasterName, [images.width, images.height])
+ }
+ annotations.forEach((annotation) => {
+ let reviewed = false
+ const { comment, prelabel, uid } = annotation
+ if (prelabel)
+ workLoad.prelabel_modify_size[uid]
+ ? workLoad.prelabel_modify_size[uid]++
+ : (workLoad.prelabel_modify_size[uid] = 1)
+ else
+ workLoad.object_size[uid]
+ ? workLoad.object_size[uid]++
+ : (workLoad.object_size[uid] = 1)
+ if (comment) {
+ comment.forEach((c) => {
+ if (c.comment_type > 0 && c.uid === user_id) reviewed = true
+ if (c.comment_type === 2 && c.uid === user_id)
+ workLoad.comment_size++
+ })
+ }
+ if (reviewed) workLoad.review_size++
+ })
+ if (key_frame) {
+ if (label_status === 2) {
+ let id = imageObjects.label1_uid!
+ if (id)
+ workLoad.key_frame_size[id]
+ ? workLoad.key_frame_size[id]++
+ : (workLoad.key_frame_size[id] = 1)
+ } else if (label_status === 4) {
+ let id = imageObjects.review1_uid!
+ if (id)
+ workLoad.review_key_frame_size[id]
+ ? workLoad.review_key_frame_size[id]++
+ : (workLoad.review_key_frame_size[id] = 1)
+ } else if (label_status === 6) {
+ let id = imageObjects.review2_uid!
+ if (id)
+ workLoad.review_key_frame_size[id]
+ ? workLoad.review_key_frame_size[id]++
+ : (workLoad.review_key_frame_size[id] = 1)
+ }
+ }
+ if (useDescToolsStore.getState().qaOperations.length) {
+ if (desc) {
+ const { other_desc } = desc
+ const descData = other_desc ? JSON.parse(other_desc) : {}
+ Object.values(descData).forEach((questionArr: any) => {
+ questionArr.forEach((q: any) => {
+ const v: any = Object.values(q)[0]
+ if (v.uid) {
+ workLoad.question_size[v.uid]
+ ? workLoad.question_size[v.uid]++
+ : (workLoad.question_size[v.uid] = 1)
+ }
+ if (v.tag) {
+ const uid = usePermissionStore.getState().user_id
+ workLoad.review_question_size[uid]
+ ? workLoad.review_question_size[uid]++
+ : (workLoad.review_question_size[uid] = 1)
+ }
+ })
+ })
+ }
+ }
+ })
+ console.log("获取时计算的工作量统计", workLoad)
+ setOldWorkLoad(workLoad)
+ const taskLabelData = new Map()
+ const pathGroupData = new Map()
+ // 重置 PathIds
+ useRightToolsStore.getState().setPathIds([])
+ // 重置 PathGroupIds
+ useRightToolsStore.getState().setPathGroupIds([])
+ // 生成标注结果
+ resultImageEntries.forEach(({ frameName, imageObjects }) => {
+ const key = frameName
+ if (!key) return
+ const categoryMap = new Map()
+ const { annotations, image_groups, desc } = imageObjects
+ // 赋值用过的 id
+ annotations.map((annotation) => {
+ useRightToolsStore.getState().pushPathId(annotation.id)
+ })
+ // 赋值用过的 groupId
+ image_groups &&
+ Object.keys(image_groups)?.map((groupId) => {
+ useRightToolsStore.getState().pushPathGroupId(+groupId)
+ })
+ annotations.forEach((annotation) => {
+ const { id, category_id, comment } = annotation
+ let current_comment: Comment[] = []
+ if (comment) {
+ current_comment = flowCommentArr.map((c) => {
+ const { turn, times } = c
+ let modified_comment = null
+ comment.forEach((item) => {
+ if (item.turn === turn && item.times === times)
+ modified_comment = item
+ })
+ return modified_comment ? modified_comment : c
+ })
+ } else {
+ current_comment = [...flowCommentArr]
+ }
+ let detail: any = {
+ ...annotation,
+ comment: current_comment,
+ }
+ // group info
+ if (image_groups) {
+ const key = findGroupKey(image_groups as any, id)
+ if (key !== -1) detail.parentGroupId = key
+ }
+ const categoryKey = `${category_id}`
+ let arr = categoryMap.has(categoryKey)
+ ? categoryMap.get(categoryKey)
+ : []
+ if (annotation.rect) {
+ // arr.push([id, getRectPoints(annotation.rect), detail, []]);
+ arr.push([id, [getRectPoints(annotation.rect)], detail, []])
+ }
+
+ if (annotation.line_segment) {
+ arr.push([
+ id,
+ annotation.line_segment.map((item) => getSegmentPoints(item)),
+ detail,
+ [],
+ ])
+ }
+
+ if (annotation.segmentation) {
+ arr.push([
+ id,
+ // getSegmentPoints(annotation.segmentation[0]),
+ annotation.segmentation.map((item) => getSegmentPoints(item)),
+ detail,
+ annotation.hollow_segmentation?.map((item) =>
+ getSegmentPoints(item)
+ ) || [],
+ ])
+ }
+ if (annotation.key_points) {
+ arr.push([
+ id,
+ // getSegmentPoints(annotation.segmentation[0]),
+ [
+ getSegmentPoints(
+ annotation.key_points.map((item) => item.point)
+ ),
+ ],
+ Object.assign(detail, {
+ circles: annotation.key_points,
+ }),
+ [],
+ ])
+ }
+
+ categoryMap.set(categoryKey, arr)
+ })
+ taskLabelData.set(key, categoryMap)
+ // 关键帧信息
+ const keyFrame = {
+ key_frame: imageObjects.key_frame || false,
+ label1_ts: imageObjects.label1_ts || 0,
+ label1_uid: imageObjects.label1_uid || 0,
+ review1_ts: imageObjects.review1_ts || 0,
+ review1_uid: imageObjects.review1_uid || 0,
+ review2_ts: imageObjects.review2_ts || 0,
+ review2_uid: imageObjects.review2_uid || 0,
+ }
+ useBottomToolsStore.getState().setKeyFrameData(key, keyFrame)
+ // 大语言模型 两种数据情况
+ if (useDescToolsStore.getState().qaOperations.length) {
+ if (desc) {
+ const { other_desc } = desc
+ let current_data: any = {}
+ const descData = other_desc ? JSON.parse(other_desc) : {}
+ Object.entries(descData).forEach(
+ ([label_class, questionArr]: any) => {
+ let arr = questionArr.map((question: any) => {
+ let [name, val]: any = Object.entries(question)[0]
+ return {
+ [name]: {
+ ...val,
+ value: val.value
+ ? `${val.value}${splitWord}${val.value}`
+ : undefined,
+ comment: val.comment
+ ? `${val.comment}${splitWord}${val.comment}`
+ : undefined,
+ },
+ }
+ })
+ current_data[label_class] = arr
+ }
+ )
+ const qaData = useDescToolsStore.getState().qaData
+ // 无文本描述时 赋予预设值
+ if (JSON.stringify(current_data) === "{}") {
+ current_data = qaData.get("init")
+ }
+ const qaMap = safeClone(qaData)
+ qaMap.set(key, current_data)
+ useDescToolsStore.getState().setQaData(qaMap)
+ }
+ } else if (useDescToolsStore.getState().descOperations.length) {
+ if (desc) {
+ const { meta_operation, other_desc } = desc
+ const currentMetaMap = safeClone(
+ useDescToolsStore.getState().metaData
+ )
+ currentMetaMap.set(key, meta_operation || [])
+ useDescToolsStore.getState().setMetaData(currentMetaMap)
+ const currentDescMap = safeClone(
+ useDescToolsStore.getState().descData
+ )
+ let descMap = new Map()
+ const descData = other_desc ? JSON.parse(other_desc) : {}
+ console.log(descData)
+ Object.entries(descData).forEach(
+ ([label_class, { value, is_correct, comment }]: any) => {
+ descMap.set(label_class, {
+ value:
+ (typeof value === "string"
+ ? `${value}${splitWord}${value}`
+ : value) || undefined,
+ is_correct: is_correct || undefined,
+ comment: comment || undefined,
+ })
+ }
+ )
+ currentDescMap.set(key, descMap)
+ useDescToolsStore.getState().setDescData(currentDescMap)
+ }
+ }
+ image_groups &&
+ pathGroupData.set(
+ key,
+ new Map(
+ Object.entries(image_groups).map(([key, value]: any) => [
+ key * 1,
+ value,
+ ])
+ )
+ )
+ })
+ setLabel(taskLabelData)
+ // 获取任务详情时设置状态栈初始值
+ setStateStack([taskLabelData])
+ useDescToolsStore.getState().updateFlag(true)
+ useRightToolsStore.getState().setPathGroupMap(pathGroupData)
+ } catch (err) {
+ if (requestSeq !== latestTaskDetailRequestSeq) return
+ console.log(err)
+ setSourceVideoNames([])
+ setOldWorkLoad(null)
+ setTaskDetail(undefined)
+ useBottomToolsStore.getState().setAllItems([])
+ setLabel(new Map())
+ setStateStack([])
+ useRightToolsStore.getState().setPathGroupMap(new Map())
+ if (err instanceof Error && err.message.includes("视频")) {
+ showNotification({
+ color: "red",
+ title: "视频解码失败",
+ message: err.message,
+ })
+ }
+ } finally {
+ if (requestSeq === latestTaskDetailRequestSeq) {
+ usePaperStore.getState().setLoadingData(false)
+ }
+ }
+ }
+ }, [
+ normalizeVideoTaskData,
+ setLabel,
+ setLabelTime,
+ setStateStack,
+ taskId,
+ user_id,
+ ])
+
+ // 保存时更新当前workload
+ const updateOldWorkLoad = (data: WorkLoad) => {
+ setOldWorkLoad(data)
+ }
+
+ useEffect(() => {
+ asyncGetProjectDetail()
+ asyncGetTaskDetail()
+ }, [asyncGetProjectDetail, asyncGetTaskDetail])
+
+ // 定义 reducer 函数
+ const reducer = useCallback(
+ (state: LabelState, action: Action): LabelState => {
+ switch (action.type) {
+ case "INIT_IMAGES":
+ const newState = new Map() // 创建状态的副本
+ action.imageIds.forEach((imageId) => {
+ newState.set(
+ imageId,
+ action.objectOperations.map((item) => item.category_id.toString())
+ ) // 将操作列表添加到新状态中
+ })
+ return newState
+
+ case "REMOVE_IMAGE":
+ const stateAfterRemoval = new Map(state)
+ if (stateAfterRemoval.has(action.imageId)) {
+ stateAfterRemoval.delete(action.imageId) // 删除该图片的条目
+ }
+ return stateAfterRemoval
+
+ default:
+ return state // 返回当前状态
+ }
+ },
+ []
+ )
+
+ const [labelState, dispatch] = useReducer(reducer, initialState)
+
+ const initImages = (
+ imageIds: string[],
+ objectOperations: Project.LabelSchemaList[]
+ ) => {
+ dispatch({
+ type: "INIT_IMAGES",
+ imageIds,
+ objectOperations,
+ })
+ }
+
+ const [isLabelTask, setIsLabelTask] = useState(false)
+
+ useEffect(() => {
+ if (!taskDetail || !projectDetail) {
+ setIsLabelTask(false)
+ setIsView(true)
+ setEditMode(false)
+ return
+ }
+ if (taskDetail) {
+ if (
+ ![201, 202, 203, 500].includes(projectDetail!.status) ||
+ ![2, 4, 6, 7].includes(taskDetail.label_status) ||
+ taskDetail.current_uid !== user_id
+ ) {
+ // 如果不是能标注的任务
+ setIsLabelTask(false)
+ setIsView(true)
+ setEditMode(false)
+ } else {
+ setIsLabelTask(true)
+ setIsView(false)
+ }
+
+ initImages(
+ taskDetail.data_name.map((item) => item.name?.[0]),
+ objectOperations
+ )
+
+ if (taskDetail.data_name?.length) setShowMultiFrame(true)
+ }
+ }, [
+ objectOperations,
+ setIsView,
+ setShowMultiFrame,
+ user_id,
+ taskDetail,
+ projectDetail,
+ setEditMode,
+ ])
+
+ const { setPaperMode, setToolOption, setGroupScale } = usePaperStore()
+
+ // @ts-expect-error to_do
+ const forceUpdate = useReducer((bool) => !bool)[1]
+
+ const getOperationSchema = useCallback(
+ (operationId: string) => {
+ return (
+ projectDetail?.label_schema_list?.find(
+ (item) => item.category_id === +operationId && item.label_type !== 201
+ ) || null
+ )
+ },
+ [projectDetail?.label_schema_list]
+ )
+
+ const setDrawType = useCallback(() => {
+ if (isView) {
+ setPaperMode("pan")
+ } else {
+ let operationSchema = getOperationSchema(activeOperation)
+ if (operationSchema) {
+ let strokeColor = `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})`
+ let fillColor = `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)`
+ let blankColor = `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)`
+
+ if (editMode) {
+ switch (
+ operationSchema &&
+ labelTypeMap.get(operationSchema.label_type)
+ ) {
+ case "多边形":
+ setToolOption({
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ })
+ setPaperMode("polygon")
+ return
+ case "关键点":
+ setToolOption({
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ })
+ setPaperMode("point")
+ return
+ case "多线段":
+ setToolOption({
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ })
+ setPaperMode("brush")
+ return
+ case "2D框":
+ setToolOption({
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ })
+ setPaperMode("rectangle")
+ return
+ default:
+ setPaperMode("polygon")
+ return
+ }
+ } else {
+ setPaperMode("pan")
+ }
+ }
+ }
+ }, [
+ activeOperation,
+ editMode,
+ getOperationSchema,
+ isView,
+ setPaperMode,
+ setToolOption,
+ ])
+
+ useEffect(() => {
+ setDrawType()
+ }, [editMode, setDrawType])
+
+ const { setShift, focusInput } = useKeyEventStore()
+
+ const handleShortcut = useCallback(
+ (key: string, ctrl: boolean) => {
+ let checkOperation = getOperationSchema(key)
+ const mode = usePaperStore.getState().mode
+ // 标注方案里面存在的非文本类型标注才能切换
+ if (checkOperation) {
+ // 辅助标注模式下非多边形类型不能切换
+ if (mode === "support" && checkOperation.label_type !== 102) return
+ if (ctrl) {
+ setActiveOperation((+key + 10).toString())
+ } else {
+ setActiveOperation(key)
+ }
+ }
+ },
+ [getOperationSchema, setActiveOperation]
+ )
+
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ // console.log(e, e.key);
+ if (focusInput || isView) return
+
+ console.log(e.key)
+ const mode = usePaperStore.getState().mode
+
+ if (e.key === "Alt" && mode !== "support") {
+ e.preventDefault()
+ // console.log(e.key);
+ setEditMode(!editMode)
+ if (!editMode) setPressA(false)
+ }
+
+ if (e.key === "Shift") {
+ e.preventDefault()
+ setShift(true)
+ useKeyboardStore.getState().setShift(true)
+ }
+
+ if (mode !== "support")
+ if (Number(e.key)) {
+ handleShortcut(e.key, e.ctrlKey)
+ } else if (Number(e.key) === 0) {
+ handleShortcut("10", e.ctrlKey)
+ }
+
+ if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ setIsConfirmSave(true)
+ }
+
+ if (e.key === "g" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ paperContainerRef.current.handleCreatePathGroup()
+ }
+
+ if (e.key === "a") {
+ e.preventDefault()
+ setPressA(true)
+ }
+
+ if (e.key === "Control") {
+ e.preventDefault()
+ useKeyboardStore.getState().setCtrl(true)
+ }
+ // 辅助标注 分割
+ if (e.key === "F1") {
+ // 判断类型
+ e.preventDefault()
+ const schema = getOperationSchema(
+ useTopToolsStore.getState().activeOperation
+ )
+ console.log(schema)
+ if (schema?.label_type !== 102) {
+ showNotification({
+ title: "错误",
+ message: "当前类型不支持辅助标注!",
+ color: "red",
+ })
+ } else {
+ if (mode === "support") {
+ // 辅助标注生成对应标注结果
+ paperContainerRef.current.saveSupportAnnotationData()
+ } else {
+ usePaperStore.getState().setPaperMode("support")
+ }
+ }
+ }
+
+ // 追踪
+ if (e.key === "i") {
+ e.preventDefault()
+ paperContainerRef.current.renderTrackingAnnotation()
+ }
+
+ // 复制
+ if (e.key === "c" && e.ctrlKey) {
+ e.preventDefault()
+ useKeyboardStore
+ .getState()
+ .setCopyDataIds(
+ useObjectStore.getState().selectedPath[
+ useBottomToolsStore.getState().activeImage
+ ]
+ )
+ }
+
+ // 粘贴
+ if (e.key === "v" && e.ctrlKey) {
+ e.preventDefault()
+ paperContainerRef.current.copyAnnotationDataToMultiFrame()
+ }
+
+ // 撤回
+ if (e.key === "z" && e.ctrlKey) {
+ e.preventDefault()
+ // 清除当前页数据
+ const clear = () => {
+ usePaperStore
+ .getState()
+ .group!.getItems({})
+ .filter((item) => item.data.id)
+ .forEach((item) => {
+ item.remove()
+ })
+ }
+ const data = useLabelStore.getState().popStateStack()
+ console.log("弹出栈的数据", data)
+ if (data) {
+ clear()
+ const imageId = useBottomToolsStore.getState().activeImage
+ const pathGroupMap = new Map()
+ for (const [imageKey, categories] of data) {
+ const groupMap = new Map()
+ for (const [key, value] of categories) {
+ value.forEach((item) => {
+ const vId = item[2].parentGroupId
+ if (vId)
+ if (groupMap.has(vId)) groupMap.get(vId).push(item[0])
+ else groupMap.set(vId, [item[0]])
+ })
+ if (imageId === imageKey)
+ paperContainerRef.current.renderPolygons(key, categories)
+ }
+ pathGroupMap.set(imageKey, groupMap)
+ }
+ useRightToolsStore.getState().setPathGroupMap(pathGroupMap)
+
+ console.log(pathGroupMap)
+ // if (data.size) {
+ // if (data.has(imageId)) {
+ // const map = data.get(imageId);
+ // if (map) {
+ // for (const [key, value] of map) {
+ // console.log(key, value);
+ // paperContainerRef.current.renderPolygons(key, map);
+ // }
+ // }
+ // }
+ // }
+ // 更新当前数据
+ useLabelStore.getState().setLabel(safeClone(data))
+ }
+ }
+
+ if (e.key === "p") {
+ e.preventDefault()
+ if (projectDetail?.label_type === 6 && qaToolContainerRef.current)
+ qaToolContainerRef.current.checkWrongWords()
+ }
+
+ // if (e.key === "=" && (e.metaKey || e.ctrlKey)) {
+ // e.preventDefault();
+ // setScale((num) => {
+ // let newScale = num + 0.1;
+ // return newScale;
+ // });
+ // } else if (e.key === "-" && (e.metaKey || e.ctrlKey)) {
+ // e.preventDefault();
+ // setScale((num) => {
+ // if (num <= 1) {
+ // let newScale = num * 0.9;
+ // return newScale;
+ // } else {
+ // let newScale = num - 0.1;
+ // return newScale;
+ // }
+ // });
+ // } else if (e.key === "3") {
+ // e.preventDefault();
+ // } else if (e.key === "4") {
+ // e.preventDefault();
+ // } else if (e.key === "5") {
+ // e.preventDefault();
+ // } else if (e.key === "a" && (e.metaKey || e.ctrlKey)) {
+ // e.preventDefault();
+ // }
+ }
+ const up = (e: KeyboardEvent) => {
+ // console.log("键盘抬起", e);
+ if (e.key === "Shift") {
+ setShift(false)
+ useKeyboardStore.getState().setShift(false)
+ }
+ if (e.key === "Control") {
+ useKeyboardStore.getState().setCtrl(false)
+ }
+ }
+ const wheel = (e: WheelEvent) => {
+ e.preventDefault()
+ const currentScale = useTopToolsStore.getState().scale
+ let newScale = e.deltaY < 0 ? currentScale * 1.1 : currentScale * 0.9
+ if (newScale >= 100) return
+ setScale(newScale)
+ setGroupScale(newScale)
+ }
+
+ const mouseclick = (_e: MouseEvent) => {
+ useTopToolsStore.getState().setSubAttrPresetShow(false)
+ }
+
+ const mousemove = (event: { clientX: number; clientY: number }) => {
+ paperContainerRef.current.handleCrosshairMove(event)
+ }
+
+ let element = document.getElementById("resize-container")
+ document.addEventListener("keydown", down)
+ document.addEventListener("keyup", up)
+ element?.addEventListener("wheel", wheel)
+ element?.addEventListener("mousedown", mouseclick)
+ element?.addEventListener("mousemove", mousemove)
+
+ return () => {
+ document.removeEventListener("keydown", down)
+ document.removeEventListener("keyup", up)
+ element?.removeEventListener("wheel", wheel)
+ element?.removeEventListener("mousedown", mouseclick)
+ element?.removeEventListener("mousemove", mousemove)
+ }
+ }, [
+ editMode,
+ focusInput,
+ getOperationSchema,
+ handleShortcut,
+ isView,
+ projectDetail?.label_type,
+ setEditMode,
+ setGroupScale,
+ setPressA,
+ setScale,
+ setShift,
+ ])
+
+ const setGlobalLoading = useCallback((flag: boolean) => {
+ setLoading(flag)
+ }, [])
+
+ // 切换图片时清空复制保存的数组
+ useEffect(() => {
+ useKeyboardStore.getState().setCopyDataIds([])
+ }, [activeImage])
+
+ const handleBeforeUnload = (e: any) => {
+ e.preventDefault()
+ // 备份
+ topRef.current.handleBackup("auto_backup")
+ // 清空操作栏状态
+ useObjectStore.getState().resetPathAndOperationStatus()
+ }
+
+ // 更新Splitter状态
+ const updateSplitterSizes = useCallback(() => {
+ console.log(
+ "document.documentElement.clientWidth",
+ document.documentElement.clientWidth
+ )
+
+ const len = document.documentElement.clientWidth - 4
+ let arr = [len, 0, 0, 0, 0, 0]
+ const flags = [
+ showTaskList,
+ showGroupList,
+ showDescList && projectDetail && projectDetail.label_type === 5,
+ showDescList && projectDetail && projectDetail.label_type === 6,
+ showDescList && projectDetail && projectDetail.label_type === 7,
+ showObjectList,
+ ]
+ let count = 0
+ flags.forEach((flag) => {
+ if (flag) count++
+ })
+ if (count === 1 || count === 2) {
+ arr[0] = count === 1 ? len * 0.8 : len * 0.6
+ flags.forEach((flag, index) => {
+ if (flag) {
+ arr[index + 1] = len * 0.2
+ }
+ })
+ } else if (count === 3) {
+ arr[0] = len * 0.55
+ flags.forEach((flag, index) => {
+ if (flag) {
+ arr[index + 1] = len * 0.15
+ }
+ })
+ } else if (count === 4) {
+ arr[0] = len * 0.6
+ flags.forEach((flag, index) => {
+ if (flag) {
+ arr[index + 1] = len * 0.1
+ }
+ })
+ }
+ setSizes(arr)
+ }, [projectDetail, showDescList, showGroupList, showObjectList, showTaskList])
+
+ useEffect(() => {
+ updateSplitterSizes()
+ }, [updateSplitterSizes])
+
+ useEffect(() => {
+ if (!isView) {
+ const delay = 10 * 60000
+ // 编辑模式下,修改labelData时启动或更新定时器
+ useTimerStore.getState().startTimer(() => {
+ useTopToolsStore.getState().setIsView(true)
+ }, delay)
+ } else {
+ useTimerStore.getState().clearTimer()
+ }
+
+ // 组件卸载时清理定时器
+ return () => {
+ useTimerStore.getState().clearTimer()
+ }
+ }, [isView, labelData])
+
+ // auto save
+ useEffect(() => {
+ let cancelled = false
+ if (autoSaveTimerRef.current !== null) {
+ clearTimeout(autoSaveTimerRef.current)
+ autoSaveTimerRef.current = null
+ }
+ if (!isAutoSave || isView) return
+
+ const minute = 60 * 1000
+ const delay = autoSaveGap * minute
+ const scheduleNext = () => {
+ autoSaveTimerRef.current = setTimeout(async () => {
+ if (cancelled) return
+ if (autoSaveInProgressRef.current) {
+ scheduleNext()
+ return
+ }
+ autoSaveInProgressRef.current = true
+ try {
+ await topRef.current?.handleSave?.()
+ } catch (error) {
+ console.error("[LabelNossr] auto save failed", error)
+ } finally {
+ autoSaveInProgressRef.current = false
+ if (!cancelled) scheduleNext()
+ }
+ }, delay)
+ }
+ scheduleNext()
+ return () => {
+ cancelled = true
+ if (autoSaveTimerRef.current !== null) {
+ clearTimeout(autoSaveTimerRef.current)
+ autoSaveTimerRef.current = null
+ }
+ }
+ }, [autoSaveGap, isAutoSave, isView])
+
+ useEffect(() => {
+ window.addEventListener("beforeunload", handleBeforeUnload)
+ return () => {
+ window.removeEventListener("beforeunload", handleBeforeUnload)
+ }
+ }, [])
+
+ const mainBoxHeight = useMemo(() => {
+ if (projectDetail?.label_type === 5 && metaOperation) {
+ let h = `calc(100% - ${headerHeight}px - 198px)`
+ return h
+ } else if (showMultiFrame) {
+ let h = `calc(100% - ${headerHeight}px - 150px)`
+ return h
+ }
+ let h = `calc(100% - ${headerHeight}px - 70px)`
+ return h
+ }, [projectDetail?.label_type, metaOperation, showMultiFrame, headerHeight])
+
+ const fullscreenRef = useRef(null)
+
+ return (
+
+ 0 && !hasReadyVideoFrame)}
+ zIndex={GLOBAL_LOADING_Z_INDEX}
+ overlayProps={{ radius: "sm", blur: 2 }}
+ />
+
+
+ {sourceVideoNames.length > 0 && (
+
+
+
+ )}
+
+
+ {/* */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showTaskList && (
+
+
+
+ )}
+ {showGroupList && (
+
+
+
+ )}
+ {showDescList && projectDetail && projectDetail.label_type === 5 && (
+
+
+
+ )}
+ {showDescList && projectDetail && projectDetail.label_type === 6 && (
+
+
+
+ )}
+ {showObjectList && (
+
+
+
+ )}
+ {/* */}
+
+
+
+
+ {isConfirmSave && (
+ {
+ try {
+ await topRef.current?.handleSave()
+ setIsConfirmSave(false)
+ } catch {}
+ }}
+ onCancel={() => {
+ setIsConfirmSave(false)
+ }}
+ />
+ )}
+
+ )
+}
+
+export default LabelPage
diff --git a/components/label/OptStore.tsx b/components/label/OptStore.tsx
new file mode 100644
index 0000000..b1b0743
--- /dev/null
+++ b/components/label/OptStore.tsx
@@ -0,0 +1,114 @@
+"use client"
+import { getName } from "@tauri-apps/api/app"
+import { getCurrentWindow } from "@tauri-apps/api/window"
+import { usePathname } from "next/navigation"
+import { useEffect } from "react"
+import { getProjectAdminList } from "./api/project"
+import { getUserAll, getUserGroupTree } from "./api/user"
+import {
+ useAllTaskStore,
+ useAllUserStore,
+ usePermissionStore,
+} from "./store/auth"
+
+export function OptStore() {
+ const detectEnvironment = async () => {
+ try {
+ const result = await getName()
+ console.log("Running in Tauri environment", result)
+ return true
+ } catch {
+ console.log("Not running in Tauri environment")
+ return false
+ }
+ }
+ const user_id = usePermissionStore.getState().user_id
+ const { needUpdate, setUserOpts, setTreeData, setAdminOpts, setFlag } =
+ useAllUserStore()
+ const pathname = usePathname()
+
+ useEffect(() => {
+ if (user_id) {
+ const getData = async () => {
+ const res = await getUserAll()
+ setUserOpts(res)
+ const gRes = await getUserGroupTree()
+ const getTreeData = (data: any, parentHasDisabledChild = false) => {
+ let hasDisabledChild = parentHasDisabledChild
+
+ const newData: any = {
+ title: data.name,
+ value: data.id,
+ key: data.id,
+ children: [],
+ disabled: false,
+ }
+
+ if (data.members && data.members.length) {
+ data.members.forEach((member: any) => {
+ newData.children.push({
+ title: member.name,
+ value: member.id,
+ key: member.id,
+ })
+ })
+ }
+
+ if (data.children && data.children.length) {
+ data.children.forEach((child: any) => {
+ const childData = getTreeData(child, hasDisabledChild)
+ newData.children.push(childData)
+ if (childData.disabled) {
+ hasDisabledChild = true
+ }
+ })
+ }
+ if (
+ data.id.includes("-") &&
+ (!data.members || !data.members.length) &&
+ (!data.children || !data.children.length || hasDisabledChild)
+ ) {
+ newData.disabled = true
+ }
+
+ return newData
+ }
+ let arr: any[] = []
+ gRes.forEach((g: any) => {
+ arr.push(getTreeData(g))
+ })
+ const aRes = await getProjectAdminList()
+ setAdminOpts(aRes)
+ setTreeData(arr)
+ setFlag(false)
+ }
+ if (needUpdate) getData()
+ }
+ }, [user_id, setTreeData, setUserOpts, needUpdate, setFlag, setAdminOpts])
+
+ // 全局监听 重置项目详情中任务列表搜索缓存
+ useEffect(() => {
+ if (pathname !== "/project/info/detail" && pathname !== "/label") {
+ useAllTaskStore.getState().resetParams()
+ }
+ }, [pathname])
+
+ // tauri://close-requested
+ useEffect(() => {
+ ;(async () => {
+ if (await detectEnvironment()) {
+ const window = getCurrentWindow()
+ const unlisten = await window.onCloseRequested(async (_event) => {
+ usePermissionStore.getState().logout()
+ // event.preventDefault();
+ })
+
+ return () => {
+ unlisten()
+ }
+ }
+ })()
+ }, [])
+
+ return <>>
+}
diff --git a/components/label/api/const.ts b/components/label/api/const.ts
new file mode 100644
index 0000000..25355ce
--- /dev/null
+++ b/components/label/api/const.ts
@@ -0,0 +1,4 @@
+export const BASE_LABEL_API =
+ process.env.NEXT_PUBLIC_ENV === "production"
+ ? "https://label.cowarobot.com"
+ : "https://label.softtest.cowarobot.com"
diff --git a/components/label/api/daily/index.ts b/components/label/api/daily/index.ts
new file mode 100644
index 0000000..b3011f9
--- /dev/null
+++ b/components/label/api/daily/index.ts
@@ -0,0 +1,49 @@
+import httpFetch from "@/api/fetch"
+import { Daily } from "./typing"
+import { BASE_LABEL_API } from "../const"
+
+export const getSelfDailyWorkList = (data: Daily.Request) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/daily_work/list/self",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+export const getTeamDailyWorkList = (data: Daily.Request) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/daily_work/list/team",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+export const addDailyWorkData = (data: Daily.CreateProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/daily_work/add",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+export const editDailyWorkData = (data: Daily.CreateProps, id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/daily_work/edit/${id}`,
+ method: "put",
+ body: JSON.stringify(data),
+ })
+}
+
+export const deleteDailyWorkData = (id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/daily_work/delete/${id}`,
+ method: "delete",
+ })
+}
+
+export const lockDailyWorkData = (lock_status: boolean, id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/daily_work/lock/${id}`,
+ method: "put",
+ body: JSON.stringify({ lock_status }),
+ })
+}
diff --git a/components/label/api/daily/typing.ts b/components/label/api/daily/typing.ts
new file mode 100644
index 0000000..df920ae
--- /dev/null
+++ b/components/label/api/daily/typing.ts
@@ -0,0 +1,84 @@
+export namespace Daily {
+ export interface Request {
+ date_end?: string
+ date_start?: string
+ /**
+ * 0-个人日报 1-团队日报
+ */
+ flag?: number
+ name?: string
+ order_by?: string
+ page_number?: number
+ page_size?: number
+ [property: string]: any
+ }
+
+ export interface Summary {
+ total_work_time: number
+ total_standard_size: number
+ total_completed_size: number
+ total_residual_size: number
+ total_residual_work_time: number
+ }
+
+ export interface Response {
+ daily_work_list: DailyWorkList[]
+ total_items: number
+ total_pages: number
+ total_summary: Summary
+ [property: string]: any
+ }
+
+ export interface DailyWorkList {
+ accounting_type?: string | null
+ actual_type?: string
+ completed_size?: number
+ date?: string
+ end_time?: string
+ id?: number
+ leader_name?: string | null
+ leader_uid?: number | null
+ lock_status?: boolean
+ obj_type?: number | null
+ performance_accounting?: boolean | null
+ project_id?: number | null
+ project_name?: string | null
+ rate?: number
+ remarks?: string | null
+ residual_pm?: boolean | null
+ residual_size?: number
+ residual_work_time?: number
+ set_type?: string
+ standard_size?: number
+ start_time?: string
+ uid?: number
+ username?: string
+ work_time?: number
+ work_type?: string | null
+ [property: string]: any
+ }
+
+ export interface CreateProps {
+ accounting_type?: string
+ actual_type: string
+ completed_size?: number
+ date: string
+ end_time?: string
+ leader_uid?: number
+ obj_type?: number
+ performance_accounting?: boolean
+ project_id?: number
+ rate?: number
+ remarks?: string
+ residual_pm?: boolean
+ residual_size?: number
+ residual_work_time?: number
+ set_type: string
+ standard_size?: number
+ start_time?: string
+ uid: number
+ work_time?: number
+ work_type?: string
+ [property: string]: any
+ }
+}
diff --git a/components/label/api/label/index.ts b/components/label/api/label/index.ts
new file mode 100644
index 0000000..d328016
--- /dev/null
+++ b/components/label/api/label/index.ts
@@ -0,0 +1,142 @@
+import httpFetch from "@/api/fetch"
+import { BASE_LABEL_API } from "../const"
+import {
+ LabelResult,
+ LoginInfo,
+ RequestLabelResult,
+ ServerResponse,
+} from "./typing"
+
+// 获取标注结果
+export const getLabelResult = (task_id: number) => {
+ return httpFetch<{
+ task_id: number
+ results: LabelResult[]
+ }>({
+ url:
+ BASE_LABEL_API +
+ `/api/v1/label_server/task/label_result?task_id=${task_id}`,
+ method: "GET",
+ })
+}
+
+export const saveLabelResult = (data: RequestLabelResult) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/task/label_result",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+export const getServerImage = async (data: {
+ data_names: string[]
+ /**
+ * 0-图片 1-点云 2-视频
+ */
+ data_type: number
+ flag?: number
+ project_id: number
+}) => {
+ const res = await httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/data/list",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+
+ if (res.proxy) {
+ throw new Error(`get_data_list requires proxy=${res.proxy}`)
+ }
+
+ const first = res.data_list?.[0]
+ if (!first) {
+ throw new Error(
+ `get_data_list empty data_list, data_name=${data.data_names?.[0] || ""}`
+ )
+ }
+
+ const payload =
+ data.data_type === 0
+ ? first.image_data
+ : data.data_type === 2
+ ? first.video_data
+ : first.pointcloud_data
+
+ if (payload == null) {
+ throw new Error(
+ `get_data_list missing payload type=${data.data_type}, data_name=${first.data_name || data.data_names?.[0] || ""}`
+ )
+ }
+ if (typeof payload === "string" && !payload.trim()) {
+ throw new Error(
+ `get_data_list empty payload type=${data.data_type}, data_name=${first.data_name || data.data_names?.[0] || ""}`
+ )
+ }
+
+ return payload
+ // .catch(async ({ proxy, params }) => {
+ // try {
+ // const res = await httpFetch({
+ // url: `/http://${proxy}:9112/api/v1/label_sync/get_data_list`,
+ // method: "POST",
+ // body: JSON.stringify(params),
+ // })
+ // return res.data.data_list?.[0].image_data || ""
+ // } catch (error: unknown) {
+ // console.log(error)
+ // const res = await httpFetch({
+ // url: BASE_LABEL_API + "/api/v1/label_server/data/list",
+ // method: "POST",
+ // body: JSON.stringify({ ...params, flag: 1 }),
+ // })
+ // return res.data_list?.[0].image_data || ""
+ // }
+ // })
+}
+
+// 获取标注图片
+export const getLabelImage = (activeImage: string, path: string) => {
+ return httpFetch({
+ url:
+ BASE_LABEL_API +
+ `/api/v1/label_server/data/image?data_name=${encodeURIComponent(
+ activeImage
+ )}&project_path=${encodeURIComponent(path)}`,
+ method: "GET",
+ headerAttr: {
+ responseType: "blob",
+ },
+ })
+}
+
+// 辅助标注
+export const getAuxiliaryAnnotation = (data: any) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/model_server/sam2/sa",
+ method: "POST",
+ body: JSON.stringify(data),
+ headerAttr: {
+ responseType: "arraybuffer",
+ },
+ })
+}
+
+// 模型标注 追踪
+export const getTrackingAuxiliaryAnnotation = (data: any) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/model_server/sam2/vos",
+ method: "POST",
+ body: JSON.stringify(data),
+ headerAttr: {
+ responseType: "arraybuffer",
+ },
+ })
+}
+
+// 用户登录
+export const userLogin = (data: { name: string; password: string }) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/user/login",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
diff --git a/components/label/api/label/typing.ts b/components/label/api/label/typing.ts
new file mode 100644
index 0000000..5d41646
--- /dev/null
+++ b/components/label/api/label/typing.ts
@@ -0,0 +1,171 @@
+export interface LabelResult {
+ data_name: string
+ image_objects?: ImageObjects
+ video_objects?: {
+ [key: string]: ImageObjects
+ }
+ pc_box_objects?: { [key: string]: any }[]
+ pc_segment_objects?: { [key: string]: any }[]
+ pc_vegetation_objects?: { [key: string]: any }[]
+}
+
+export interface ImageObjects {
+ annotations: Annotation[]
+ categories: Category[]
+ desc?: Desc
+ image_groups: ImageGroups
+ images: Images
+ key_frame?: boolean
+ label1_ts?: number
+ label1_uid?: number
+ review1_ts?: number
+ review1_uid?: number
+ review2_ts?: number
+ review2_uid?: number
+}
+
+export interface Annotation {
+ bbox?: number[]
+ category_id: number
+ comment?: Comment[]
+ create_timestamp: number
+ first_modified_timestamp: number
+ first_modified_uid: number
+ hollow_segmentation?: Array>
+ id: number
+ image_id: number
+ key_points?: KeyPoint[]
+ last_modified_timestamp: number
+ last_modified_uid: number
+ line_segment?: Array>
+ prelabel: boolean
+ rect?: number[]
+ segmentation?: Array>
+ sub_attributes?: { [key: string]: any }
+ uid: number
+}
+
+export interface Comment {
+ check_box_comments: string[]
+ comment_type: number
+ date: number
+ scene_info: SceneInfo
+ text_comments: string[]
+ times: number
+ turn: number
+ uid: number
+}
+
+export interface SceneInfo {
+ phi?: number
+ point_idx?: number[]
+ theta?: number
+ type: number
+ vfov_factor?: number
+ x?: number
+ x_offset?: number
+ y?: number
+ y_offset?: number
+ z?: number
+ zoom?: number
+}
+
+export interface KeyPoint {
+ attributes?: { [key: string]: any }
+ point: [number, number]
+}
+
+export interface Category {
+ id: number
+ name: string
+ supercategory: string
+}
+
+export interface Desc {
+ meta_operation: string[]
+ other_desc: string
+}
+
+export interface ImageGroups {
+ // "01J4GZMQ2G8KKQVF52S6EXBGRA": string;
+}
+
+export interface Images {
+ file_name: string
+ height: number
+ id: number
+ width: number
+}
+
+export interface RequestLabelResult {
+ label_object_size: { [key: string]: any }
+ results: LabelResult[]
+ task_id: number
+ uid: number
+ work_load: WorkLoad
+ work_time: { [key: string]: any }
+}
+
+export interface WorkLoad {
+ comment_size: number
+ dynamic_size: { [key: number]: number }
+ key_frame_size: { [key: number]: number }
+ object_size: { [key: number]: number }
+ prelabel_modify_size: { [key: number]: number }
+ question_size: { [key: number]: number }
+ review_dynamic_size: { [key: number]: number }
+ review_key_frame_size: { [key: number]: number }
+ review_question_size: { [key: number]: number }
+ review_size: number
+ review_static_size: { [key: number]: number }
+ static_size: { [key: number]: number }
+ work_time: number
+}
+
+export interface ServerResponse {
+ data_list?: DataList[]
+ proxy?: string
+ [property: string]: any
+}
+
+export interface DataList {
+ data_name: string
+ data_type: string
+ image_data?: string
+ video_data?: string
+ pointcloud_data?: PointcloudDatum[]
+ [property: string]: any
+}
+
+export interface PointcloudDatum {
+ intensity: number
+ time_offset: number
+ x: number
+ y: number
+ z: number
+ [property: string]: any
+}
+
+export interface AuxiliaryParams {
+ project_id: number
+ file_name: string
+ image_hw: [number, number]
+ points_and_box: Array<[number, number]>
+ tags: number[]
+ clear_previous_state?: boolean
+}
+
+interface UserInfo {
+ uid: number
+ name: string
+ all_projects: { [x: number]: number[] }
+ collect_projects: number[]
+ group_name: string
+ work_status: number
+ base_city: string
+}
+
+export interface LoginInfo extends UserInfo {
+ token: string
+ refresh_token: string
+}
diff --git a/components/label/api/project/index.ts b/components/label/api/project/index.ts
new file mode 100644
index 0000000..efdb526
--- /dev/null
+++ b/components/label/api/project/index.ts
@@ -0,0 +1,198 @@
+import httpFetch from "@/api/fetch"
+import { Project, ProjectDetail, ReviewUsers } from "./typing"
+import { BASE_LABEL_API } from "../const"
+
+// 获取项目列表
+export const getProjectList = (data: Project.ListRequest) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/list",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 获取项目详情
+export const getProjectDetailById = (id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/project/detail/${id}`,
+ method: "GET",
+ })
+}
+
+// 校验项目源数据
+export const checkLabelDataSource = (data: Project.CheckRequest) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/check_data",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 创建项目
+export const projectAdd = (data: Project.CreateProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/add",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 审核项目
+export const projectReview = (data: Project.CreateProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/review",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 配置项目
+export const projectConfig = (data: Project.ConfigProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/config",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 编辑项目
+export const projectEditById = (data: any, id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/project/edit/${id}`,
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 审核试标项目
+export const projectPreLabelReview = (data: Project.PreLabelProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/prototype_review",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 提交验收
+export const projectDeliver = (data: { project_id: number }) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/deliver",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 完成项目
+export const projectAccept = (data: { project_id: number; pass: boolean }) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/accept",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 收藏项目
+export const projectCollect = (data: {
+ project_id: number
+ is_collect: boolean
+}) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/collect",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 获取项目抽检比例
+export const getReviewScaleList = (params: { project_id: number }) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/review_scale/list",
+ method: "GET",
+ data: params,
+ })
+}
+
+// 保存抽检比例
+export const saveUserReviewScale = (data: Project.ReviewScaleProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/review_scale/save",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+export const getProjectDetail = (id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/project/detail/${id}`,
+ method: "GET",
+ })
+}
+
+// 重新生成Embedding
+export const regenerateProjectEmbedding = (data: { project_id: number }) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/project/regenerate_embedding`,
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 获取个人参与项目
+export const getPersonProjectDashBoard = () => {
+ return httpFetch>({
+ url:
+ BASE_LABEL_API + `/api/v1/label_server/data_statistics/personal_project`,
+ method: "GET",
+ })
+}
+
+// 获取个人看板数据
+export const getCurrentPersonData = (params: { date: string }) => {
+ return httpFetch<{
+ label_data_size: number
+ label_work_time: number
+ review1_data_size: number
+ review1_work_time: number
+ review2_data_size: number
+ review2_work_time: number
+ }>({
+ url:
+ BASE_LABEL_API +
+ "/api/v1/label_server/data_statistics/personal_dashboard",
+ method: "GET",
+ data: params,
+ })
+}
+
+/* ****************************** Options ****************************** */
+// 获取项目所属业务列表
+export const getProjectTypeList = () => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/type_list",
+ method: "GET",
+ })
+}
+
+// 获取项目管理员列表
+export const getProjectAdminList = () => {
+ return httpFetch>({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/admin_list",
+ method: "GET",
+ })
+}
+
+// 获取项目审核员
+export const getProjectReviewers = () => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/reviewer_map",
+ method: "GET",
+ })
+}
+
+// 获取用户有权限的项目
+export const getSelfProjectList = () => {
+ return httpFetch>({
+ url: BASE_LABEL_API + "/api/v1/label_server/project/self",
+ method: "GET",
+ })
+}
diff --git a/components/label/api/project/typing.ts b/components/label/api/project/typing.ts
new file mode 100644
index 0000000..a3b8e42
--- /dev/null
+++ b/components/label/api/project/typing.ts
@@ -0,0 +1,474 @@
+export namespace Project {
+ export interface DataProps {
+ key: React.Key
+ id: number // 项目ID
+ name: string // 项目名称
+ is_collect: boolean // 是否收藏
+ project_type?: string // 所属业务
+ label_schema_name?: string // 标注方案
+ label_schema_version?: string
+ status: number // 项目状态
+ labeled_scale?: number // 标注完成比例
+ reviewed_scale?: number // 审核完成比例
+ data_size?: number // 总数据量
+ rate_speed?: number // 额定倍速
+ create_user: string // 创建人
+ owner: string // 项目负责人
+ label_process?: string[] // 工序
+ admin_user: number[] // 标注管理员id
+ data_source?: string // 数据源
+ remarks?: string // 备注
+ // reviewer_user?: string; // 审核人
+ // reviewer_comment?: string; // 审核意见
+ create_at?: string // 创建时间
+ update_at?: string // 更新时间
+ modify?: number //0=可以发起变更,无变更 1=正在变更,无变更 2=可以发起变更,有变更 3=正在变更,有变更
+ }
+
+ export interface CheckRequest {
+ project_type: string
+ nfs_path: string
+ flag: number
+ }
+
+ export interface CheckResponse {
+ data_size: number
+ error_code: number
+ }
+
+ export interface CreateProps {
+ admin_user: string[]
+ create_user: string
+ data_size: number
+ data_source: string
+ label_schema_name: string
+ label_schema_version: string
+ project_name: string
+ project_type: string
+ related_task?: number[]
+ remarks?: string
+ [property: string]: any
+ }
+
+ export interface ReviewProps {
+ pass: boolean
+ project_id: number
+ project_name: string
+ review_comment?: string
+ review_user: string
+ test_proportion?: number
+ }
+
+ export interface ListRequest {
+ admin_user?: string
+ create_user?: string
+ end_date?: string
+ page_number: number
+ page_size: number
+ project_id?: number
+ project_name?: string
+ project_type?: string
+ review_user?: string
+ start_date?: string
+ status?: number[]
+ [property: string]: any
+ }
+
+ export interface ListResponse {
+ project_list: DataProps[]
+ total_pages: number
+ total_items: number
+ }
+
+ export interface LabelConfig {
+ acquire_mode: number
+ acquire_size: number
+ avg_remind_threshold: number
+ dynamic_label_speed: number
+ filter_pre_label: boolean
+ fix_remind_threshold: number
+ label_accuracy_rate: number
+ label_update_month: number
+ max_size: number
+ pre_label_confirm: boolean
+ static_label_speed: number
+ }
+
+ export interface Review1Config {
+ acquire_mode: number
+ acquire_size: number
+ dynamic_review1_speed: number
+ end_date?: string
+ filter_label_first_sub: boolean
+ filter_objs: boolean
+ filter_rework: boolean
+ filter_task_batch: boolean
+ filter_track_user: boolean
+ is_rework?: boolean
+ label_first_sub_end_date?: string
+ label_first_sub_start_date?: string
+ max_objs?: number
+ max_size: number
+ min_objs?: number
+ review1_accuracy_rate: number
+ review1_update_month: number
+ start_date?: string
+ static_review1_speed: number
+ track_user_list?: number[]
+ }
+
+ export interface Review2Config {
+ acquire_mode: number
+ acquire_size: number
+ dynamic_review2_speed: number
+ end_date?: string
+ filter_objs: boolean
+ filter_review1_first_sub: boolean
+ filter_review1_user: boolean
+ filter_rework: boolean
+ filter_task_batch: boolean
+ filter_track_user: boolean
+ is_rework?: boolean
+ max_objs?: number
+ max_size: number
+ min_objs?: number
+ review1_first_sub_end_date?: string
+ review1_first_sub_start_date?: string
+ review1_user_list?: number[]
+ review2_update_month: number
+ start_date?: string
+ static_review2_speed: number
+ track_user_list?: number[]
+ }
+
+ export interface ConfigProps {
+ admin_user?: number[]
+ check_frame?: boolean
+ check_task?: boolean
+ comment_rule?: { [key: string]: any }
+ default_pose?: string
+ is_lock_object?: boolean
+ label_config?: LabelConfig
+ label_users?: number[]
+ modifiable_types?: string[]
+ project_id: number
+ project_name?: string
+ project_remark?: string
+ review1_config?: Review1Config
+ review1_users?: number[]
+ review2_config?: Review2Config
+ review2_users?: number[]
+ user_grade?: { [key: string]: any }
+ }
+
+ // 试标审核
+ export interface PreLabelProps {
+ id: number
+ review_user: string
+ action: number // 1-试标完成,2-试标通过,3-试标不通过
+ detail?: string
+ }
+
+ export interface ReviewScaleProps {
+ frame_scale?: number
+ grade?: string
+ project_id: number
+ task_scale?: number
+ uids: number[]
+ }
+ /**
+ * DetailResponse
+ */
+ export interface DetailResponse {
+ admin_user: string[]
+ alarm_threshold: AlarmThreshold
+ check_frame: boolean
+ check_task: boolean
+ comment_rule: CommentRule
+ create_at: string
+ create_user: string
+ data_size: number
+ default_pose: null
+ id: number
+ is_lock_object: boolean
+ label_process: LabelProcess
+ label_schema_list: LabelSchemaList[]
+ label_type: number
+ labeled_multiplier: LabeledMultiplier
+ modifiable_types: string[]
+ name: string
+ project_progress: ProjectProgress
+ remarks: string
+ rpath: string
+ status: number
+ task_size: number
+ user_grade: UserGrade
+ }
+
+ export interface AlarmThreshold {
+ avg_remind_threshold: number
+ fix_remind_threshold: number
+ }
+
+ export interface CommentRule {
+ check_box_rules: {
+ create_date: string
+ create_user: string
+ text: string
+ }[]
+ text_rules: string[]
+ }
+
+ export interface LabelProcess {
+ filter_pre_label: boolean
+ filter_task_info: string[]
+ pre_label_confirm: boolean
+ step_detail: StepDetail[]
+ }
+
+ export interface StepDetail {
+ acquire_mode: number
+ acquire_size: number
+ action: number
+ max_size: number
+ user_list: number[]
+ }
+
+ export interface LabelSchemaList {
+ bbox_range: number[]
+ bbox_size: number[]
+ bbox_type: number
+ category_id: number
+ color: [number, number, number]
+ label_class: string
+ label_type: number
+ radius: number
+ sensor_list: string[]
+ sub_attributes: string[]
+ sub_attributes_describe: SubAttributesDescribe[]
+ super_category: string
+ }
+
+ export interface SubAttributesDescribe {
+ chinese_name: string
+ english_name: string
+ isrequired: number
+ optional_item: string[]
+ round_id: number
+ select_mode: number
+ }
+
+ export interface LabeledMultiplier {
+ dynamic_label_speed: null
+ dynamic_review1_speed: null
+ dynamic_review2_speed: null
+ label_accuracy_rate: null
+ label_update_month: null
+ review1_accuracy_rate: null
+ review1_update_month: null
+ review2_update_month: null
+ static_label_speed: null
+ static_review1_speed: null
+ static_review2_speed: null
+ }
+
+ export interface ProjectProgress {
+ normal_labeled_task_size: number
+ normal_reviewed1_task_size: number
+ normal_reviewed2_task_size: number
+ rework_labeled_task_size: number
+ rework_reviewed1_task_size: number
+ rework_reviewed2_task_size: number
+ unlabeled_task_size: number
+ }
+
+ export interface UserGrade {
+ A: {
+ frame: number
+ task: number
+ }
+ B: {
+ frame: number
+ task: number
+ }
+ C: {
+ frame: number
+ task: number
+ }
+ }
+ /**
+ * DetailResponse
+ */
+
+ export interface PersonProjectDashboardResponseItem {
+ project_id: number
+ project_label_type: string
+ project_name: string
+ project_type: string
+ work_time: number
+ }
+}
+
+export interface ReviewUsers {
+ reviewer1: string[]
+ reviewer2: string[]
+ reviewer3: string[]
+}
+
+export namespace ProjectDetail {
+ export interface DataProps {
+ admin_user: number[]
+ alarm_threshold: AlarmThreshold
+ check_frame: boolean
+ check_task: boolean
+ comment_rule: CommentRule
+ create_at: string
+ create_user: string
+ data_size: number
+ default_pose: null
+ id: number
+ is_lock_object: boolean
+ label_process: LabelProcess
+ label_schema_list: LabelSchemaList[]
+ label_type: number
+ labeled_multiplier: LabeledMultiplier
+ modifiable_types: string[]
+ name: string
+ project_progress: ProjectProgress
+ remarks: string
+ rpath: string
+ status: number
+ task_size: number
+ user_grade: UserGrade
+ [property: string]: any
+ }
+
+ export interface AlarmThreshold {
+ avg_remind_threshold: number
+ fix_remind_threshold: number
+ [property: string]: any
+ }
+
+ export interface CommentRule {
+ check_box_rules: string[]
+ text_rules: string[]
+ [property: string]: any
+ }
+
+ export interface LabelProcess {
+ filter_pre_label: boolean
+ filter_task_info: FilterTaskInfo[]
+ pre_label_confirm: boolean
+ step_detail: StepDetail[]
+ [property: string]: any
+ }
+
+ export interface FilterTaskInfo {
+ action: number
+ end_date: string
+ filter_label_first_sub: boolean
+ filter_objs: boolean
+ filter_review1_first_sub: boolean
+ filter_review1_user: boolean
+ filter_rework: boolean
+ filter_task_batch: boolean
+ filter_track_user: boolean
+ is_rework: boolean
+ label_first_sub_end_date: string
+ label_first_sub_start_date: string
+ max_objs: number
+ min_objs: number
+ review1_first_sub_end_date: string
+ review1_first_sub_start_date: string
+ review1_user_list: number[]
+ start_date: string
+ track_user_list: number[]
+ [property: string]: any
+ }
+
+ export interface StepDetail {
+ acquire_mode: number
+ acquire_size: number
+ action: number
+ max_size: number
+ user_list: number[]
+ [property: string]: any
+ }
+
+ export interface LabelSchemaList {
+ bbox_range: number[]
+ bbox_size: number[]
+ bbox_type: number
+ category_id: number
+ color: number[]
+ label_class: string
+ label_type: number
+ radius: number
+ sensor_list: string[]
+ sub_attributes: string[]
+ sub_attributes_describe: SubAttributesDescribe[]
+ super_category: string
+ [property: string]: any
+ }
+
+ export interface SubAttributesDescribe {
+ chinese_name: string
+ english_name: string
+ isrequired: number
+ optional_item: string[]
+ round_id: number
+ select_mode: number
+ [property: string]: any
+ }
+
+ export interface LabeledMultiplier {
+ dynamic_label_speed: number
+ dynamic_review1_speed: number
+ dynamic_review2_speed: number
+ label_accuracy_rate: number
+ label_update_month: number
+ review1_accuracy_rate: number
+ review1_update_month: number
+ review2_update_month: number
+ static_label_speed: number
+ static_review1_speed: number
+ static_review2_speed: number
+ [property: string]: any
+ }
+
+ export interface ProjectProgress {
+ normal_labeled_task_size: number
+ normal_reviewed1_task_size: number
+ normal_reviewed2_task_size: number
+ rework_labeled_task_size: number
+ rework_reviewed1_task_size: number
+ rework_reviewed2_task_size: number
+ unlabeled_task_size: number
+ [property: string]: any
+ }
+
+ export interface UserGrade {
+ A: A
+ B: B
+ C: C
+ [property: string]: any
+ }
+
+ export interface A {
+ frame: number
+ task: number
+ [property: string]: any
+ }
+
+ export interface B {
+ frame: number
+ task: number
+ [property: string]: any
+ }
+
+ export interface C {
+ frame: number
+ task: number
+ [property: string]: any
+ }
+}
diff --git a/components/label/api/scheme/index.ts b/components/label/api/scheme/index.ts
new file mode 100644
index 0000000..445f9fd
--- /dev/null
+++ b/components/label/api/scheme/index.ts
@@ -0,0 +1,61 @@
+import httpFetch from "@/api/fetch"
+import { Scheme } from "./typing"
+import { BASE_LABEL_API } from "../const"
+
+// 获取标注方案列表
+export const getLabelSchemeList = (params: Scheme.ListRequest) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/label_schema/list",
+ method: "GET",
+ data: params,
+ })
+}
+
+// 新增标注方案
+export const addNewPlan = (data: Scheme.CreateProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/label_schema/add",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 获取某一标注方案版本列表
+export const getAllVersionByName = (name: string) => {
+ return httpFetch({
+ url:
+ BASE_LABEL_API +
+ `/api/v1/label_server/label_schema/version/list?name=${name}`,
+ method: "GET",
+ })
+}
+
+// 删除标注方案
+export const deleteLabelPlan = (data: { name: string; version?: string }) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/label_schema/delete",
+ method: "delete",
+ body: JSON.stringify(data),
+ })
+}
+
+// 保存错词库
+export const saveWrongWordList = (data: {
+ data: { error: string; correct: string }[]
+}) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/error_repository/save",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 查询错词库
+export const getWrongWordList = () => {
+ return httpFetch<{
+ data: { error: string; correct: string }[]
+ }>({
+ url: BASE_LABEL_API + "/api/v1/label_server/error_repository/query",
+ method: "GET",
+ })
+}
diff --git a/components/label/api/scheme/typing.ts b/components/label/api/scheme/typing.ts
new file mode 100644
index 0000000..3c32728
--- /dev/null
+++ b/components/label/api/scheme/typing.ts
@@ -0,0 +1,45 @@
+export namespace Scheme {
+ export interface ListRequest {
+ create_user?: string
+ end_date?: string
+ name?: string
+ page_number?: number
+ page_size?: number
+ start_date?: string
+ }
+
+ interface ListDataProps {
+ name: string
+ create_at: string
+ create_user: string
+ version_size: number
+ }
+
+ export interface ListResponse {
+ label_schema_list: ListDataProps[]
+ number_of_items: number
+ number_of_pages: number
+ }
+
+ export interface CreateProps {
+ create_user: string
+ flag: number // 0-新增标注方案 1-新增标注方案版本
+ json: any[]
+ name: string
+ version: string
+ version_desc: string
+ }
+
+ interface VersionListDataProps {
+ id: number
+ version: string
+ version_desc: string
+ create_at: string
+ create_user: string
+ json: any[]
+ }
+
+ export interface VersionListResponse {
+ label_schema_version_list: VersionListDataProps[]
+ }
+}
diff --git a/components/label/api/sync/index.ts b/components/label/api/sync/index.ts
new file mode 100644
index 0000000..f85717a
--- /dev/null
+++ b/components/label/api/sync/index.ts
@@ -0,0 +1,66 @@
+import httpFetch from "@/api/fetch"
+import { BASE_LABEL_API } from "../const"
+import { Params, Response } from "./typing"
+
+// 获取同步服务列表
+export const getDataSyncServerList = (params: Params.ServerRequest) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/data_sync/list",
+ method: "GET",
+ data: params,
+ })
+}
+
+// 更新同步服务所在地
+export const updateSyncServerLocation = (location: string, id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/data_sync/update/${id}`,
+ method: "PUT",
+ body: JSON.stringify({ location }),
+ })
+}
+
+// 获取所有同步服务
+export const getAllSyncServerList = () => {
+ return httpFetch<
+ {
+ id: number
+ ip: string
+ is_online: 0 | 1
+ }[]
+ >({
+ url: BASE_LABEL_API + "/api/v1/label_server/data_sync/all",
+ method: "GET",
+ })
+}
+
+// 获取同步任务列表
+export const getDataSyncTaskList = (params: Params.TaskRequest) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/data_sync/sync_task_list",
+ method: "GET",
+ data: params,
+ })
+}
+
+// 新增同步任务
+export const newSyncTask = (data: {
+ project_id: number
+ sync_server_id: number
+}) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/data_sync/add_sync_task",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 更新同步任务状态
+export const updateSyncTaskStatus = (id: number, status: number) => {
+ return httpFetch({
+ url:
+ BASE_LABEL_API + `/api/v1/label_server/data_sync/update_sync_task/${id}`,
+ method: "PUT",
+ body: JSON.stringify({ status }),
+ })
+}
diff --git a/components/label/api/sync/typing.ts b/components/label/api/sync/typing.ts
new file mode 100644
index 0000000..1bc9ea6
--- /dev/null
+++ b/components/label/api/sync/typing.ts
@@ -0,0 +1,64 @@
+export interface TimeFilter {
+ start_date: string
+ end_date: string
+}
+
+export namespace Params {
+ export interface ServerRequest {
+ is_online?: boolean
+ location?: string
+ page_number: number
+ page_size: number
+ [property: string]: any
+ }
+
+ export interface TaskRequest {
+ location?: string
+ page_number: number
+ page_size: number
+ project_id?: number
+ status?: number
+ sync_server_id?: number
+ [property: string]: any
+ }
+}
+
+export namespace Response {
+ export interface SyncServerRes {
+ sync_servers: SyncServer[]
+ total_items: number
+ total_pages: number
+ [property: string]: any
+ }
+
+ export interface SyncServer {
+ create_at?: string
+ id?: number
+ ip?: string
+ is_online?: boolean
+ location?: string
+ update_at?: string
+ [property: string]: any
+ }
+
+ export interface SyncTaskRes {
+ sync_tasks: SyncTask[]
+ total_items: number
+ total_pages: number
+ [property: string]: any
+ }
+
+ export interface SyncTask {
+ create_at?: string
+ id?: number
+ ip?: string
+ location?: string
+ project_id?: number
+ project_name?: string
+ status?: number
+ sync_server_id?: number
+ update_at?: string
+ uuid?: string
+ [property: string]: any
+ }
+}
diff --git a/components/label/api/task/index.ts b/components/label/api/task/index.ts
new file mode 100644
index 0000000..a02c752
--- /dev/null
+++ b/components/label/api/task/index.ts
@@ -0,0 +1,77 @@
+import httpFetch from "@/api/fetch"
+import { Task } from "./typing"
+import { BASE_LABEL_API } from "../const"
+
+// 获取任务列表
+export const getTaskList = (data: Task.ListRequest) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/task/list",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 领取任务
+export const taskAcquire = (data: Task.AcquireReqProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/task/acquire",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 调度任务
+export const taskDispatch = (data: Task.DispatchReqProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/task/dispatch",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 释放任务
+export const taskRelease = (data: Task.ReleaseReqProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/task/release",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 获取下一个任务
+export const getNextTaskId = (data: Task.NextReqProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/task/next",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 提交任务
+export const taskCommit = (data: Task.CommitReqProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/task/commit",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 获取任务的工作流程
+export const getTaskWorkFlow = (id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/task/workflow/${id}`,
+ method: "GET",
+ })
+}
+
+// 获取个人任务列表
+export const getUserTaskList = (params: {
+ page_size: number
+ page_number: number
+}) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/task/list_by_user`,
+ method: "GET",
+ data: params,
+ })
+}
diff --git a/components/label/api/task/typing.ts b/components/label/api/task/typing.ts
new file mode 100644
index 0000000..3ca89f2
--- /dev/null
+++ b/components/label/api/task/typing.ts
@@ -0,0 +1,179 @@
+export namespace Task {
+ export interface DataProps {
+ id: number
+ create_date: string
+ data_type?: number
+ data_name: {
+ name: [string]
+ related_images: []
+ }[]
+ project_id: number
+ project_name: string
+ project_uuid: string
+ label_status: number
+ current_uid?: number
+ current_username?: string
+ label_user?: number
+ label_username?: string
+ review_user1?: number
+ review1_username?: string
+ review_user2?: number
+ review2_username?: string
+ rejected: boolean
+ work_time: {
+ label_work_time: number
+ first_review_work_time: number
+ second_review_work_time: number
+ }
+ label_object_size: {
+ object_size: number
+ valid_size: number
+ first_comment_size: number
+ second_comment_size: number
+ new_size: number
+ prelabel_size: number
+ prelabel_modify_size: number
+ dynamic_size: number
+ static_size: number
+ review_size: number
+ review_dynamic_size: number
+ review_static_size: number
+ key_frame_size: number
+ question_size: number
+ total_qa_group_size: number
+ single_qa_group_size: number
+ multiple_qa_group_size: number
+ new_qa_group_size: number
+ new_single_qa_group_size: number
+ new_multiple_qa_group_size: number
+ review_question_size: number
+ comment_question_size: number
+ }
+ first_commit_label_time?: string
+ first_commit_review1_time?: string
+ reject_times: number
+ review_rate: {
+ review1_rate: number
+ review2_rate: number
+ }
+ label_first_work_time: number
+ review1_first_work_time: number
+ review2_first_work_time: number
+ first_reject_label_time?: string
+ }
+
+ export interface ListRequest {
+ data_type?: number
+ current_uid?: number[]
+ data_name?: string
+ first_commit_label_time_end?: string
+ first_commit_label_time_start?: string
+ first_commit_review1_time_end?: string
+ first_commit_review1_time_start?: string
+ get_data: boolean
+ id?: number[]
+ label_status?: number[]
+ label_user?: number[]
+ order_by?: string
+ page_number: number
+ page_size: number
+ project_id?: number[]
+ rejected?: number
+ review_user1?: number[]
+ review_user2?: number[]
+ }
+
+ export interface SizeProps {
+ object_size: number
+ valid_size: number
+ first_comment_size: number
+ second_comment_size: number
+ new_size: number
+ prelabel_size: number
+ prelabel_modify_size: number
+ dynamic_size: number
+ static_size: number
+ review_size: number
+ review_dynamic_size: number
+ review_static_size: number
+ key_frame_size: number
+ question_size: number
+ total_qa_group_size: number
+ single_qa_group_size: number
+ multiple_qa_group_size: number
+ new_qa_group_size: number
+ new_single_qa_group_size: number
+ new_multiple_qa_group_size: number
+ review_question_size: number
+ comment_question_size: number
+ }
+
+ export interface ListResponse {
+ task_list: DataProps[]
+ total_items: number
+ total_pages: number
+ total_size: SizeProps
+ }
+
+ export interface AcquireReqProps {
+ uid: number
+ project_id: number
+ label_action: 1 | 2 | 3 // 1-领取标注任务 2-领取审核任务 3-领取复审任务
+ }
+
+ export interface DispatchReqProps {
+ operation_uid: number
+ reject: boolean
+ task_ids: number[]
+ task_status_dst: number
+ task_status_src: number
+ uid_dst: number | null
+ uid_src: number | null
+ }
+
+ export interface ReleaseReqProps {
+ operation_uid: number
+ reject: boolean
+ task_ids: number[]
+ task_status: number
+ uid: number
+ }
+
+ export interface NextReqProps {
+ project_id: number
+ task_status: number
+ rejected: boolean
+ last_task_id: number
+ }
+
+ export interface NextResProps {
+ project_id: number
+ task_id: number | null
+ }
+
+ export interface CommitReqProps {
+ task_id: number
+ uid: number
+ commit_action: number //1-提交标注 2-提交审核1 3-提交审核2 4-驳回 5-无法标注
+ work_time: number
+ comment_question_size?: number
+ }
+
+ export interface ResGetUserTaskList {
+ simple_task_list: SimpleTaskItem[]
+ total_items: number
+ total_pages: number
+ }
+
+ export interface SimpleTaskItem {
+ id: number
+ label_status: number
+ label_type: number
+ label_username?: string
+ project_id: number
+ project_name: string
+ rejected: boolean
+ review1_username?: string
+ review2_username?: string
+ }
+}
diff --git a/components/label/api/user/index.ts b/components/label/api/user/index.ts
new file mode 100644
index 0000000..50de510
--- /dev/null
+++ b/components/label/api/user/index.ts
@@ -0,0 +1,98 @@
+import httpFetch from "@/api/fetch"
+import { BASE_LABEL_API } from "../const"
+import { Organization, User } from "./typing"
+
+// 获取用户列表
+export const getUserList = (data: User.ListRequest) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/user/list",
+ method: "POST",
+ body: JSON.stringify({ ...data, tenant: "cowarobot" }),
+ })
+}
+
+// 新增用户
+export const userAdd = (data: User.CreateProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/user/add",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 批量导入用户
+export const userImport = (data: { user_infos: any[] }) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/user/batch_add",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 编辑用户
+export const userEditById = (data: User.EditProps, id: number) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/user/edit/${id}`,
+ method: "PUT",
+ body: JSON.stringify(data),
+ })
+}
+
+// 编辑用户组织
+export const userUpdateGroup = (data: User.UpdateGroupProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + `/api/v1/label_server/user/update/group`,
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 获取用户组织树
+export const getUserGroupTree = () => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/user/group/tree",
+ method: "GET",
+ })
+}
+
+// 新增用户组
+export const userGroupAdd = (data: Organization.CreateProps) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/user/group/add",
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+// 修改用户密码
+export const modifyUserPassword = (data: {
+ old_password: string
+ new_password: string
+}) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/user/modify_password",
+ method: "PUT",
+ body: JSON.stringify(data),
+ })
+}
+
+/* ****************************** Options ****************************** */
+interface Option {
+ label: string
+ value: string
+}
+// 获取所有用户组
+export const getUserGroupAll = () => {
+ return httpFetch>({
+ url: BASE_LABEL_API + `/api/v1/label_server/user/group/all`,
+ method: "GET",
+ })
+}
+
+// 获取所有用户
+export const getUserAll = () => {
+ return httpFetch>({
+ url: BASE_LABEL_API + "/api/v1/label_server/user/all",
+ method: "GET",
+ })
+}
diff --git a/components/label/api/user/typing.ts b/components/label/api/user/typing.ts
new file mode 100644
index 0000000..6e4c62b
--- /dev/null
+++ b/components/label/api/user/typing.ts
@@ -0,0 +1,76 @@
+export namespace User {
+ export interface UserInfo {
+ uid: number
+ name: string
+ all_projects: { [x: number]: number[] }
+ collect_projects: number[]
+ group_name: string
+ group_uuid: string
+ work_status: number
+ base_city: string
+ }
+
+ export interface LoginInfo extends UserInfo {
+ token: string
+ refresh_token: string
+ }
+
+ export interface ListRequest {
+ name?: string
+ page_number?: number
+ page_size?: number
+ }
+
+ export interface ListResponse {
+ user_list: UserInfo[]
+ total_pages: number
+ total_items: number
+ }
+
+ export interface CreateProps {
+ user_name: string
+ group_uuid: string
+ base_city: string
+ }
+
+ export interface EditProps {
+ user_name?: string
+ password?: string
+ group_uuid?: string
+ base_city?: string
+ work_status?: number
+ }
+
+ export interface UpdateGroupProps {
+ user_id: number
+ group_uuid: string
+ }
+}
+
+export namespace Organization {
+ export interface CreateProps {
+ group_name: string
+ leader_uid: number
+ parent_group_uuid: string
+ }
+
+ interface Member {
+ base_city: string
+ id: number
+ name: string
+ work_status: number
+ }
+
+ export interface Response {
+ children?: Response[]
+ create_at?: string
+ create_user?: string
+ group_uuid: string
+ leader_uid?: number
+ leader_name?: string
+ members?: Member[]
+ name: string
+ parent_group_uuid: string
+ [key: string]: boolean | string | number | Response[] | Member[] | undefined
+ }
+}
diff --git a/components/label/api/workload/index.ts b/components/label/api/workload/index.ts
new file mode 100644
index 0000000..7ed353f
--- /dev/null
+++ b/components/label/api/workload/index.ts
@@ -0,0 +1,65 @@
+import httpFetch from "@/api/fetch"
+import { BASE_LABEL_API } from "../const"
+import { TimeFilter, WorkLoad } from "./typing"
+
+// 标注工作量统计
+export const getLabelStatisticsList = (params: TimeFilter) => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/workload/label_statistics/list",
+ method: "GET",
+ data: params,
+ })
+}
+
+// 审核1工作量统计
+export const getReview1StatisticsList = (params: TimeFilter) => {
+ return httpFetch({
+ url:
+ BASE_LABEL_API + "/api/v1/label_server/workload/review1_statistics/list",
+ method: "GET",
+ data: params,
+ })
+}
+
+// 审核2工作量统计
+export const getReview2StatisticsList = (params: TimeFilter) => {
+ return httpFetch({
+ url:
+ BASE_LABEL_API + "/api/v1/label_server/workload/review2_statistics/list",
+ method: "GET",
+ data: params,
+ })
+}
+
+// 结算表格列表
+export const getDownloadLog = () => {
+ return httpFetch({
+ url: BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/list",
+ method: "GET",
+ })
+}
+
+// 导出结算表格
+export const exportSettlementForm = (params: {
+ year: number
+ month: number
+}) => {
+ return httpFetch({
+ url:
+ BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/export",
+ method: "GET",
+ data: params,
+ isDownload: true,
+ })
+}
+
+// 下载结算表格
+export const downloadSettlementForm = (params: { id: number }) => {
+ return httpFetch({
+ url:
+ BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/download",
+ method: "GET",
+ data: params,
+ isDownload: true,
+ })
+}
diff --git a/components/label/api/workload/typing.ts b/components/label/api/workload/typing.ts
new file mode 100644
index 0000000..b5e0767
--- /dev/null
+++ b/components/label/api/workload/typing.ts
@@ -0,0 +1,107 @@
+export interface TimeFilter {
+ start_date: string
+ end_date: string
+}
+
+export namespace WorkLoad {
+ export interface LabelData {
+ commented_question_size: number
+ commented_size: number
+ commit_label_1: number
+ commit_reviewed_1: number
+ confirmed_prelabel_size: number
+ dynamic_size: number
+ group_name?: string
+ key_frame_size: number
+ object_size: number
+ prelabel_object_size: number
+ project_id: number
+ project_name: string
+ project_type: string
+ question_size: number
+ rejected_label_1: number
+ reviewed_question_size: number
+ reviewed_size: number
+ static_size: number
+ total_reviewed_size: number
+ uid: number
+ user_name: string
+ work_time: number
+ [property: string]: any
+ }
+
+ export interface LabelResponse {
+ all: LabelData[]
+ order_by_group_name: any[]
+ order_by_project_name: any[]
+ order_by_project_type: any[]
+ order_by_user_name: any[]
+ }
+
+ export interface Review1Data {
+ comment_size: number
+ commented_size: number
+ commit_review_1: number
+ commit_reviewed_2: number
+ confirmed_prelabel_size: number
+ group_name?: string
+ object_size: number
+ prelabel_object_size: number
+ project_id: number
+ project_name: string
+ project_type: string
+ reject_review_1: number
+ rejected_review_1: number
+ review_dynamic_size: number
+ review_key_frame_size: number
+ review_question_size: number
+ review_size: number
+ review_static_size: number
+ reviewed_size: number
+ total_review_size: number
+ total_reviewed_size: number
+ uid: number
+ user_name: string
+ work_time: number
+ [property: string]: any
+ }
+
+ export interface Review1Response {
+ all: Review1Data[]
+ order_by_group_name: any[]
+ order_by_project_name: any[]
+ order_by_project_type: any[]
+ order_by_user_name: any[]
+ }
+
+ export interface Review2Data {
+ comment_size: number
+ commit_review_2: number
+ confirmed_prelabel_size: number
+ group_name?: string
+ object_size: number
+ prelabel_object_size: number
+ project_id: number
+ project_name: string
+ project_type: string
+ reject_review_2: number
+ review_dynamic_size: number
+ review_key_frame_size: number
+ review_question_size: number
+ review_size: number
+ review_static_size: number
+ total_review_size: number
+ uid: number
+ user_name: string
+ work_time: number
+ [property: string]: any
+ }
+
+ export interface Review2Response {
+ all: Review2Data[]
+ order_by_group_name: any[]
+ order_by_project_name: any[]
+ order_by_project_type: any[]
+ order_by_user_name: any[]
+ }
+}
diff --git a/components/label/components/BackConfirmModal.tsx b/components/label/components/BackConfirmModal.tsx
new file mode 100644
index 0000000..5760753
--- /dev/null
+++ b/components/label/components/BackConfirmModal.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import CustomModal from "./CustomModal"
+import { IconInfoCircle } from "@tabler/icons-react"
+import { Button, Group, Text, Flex, ThemeIcon } from "@mantine/core"
+
+interface ComponentProps {
+ open: boolean
+ handleOk: () => void
+ handleCancel: () => void
+ onClose: () => void
+}
+
+const BackConfirmModal = ({
+ open,
+ handleOk,
+ handleCancel,
+ onClose,
+}: ComponentProps) => {
+ return (
+
+
+
+
+
+ }
+ onCancel={onClose}>
+
+
+
+
+ 未保存当前标注结果,是否确认保存并退出?
+
+
+ )
+}
+
+export default BackConfirmModal
diff --git a/components/label/components/BackupModal.tsx b/components/label/components/BackupModal.tsx
new file mode 100644
index 0000000..88f60ee
--- /dev/null
+++ b/components/label/components/BackupModal.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import CustomModal from "./CustomModal"
+import { TextInput } from "@mantine/core"
+import { useForm } from "@mantine/form"
+
+interface ComponentProps {
+ open: boolean
+ handleCancel: () => void
+ handleOk: (name: string) => void
+}
+
+const BackupModal = ({ open, handleCancel, handleOk }: ComponentProps) => {
+ const form = useForm({
+ initialValues: {
+ backup_name: "",
+ },
+ validate: {
+ backup_name: (value) => (value.length < 1 ? "请输入备份名称" : null),
+ },
+ })
+
+ const handleSubmit = () => {
+ const { hasErrors } = form.validate()
+ if (!hasErrors) {
+ handleOk(form.values.backup_name)
+ }
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default BackupModal
diff --git a/components/label/components/BottomTools.tsx b/components/label/components/BottomTools.tsx
new file mode 100644
index 0000000..35b382d
--- /dev/null
+++ b/components/label/components/BottomTools.tsx
@@ -0,0 +1,734 @@
+"use client"
+
+import {
+ ActionIcon,
+ Box,
+ Button,
+ Flex,
+ Group,
+ ScrollArea,
+ Text,
+ TextInput,
+} from "@mantine/core"
+import { showNotification } from "@mantine/notifications"
+import {
+ IconChevronLeft,
+ IconChevronRight,
+ IconChevronsLeft,
+ IconChevronsRight,
+ IconFlag,
+} from "@tabler/icons-react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { Project } from "../api/project/typing"
+import { Task } from "../api/task/typing"
+import { LabelState } from "../LabelNossr"
+import { useLabelStore } from "../store"
+import { usePermissionStore } from "../store/auth"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { useDescToolsStore } from "../useDescToolsStore"
+import { useKeyboardStore } from "../useKeyBoardStore"
+import { useOtherToolsStore } from "../useOtherToolsStore"
+import { usePaperStore } from "../usePaperStore"
+import { useLabelTimeStore } from "../useTimerStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+import { getSubAttrStatus } from "../util"
+import { safeClone } from "../utils/clone"
+import CustomModal from "./CustomModal"
+import MetaOperationTool from "./MetaOperationTool"
+
+interface BottomToolsComponentProps {
+ labelState: LabelState
+ taskDetail?: Task.DataProps
+ projectDetail?: Project.DetailResponse
+ renderPolygons: (
+ operationId: string,
+ imageData: Map | undefined
+ ) => void | null
+}
+
+const BottomTools: React.FC = (props) => {
+ const { labelState, projectDetail, taskDetail, renderPolygons } = props
+
+ const itemRefs = useRef>({})
+
+ const storeLabel = useLabelStore((state) => state.label)
+ const setLabel = useLabelStore((state) => state.setLabel)
+ const pushStateStack = useLabelStore((state) => state.pushStateStack)
+ const {
+ inputNumber,
+ setInputNumber,
+ activeImage,
+ setActiveImage,
+ setActiveImageId,
+ onlyKeyFrame,
+ setOnlyKeyFrame,
+ keyFrameData,
+ setKeyFrameData,
+ selectedItems,
+ setSelectedItems,
+ } = useBottomToolsStore()
+ const { metaOperation } = useDescToolsStore()
+ const { ctrl: ctrlKey, shift: shiftKey } = useKeyboardStore()
+
+ const [open, setOpen] = useState(false)
+ const [conflictOpen, setConflictOpen] = useState(false)
+ const isShowKeyFrame =
+ projectDetail &&
+ [5, 6].includes(projectDetail.label_type) &&
+ Array.from(labelState.keys()).some(
+ (k) => keyFrameData.has(k) && keyFrameData.get(k)?.key_frame
+ ) &&
+ onlyKeyFrame
+ const currentKeys = Array.from(labelState.keys()).filter((k) =>
+ isShowKeyFrame
+ ? keyFrameData.has(k) && keyFrameData.get(k)?.key_frame
+ : true
+ )
+ // 上一帧图片id
+ const previousFrameKey = useMemo(() => {
+ let key = ""
+ const keys = Array.from(labelState.keys()).filter((k) =>
+ isShowKeyFrame
+ ? keyFrameData.has(k) && keyFrameData.get(k)?.key_frame
+ : true
+ )
+ keys.forEach((k, index) => {
+ if (k === activeImage && index > 0) key = keys[index - 1]
+ })
+ return key
+ }, [activeImage, isShowKeyFrame, keyFrameData, labelState])
+
+ useEffect(() => {
+ const keys = Array.from(labelState.keys())
+ if (!keys.length) {
+ setActiveImage("")
+ setInputNumber("")
+ setActiveImageId(0)
+ return
+ }
+
+ const currentIndex = activeImage
+ ? keys.findIndex((k) => k === activeImage)
+ : -1
+ if (currentIndex >= 0) {
+ setInputNumber(String(currentIndex + 1))
+ setActiveImageId(currentIndex + 1)
+ return
+ }
+
+ const firstKey = keys[0]
+ setActiveImage(firstKey)
+ setInputNumber("1")
+ setActiveImageId(1)
+ }, [
+ activeImage,
+ labelState,
+ setActiveImage,
+ setActiveImageId,
+ setInputNumber,
+ ])
+
+ const scrollToIndex = (index: number) => {
+ const key = currentKeys[index]
+ if (key && itemRefs.current[key]) {
+ itemRefs.current[key]?.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ inline: "center",
+ })
+ }
+ }
+
+ const closeRightContext = useCallback(() => {
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: useOtherToolsStore.getState().imageId,
+ operationId: useOtherToolsStore.getState().operationId,
+ id: useOtherToolsStore.getState().id,
+ })
+ }, [])
+
+ const getOperationSchema = useCallback(
+ (operationId: string) => {
+ return (
+ projectDetail?.label_schema_list?.find(
+ (item) => item.category_id === +operationId
+ ) || null
+ )
+ },
+ [projectDetail?.label_schema_list]
+ )
+
+ // child 是否存在不符合要求的子属性
+ const getOperationSubAttrStatus = useCallback(
+ (imageId: string) => {
+ let parentBool = labelState.get(imageId)?.some((operationId) => {
+ let bool = storeLabel
+ .get(imageId)
+ ?.get(operationId)
+ ?.some((child) => {
+ return getSubAttrStatus(
+ getOperationSchema(operationId),
+ child[2]?.sub_attributes
+ )
+ })
+ return bool
+ })
+ return parentBool
+ },
+ [getOperationSchema, labelState, storeLabel]
+ )
+
+ // 获取上一帧和当前帧标注对象数组
+ const getLabelObjectsData = () => {
+ let map1 =
+ storeLabel.get(previousFrameKey) ??
+ new Map()
+ let arr1 = []
+ for (const value of map1.values()) arr1.push(...value)
+ let map2 =
+ storeLabel.get(activeImage) ??
+ new Map()
+ let arr2 = []
+ for (const value of map2.values()) arr2.push(...value)
+ return [arr1, arr2]
+ }
+
+ // 上一帧是否有文本描述
+ const prevFramehasTextDesc = () => {
+ if (projectDetail?.label_type === 5) {
+ let flag = false
+ const currentDescData = safeClone(useDescToolsStore.getState().descData)
+ const prevData1 = currentDescData.get(previousFrameKey) ?? new Map()
+ flag = prevData1.size ? true : false
+ if (flag) return true
+ const currentMetaData = safeClone(useDescToolsStore.getState().metaData)
+ const prevData2 = currentMetaData.get(previousFrameKey) ?? []
+ flag = prevData2.length ? true : false
+ return flag
+ } else if (projectDetail?.label_type === 6) {
+ const currentQaData = safeClone(useDescToolsStore.getState().qaData)
+ const prevData = currentQaData.get(previousFrameKey) ?? {}
+ return JSON.stringify(prevData) === "{}" ? false : true
+ }
+ return false
+ }
+
+ // 更新数据绘制
+ const updateDataRender = (
+ data: Map
+ ) => {
+ if (!renderPolygons) return
+ const list = usePaperStore.getState().group?.getItems({})
+ list?.forEach((item) => {
+ if (item.data.id) {
+ item.remove()
+ }
+ })
+
+ useTopToolsStore.getState().objectOperations?.forEach((item) => {
+ renderPolygons(item.category_id.toString(), data)
+ })
+ }
+
+ // 大语言标注继承上一帧时数据处理
+ const handleLLMData = () => {
+ // LLM
+ if (projectDetail?.label_type === 5) {
+ // 文本描述
+ const currentDescData = safeClone(useDescToolsStore.getState().descData)
+ const prevData1 = currentDescData.get(previousFrameKey) ?? new Map()
+ currentDescData.set(activeImage, prevData1)
+ useDescToolsStore.getState().setDescData(currentDescData)
+ useDescToolsStore.getState().updateFlag(true)
+ // 元操作序列
+ const currentMetaData = safeClone(useDescToolsStore.getState().metaData)
+ const prevData2 = currentMetaData.get(previousFrameKey) ?? []
+ currentMetaData.set(activeImage, prevData2)
+ useDescToolsStore.getState().setMetaData(currentMetaData)
+ }
+ // QA
+ if (projectDetail?.label_type === 6) {
+ const currentQaData = safeClone(useDescToolsStore.getState().qaData)
+ const prevData = currentQaData.get(previousFrameKey) ?? {}
+ currentQaData.set(activeImage, prevData)
+ useDescToolsStore.getState().setQaData(currentQaData)
+ useDescToolsStore.getState().updateFlag(true)
+ }
+ }
+
+ // 继承上一帧的数据
+ const handleCopyData = () => {
+ // 图形绘制
+ const [perviousData] = getLabelObjectsData()
+ let map =
+ storeLabel.get(activeImage) ??
+ new Map()
+ perviousData.forEach((item) => {
+ const id = item[2].category_id.toString()
+ if (map.has(id)) {
+ let data = map.get(id)
+ data!.push(item)
+ map.set(id, data as any)
+ } else {
+ map.set(id, [item])
+ }
+ })
+ let updateData = new Map()
+ for (const [key, value] of storeLabel) {
+ if (key === activeImage) updateData.set(key, map)
+ else updateData.set(key, value)
+ }
+ if (!storeLabel.has(activeImage)) updateData.set(activeImage, map)
+ updateDataRender(updateData.get(activeImage)!)
+ setLabel(updateData)
+ pushStateStack(updateData)
+ handleLLMData()
+ setOpen(false)
+ }
+
+ const handleCopyConflictData = (flag: boolean) => {
+ const [perviousData, nowData] = getLabelObjectsData()
+
+ let map = new Map()
+ let arr = flag
+ ? [
+ ...perviousData,
+ ...nowData.filter(
+ (item) => !perviousData.map((p) => p[0]).includes(item[0])
+ ),
+ ]
+ : [
+ ...perviousData.filter(
+ (item) => !nowData.map((d) => d[0]).includes(item[0])
+ ),
+ ...nowData,
+ ]
+ arr.forEach((item) => {
+ const id = item[2].category_id.toString()
+ if (map.has(id)) {
+ let data = map.get(id)
+ data!.push(item)
+ map.set(id, data as any)
+ } else {
+ map.set(id, [item])
+ }
+ })
+ let updateData = new Map()
+ for (const [key, value] of storeLabel) {
+ if (key === activeImage) updateData.set(key, map)
+ else updateData.set(key, value)
+ }
+ updateDataRender(updateData.get(activeImage)!)
+ setLabel(updateData)
+ pushStateStack(updateData)
+ handleLLMData()
+ setConflictOpen(false)
+ }
+
+ return (
+
+ {projectDetail?.label_type === 5 && metaOperation ? (
+
+
+ 元操作序列
+
+
+
+
+
+ ) : null}
+
+
+
+
+ {
+ scrollToIndex(0)
+ currentKeys.length && setActiveImage(currentKeys[0])
+ setInputNumber("1")
+ setActiveImageId(1)
+ closeRightContext()
+ }}>
+
+
+ {
+ if (inputNumber !== "1") {
+ let newActiveIndex = +inputNumber - 1
+ setInputNumber(newActiveIndex.toString())
+ setActiveImageId(newActiveIndex)
+ scrollToIndex(newActiveIndex - 1)
+
+ setActiveImage(currentKeys?.[newActiveIndex - 1])
+ closeRightContext()
+ }
+ }}>
+
+
+ {
+ setInputNumber(e.target.value)
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ scrollToIndex(+inputNumber - 1)
+ setActiveImage(currentKeys?.[+inputNumber - 1])
+ setActiveImageId(+inputNumber)
+ closeRightContext()
+ }
+ }}
+ />
+ / {currentKeys.length}
+ {
+ if (+inputNumber < currentKeys.length) {
+ let newActiveIndex = +inputNumber + 1
+ setInputNumber(newActiveIndex.toString())
+ setActiveImageId(newActiveIndex)
+ scrollToIndex(newActiveIndex - 1)
+ setActiveImage(currentKeys?.[newActiveIndex - 1])
+ closeRightContext()
+ }
+ }}>
+
+
+ {
+ scrollToIndex(currentKeys.length - 1)
+ setInputNumber(currentKeys.length.toString())
+ setActiveImageId(currentKeys.length)
+ setActiveImage(currentKeys?.[currentKeys.length - 1])
+ closeRightContext()
+ }}>
+
+
+
+
+ {projectDetail && [5, 6].includes(projectDetail.label_type) ? (
+ <>
+ {isShowKeyFrame ? (
+
+ ) : (
+
+ )}
+ {keyFrameData.has(activeImage) &&
+ keyFrameData.get(activeImage)?.key_frame ? (
+
+ ) : (
+
+ )}
+ >
+ ) : null}
+
+
+
+
+
+
+
+ {currentKeys.map((key, index) => {
+ const isActive = activeImage === key
+ const isSelected =
+ selectedItems.length && selectedItems.includes(key)
+ const isHighlight = isActive || isSelected
+
+ let textColor = "inherit"
+ if (getOperationSubAttrStatus(key)) {
+ textColor = "red"
+ } else if (isHighlight) {
+ textColor = "white"
+ }
+
+ return (
+ {
+ itemRefs.current[key] = el
+ }}>
+ {
+ if (e.shiftKey) {
+ e.preventDefault()
+ }
+ if (ctrlKey || shiftKey) {
+ setSelectedItems(key, ctrlKey, shiftKey)
+ } else {
+ setInputNumber(index + 1 + "")
+ setActiveImage(key)
+ setActiveImageId(index + 1)
+ setSelectedItems("", false, false)
+ }
+ closeRightContext()
+ }}>
+ {keyFrameData.has(key) &&
+ keyFrameData.get(key)?.key_frame ? (
+
+
+
+ ) : null}
+
+ {index + 1}
+
+
+
+ )
+ })}
+
+
+
+ {
+ setOpen(false)
+ }}
+ onOk={handleCopyData}>
+ 确实要复制上一帧数据吗?
+
+ {
+ setConflictOpen(false)
+ }}
+ footer={
+
+
+
+
+
+ }>
+ 与上一帧数据存在冲突,是否要复制上一帧数据并覆盖吗?
+
+
+ )
+}
+
+export default BottomTools
diff --git a/components/label/components/ConfirmModal.tsx b/components/label/components/ConfirmModal.tsx
new file mode 100644
index 0000000..c155452
--- /dev/null
+++ b/components/label/components/ConfirmModal.tsx
@@ -0,0 +1,37 @@
+import { IconInfoCircle } from "@tabler/icons-react"
+import CustomModal from "./CustomModal"
+import { Flex, Text, ThemeIcon } from "@mantine/core"
+
+interface ComponentProps {
+ open: boolean
+ title: string
+ content: string
+ onOk: () => void
+ onCancel: () => void
+}
+
+const ConfirmModal = ({
+ open,
+ title,
+ content,
+ onOk,
+ onCancel,
+}: ComponentProps) => {
+ return (
+
+
+
+
+
+ {content}
+
+
+ )
+}
+
+export default ConfirmModal
diff --git a/components/label/components/CrosshairComponent.tsx b/components/label/components/CrosshairComponent.tsx
new file mode 100644
index 0000000..68d960e
--- /dev/null
+++ b/components/label/components/CrosshairComponent.tsx
@@ -0,0 +1,175 @@
+"use client"
+import React, {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+} from "react"
+import { useTopToolsStore } from "../useTopToolsStore"
+
+interface CrosshairComponentProps {
+ isCarved: boolean
+}
+
+const CrosshairComponent = (
+ props: CrosshairComponentProps,
+ ref: React.Ref | undefined
+) => {
+ const { isCarved } = props
+
+ const { scale } = useTopToolsStore()
+
+ const canvasRef = useRef(null)
+
+ const getFixed = (sparsity: number) => {
+ const pointIdx = String(sparsity).indexOf(".")
+ const len = String(sparsity).length
+ return pointIdx < 0 ? 0 : len - pointIdx - 1
+ }
+
+ const isCloseToInteger = (num: number) => {
+ return Math.abs(num - Math.round(num)) < 0.0000001
+ }
+
+ const getSparsity = (scale: number) => {
+ if (scale <= 1) {
+ return Math.round(100 / scale)
+ } else if (scale <= 3) {
+ return 50
+ } else if (scale <= 4) {
+ return 20
+ } else if (scale <= 5) {
+ return 10
+ }
+ return 5
+ }
+
+ // 绘制十字线的函数
+ const drawCrosshair = useCallback(
+ (centerX: number, centerY: number) => {
+ const lineWidth = 2
+
+ const tickLength = 10
+
+ const canvas = canvasRef.current
+ const ctx = canvas?.getContext("2d")
+ if (ctx && canvas) {
+ // 清空画布
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
+
+ // 绘制水平和垂直线
+ ctx.beginPath()
+ // 水平
+ ctx.moveTo(centerX - canvas.width, centerY - lineWidth / 2)
+ ctx.lineTo(centerX + canvas.width, centerY - lineWidth / 2)
+ // 垂直
+ ctx.moveTo(centerX - lineWidth / 2, centerY - canvas.height)
+ ctx.lineTo(centerX - lineWidth / 2, centerY + canvas.height)
+ //
+ ctx.lineWidth = lineWidth
+ ctx.strokeStyle = "#EF4444"
+ ctx.setLineDash([5, 2])
+ ctx.stroke()
+
+ if (isCarved) {
+ ctx.beginPath()
+ ctx.setLineDash([])
+ ctx.strokeStyle = "#EF4444"
+ let carveLineWidth = 1
+ ctx.lineWidth = carveLineWidth
+
+ // 间隔
+ const sparsity = getSparsity(scale)
+ const part = 10
+ const pixelPerUnit = scale * sparsity
+ const gap = pixelPerUnit / part
+
+ const fixed = getFixed(sparsity)
+
+ // 横向刻度
+ let indexX =
+ centerX % gap > 0 ? gap - (centerX % gap) : -centerX % gap
+ do {
+ const num = ((centerX + indexX) / pixelPerUnit) * sparsity
+ if (isCloseToInteger(num / sparsity)) {
+ ctx.moveTo(indexX, centerY - tickLength * 0.7 - carveLineWidth)
+ ctx.lineTo(indexX, centerY + tickLength * 0.7)
+ const text = (
+ ((indexX - centerX) / pixelPerUnit) *
+ sparsity
+ ).toFixed(fixed)
+ const textWidth = ctx.measureText(text).width
+ ctx.fillText(text, indexX - textWidth / 2, centerY - tickLength)
+ } else {
+ ctx.moveTo(indexX, centerY - tickLength / 2 - carveLineWidth)
+ ctx.lineTo(indexX, centerY + tickLength / 2)
+ }
+ indexX += gap
+ } while (indexX < 2 * canvas.width)
+ // 纵向刻度
+ let indexY =
+ centerY % gap > 0 ? gap - (centerY % gap) : -centerY % gap
+ do {
+ const num = ((centerY + indexY) / pixelPerUnit) * sparsity
+ if (isCloseToInteger(num / sparsity)) {
+ ctx.moveTo(centerX - tickLength * 0.7 - carveLineWidth, indexY)
+ ctx.lineTo(centerX + tickLength * 0.7, indexY)
+ const text = (
+ ((-indexY + centerY) / pixelPerUnit) *
+ sparsity
+ ).toFixed(fixed)
+ const textWidth = ctx.measureText(text).width
+ ctx.fillStyle = "#EF4444"
+ ctx.fillText(text, centerX + tickLength, indexY - textWidth / 2)
+ } else {
+ ctx.moveTo(centerX - tickLength / 2 - carveLineWidth, indexY)
+ ctx.lineTo(centerX + tickLength / 2, indexY)
+ }
+ indexY += gap
+ } while (indexY < 2 * canvas.height)
+
+ ctx.stroke()
+ }
+ }
+ },
+ [isCarved, scale]
+ )
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (canvas) {
+ // 设置canvas尺寸
+ canvas.width = window.innerWidth
+ canvas.height = window.innerHeight
+ const rect = canvas.getBoundingClientRect()
+ drawCrosshair(rect.width / 2, rect.height / 2)
+ }
+ }, [drawCrosshair])
+
+ const updateCrosshair = (event: { clientX: number; clientY: number }) => {
+ const canvas = canvasRef.current
+ if (canvas) {
+ const rect = canvas.getBoundingClientRect()
+ const centerX = event.clientX - rect.left
+ const centerY = event.clientY - rect.top
+ drawCrosshair(centerX, centerY)
+ }
+ }
+
+ useImperativeHandle(ref, () => ({ updateCrosshair }))
+
+ return (
+
+ )
+}
+
+export default forwardRef(CrosshairComponent)
diff --git a/components/label/components/CustomModal.tsx b/components/label/components/CustomModal.tsx
new file mode 100644
index 0000000..b70ee55
--- /dev/null
+++ b/components/label/components/CustomModal.tsx
@@ -0,0 +1,67 @@
+import { Modal, Button, Group, ModalProps, Flex, Box } from "@mantine/core"
+import { ReactNode } from "react"
+
+type BaseModalProps = Omit
+
+interface CustomModalProps extends BaseModalProps {
+ open: boolean
+ onCancel: () => void
+ onOk?: () => void
+ title?: ReactNode
+ footer?: ReactNode
+ okText?: string
+ cancelText?: string
+ confirmLoading?: boolean
+ width?: string | number
+}
+
+const CustomModal = ({
+ open,
+ onCancel,
+ onOk,
+ title,
+ children,
+ footer,
+ okText = "确定",
+ cancelText = "取消",
+ confirmLoading,
+ width,
+ ...props
+}: CustomModalProps) => {
+ return (
+
+ {/* Preserving potential decorative intent with a Box if needed,
+ but for now assuming title text is sufficient or passed as Node.
+ If the original had a blue bar, we could add:
+
+ */}
+ {title}
+
+ }
+ size={width || "md"}
+ centered
+ {...props}>
+ {children}
+ {footer === undefined ? (
+
+
+ {onOk && (
+
+ )}
+
+ ) : (
+ footer
+ )}
+
+ )
+}
+
+export default CustomModal
diff --git a/components/label/components/EditorContainer.md b/components/label/components/EditorContainer.md
new file mode 100644
index 0000000..79f6f20
--- /dev/null
+++ b/components/label/components/EditorContainer.md
@@ -0,0 +1,32 @@
+# EditorContainer
+
+A rich text editor component based on `@mantine/tiptap`.
+
+## Features
+- Basic formatting (Bold, Italic, Underline, Strike, Code)
+- Headings (H1-H4)
+- Lists (Bullet, Ordered)
+- Blockquote, Horizontal Rule
+- Links
+- Image Upload (Data URI)
+
+## Usage
+
+```tsx
+import EditorContainer from './EditorContainer';
+
+ console.log(val)}
+ disabled={false}
+/>
+```
+
+## Props
+
+| Prop | Type | Description |
+| --- | --- | --- |
+| `value` | `string` | HTML content of the editor. |
+| `onChange` | `(value: string) => void` | Callback when content changes. Returns `html@1024-text`. |
+| `disabled` | `boolean` | Whether the editor is read-only. |
+| `color` | `string` | Class name for the container border/style. |
diff --git a/components/label/components/EditorContainer.tsx b/components/label/components/EditorContainer.tsx
new file mode 100644
index 0000000..b72d431
--- /dev/null
+++ b/components/label/components/EditorContainer.tsx
@@ -0,0 +1,148 @@
+"use client"
+
+import { useEditor } from "@tiptap/react"
+import StarterKit from "@tiptap/starter-kit"
+import Underline from "@tiptap/extension-underline"
+import Link from "@tiptap/extension-link"
+import Image from "@tiptap/extension-image"
+import Placeholder from "@tiptap/extension-placeholder"
+import { RichTextEditor } from "@mantine/tiptap"
+import { useTopToolsStore } from "../useTopToolsStore"
+import { useKeyEventStore } from "../store"
+import { useEffect, useRef } from "react"
+import { Box } from "@mantine/core"
+import { IconPhoto } from "@tabler/icons-react"
+import "@mantine/tiptap/styles.css"
+
+export const splitWord = "@1024-"
+
+interface EditorContainerProps {
+ value: string
+ onChange: (value: string) => void
+ color?: string
+ disabled?: boolean
+}
+
+const EditorContainer = ({
+ value,
+ onChange,
+ color,
+ disabled,
+}: EditorContainerProps) => {
+ const { isView } = useTopToolsStore()
+ const fileInputRef = useRef(null)
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Underline,
+ Link,
+ Image,
+ Placeholder.configure({ placeholder: "请输入内容..." }),
+ ],
+ content: value,
+ editable: !disabled && !isView,
+ onUpdate: ({ editor }) => {
+ // Preserve splitWord format
+ onChange(`${editor.getHTML()}${splitWord}${editor.getText()}`)
+ },
+ onFocus: () => {
+ useKeyEventStore.getState().setFocusInput(true)
+ },
+ onBlur: () => {
+ useKeyEventStore.getState().setFocusInput(false)
+ },
+ })
+
+ useEffect(() => {
+ if (editor) {
+ editor.setEditable(!disabled && !isView)
+ }
+ }, [disabled, isView, editor])
+
+ useEffect(() => {
+ // Sync external value changes to editor
+ // Check if editor content differs from value to avoid cursor jump and loops
+ if (editor && value && editor.getHTML() !== value) {
+ // Only set content if it's substantially different.
+ // Note: editor.getHTML() might return different string than value (e.g. attribute order).
+ // Ideally we should compare content, but string comparison is a basic check.
+ // If value is empty string, we should clear.
+ editor.commands.setContent(value)
+ }
+ }, [value, editor])
+
+ const handleImageUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (file && editor) {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const result = e.target?.result as string
+ editor.chain().focus().setImage({ src: result }).run()
+ }
+ reader.readAsDataURL(file)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fileInputRef.current?.click()}
+ aria-label="Upload image"
+ title="Upload image">
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default EditorContainer
diff --git a/components/label/components/Icon.tsx b/components/label/components/Icon.tsx
new file mode 100644
index 0000000..404c552
--- /dev/null
+++ b/components/label/components/Icon.tsx
@@ -0,0 +1,946 @@
+import { SVGProps } from "react"
+
+export type IconSvgProps = SVGProps & {
+ size?: number
+}
+
+export const Logo: React.FC = () => (
+
+)
+
+export const LoginUserIcon = () => (
+
+)
+
+export const LoginPasswordIcon = () => (
+
+)
+
+export const LoginCodeIcon = () => (
+
+)
+
+export const DashboardMenuIcon = () => (
+
+)
+
+export const TaskMenuIcon = () => (
+
+)
+
+export const DeviceMenuIcon = () => (
+
+)
+
+export const CountMenuIcon = () => (
+
+)
+
+export const UserMenuIcon = () => (
+
+)
+
+export const AuthMenuIcon = () => (
+
+)
+
+export const PersonalMenuIcon = () => (
+
+)
+
+export const DevLogo = ({ ...props }) => (
+
+)
+
+export const ExportIcon = () => (
+
+)
+
+export const BasicInfoIcon = () => (
+
+)
+
+export const BankCardIcon = () => (
+
+)
+
+export const BusinessCardIcon = () => (
+
+)
+
+export const CollectionCardIcon = () => (
+
+)
+
+export const BillRecordCardIcon = () => (
+
+)
+
+export const ToolTipIcon = () => (
+
+)
+
+export const IDLogoIcon = () => (
+
+)
+
+export const ChevronLeftIcon: React.FC = ({ ...props }) => (
+
+)
+
+export const DesktopIcon: React.FC = ({ ...props }) => (
+
+)
+
+export const LabelStatusIcon: React.FC = ({ ...props }) => (
+
+)
+
+export const LabelStatusWaitIcon = ({ ...props }) => (
+
+)
+
+// 待验收
+export const LabelStatusAcceptanceIcon = ({ ...props }) => (
+
+)
+
+// 审核失败
+export const LabelStatusFailIcon = ({ ...props }) => (
+
+)
+
+// 试标
+export const LabelPretestIcon = () => (
+
+)
+
+export const LabelStatusStopIcon = ({ ...props }) => (
+
+)
+
+export const InfoCircleIcon = ({ ...props }) => (
+
+)
+
+/************************* 标注状态icon ****************************/
+// 待领取
+export const TaskIcon1 = ({ ...props }) => (
+
+)
+
+// 标注中
+export const TaskIcon2 = ({ ...props }) => (
+
+)
+
+// 标注完成
+export const TaskIcon3 = ({ ...props }) => (
+
+)
+
+// 审核中
+export const TaskIcon4 = ({ ...props }) => (
+
+)
+
+// 审核完成
+export const TaskIcon5 = ({ ...props }) => (
+
+)
+
+// 复审中 / 复审完成
+export const TaskIcon6 = ({ ...props }) => (
+
+)
+
+// 无法标注
+export const TaskIcon7 = ({ ...props }) => (
+
+)
+
+export const DispatchTaskIcon = () => (
+
+)
+
+export const FreeTaskIcon = () => (
+
+)
diff --git a/components/label/components/MetaOperationTool.tsx b/components/label/components/MetaOperationTool.tsx
new file mode 100644
index 0000000..6423f3a
--- /dev/null
+++ b/components/label/components/MetaOperationTool.tsx
@@ -0,0 +1,204 @@
+import { Box, Flex, HoverCard } from "@mantine/core"
+import { ArrowBigRightDashIcon, MinusCircleIcon } from "lucide-react"
+import { useCallback } from "react"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { useDescToolsStore } from "../useDescToolsStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+
+const MetaOperationTool = () => {
+ const { metaOperation, metaData } = useDescToolsStore()
+ const { activeImage } = useBottomToolsStore()
+ const currentImageData = metaData.get(activeImage) || []
+
+ const HoverContent = useCallback(
+ (index?: number) => {
+ if (!metaOperation) return <>>
+ const { sub_attributes_describe } = metaOperation
+
+ return (
+
+
+ 子属性选择
+
+ {sub_attributes_describe.length ? (
+
+ {sub_attributes_describe.map((subDesc) => (
+
+
+ {subDesc.chinese_name}
+
+
+ {subDesc.optional_item.map((option) => (
+ {
+ e.currentTarget.style.backgroundColor =
+ "var(--mantine-color-gray-1)"
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = "transparent"
+ }}
+ onClick={() => {
+ const map = useDescToolsStore.getState().metaData
+ if (map.has(activeImage)) {
+ let arr = map.get(activeImage) || []
+ if (index || index === 0) arr[index] = option
+ else arr.push(option)
+ map.set(activeImage, arr)
+ } else {
+ map.set(activeImage, [option])
+ }
+ useDescToolsStore.getState().setMetaData(map)
+ }}>
+ {option}
+
+ ))}
+
+
+ ))}
+
+ ) : (
+
+ 无
+
+ )}
+
+ )
+ },
+ [activeImage, metaOperation]
+ )
+
+ const handleDelete = (index: number) => {
+ const map = useDescToolsStore.getState().metaData
+ let arr = (map.get(activeImage) || []).filter((_v, i) => i !== index)
+ map.set(activeImage, arr)
+ useDescToolsStore.getState().setMetaData(map)
+ }
+
+ return (
+
+ {currentImageData.map((text, index) =>
+ !useTopToolsStore.getState().isView ? (
+
+ {/* Replaced Popover with HoverCard for better hover interaction */}
+
+
+
+ {text}
+ {
+ e.stopPropagation()
+ handleDelete(index)
+ }}>
+
+
+
+
+ {HoverContent(index)}
+
+ {index < currentImageData.length - 1 ? (
+
+
+
+ ) : null}
+
+ ) : (
+
+
+ {text}
+
+ {index < currentImageData.length - 1 ? (
+
+
+
+ ) : null}
+
+ )
+ )}
+ {!useTopToolsStore.getState().isView ? (
+
+ {/* Replaced Popover with HoverCard */}
+
+
+
+ 新增
+
+
+ {HoverContent()}
+
+
+ ) : null}
+
+ )
+}
+
+export default MetaOperationTool
diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx
new file mode 100644
index 0000000..3efc9ab
--- /dev/null
+++ b/components/label/components/PaperContainer.tsx
@@ -0,0 +1,2233 @@
+"use client"
+
+import {
+ Box,
+ Button,
+ Card,
+ Checkbox,
+ Group,
+ Modal,
+ Radio,
+ ScrollArea,
+ Select,
+ Stack,
+ Text,
+ Textarea,
+ TextInput,
+} from "@mantine/core"
+import { useForm } from "@mantine/form"
+import { notifications } from "@mantine/notifications"
+import dayjs from "dayjs"
+import msgpack from "msgpack-lite"
+import paper from "paper"
+import React, {
+ DispatchWithoutAction,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+import {
+ getAuxiliaryAnnotation,
+ getServerImage,
+ getTrackingAuxiliaryAnnotation,
+} from "../api/label"
+import { Project } from "../api/project/typing"
+import { LabelState } from "../LabelNossr"
+import {
+ initialDetail,
+ useImagesStore,
+ useKeyEventStore,
+ useLabelStore,
+ useObjectStore,
+} from "../store"
+import { usePermissionStore } from "../store/auth"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { useKeyboardStore } from "../useKeyBoardStore"
+import { useOtherToolsStore } from "../useOtherToolsStore"
+import { clearSupportAnnotationItem, usePaperStore } from "../usePaperStore"
+import { renderGroupPath } from "../useRenderGroupPath"
+import useRenderTag from "../useRenderTag"
+import { useRightToolsStore } from "../useRightToolsStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+import { checkCommentsIsSame } from "../util"
+import { labelTypeMap } from "../utils/constants"
+import { safeClone } from "../utils/clone"
+import { adjustPoints } from "../utils/paperjs"
+import CrosshairComponent from "./CrosshairComponent"
+import { renderOperationIcon } from "./RightObjectTools"
+
+interface PaperComponentProps {
+ imgSrc: string
+ forceUpdate: DispatchWithoutAction
+ projectDetail?: Project.DetailResponse
+ labelState: LabelState
+ updateLoadingFlag: (flag: boolean) => void
+}
+
+const renderPath = (paperGroup: paper.Group, option: any) => {
+ return new paper.Path(
+ Object.assign({ parent: paperGroup, closed: true }, option)
+ )
+}
+
+const renderCompoundPath = (paperGroup: paper.Group, option: any) => {
+ return new paper.CompoundPath(
+ Object.assign({ parent: paperGroup, closed: true }, option)
+ )
+}
+
+const renderRectangle = (paperGroup: paper.Group, option: any) => {
+ return new paper.Path.Rectangle(Object.assign({ parent: paperGroup }, option))
+}
+
+const renderCircle = (paperGroup: paper.Group, option: any) => {
+ return new paper.Path.Circle(Object.assign({ parent: paperGroup }, option))
+}
+
+const renderRaster = (paperGroup: paper.Group, option: any) => {
+ return new paper.Raster(Object.assign({ parent: paperGroup }, option))
+}
+
+const PaperContainer = (
+ props: PaperComponentProps,
+ ref: React.Ref | undefined
+) => {
+ const { forceUpdate, projectDetail, labelState, updateLoadingFlag } = props
+ const containerRef = useRef(null)
+ const [imgSize, setImageSize] = useState<{
+ clientWidth: number
+ clientHeight: number
+ }>()
+
+ const [firstRender, setFirstRender] = useState(false)
+ const [confirmModalOpened, setConfirmModalOpened] = useState(false)
+ const [confirmModalCallback, setConfirmModalCallback] = useState<
+ ((flag?: boolean) => void) | null
+ >(null)
+
+ // 监听画布容器宽高变化
+ useEffect(() => {
+ const container = containerRef.current
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (let entry of entries) {
+ if (entry.target === container) {
+ setImageSize({
+ clientWidth: entry.contentRect.width,
+ clientHeight: entry.contentRect.height,
+ })
+ if (!firstRender) setFirstRender(true)
+ }
+ }
+ })
+ if (container) {
+ resizeObserver.observe(container)
+ }
+
+ return () => {
+ if (container) {
+ resizeObserver.unobserve(container)
+ }
+ }
+ }, [firstRender])
+
+ const canvasElem = useRef(null)
+
+ const {
+ init,
+ initViewTool,
+ initPanTool,
+ initPolygonTool,
+ initRectangleTool,
+ initBrushTool,
+ initPointTool,
+ initSupportTool,
+ addPolygon,
+ addBrush,
+ addPointLine,
+ addPoint,
+ addCompoundPath,
+ addRectangle,
+ setRasterPath,
+ setRasterSize,
+ setRasterScale,
+ loadingData,
+ } = usePaperStore()
+
+ const { activeImage } = useBottomToolsStore()
+
+ const storeLabel = useLabelStore((state) => state.label)
+ const deleteOperation = useLabelStore((state) => state.deleteOperation)
+ const setOperation = useLabelStore((state) => state.setOperation)
+ const getLabelDetailDataByKeys = useLabelStore(
+ (state) => state.getLabelDetailDataByKeys
+ )
+
+ const {
+ objectOperations,
+ imageFilter,
+ crosshairStatus,
+ showTags,
+ showTagsConfigs,
+ } = useTopToolsStore()
+
+ const {
+ pathStatus: paperPathStatus,
+ selectedPath,
+ updateSelectedPath,
+ } = useObjectStore()
+
+ const {
+ showContextMenu,
+ position,
+ operationId,
+ id,
+ setContextMenuId,
+ setContextMenuOperationId,
+ } = useOtherToolsStore()
+ const { renderTag } = useRenderTag()
+
+ const [setup, setSetup] = useState(false)
+
+ const paperScope = useRef(null)
+ useEffect(() => {
+ if (firstRender) {
+ paperScope.current = new paper.PaperScope()
+ if (canvasElem.current) {
+ paperScope.current.setup(canvasElem.current)
+ setSetup(true)
+ }
+ }
+ }, [firstRender])
+
+ useEffect(() => {
+ const pScope = paperScope.current
+ if (pScope) {
+ const view = pScope.view
+ view.bounds.height = imgSize!.clientHeight
+ view.bounds.width = imgSize!.clientWidth
+ // 调整可见图层大小
+ const layer = pScope.project.activeLayer
+ layer.view.viewSize = new paper.Size(
+ imgSize!.clientWidth,
+ imgSize!.clientHeight
+ )
+ view.center = new paper.Point(
+ view.bounds.width / 2,
+ view.bounds.height / 2
+ )
+ }
+ }, [imgSize])
+
+ const renderSupportAnnotation = useCallback(
+ async (
+ points: Array<[number, number]>,
+ tags: number[],
+ clear_previous_state = false
+ ) => {
+ try {
+ notifications.show({
+ id: "sam2",
+ message: "模型生成中",
+ loading: true,
+ autoClose: false,
+ })
+ const operationSchema =
+ projectDetail?.label_schema_list?.find(
+ (item) =>
+ item.category_id === +useTopToolsStore.getState().activeOperation
+ ) || null
+ const strokeColor = operationSchema
+ ? `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})`
+ : "rgb(0,0,0)"
+ const fillColor = operationSchema
+ ? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)`
+ : "rgba(0,0,0,0.3)"
+ const blankColor = operationSchema
+ ? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)`
+ : "rgba(0,0,0,0.01)"
+ const currentScale =
+ usePaperStore.getState().rasterScale[activeImage] ?? 1
+
+ const draw = () => {
+ points.forEach((point, index) => {
+ const drawPoint = new paper.Path.Circle({
+ center: point,
+ radius: 2,
+ fillColor: tags[index] === 1 ? "red" : "blue",
+ strokeColor: tags[index] === 1 ? "red" : "blue",
+ data: {
+ id: "support",
+ type: "point",
+ },
+ })
+ drawPoint.parent = usePaperStore.getState().group!
+ const raster = usePaperStore
+ .getState()
+ .group!.getItems({ data: { name: "pic" } })[0]
+ if (!raster.contains(drawPoint.position)) {
+ throw new Error("点位超出图片范围")
+ }
+ })
+ }
+ console.log(points, tags)
+ // 绘制点位
+ clearSupportAnnotationItem()
+ draw()
+
+ const picSize = usePaperStore.getState().rasterSize[activeImage]
+
+ const params = {
+ project_id: projectDetail?.id || 0,
+ file_name: activeImage,
+ image_hw:
+ picSize && picSize.length ? [picSize[1], picSize[0]] : [0, 0],
+ points_and_box: points.map(([x, y]) => [
+ Math.round(x / currentScale),
+ Math.round(y / currentScale),
+ ]),
+ tags,
+ clear_previous_state,
+ }
+ console.log("传参", params)
+ try {
+ const res = await getAuxiliaryAnnotation(
+ msgpack.encode(params) as any
+ )
+ const data = msgpack.decode(new Uint8Array(res))
+ console.log(data)
+ if (data.msg === "success") {
+ const { contours, hollows } = data
+ const outerPaths: any[] = []
+ const innerPaths: any[] = []
+ contours.forEach((contour: any, index: number) => {
+ if (contour.length > 3) {
+ const path = new paper.Path({
+ segments: contour.map(([x, y]: [number, number]) => [
+ x * currentScale,
+ y * currentScale,
+ ]),
+ fillColor: blankColor,
+ strokeColor: strokeColor,
+ closed: true,
+ data: {
+ id: "support",
+ type: "polygon",
+ isHollowPolygon: false,
+ fillColor,
+ strokeColor,
+ blankColor,
+ },
+ parent: usePaperStore.getState().group,
+ strokeScaling: false,
+ })
+ if (
+ Math.abs(path.area) < useTopToolsStore.getState().checkSize
+ ) {
+ path.remove()
+ } else {
+ // path.scaling = usePaperStore.getState().group!.scaling;
+ if (hollows[index]) {
+ // 更新标志位
+ path.data.isHollowPolygon = true
+ innerPaths.push(path)
+ } else {
+ // 上色
+ path.fillColor = new paper.Color(fillColor)
+ outerPaths.push(path)
+ }
+ }
+ }
+ })
+ const compoundPath = new paper.CompoundPath({
+ children: [...outerPaths, ...innerPaths],
+ data: {
+ id: "support",
+ type: "parent",
+ },
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ fillColor: fillColor,
+ })
+ compoundPath.parent = usePaperStore.getState().group!
+ notifications.show({
+ id: "sam2",
+ message: "模型生成成功",
+ color: "green",
+ })
+ }
+ } catch (error) {
+ console.log(error)
+ }
+ } catch (error: any) {
+ console.log(error)
+ notifications.show({
+ id: "sam2",
+ message: error.message ?? "模型生成失败",
+ color: "red",
+ })
+ if (error.message === "点位超出图片范围") {
+ usePaperStore
+ .getState()
+ .supportTool?.emit("keydown", { key: "escape" })
+ }
+ }
+ },
+ [activeImage, projectDetail]
+ )
+
+ const renderTrackingAnnotation = useCallback(async () => {
+ updateLoadingFlag(true)
+ try {
+ notifications.show({
+ id: "sam2",
+ message: "模型生成中",
+ loading: true,
+ autoClose: false,
+ })
+ const ids =
+ useObjectStore.getState().selectedPath[
+ useBottomToolsStore.getState().activeImage
+ ]
+ const picArr = useBottomToolsStore.getState().selectedItems
+ const [width, height] = usePaperStore.getState().rasterSize[
+ activeImage
+ ] ?? [0, 0]
+ const currentScale =
+ usePaperStore.getState().rasterScale[activeImage] ?? 1
+ if (ids.length === 1) {
+ const id = ids[0]
+ let categoryId: any = null
+ let data: any = null
+ const activeImageData = useLabelStore.getState().label.get(activeImage)!
+ for (let [category, objArr] of activeImageData.entries()) {
+ objArr.forEach((item) => {
+ if (item[0] === id) {
+ categoryId = category
+ data = item
+ }
+ })
+ }
+ console.log(data)
+ if (categoryId && data) {
+ const contours: [number, number][] = []
+ const tags: boolean[] = []
+ data[1].forEach((item: any) => {
+ contours.push(
+ item.map(([x, y]: any) => [
+ Math.round(x / currentScale),
+ Math.round(y / currentScale),
+ ])
+ )
+ tags.push(false)
+ })
+ data[3].forEach((item: any) => {
+ contours.push(
+ item.map(([x, y]: any) => [
+ Math.round(x / currentScale),
+ Math.round(y / currentScale),
+ ])
+ )
+ tags.push(true)
+ })
+ const file_names = [...new Set([activeImage, ...picArr])]
+ const params = {
+ project_id: projectDetail?.id || 0,
+ file_names,
+ image_hw: [height, width],
+ contours,
+ hollows: tags,
+ }
+ console.log("传参", params)
+ try {
+ const res = await getTrackingAuxiliaryAnnotation(
+ msgpack.encode(params)
+ )
+ const resData = msgpack.decode(new Uint8Array(res))
+ console.log(resData)
+ if (resData.msg === "success") {
+ const {
+ future_contours,
+ }: {
+ future_contours: {
+ contours: [number, number][][]
+ hollows: boolean[]
+ }[]
+ } = resData
+ const nowTaskData = safeClone(useLabelStore.getState().label)
+ future_contours.forEach(({ contours, hollows }, index) => {
+ const fileName = file_names[index + 1]
+ const imgScale =
+ usePaperStore.getState().rasterScale[fileName] ?? 1
+ let newId =
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1
+ useRightToolsStore.getState().pushPathId(newId)
+ // 处理点位数据
+ let segmentation: Array<[number, number][]> = []
+ let hollow_segmentation: Array<[number, number][]> = []
+ contours.forEach((contour, index) => {
+ if (!hollows[index])
+ segmentation.push(
+ contour.map(([x, y]: any) => [x * imgScale, y * imgScale])
+ )
+ else
+ hollow_segmentation.push(
+ contour.map(([x, y]: any) => [x * imgScale, y * imgScale])
+ )
+ })
+ // 详情数据
+ let detail = {
+ ...initialDetail,
+ uid: usePermissionStore.getState().user_id,
+ comment: [...useLabelStore.getState().labelDefaultComments],
+ create_timestamp: dayjs().unix(),
+ sub_attributes: {},
+ image_id: activeImage,
+ category_id: categoryId,
+ }
+ const updateData: any = [
+ newId,
+ segmentation,
+ detail,
+ hollow_segmentation,
+ ]
+ if (nowTaskData.has(fileName)) {
+ const categoryMap = nowTaskData.get(fileName)!
+ if (categoryMap.has(categoryId)) {
+ const existArr = categoryMap.get(categoryId)
+ existArr?.push(updateData)
+ } else {
+ categoryMap.set(categoryId, [updateData])
+ }
+ } else {
+ let m = new Map()
+ m.set(categoryId, [updateData])
+ nowTaskData.set(fileName, m)
+ }
+ })
+ console.log(nowTaskData)
+ useLabelStore.getState().setLabel(nowTaskData)
+ useLabelStore.getState().pushStateStack(nowTaskData)
+ notifications.show({
+ id: "sam2",
+ message: "模型生成成功",
+ color: "green",
+ })
+ }
+ } catch (error) {
+ console.log(error)
+ }
+ }
+ } else if (!ids.length) {
+ notifications.show({
+ id: "sam2",
+ message: "请先选中标注对象后再进行模型调用",
+ color: "red",
+ })
+ } else {
+ notifications.show({
+ id: "sam2",
+ message: "请勿选择多个标注对象进行模型调用",
+ color: "red",
+ })
+ }
+ } catch (error) {
+ console.log(error)
+ notifications.show({
+ id: "sam2",
+ message: "模型生成失败",
+ color: "red",
+ })
+ } finally {
+ updateLoadingFlag(false)
+ }
+ }, [activeImage, projectDetail?.id, updateLoadingFlag])
+
+ // const getBase64 = (file: any): Promise =>
+ // new Promise((resolve, reject) => {
+ // const reader = new FileReader();
+ // reader.readAsDataURL(file);
+ // reader.onload = () => resolve(reader.result as string);
+ // reader.onerror = (error) => reject(error);
+ // });
+
+ useEffect(() => {
+ if (setup) {
+ const newGroup = new paper.Group({
+ applyMatrix: false,
+ selected: true,
+ strokeScaling: false,
+ })
+
+ if (canvasElem.current && paper) {
+ init(canvasElem.current, paperScope.current!, newGroup)
+ canvasElem.current.oncontextmenu = (e) => {
+ e.preventDefault()
+ }
+ let viewTool = new paper.Tool()
+ initViewTool(viewTool)
+ let panTool = new paper.Tool()
+ initPanTool(panTool, renderRectangle, forceUpdate)
+ let polygonTool = new paper.Tool()
+ initPolygonTool(
+ polygonTool,
+ renderPath,
+ renderCompoundPath,
+ forceUpdate
+ )
+ let rectangleTool = new paper.Tool()
+ initRectangleTool(rectangleTool, renderRectangle, forceUpdate)
+ let brushTool = new paper.Tool()
+ initBrushTool(brushTool, renderPath, renderCompoundPath, forceUpdate)
+ let pointTool = new paper.Tool()
+ initPointTool(pointTool, renderCircle, renderPath, forceUpdate)
+ let supportTool = new paper.Tool()
+ initSupportTool(supportTool, renderSupportAnnotation)
+ }
+ return () => {
+ newGroup.remove()
+ }
+ }
+ }, [
+ forceUpdate,
+ init,
+ initBrushTool,
+ initPanTool,
+ initPointTool,
+ initPolygonTool,
+ initRectangleTool,
+ initSupportTool,
+ initViewTool,
+ renderSupportAnnotation,
+ setup,
+ ])
+
+ const [paperSelectedPath] = useState<{
+ // key = imageId
+ [key: string]: number[]
+ }>(selectedPath)
+
+ const getOperationSchema = useCallback(
+ (operationId: string) => {
+ return (
+ projectDetail?.label_schema_list?.find(
+ (item) => item.category_id === +operationId
+ ) || null
+ )
+ },
+ [projectDetail?.label_schema_list]
+ )
+
+ const { getItemById, getItemsById } = usePaperStore()
+
+ const renderPolygons = useCallback(
+ (
+ operationId: string,
+ imageData: Map | undefined
+ ) => {
+ let operationSchema = getOperationSchema(operationId)
+
+ if (operationSchema && imageData) {
+ const toPaperPoint = (segment: any): paper.Point | null => {
+ if (segment instanceof paper.Point) return segment
+ if (segment instanceof paper.Segment) {
+ return new paper.Point(segment.point)
+ }
+ if (
+ Array.isArray(segment) &&
+ segment.length >= 2 &&
+ typeof segment[0] === "number" &&
+ typeof segment[1] === "number"
+ ) {
+ return new paper.Point(segment[0], segment[1])
+ }
+ if (
+ Array.isArray(segment) &&
+ Array.isArray(segment[0]) &&
+ segment[0].length >= 2 &&
+ typeof segment[0][0] === "number" &&
+ typeof segment[0][1] === "number"
+ ) {
+ return new paper.Point(segment[0][0], segment[0][1])
+ }
+ if (
+ segment?.point &&
+ typeof segment.point.x === "number" &&
+ typeof segment.point.y === "number"
+ ) {
+ return new paper.Point(segment.point.x, segment.point.y)
+ }
+ if (
+ segment?.point &&
+ Array.isArray(segment.point) &&
+ segment.point.length >= 2 &&
+ typeof segment.point[0] === "number" &&
+ typeof segment.point[1] === "number"
+ ) {
+ return new paper.Point(segment.point[0], segment.point[1])
+ }
+ return null
+ }
+ const toPaperPoints = (segments: any[]) => {
+ return segments
+ .map((segment) => toPaperPoint(segment))
+ .filter((point): point is paper.Point => point !== null)
+ }
+
+ let strokeColor = `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})`
+ let fillColor = `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)`
+ let blankColor = `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)`
+
+ let paths = imageData.get(operationId)
+
+ switch (
+ operationSchema &&
+ labelTypeMap.get(operationSchema.label_type)
+ ) {
+ case "多边形":
+ paths?.forEach((item) => {
+ let outerPaths =
+ item[1]
+ ?.map((child) => {
+ const points = toPaperPoints(child)
+ if (!points.length) return null
+ return addPolygon(
+ renderPath,
+ points,
+ {
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ // fillColor: fillColor,
+ fillColor: blankColor,
+ visible:
+ !paperPathStatus[activeImage + item[0]]?.["HIDE"],
+ },
+ {
+ type: "polygon",
+ id: item[0],
+ imageId: activeImage,
+ operationId: operationId,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ selected: paperSelectedPath[activeImage]?.includes(
+ item[0]
+ ),
+ }
+ )
+ })
+ .filter((path): path is paper.Path => !!path) || []
+ let innerPaths =
+ item[3]
+ ?.map((child) => {
+ const points = toPaperPoints(child)
+ if (!points.length) return null
+ return addPolygon(
+ renderPath,
+ points,
+ {
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ fillColor: fillColor,
+ visible:
+ !paperPathStatus[activeImage + item[0]]?.["HIDE"],
+ },
+ {
+ type: "polygon",
+ id: item[0],
+ imageId: activeImage,
+ operationId: operationId,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ selected: paperSelectedPath[activeImage]?.includes(
+ item[0]
+ ),
+ isHollowPolygon: true,
+ }
+ )
+ })
+ .filter((path): path is paper.Path => !!path) || []
+
+ addCompoundPath(
+ renderCompoundPath,
+ [...outerPaths, ...innerPaths],
+ {
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ // fillColor: fillColor,
+ fillColor: blankColor,
+ visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"],
+ },
+ {
+ type: "polygon",
+ id: item[0],
+ imageId: activeImage,
+ operationId: operationId,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ selected: paperSelectedPath[activeImage]?.includes(item[0]),
+ }
+ )
+
+ // renderCompoundPath(usePaperStore.getState().group!, { children });
+ // item[1].forEach((child) => {
+ // addPolygon(
+ // renderPath,
+ // child.map((segment: any) => {
+ // if (segment instanceof paper.Point) {
+ // return segment;
+ // } else {
+ // return new paper.Point(segment[0], segment[1]);
+ // }
+ // }),
+ // {
+ // strokeColor: strokeColor,
+ // strokeWidth: 2,
+ // // fillColor: fillColor,
+ // fillColor: blankColor,
+ // visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"],
+ // selected: paperSelectedPath[activeImage]?.includes(item[0]),
+ // },
+ // {
+ // type: "polygon",
+ // id: item[0],
+ // imageId: activeImage,
+ // operationId: operationId,
+ // fillColor: fillColor,
+ // blankColor: blankColor,
+ // }
+ // );
+ // });
+ })
+ return
+ case "关键点":
+ paths?.forEach((item) => {
+ item[1]?.forEach((child) => {
+ // 保存前排好序
+ child.forEach((segment: any, segmentIndex: number) => {
+ let point = toPaperPoint(segment)
+ if (!point) return
+ addPoint(
+ renderCircle,
+ point,
+ {
+ fillColor: null,
+ strokeColor: strokeColor,
+ radius: 3,
+ },
+ {
+ imageId: useBottomToolsStore.getState().activeImage,
+ operationId: useTopToolsStore.getState().activeOperation,
+ text: segmentIndex + 1,
+ id: item[0],
+ attributes: item[2]?.circles?.[segmentIndex]?.attributes,
+ }
+ )
+ })
+ const points = toPaperPoints(child)
+ if (!points.length) return
+ addPointLine(
+ renderPath,
+ points,
+ {
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ // fillColor: fillColor,
+ fillColor: blankColor,
+ visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"],
+ },
+ {
+ id: item[0],
+ imageId: activeImage,
+ operationId: operationId,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ selected: paperSelectedPath[activeImage]?.includes(item[0]),
+ }
+ )
+ })
+ })
+ return
+ case "多线段":
+ paths?.forEach((item) => {
+ let outerPaths =
+ item[1]
+ ?.map((child) => {
+ const points = toPaperPoints(child)
+ if (!points.length) return null
+ return addBrush(
+ renderPath,
+ points,
+ {
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ // fillColor: fillColor,
+ fillColor: blankColor,
+ visible:
+ !paperPathStatus[activeImage + item[0]]?.["HIDE"],
+ },
+ {
+ type: "brush",
+ id: item[0],
+ imageId: activeImage,
+ operationId: operationId,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ selected: paperSelectedPath[activeImage]?.includes(
+ item[0]
+ ),
+ }
+ )
+ })
+ .filter((path): path is paper.Path => !!path) || []
+
+ addCompoundPath(
+ renderCompoundPath,
+ outerPaths,
+ {
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ // fillColor: fillColor,
+ fillColor: blankColor,
+ visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"],
+ },
+ {
+ type: "brush",
+ id: item[0],
+ imageId: activeImage,
+ operationId: operationId,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ selected: paperSelectedPath[activeImage]?.includes(item[0]),
+ }
+ )
+ })
+ return
+ case "2D框":
+ paths?.forEach((item) => {
+ item[1].forEach((child) => {
+ let p = toPaperPoint(child[1])
+ let dp = toPaperPoint(child[3])
+ if (!p || !dp) {
+ console.warn(
+ "[PaperContainer] skip invalid rectangle segment",
+ {
+ id: item[0],
+ child,
+ }
+ )
+ return
+ }
+
+ addRectangle(
+ renderRectangle,
+ {
+ from: p,
+ to: dp,
+ strokeColor: strokeColor,
+ strokeWidth: 2,
+ // fillColor: fillColor,
+ fillColor: blankColor,
+ visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"],
+ },
+ {
+ type: "rectangle",
+ id: item[0],
+ imageId: activeImage,
+ operationId: operationId,
+ fillColor: fillColor,
+ blankColor: blankColor,
+ selected: paperSelectedPath[activeImage]?.includes(item[0]),
+ }
+ )
+ })
+ })
+ return
+ default:
+ return
+ }
+ }
+ },
+ [
+ activeImage,
+ addBrush,
+ addCompoundPath,
+ addPoint,
+ addPointLine,
+ addPolygon,
+ addRectangle,
+ getOperationSchema,
+ paperPathStatus,
+ paperSelectedPath,
+ ]
+ )
+
+ // 生成辅助标注结果数据并绘制
+ const saveSupportAnnotationData = useCallback(() => {
+ const group = usePaperStore.getState().group
+ if (!group) return
+ const compoundPath = group.getItems({
+ data: { id: "support", type: "parent" },
+ })?.[0]
+ if (compoundPath) {
+ console.log(compoundPath)
+ const children = compoundPath.children as paper.Path[]
+ const id =
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1
+ useRightToolsStore.getState().pushPathId(id)
+ // 处理点位数据
+ let segmentation: Array<[number, number][]> = []
+ let hollow_segmentation: Array<[number, number][]> = []
+ children.forEach((child) => {
+ if (child.data.isHollowPolygon) {
+ hollow_segmentation.push(
+ child.segments.map((seg) => [seg.point.x, seg.point.y])
+ )
+ } else {
+ segmentation.push(
+ child.segments.map((seg) => [seg.point.x, seg.point.y])
+ )
+ }
+ })
+ console.log(segmentation, hollow_segmentation)
+ // 处理详情数据
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) =>
+ item.category_id.toString() ===
+ useTopToolsStore.getState().activeOperation
+ )
+ let label_class = category?.label_class || ""
+ let subAttr = Object.assign(
+ {},
+ useTopToolsStore.getState().subAttributes[label_class]
+ )
+ let detail = {
+ ...initialDetail,
+ uid: usePermissionStore.getState().user_id,
+ comment: [...useLabelStore.getState().labelDefaultComments],
+ create_timestamp: dayjs().unix(),
+ sub_attributes: subAttr,
+ image_id: activeImage,
+ category_id: Number(useTopToolsStore.getState().activeOperation),
+ }
+ // 清空support对象
+ clearSupportAnnotationItem()
+ setOperation(activeImage, useTopToolsStore.getState().activeOperation, [
+ id,
+ segmentation,
+ detail,
+ hollow_segmentation,
+ ])
+ console.log(usePaperStore.getState().group)
+ // console.log(first)
+ // 更新当前页绘制数据
+ usePaperStore
+ .getState()
+ .group!.getItems({})
+ .filter((item) => item.data.id)
+ .forEach((item) => {
+ item.remove()
+ })
+ const imgMap = useLabelStore.getState().label.get(activeImage)!
+ for (const key of imgMap.keys()) {
+ renderPolygons(key, imgMap)
+ }
+ // 模型数据生成完毕 调整绘制模式
+ usePaperStore.getState().setPaperMode("pan")
+ }
+ }, [activeImage, renderPolygons, setOperation])
+
+ const copyAnnotationDataToMultiFrame = useCallback(() => {
+ const picArr = useBottomToolsStore
+ .getState()
+ .selectedItems.filter((k) => k !== activeImage)
+ const copyArr = useKeyboardStore.getState().copyDataIds
+ if (picArr.length && copyArr.length) {
+ const data = safeClone(useLabelStore.getState().label)
+ // 先判断是否有重复的id
+ let existedIds: number[] = []
+ picArr.forEach((imageId) => {
+ if (data.has(imageId) && data.get(imageId)?.size) {
+ for (let arr of data.get(imageId)!.values()) {
+ arr.forEach((i) => existedIds.push(i[0]))
+ }
+ }
+ })
+
+ const handleData = (flag = false) => {
+ try {
+ const activeData = data.get(activeImage) ?? new Map()
+ const activeScale =
+ usePaperStore.getState().rasterScale[activeImage] ?? 1
+ const needCopyMap = new Map()
+ for (let [key, value] of activeData.entries()) {
+ let arr = value.filter(([id]: any) => copyArr.includes(id))
+ console.log(arr)
+ if (arr.length)
+ needCopyMap.set(
+ key,
+ arr.map(([id, seg, detail, hollow]: any) => [
+ id,
+ seg.map((item: any) =>
+ item.map((p: any) => {
+ if (p instanceof paper.Point) {
+ return [p.x / activeScale, p.y / activeScale]
+ } else {
+ return [p[0] / activeScale, p[1] / activeScale]
+ }
+ })
+ ),
+ {
+ ...detail,
+ uid: usePermissionStore.getState().user_id,
+ create_timestamp: dayjs().unix(),
+ first_modified_timestamp: 0,
+ first_modified_uid: 0,
+ last_modified_timestamp: 0,
+ last_modified_uid: 0,
+ },
+ hollow.map((item: any) =>
+ item.map((p: any) => {
+ if (paper instanceof paper.Segment) {
+ return [
+ p.point.x / activeScale,
+ p.point.y / activeScale,
+ ]
+ } else {
+ return [p[0] / activeScale, p[1] / activeScale]
+ }
+ })
+ ),
+ ])
+ )
+ }
+
+ picArr.forEach((imageId) => {
+ const picScale = usePaperStore.getState().rasterScale[imageId] ?? 1
+
+ if (data.has(imageId)) {
+ const categoryMap = data.get(imageId)
+ if (categoryMap?.size) {
+ let needCopyIds: number[] = []
+ let needCopyCategoryIds: string[] = []
+ for (let [key, needCopyItemsArr] of needCopyMap.entries()) {
+ // console.log(key);
+ needCopyItemsArr.forEach((item: any) => {
+ needCopyIds.push(item[0])
+ needCopyCategoryIds.push(key)
+ })
+ // let arr = needCopyItemsArr.map((item: any) => [
+ // item[0],
+ // item[1].map((path: any) =>
+ // path.map(([x, y]: any) => [x * picScale, y * picScale])
+ // ),
+ // item[2],
+ // item[3].map((path: any) =>
+ // path.map(([x, y]: any) => [x * picScale, y * picScale])
+ // ),
+ // ]);
+ // needCopyMap.set(key, arr);
+ }
+ let currentImageItemIds: number[] = []
+ let currentImageCategoryIds: string[] = []
+ for (let [key, items] of categoryMap.entries()) {
+ items.forEach((item) => {
+ currentImageItemIds.push(item[0])
+ currentImageCategoryIds.push(key)
+ })
+ }
+ let finalArr: number[] = []
+ let finalCategory: string[] = []
+ needCopyIds.forEach((id, index) => {
+ const findIndex = currentImageItemIds.findIndex(
+ (i) => i === id
+ )
+ if (findIndex !== -1) {
+ if (flag) {
+ console.log(findIndex)
+ // 确认覆盖
+ finalArr.push(id)
+ finalCategory.push(needCopyCategoryIds[index])
+ let categoryId = currentImageCategoryIds[findIndex]
+ let arr = categoryMap.get(categoryId)!
+ let findIndexInArr = arr.findIndex((v) => v[0] === id)
+ let oldData = arr.find((a) => a[0] === id)!
+ let arr2 = needCopyMap.get(needCopyCategoryIds[index])!
+ let newData = {
+ ...arr2.find((b: any) => b[0] === id),
+ uid: oldData[2].uid,
+ create_timestamp: oldData[2].create_timestamp,
+ first_modified_timestamp:
+ oldData[2].first_modified_timestamp || dayjs().unix(),
+ first_modified_uid:
+ oldData[2].first_modified_uid ||
+ usePermissionStore.getState().user_id,
+ last_modified_timestamp: dayjs().unix(),
+ last_modified_uid:
+ usePermissionStore.getState().user_id,
+ }
+ let updateArr = arr2.map((b: any) => {
+ return b[0] === id ? [b[0], b[1], newData, b[3]] : b
+ })
+ needCopyMap.set(needCopyCategoryIds[index], updateArr)
+ categoryMap.set(
+ categoryId,
+ arr.filter((_v, i) => i !== findIndexInArr)
+ )
+ }
+ } else {
+ finalArr.push(id)
+ finalCategory.push(needCopyCategoryIds[index])
+ }
+ })
+
+ finalCategory.forEach((category, index) => {
+ const categoryArr = categoryMap.get(category) ?? []
+ const copyCategoryArr = needCopyMap.get(category)!
+ let copyId = finalArr[index]
+
+ let newData = copyCategoryArr.find(
+ (copyItem: any) => copyItem[0] === copyId
+ )
+ newData = [
+ newData[0],
+ newData[1].map((path: any) =>
+ path.map(([x, y]: any) => [x * picScale, y * picScale])
+ ),
+ newData[2],
+ newData[3].map((path: any) =>
+ path.map(([x, y]: any) => [x * picScale, y * picScale])
+ ),
+ ]
+ categoryArr.push(newData)
+ categoryMap.set(category, categoryArr)
+ })
+ } else {
+ // categoryMap实际没有值 需要重新生成
+ let newCategoryMap = new Map()
+ for (let [key, needCopyItemsArr] of needCopyMap) {
+ let arr = needCopyItemsArr.map((item: any) => [
+ item[0],
+ item[1].map((path: any) =>
+ path.map(([x, y]: any) => [x * picScale, y * picScale])
+ ),
+ item[2],
+ item[3].map((path: any) =>
+ path.map(([x, y]: any) => [x * picScale, y * picScale])
+ ),
+ ])
+ newCategoryMap.set(key, arr)
+ }
+ data.set(imageId, newCategoryMap)
+ }
+ } else {
+ data.set(imageId, needCopyMap)
+ }
+ })
+ notifications.show({ message: "复制成功", color: "green" })
+ useLabelStore.getState().setLabel(data)
+ useLabelStore.getState().pushStateStack(data)
+ useBottomToolsStore.getState().setSelectedItems("", false, false)
+ setConfirmModalOpened(false)
+ } catch (error) {
+ console.log(error)
+ notifications.show({ message: "复制失败", color: "red" })
+ }
+ }
+
+ let flag = existedIds.find((item) => copyArr.includes(item))
+ if (flag) {
+ setConfirmModalCallback(() => handleData)
+ setConfirmModalOpened(true)
+ return
+ } else {
+ handleData()
+ }
+ }
+ }, [activeImage])
+
+ const [rasterInited, setRasterInited] = useState(false)
+ const rasterLoadRequestIdRef = useRef(0)
+
+ const renderAndScaleRaster = useCallback(
+ async (paperGroup: paper.Group) => {
+ // const raster = renderRaster(paperGroup, {
+ // source: `${process.env.NEXT_PUBLIC_URL}/api/v1/label_server/data/image?data_name=${activeImage}&project_path=${projectDetail?.rpath}`,
+ // });
+
+ if (activeImage && projectDetail?.id) {
+ const requestId = ++rasterLoadRequestIdRef.current
+ const requestImage = activeImage
+ const isStaleRasterRequest = () => {
+ const currentImage = useBottomToolsStore.getState().activeImage
+ const stale =
+ requestId !== rasterLoadRequestIdRef.current ||
+ currentImage !== requestImage
+ if (stale) {
+ console.warn("[PaperContainer] ignore stale raster callback", {
+ requestId,
+ latestRequestId: rasterLoadRequestIdRef.current,
+ requestImage,
+ currentImage,
+ })
+ }
+ return stale
+ }
+
+ console.log(usePaperStore.getState().paperScope)
+ // console.log(activeImage, projectDetail?.rpath);
+ notifications.show({
+ message: "图片加载中...",
+ loading: true,
+ autoClose: false,
+ id: "loadingRaster",
+ })
+ try {
+ let raster
+ let text
+ const images = useImagesStore.getState().images
+ const currentImageBase64Text = images.get(activeImage)
+ if (currentImageBase64Text) {
+ text = currentImageBase64Text
+ } else {
+ const response = await getServerImage({
+ data_names: [activeImage],
+ data_type: 0,
+ project_id: projectDetail?.id,
+ })
+ if (isStaleRasterRequest()) return
+ text = `data:image/jpeg;base64,${response}`
+ images.set(activeImage, text)
+ useImagesStore.getState().setImages(images)
+ }
+ if (isStaleRasterRequest()) return
+ if (!text) return
+ raster = renderRaster(paperGroup, {
+ source: text,
+ data: {
+ name: "pic",
+ },
+ })
+ if (!raster) return
+
+ raster.onLoad = function () {
+ if (isStaleRasterRequest()) {
+ raster.remove()
+ return
+ }
+ // Scale the raster to fit the canvas while maintaining aspect ratio
+ let canvasWidth =
+ imgSize?.clientWidth ?? paperScope.current?.view.bounds.width
+ let canvasHeight =
+ imgSize?.clientHeight ?? paperScope.current?.view.bounds.height
+
+ if (!canvasWidth || !canvasHeight) {
+ console.warn(
+ "[PaperContainer] skip raster scale: invalid canvas",
+ {
+ requestId,
+ requestImage,
+ canvasWidth,
+ canvasHeight,
+ }
+ )
+ raster.remove()
+ return
+ }
+
+ let scaleX = canvasWidth! / raster.bounds.width
+ let scaleY = canvasHeight! / raster.bounds.height
+ setRasterSize(activeImage, [
+ raster.bounds.width,
+ raster.bounds.height,
+ ])
+
+ let scale = Math.min(scaleX, scaleY) // Maintain aspect ratio
+ if (!Number.isFinite(scale) || scale <= 0) {
+ console.warn(
+ "[PaperContainer] skip raster scale: invalid scale",
+ {
+ requestId,
+ requestImage,
+ scale,
+ scaleX,
+ scaleY,
+ }
+ )
+ raster.remove()
+ return
+ }
+
+ // 将raster以(0,0)为中心点进行缩放
+ raster.scale(scale, new paper.Point(0, 0))
+ const currentImgScale =
+ usePaperStore.getState().rasterScale[activeImage]
+ console.debug("[PaperContainer] raster scale reconcile", {
+ image: activeImage,
+ currentImgScale,
+ nextScale: scale,
+ branch: !currentImgScale
+ ? "init-scale"
+ : currentImgScale !== scale
+ ? "rescale"
+ : "keep",
+ })
+ // 当前图片无缩放比例或缩放比例不等于上次记录比例时
+ if (!currentImgScale) {
+ setRasterScale(activeImage, scale)
+ const newData = adjustPoints(
+ activeImage,
+ useLabelStore.getState().label,
+ scale
+ )
+ // 处理hollowPoints
+ useLabelStore.getState().setLabel(newData)
+ let stack = useLabelStore.getState().stateStack
+ if (stack.length) {
+ useLabelStore
+ .getState()
+ .setStateStack(
+ stack.map((item) => adjustPoints(activeImage, item, scale))
+ )
+ }
+ } else if (currentImgScale !== scale) {
+ // 先将相关数据恢复到原始比例
+ const restoreScale =
+ usePaperStore.getState().reciprocalRasterScale[activeImage]
+ const restoreData = adjustPoints(
+ activeImage,
+ useLabelStore.getState().label,
+ restoreScale
+ )
+ setRasterScale(activeImage, scale)
+ const newData = adjustPoints(activeImage, restoreData, scale)
+ useLabelStore.getState().setLabel(newData)
+ let stack = useLabelStore.getState().stateStack
+ if (stack.length) {
+ useLabelStore
+ .getState()
+ .setStateStack(
+ stack.map((item) => adjustPoints(activeImage, item, scale))
+ )
+ }
+ }
+ // 调整raster的左上角到(0,0)
+ raster.position = new paper.Point(
+ raster.bounds.width / 2,
+ raster.bounds.height / 2
+ )
+ // if (!useTopToolsStore.getState().saveCurrentScale)
+ usePaperStore.getState().group!.position = raster.position
+ setRasterPath(new paper.Path.Rectangle(raster.bounds))
+ // 将raster置底
+ raster.sendToBack()
+ setRasterInited(true)
+ console.log(usePaperStore.getState().group)
+ usePaperStore.getState().group?.bringToFront()
+ }
+ } catch (error) {
+ console.log(error)
+ } finally {
+ notifications.hide("loadingRaster")
+ }
+ }
+ },
+ [
+ activeImage,
+ imgSize?.clientHeight,
+ imgSize?.clientWidth,
+
+ projectDetail?.id,
+ setRasterPath,
+ setRasterScale,
+ setRasterSize,
+ ]
+ )
+
+ const loadImageAndPolygons = useCallback(
+ async (
+ // imageData: Map | undefined,
+ group: paper.Group
+ ) => {
+ // 清除当前的 raster 和多边形
+ group.removeChildren()
+ // 添加新的 raster
+ setRasterInited(false)
+ renderAndScaleRaster(group)
+ // if (imageData) {
+ // objectOperations?.forEach((item) => {
+ // renderPolygons(item.category_id.toString(), imageData);
+ // });
+ // }
+ },
+ [renderAndScaleRaster]
+ )
+
+ useEffect(() => {
+ const hasCachedImage = !!useImagesStore.getState().images.get(activeImage)
+ if (loadingData && !hasCachedImage) return
+ if (!imgSize?.clientWidth || !imgSize?.clientHeight) return
+ const currentGroup = usePaperStore.getState().group
+ if (currentGroup && projectDetail) {
+ if (!useTopToolsStore.getState().saveCurrentScale) {
+ currentGroup.scaling = new paper.Point(1, 1)
+ useTopToolsStore.getState().setScale(1)
+ } else {
+ usePaperStore
+ .getState()
+ .setGroupScale(useTopToolsStore.getState().scale)
+ }
+ loadImageAndPolygons(
+ // storeLabel.get(activeImage),
+ currentGroup
+ )
+ }
+ }, [
+ activeImage,
+ imgSize?.clientHeight,
+ imgSize?.clientWidth,
+ loadImageAndPolygons,
+ loadingData,
+ projectDetail,
+ ])
+
+ useEffect(() => {
+ if (rasterInited) {
+ if (storeLabel.get(activeImage)) {
+ objectOperations?.forEach((item) => {
+ renderPolygons(
+ item.category_id.toString(),
+ storeLabel.get(activeImage)
+ )
+ })
+ renderGroupPath()
+ renderTag()
+ }
+ setRasterInited(false)
+ }
+ }, [
+ activeImage,
+ objectOperations,
+ rasterInited,
+ renderPolygons,
+ renderTag,
+ storeLabel,
+ ])
+
+ useEffect(() => {
+ renderGroupPath()
+ }, [storeLabel])
+
+ useEffect(() => {
+ renderTag()
+ }, [showTags, showTagsConfigs, storeLabel, renderTag])
+
+ const [ids, setIds] = useState([])
+ const [existIds, setExistIds] = useState([])
+ // select use existIds todo
+ false && console.log(existIds)
+
+ const [comments, setComments] = useState([])
+
+ const form = useForm({
+ initialValues: {
+ id: undefined,
+ operation: undefined,
+ sub_attributes: {},
+ check_box_comments: [],
+ text_comments: "",
+ },
+ })
+ const checkboxComments = form.values.check_box_comments
+ const textComment = form.values.text_comments
+
+ const filterOperations = useMemo(() => {
+ let currentLabelType = objectOperations.find(
+ (operation) => operation.category_id.toString() === operationId
+ )?.label_type
+
+ return objectOperations.filter(
+ (operation) => operation.label_type === currentLabelType
+ )
+ }, [objectOperations, operationId])
+
+ useEffect(() => {
+ if (projectDetail?.comment_rule) {
+ if (projectDetail?.comment_rule?.check_box_rules?.length) {
+ setComments(
+ projectDetail?.comment_rule?.check_box_rules.map((item) => item.text)
+ )
+ }
+ }
+ }, [projectDetail?.comment_rule])
+
+ const pathItem = useMemo(() => {
+ return storeLabel
+ .get(activeImage)
+ ?.get(operationId)
+ ?.find((item) => item[0] === id)
+ }, [activeImage, id, operationId, storeLabel])
+
+ // 打开弹窗时,初始化form数据
+ useEffect(() => {
+ if (showContextMenu) {
+ form.reset()
+
+ const labelData = useLabelStore.getState().label
+ const currentPath = labelData
+ .get(activeImage)
+ ?.get(operationId)
+ ?.find((item) => item[0] === id)
+
+ form.setFieldValue("id", id)
+ form.setFieldValue("operation", operationId)
+ if (currentPath?.[2].sub_attributes) {
+ let operationSchema = getOperationSchema(operationId)
+ let obj: {
+ [key: string]: string | string[]
+ } = {}
+ Object.entries(currentPath?.[2].sub_attributes).map(([key, value]) => {
+ let select_mode = operationSchema?.sub_attributes_describe?.find(
+ (item) => item.chinese_name === key
+ )?.select_mode
+ switch (select_mode) {
+ case 2:
+ obj[key] = (value as string).split(",")
+ return
+ default:
+ obj[key] = value as string
+ return
+ }
+ })
+ form.setFieldValue("sub_attributes", obj)
+ }
+ if (currentPath?.[2].comment) {
+ const nowComment =
+ currentPath?.[2].comment[currentPath?.[2].comment.length - 1]
+ form.setFieldValue(
+ "check_box_comments",
+ nowComment && nowComment.check_box_comments.length
+ ? nowComment.check_box_comments
+ : []
+ )
+ form.setFieldValue(
+ "text_comments",
+ nowComment && nowComment.text_comments.length
+ ? nowComment.text_comments[0]
+ : ""
+ )
+ }
+ // 可选的ids
+ let images = Array.from(labelState.keys()).filter(
+ (key) => key !== activeImage
+ )
+ let ids: number[] = [id]
+ images.map((imageId) => {
+ // 可切换成其他图片同一类型的id
+ filterOperations.map((operation) => {
+ labelData
+ .get(imageId)
+ ?.get(operation.category_id.toString())
+ ?.map((arr) => arr[0])?.length &&
+ ids.push(
+ ...labelData
+ .get(imageId)
+ ?.get(operation.category_id.toString())
+ ?.map((arr) => arr[0])!
+ )
+ })
+ })
+ let existIds: number[] = []
+ objectOperations.forEach((operation) => {
+ labelData
+ .get(activeImage)
+ ?.get(operation.category_id.toString())
+ ?.map((arr) => arr[0])?.length &&
+ existIds.push(
+ ...labelData
+ .get(activeImage)
+ ?.get(operation.category_id.toString())
+ ?.map((arr) => arr[0])!
+ )
+ })
+ setExistIds(Array.from(new Set(existIds)))
+
+ ids.push(
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1
+ )
+ let selectIds = ids.filter((id) => !existIds.includes(id))
+
+ setIds(Array.from(new Set(selectIds)))
+ } else {
+ // console.log(form.getFieldValue("id"), id);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ activeImage,
+ filterOperations,
+ getOperationSchema,
+ id,
+ labelState,
+ objectOperations,
+ operationId,
+ showContextMenu,
+ ])
+
+ // 监听当前标注对象批注值变化
+ useEffect(() => {
+ if (!showContextMenu) return
+ if (!id || !operationId || !activeImage) return
+ if (useTopToolsStore.getState().currentTimeKey === "label") return
+ const currentDetail = useLabelStore
+ .getState()
+ .getLabelDetailDataByKeys(activeImage, operationId, id)
+ if (!currentDetail) return
+ const { comment } = currentDetail
+ let updatedComment: any[] = []
+ // 当表单当前值跟标注对象detail中comment值相同时不更新detail中comment状态
+ if (
+ checkCommentsIsSame(
+ comment[comment.length - 1].check_box_comments,
+ comment[comment.length - 1].text_comments,
+ checkboxComments,
+ textComment ? [textComment] : []
+ )
+ )
+ return
+ // 无批注
+ if ((!checkboxComments || !checkboxComments.length) && !textComment) {
+ updatedComment = comment.map((item: Comment, index: number) =>
+ index < comment.length - 1
+ ? item
+ : {
+ ...item,
+ comment_type: 1,
+ date: dayjs().unix(),
+ check_box_comments: [],
+ text_comments: [],
+ }
+ )
+ } else {
+ let latestItem = comment[comment.length - 1]
+ latestItem = {
+ ...latestItem,
+ comment_type: 2,
+ date: dayjs().unix(),
+ check_box_comments:
+ checkboxComments && checkboxComments.length ? checkboxComments : [],
+ text_comments: textComment ? [textComment] : [],
+ }
+ updatedComment = comment.map((item: Comment, index: number) =>
+ index < comment.length - 1 ? item : latestItem
+ )
+ }
+ useLabelStore
+ .getState()
+ .updateLabelDetailDataByKeys(activeImage, operationId, id, {
+ ...currentDetail,
+ comment: updatedComment,
+ })
+ }, [
+ activeImage,
+ checkboxComments,
+ id,
+ operationId,
+ showContextMenu,
+ textComment,
+ ])
+
+ const newDetail = useMemo(() => {
+ const currentDetail = getLabelDetailDataByKeys(activeImage, operationId, id)
+ const detail = currentDetail
+ ? {
+ ...currentDetail,
+ first_modified_timestamp:
+ currentDetail.first_modified_timestamp || dayjs().unix(),
+ first_modified_uid:
+ currentDetail.first_modified_uid ||
+ usePermissionStore.getState().user_id,
+ last_modified_timestamp: dayjs().unix(),
+ last_modified_uid: usePermissionStore.getState().user_id,
+ }
+ : {
+ ...initialDetail,
+ create_timestamp: dayjs().unix(),
+ }
+ return detail
+ }, [activeImage, getLabelDetailDataByKeys, id, operationId])
+
+ const handleChangeContextMenuId = useCallback(
+ (newId: number) => {
+ // 设置选中id
+ updateSelectedPath(activeImage, "DELETE", id)
+ updateSelectedPath(activeImage, "ADD", newId)
+ // 修改多边形data.id storelabel中 id detail
+ if (getItemById(id)) getItemById(id)!.data.id = newId
+ setOperation(activeImage, operationId, [
+ newId,
+ pathItem?.[1] || [],
+ newDetail,
+ pathItem?.[3] || [],
+ ])
+ deleteOperation(activeImage, operationId, id)
+ // 设置菜单id
+ setContextMenuId(newId)
+ useRightToolsStore.getState().pushPathId(newId)
+ },
+ [
+ activeImage,
+ deleteOperation,
+ getItemById,
+ id,
+ newDetail,
+ operationId,
+ pathItem,
+ setContextMenuId,
+ setOperation,
+ updateSelectedPath,
+ ]
+ )
+
+ const handleChangeContextMenuOperation = useCallback(
+ (newOperation: string) => {
+ const operationSchema = getOperationSchema(newOperation)
+ const strokeColor = operationSchema
+ ? `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})`
+ : "rgb(0,0,0)"
+ const fillColor = operationSchema
+ ? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)`
+ : "rgba(0,0,0,0.3)"
+ const blankColor = operationSchema
+ ? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)`
+ : "rgba(0,0,0,0.01)"
+ if (getItemsById(id)) {
+ getItemsById(id).forEach((item) => {
+ const data = item.data
+ if (data.operationId) {
+ data.operationId = newOperation
+ if (data.type !== "text")
+ item.strokeColor = new paper.Color(strokeColor)
+ if (data.type !== "brush" && data.type !== "point")
+ item.fillColor = new paper.Color(fillColor)
+ item.data =
+ data.type !== "mask" && data.type !== "text"
+ ? Object.assign(data || {}, {
+ fillColor,
+ blankColor,
+ strokeColor,
+ })
+ : data
+ // // 如果是组合类型 则遍历将children的data变更
+ // if (item instanceof paper.CompoundPath) {
+ // item.children.forEach((path) => {
+ // path.strokeColor = new paper.Color(strokeColor);
+ // path.fillColor = new paper.Color(fillColor);
+ // path.data = Object.assign(path.data || {}, {
+ // operationId: newOperation,
+ // fillColor,
+ // blankColor,
+ // strokeColor,
+ // });
+ // });
+ // }
+ }
+ })
+ }
+ // newOperation的subAttributes
+ let subAttr = Object.assign(
+ {},
+ useTopToolsStore.getState().subAttributes[
+ operationSchema?.label_class ?? ""
+ ]
+ )
+ // 修改labeldata
+ setOperation(activeImage, newOperation, [
+ id,
+ pathItem?.[1] || [],
+ { ...newDetail, sub_attributes: subAttr },
+ pathItem?.[3] || [],
+ ])
+ deleteOperation(activeImage, operationId, id)
+ // 修改context的operationId
+ setContextMenuOperationId(newOperation)
+ },
+ [
+ activeImage,
+ deleteOperation,
+ getItemsById,
+ getOperationSchema,
+ id,
+ newDetail,
+ operationId,
+ pathItem,
+ setContextMenuOperationId,
+ setOperation,
+ ]
+ )
+
+ const handleChangeContextMenuSubAttr = useCallback(
+ (key: string, value: string) => {
+ setOperation(activeImage, operationId, [
+ id,
+ pathItem?.[1] || [],
+ {
+ ...newDetail,
+ ...(pathItem?.[2] || {}),
+ sub_attributes: Object.assign(newDetail.sub_attributes || {}, {
+ [key]: value,
+ }),
+ },
+ pathItem?.[3] || [],
+ ])
+ },
+ [activeImage, id, newDetail, operationId, pathItem, setOperation]
+ )
+
+ const contextMenu = useMemo(() => {
+ let operationSchema = getOperationSchema(operationId)
+ const currentType = useTopToolsStore.getState().currentTimeKey
+
+ return (
+
+
+
+
+
+
+ )
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ comments,
+ filterOperations,
+ getOperationSchema,
+ handleChangeContextMenuId,
+ handleChangeContextMenuOperation,
+ handleChangeContextMenuSubAttr,
+ id,
+ ids,
+ operationId,
+ position.left,
+ position.top,
+ showContextMenu,
+ ])
+
+ const imageFilterCss = useMemo(() => {
+ return {
+ brightness: +(1 + imageFilter.brightness / 100).toFixed(2),
+ saturate: +(1 + imageFilter.saturate / 100).toFixed(2),
+ contrast: +(1 + imageFilter.contrast / 100).toFixed(2),
+ }
+ }, [imageFilter.brightness, imageFilter.contrast, imageFilter.saturate])
+
+ const handleCreatePathGroup = useCallback(() => {
+ let selectedPaths =
+ useObjectStore.getState().selectedPath[activeImage] || []
+ let groupId =
+ useRightToolsStore
+ .getState()
+ .pathGroupIds.reduce((a, b) => Math.max(a, b), 0) + 1
+ if (selectedPaths && selectedPaths.length > 1) {
+ // 修改group Map
+ useRightToolsStore
+ .getState()
+ .addPathGroupInMap(activeImage, groupId, selectedPaths)
+ // push groupId
+ useRightToolsStore.getState().pushPathGroupId(groupId)
+ }
+ }, [activeImage])
+
+ const crosshairComponentRef = useRef(null)
+
+ const handleCrosshairMove = (event: { clientX: number; clientY: number }) => {
+ crosshairComponentRef.current?.updateCrosshair(event)
+ }
+
+ useImperativeHandle(ref, () => ({
+ handleCreatePathGroup,
+ renderPolygons,
+ handleCrosshairMove,
+ renderTrackingAnnotation,
+ saveSupportAnnotationData,
+ copyAnnotationDataToMultiFrame,
+ }))
+
+ return (
+
+ {crosshairStatus !== "hidden" && (
+
+ )}
+ {imgSize?.clientWidth && imgSize?.clientHeight && (
+
+ )}
+ {contextMenu}
+ setConfirmModalOpened(false)}
+ title="提示"
+ centered>
+ 其他帧已存在相同id的对象,是否覆盖?
+
+
+
+
+
+
+
+ )
+}
+
+export default forwardRef(PaperContainer)
diff --git a/components/label/components/RecoverModal.tsx b/components/label/components/RecoverModal.tsx
new file mode 100644
index 0000000..1d180d1
--- /dev/null
+++ b/components/label/components/RecoverModal.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import CustomModal from "./CustomModal"
+import { Select } from "@mantine/core"
+import { useForm } from "@mantine/form"
+import { useSearchParams } from "next/navigation"
+import { useEffect, useState } from "react"
+import { storage } from "../store"
+
+interface ComponentProps {
+ open: boolean
+ handleCancel: () => void
+ handleOk: (name: string) => void
+}
+
+const RecoverModal = ({ open, handleCancel, handleOk }: ComponentProps) => {
+ const searchParams = useSearchParams()
+ const taskId = searchParams.get("task_id")
+ const form = useForm({
+ initialValues: {
+ name: "",
+ },
+ })
+
+ const [options, setOptions] = useState([])
+
+ useEffect(() => {
+ const getOpts = async () => {
+ const taskLabelData = await storage.getItem(taskId!)
+ if (taskLabelData) {
+ setOptions(
+ Object.keys(taskLabelData.state)
+ .filter((item) => !item.includes("-text"))
+ .map((key) => ({
+ label: key,
+ value: key,
+ }))
+ )
+ } else {
+ setOptions([])
+ }
+ }
+ getOpts()
+ }, [taskId])
+
+ return (
+ {
+ await form.validateField("name")
+ handleOk(form.values.name)
+ }}
+ centered
+ closeOnClickOutside={false}>
+
+
+ )
+}
+
+export default RecoverModal
diff --git a/components/label/components/RightDescTools.tsx b/components/label/components/RightDescTools.tsx
new file mode 100644
index 0000000..caa8de8
--- /dev/null
+++ b/components/label/components/RightDescTools.tsx
@@ -0,0 +1,306 @@
+"use client"
+
+import {
+ Box,
+ Button,
+ Checkbox,
+ Flex,
+ Group,
+ Radio,
+ ScrollArea,
+ Stack,
+ Text,
+ TextInput,
+} from "@mantine/core"
+import { useForm } from "@mantine/form"
+import { useEffect, useRef } from "react"
+import { Task } from "../api/task/typing"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { useDescToolsStore } from "../useDescToolsStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+import EditorContainer from "./EditorContainer"
+
+interface ComponentProps {
+ taskDetail?: Task.DataProps
+ // projectDetail?: Project.DetailResponse;
+}
+
+const RightDescTools = ({ taskDetail }: ComponentProps) => {
+ const { isView } = useTopToolsStore()
+ const { activeImage } = useBottomToolsStore()
+ const {
+ descOperations,
+ updateDescDataFlag,
+ setDescData,
+ getDataByClassName,
+ } = useDescToolsStore()
+
+ const form = useForm({
+ initialValues: {},
+ })
+
+ const isUpdatingFromStore = useRef(false)
+
+ const handleFormUpdate = () => {
+ if (isUpdatingFromStore.current) return
+
+ const data = form.values as any
+ let dataMap = new Map()
+ taskDetail?.data_name.forEach((detail_name) => {
+ if (detail_name.name[0] !== activeImage) {
+ if (useDescToolsStore.getState().descData.has(detail_name.name[0])) {
+ dataMap.set(
+ detail_name.name[0],
+ useDescToolsStore.getState().descData.get(detail_name.name[0])
+ )
+ }
+ } else {
+ const updateMap = new Map()
+ Object.entries(data).forEach(([label_class, val]: any) => {
+ let value = { ...val }
+ if (value.is_correct !== undefined) {
+ value.is_correct = value.is_correct === "true"
+ }
+ const keys = Object.keys(value)
+ if (keys.includes("value")) {
+ updateMap.set(label_class, value)
+ } else {
+ updateMap.set(label_class, { value: value })
+ }
+ })
+ dataMap.set(activeImage, updateMap)
+ }
+ })
+ setDescData(dataMap)
+ }
+
+ useEffect(() => {
+ handleFormUpdate()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [form.values])
+
+ useEffect(() => {
+ isUpdatingFromStore.current = true
+ form.reset()
+ if (activeImage) {
+ const formData = useDescToolsStore
+ .getState()
+ .getFormDataByImageId(activeImage)
+ if (formData) {
+ const newFormData = { ...formData }
+ Object.keys(newFormData).forEach((key) => {
+ if (newFormData[key]?.is_correct !== undefined) {
+ newFormData[key] = {
+ ...newFormData[key],
+ is_correct: String(newFormData[key].is_correct),
+ }
+ }
+ })
+ form.setValues(newFormData)
+ } else {
+ form.setValues({})
+ }
+ }
+ setTimeout(() => {
+ isUpdatingFromStore.current = false
+ }, 0)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeImage])
+
+ useEffect(() => {
+ if (updateDescDataFlag) {
+ isUpdatingFromStore.current = true
+ const formData = useDescToolsStore
+ .getState()
+ .getFormDataByImageId(useBottomToolsStore.getState().activeImage)
+ if (formData) {
+ const newFormData = { ...formData }
+ Object.keys(newFormData).forEach((key) => {
+ if (newFormData[key]?.is_correct !== undefined) {
+ newFormData[key] = {
+ ...newFormData[key],
+ is_correct: String(newFormData[key].is_correct),
+ }
+ }
+ })
+ form.setValues(newFormData)
+ }
+ useDescToolsStore.getState().updateFlag(false)
+ setTimeout(() => {
+ isUpdatingFromStore.current = false
+ }, 0)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [updateDescDataFlag])
+
+ return (
+
+
+
+ 文本描述
+
+
+
+
+ {descOperations.map((operation) => {
+ const isOptions = operation.sub_attributes_describe.length > 0
+ const isReview = [4, 6].includes(taskDetail?.label_status || 0)
+
+ const currentData = getDataByClassName(
+ activeImage,
+ operation.label_class
+ )
+ const hasComment = currentData?.comment
+
+ return (
+
+
+ {isOptions ? (
+
+ {operation.label_class}
+
+ ) : (
+
+
+ {operation.label_class}
+
+
+
+
+
+
+
+
+ )}
+ {isReview && !hasComment && !isOptions ? (
+
+ ) : null}
+
+ {isOptions ? (
+ operation.sub_attributes_describe.map((subDesc) => (
+
+ {subDesc.select_mode === 0 ? (
+
+ ) : subDesc.select_mode === 1 ? (
+
+
+ {subDesc.optional_item.map((option) => (
+
+ ))}
+
+
+ ) : subDesc.select_mode === 2 ? (
+
+
+ {subDesc.optional_item.map((option) => (
+
+ ))}
+
+
+ ) : null}
+
+ ))
+ ) : (
+ <>
+
+
+ form.setFieldValue(
+ `${operation.label_class}.value`,
+ val
+ )
+ }
+ />
+
+
+
+ form.setFieldValue(
+ `${operation.label_class}.comment`,
+ val
+ )
+ }
+ />
+
+ >
+ )}
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default RightDescTools
diff --git a/components/label/components/RightGroupEditModal.tsx b/components/label/components/RightGroupEditModal.tsx
new file mode 100644
index 0000000..b308164
--- /dev/null
+++ b/components/label/components/RightGroupEditModal.tsx
@@ -0,0 +1,117 @@
+"use client"
+
+import CustomModal from "./CustomModal"
+import { Autocomplete, Box } from "@mantine/core"
+import { useEffect, useState } from "react"
+import { useLabelStore } from "../store"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { usePaperStore } from "../usePaperStore"
+import { useRightToolsStore } from "../useRightToolsStore"
+import { safeClone } from "../utils/clone"
+
+interface ComponentProps {
+ open: boolean
+ editId: number | null
+ afterChange: (id: number) => void
+ closeModal: () => void
+}
+
+const RightGroupEditModal = ({
+ open,
+ editId,
+ afterChange,
+ closeModal,
+}: ComponentProps) => {
+ const [items, setItems] = useState([])
+ const [inputValue, setInputValue] = useState(editId ? String(editId) : "")
+
+ useEffect(() => {
+ if (open) {
+ const groupMap = useRightToolsStore
+ .getState()
+ .pathGroupMap.get(useBottomToolsStore.getState().activeImage)
+ const currentImgIds = Array.from(groupMap?.keys() ?? [])
+ const pathIds = useRightToolsStore.getState().pathGroupIds
+ const ids = pathIds.filter((id) => !currentImgIds.includes(id))
+ let maxId =
+ useRightToolsStore
+ .getState()
+ .pathGroupIds.reduce((a, b) => Math.max(a, b), 0) + 1
+
+ setTimeout(() => {
+ setItems(
+ Array.from(new Set([...ids, editId || maxId, maxId]))
+ .filter((item): item is number => item !== null)
+ .sort((a, b) => a - b)
+ .map((item) => String(item))
+ )
+ }, 0)
+ }
+ }, [editId, open])
+
+ const commitChange = (val: string) => {
+ if (!val || !editId) return
+ const id = Number(val)
+ if (isNaN(id)) return
+
+ const imageId = useBottomToolsStore.getState().activeImage
+ const groupMap = useRightToolsStore.getState().pathGroupMap.get(imageId)
+
+ if (groupMap?.has(id)) return
+
+ const oldIds = groupMap?.get(editId) ?? []
+ const labelData = safeClone(useLabelStore.getState().label)
+ // update path
+ let operationIds: any[] = []
+ oldIds.forEach((childId) => {
+ const childPath = usePaperStore.getState().getItemById(childId)!
+ operationIds.push(childPath.data.operationId)
+ })
+ operationIds.forEach((operationId, index) => {
+ if (labelData.get(imageId)?.get(operationId)?.length) {
+ let result =
+ labelData
+ .get(imageId)
+ ?.get(operationId)
+ ?.map((item) =>
+ item[0] !== oldIds[index]
+ ? item
+ : [item[0], item[1], { ...item[2], parentGroupId: id }, item[3]]
+ ) || []
+ labelData.get(imageId)?.set(operationId, result as any)
+ }
+ })
+ // update groupMap
+ groupMap?.set(id, oldIds)
+ groupMap?.delete(editId)
+ afterChange(id)
+ useRightToolsStore.getState().pushPathGroupId(id)
+ useLabelStore.getState().setLabel(labelData)
+ }
+
+ return (
+
+
+ commitChange(inputValue)}
+ onOptionSubmit={(val) => {
+ setInputValue(val)
+ commitChange(val)
+ }}
+ data={items}
+ w="100%"
+ />
+
+
+ )
+}
+
+export default RightGroupEditModal
diff --git a/components/label/components/RightGroupTools.tsx b/components/label/components/RightGroupTools.tsx
new file mode 100644
index 0000000..e602285
--- /dev/null
+++ b/components/label/components/RightGroupTools.tsx
@@ -0,0 +1,471 @@
+"use client"
+
+import {
+ ActionIcon,
+ Box,
+ Collapse,
+ Flex,
+ Group,
+ Paper,
+ ScrollArea,
+ Stack,
+ Switch,
+ Text,
+ Title,
+ Tooltip,
+} from "@mantine/core"
+import { ChevronDown, ChevronUp, Link2Off, Pencil, Trash2 } from "lucide-react"
+import { useCallback, useState } from "react"
+import { Project } from "../api/project/typing"
+import { LabelState } from "../LabelNossr"
+import { useKeyEventStore, useLabelStore, useObjectStore } from "../store"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { useOtherToolsStore } from "../useOtherToolsStore"
+import { getShowContextMenuPosition, usePaperStore } from "../usePaperStore"
+import { renderGroupPath } from "../useRenderGroupPath"
+import { useRightToolsStore } from "../useRightToolsStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+import { labelTypeMap } from "../utils/constants"
+import { safeClone } from "../utils/clone"
+import RightGroupEditModal from "./RightGroupEditModal"
+
+interface RightGroupToolsComponentProps {
+ labelState: LabelState
+ projectDetail?: Project.DetailResponse
+}
+
+const RightGroupTools: React.FC = (props) => {
+ const { projectDetail } = props
+
+ const [open, setOpen] = useState(false)
+ const [editId, setEditId] = useState(null)
+ const getOperationSchema = useCallback(
+ (operationId: string) => {
+ return (
+ projectDetail?.label_schema_list?.find(
+ (item) => item.category_id === +operationId
+ ) || null
+ )
+ },
+ [projectDetail?.label_schema_list]
+ )
+ const setLabel = useLabelStore((state) => state.setLabel)
+ const pushStateStack = useLabelStore((state) => state.pushStateStack)
+ const { pathGroupMap, groupPathVisible, setGroupPathVisible } =
+ useRightToolsStore()
+ const { activeImage } = useBottomToolsStore()
+ const { groupStatus, updateGroupStatus, selectedPath, updateSelectedPath } =
+ useObjectStore()
+ const { getItemById, getItemsById } = usePaperStore()
+ const { shift: pressShift } = useKeyEventStore()
+ const [isGroupCollapsed, setIsGroupCollapsed] = useState(true)
+
+ const handleSelect = useCallback(
+ (id: number) => {
+ let operationId = getItemById(id)?.data.operationId
+ if (selectedPath[activeImage]?.length) {
+ if (!selectedPath[activeImage]?.includes(id)) {
+ if (pressShift) {
+ if (getItemsById(id)?.length) {
+ getItemsById(id).forEach((item) => {
+ item.fillColor = item.data?.fillColor
+ })
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === operationId
+ )
+ const globalPosition = getItemById(id)!.globalMatrix.transform(
+ getItemById(id)!.position
+ )
+ const { top, left } = getShowContextMenuPosition(
+ globalPosition,
+ category!
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ } else {
+ selectedPath[activeImage].forEach((selectedId) => {
+ if (getItemsById(selectedId)?.length) {
+ getItemsById(selectedId).forEach((item) => {
+ item.fillColor = item.data?.blankColor
+ })
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ })
+ if (getItemsById(id)?.length) {
+ getItemsById(id).forEach((item) => {
+ item.fillColor = item.data?.fillColor
+ })
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === operationId
+ )
+ const globalPosition = getItemById(id)!.globalMatrix.transform(
+ getItemById(id)!.position
+ )
+ const { top, left } = getShowContextMenuPosition(
+ globalPosition,
+ category!
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ }
+ updateSelectedPath(activeImage, "ADD", id)
+ } else {
+ updateSelectedPath(activeImage, "DELETE", id)
+ if (getItemsById(id)?.length) {
+ getItemsById(id).forEach((item) => {
+ item.fillColor = item.data?.blankColor
+ })
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ }
+ } else {
+ updateSelectedPath(activeImage, "ADD", id)
+ if (getItemsById(id)?.length) {
+ getItemsById(id).forEach((item) => {
+ item.fillColor = item.data?.fillColor
+ })
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === operationId
+ )
+ const globalPosition = getItemById(id)!.globalMatrix.transform(
+ getItemById(id)!.position
+ )
+ const { top, left } = getShowContextMenuPosition(
+ globalPosition,
+ category!
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ }
+ },
+ [
+ activeImage,
+ getItemById,
+ getItemsById,
+ pressShift,
+ selectedPath,
+ updateSelectedPath,
+ ]
+ )
+
+ const getOperationIdByPathId = (pathId: number) => {
+ let pathItem = getItemById(pathId)
+ return pathItem?.data?.operationId || ""
+ }
+
+ const unlinkGroup = (id: number) => {
+ const group = usePaperStore.getState().group
+ const imageId = useBottomToolsStore.getState().activeImage
+ const groupMap =
+ useRightToolsStore.getState().pathGroupMap.get(imageId) ??
+ new Map()
+
+ if (!group) return
+ if (!Array.from(groupMap.values()).length) return
+ // update children path data
+ const currentGroupChildrenIds = groupMap.get(id) ?? []
+ const needUpdateOperationIds: any[] = []
+ currentGroupChildrenIds?.forEach((childId, index) => {
+ const childPath = usePaperStore.getState().getItemById(childId)!
+ childPath.data.parentGroupId = null
+ if (childPath.data.operationId && !needUpdateOperationIds[index])
+ needUpdateOperationIds[index] = childPath.data.operationId
+ })
+ // handle labeldata update
+ const currentData = safeClone(useLabelStore.getState().label)
+ needUpdateOperationIds.forEach((operationId, index) => {
+ if (currentData.get(imageId)?.get(operationId)?.length) {
+ let result =
+ currentData
+ .get(imageId)
+ ?.get(operationId)
+ ?.map((item) =>
+ item[0] !== currentGroupChildrenIds[index]
+ ? item
+ : [
+ item[0],
+ item[1],
+ { ...item[2], parentGroupId: null },
+ item[3],
+ ]
+ ) || []
+ currentData.get(imageId)?.set(operationId, result as any)
+ }
+ })
+ // update group tool
+ useRightToolsStore.getState().removePathGroupInMap(imageId, id)
+ // setlabeldata
+ setLabel(currentData)
+ pushStateStack(currentData)
+ }
+
+ const deleteGroup = (id: number) => {
+ const group = usePaperStore.getState().group
+ const imageId = useBottomToolsStore.getState().activeImage
+ const groupMap =
+ useRightToolsStore.getState().pathGroupMap.get(imageId) ??
+ new Map()
+
+ if (!group) return
+ if (!Array.from(groupMap.values()).length) return
+
+ // remove group children
+ const currentGroupChildrenIds = groupMap.get(id) ?? []
+ const needRemoveOperationIds: any[] = []
+ currentGroupChildrenIds?.forEach((childId, index) => {
+ const childPaths = group.getItems({ data: { id: childId } })
+ childPaths.forEach((p) => {
+ p.remove()
+ if (p.data.operationId && !needRemoveOperationIds[index])
+ needRemoveOperationIds[index] = p.data.operationId
+ })
+ })
+ const currentData = safeClone(useLabelStore.getState().label)
+ needRemoveOperationIds.forEach((operationId, index) => {
+ if (currentData.get(imageId)?.get(operationId)?.length) {
+ let result =
+ currentData
+ .get(imageId)
+ ?.get(operationId)
+ ?.filter((item) => item[0] !== currentGroupChildrenIds[index]) || []
+ currentData.get(imageId)?.set(operationId, result)
+ }
+ })
+ // update group tool
+ useRightToolsStore.getState().removePathGroupInMap(imageId, id)
+ setLabel(currentData)
+ pushStateStack(currentData)
+ }
+
+ return (
+
+
+
+
+ 群组列表
+
+ setIsGroupCollapsed(!isGroupCollapsed)}>
+ {isGroupCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ 展示群组框
+ {
+ setGroupPathVisible(event.currentTarget.checked)
+ renderGroupPath()
+ }}
+ />
+
+
+
+
+ {pathGroupMap.get(activeImage) &&
+ Array.from(pathGroupMap.get(activeImage)!.keys())?.map(
+ (groupId) => {
+ const isOpen =
+ !groupStatus[activeImage + groupId]?.["COLLAPSE"]
+ return (
+
+
+
+
+
+ @{groupId}
+
+
+
+ {
+ setOpen(true)
+ setEditId(groupId)
+ }}>
+
+
+
+
+ {
+ unlinkGroup(groupId)
+ }}>
+
+
+
+
+ {
+ deleteGroup(groupId)
+ }}>
+
+
+
+
+
+
+
+ {pathGroupMap.get(activeImage)?.get(groupId)
+ ?.length || 0}
+
+ {
+ updateGroupStatus(
+ activeImage + groupId,
+ "COLLAPSE",
+ isOpen
+ )
+ }}>
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {pathGroupMap
+ .get(activeImage)
+ ?.get(groupId)
+ ?.map((child) => {
+ const isSelected =
+ selectedPath[activeImage]?.includes(child)
+ return (
+ {
+ handleSelect(child)
+ }}
+ style={{ cursor: "pointer" }}>
+
+
+ {child}-
+ {labelTypeMap.get(
+ getOperationSchema(
+ getOperationIdByPathId(child)
+ )?.label_type || 0
+ )}
+
+
+
+ )
+ })}
+
+
+
+ )
+ }
+ )}
+
+
+
+
+
+ {open && (
+ {
+ setEditId(val)
+ }}
+ closeModal={() => {
+ setOpen(false)
+ setEditId(null)
+ }}
+ />
+ )}
+
+ )
+}
+
+export default RightGroupTools
diff --git a/components/label/components/RightObjectTools.tsx b/components/label/components/RightObjectTools.tsx
new file mode 100644
index 0000000..315101a
--- /dev/null
+++ b/components/label/components/RightObjectTools.tsx
@@ -0,0 +1,955 @@
+"use client"
+
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Checkbox,
+ Collapse,
+ Flex,
+ Group,
+ Paper,
+ Popover,
+ ScrollArea,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+ Tooltip,
+} from "@mantine/core"
+import dayjs from "dayjs"
+import {
+ ChevronDown,
+ ChevronUp,
+ CircleDot,
+ Eye,
+ EyeOff,
+ Info,
+ RectangleHorizontal,
+ Settings,
+ Spline,
+} from "lucide-react"
+import paper from "paper"
+import { useCallback, useState } from "react"
+import { Project } from "../api/project/typing"
+import { Task } from "../api/task/typing"
+import { LabelState } from "../LabelNossr"
+import { useKeyEventStore, useLabelStore, useObjectStore } from "../store"
+import { useAllUserStore } from "../store/auth"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { useOtherToolsStore } from "../useOtherToolsStore"
+import { getShowContextMenuPosition, usePaperStore } from "../usePaperStore"
+import { usePaperSupportStore } from "../usePaperSupportStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+import { getSubAttrStatus } from "../util"
+import { labelTypeMap } from "../utils/constants"
+
+interface RightObjectToolsComponentProps {
+ labelState: LabelState
+ projectDetail?: Project.DetailResponse
+ taskDetail?: Task.DataProps
+}
+
+export const renderOperationIcon = (
+ operationSchema: Project.LabelSchemaList | null
+) => {
+ if (operationSchema) {
+ let color = `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})`
+ const style = { color }
+ switch (operationSchema && labelTypeMap.get(operationSchema.label_type)) {
+ case "多边形": {
+ return
+ }
+ case "关键点": {
+ return
+ }
+ case "多线段": {
+ return
+ }
+ case "2D框": {
+ return
+ }
+ default:
+ return
+ }
+ } else return
+}
+
+const RightObjectTools: React.FC = (props) => {
+ const { labelState, projectDetail, taskDetail } = props
+
+ const storeLabel = useLabelStore((state) => state.label)
+
+ const { activeImage } = useBottomToolsStore()
+
+ const {
+ operationStatus,
+ pathStatus,
+ selectedPath,
+ imageStatus,
+ updateImageStatus,
+ updateOperationStatus,
+ updatePathStatus,
+ updateSelectedPath,
+ } = useObjectStore()
+
+ const { userOpts } = useAllUserStore()
+
+ const [searchId, setSearchId] = useState("")
+ const [searchSubAttrs, setSearchSubAttrs] = useState<{
+ [x: string]: string
+ }>({})
+ const [checkBoxsChecked, setCheckBoxsChecked] = useState([
+ true,
+ true,
+ true,
+ true,
+ ])
+
+ const getOperationSchema = useCallback(
+ (operationId: string) => {
+ return (
+ projectDetail?.label_schema_list?.find(
+ (item) => item.category_id === +operationId
+ ) || null
+ )
+ },
+ [projectDetail?.label_schema_list]
+ )
+
+ const renderShortcutKey = (operationId: string) => {
+ if (+operationId > 10) {
+ return "ctrl+" + (+operationId % 10).toString()
+ } else {
+ return (+operationId % 10).toString()
+ }
+ }
+
+ const getCheckBoxColor = useCallback((type: number) => {
+ switch (type) {
+ case 3:
+ return "yellow"
+ case 2:
+ return "red"
+ case 1:
+ return "green"
+ case 0:
+ return "gray"
+ default:
+ return "blue"
+ }
+ }, [])
+
+ const { shift: pressShift } = useKeyEventStore()
+
+ const { getItemById, getItemsById } = usePaperStore()
+
+ // 切换选中对象时,清除所有其他对象的当前选中状态
+ const clearSelectedItems = () => {
+ usePaperStore
+ .getState()
+ .paperScope?.project.selectedItems.forEach((item) => {
+ item.data.selected = false
+ })
+ // const group = usePaperStore.getState().group!;
+ // const needClear = [
+ // ...group.getItems({ data: { type: "mask" } }),
+ // ...group.getItems({ data: { type: "text" } }),
+ // ];
+ // needClear.forEach((item) => {
+ // item.remove();
+ // });
+ usePaperSupportStore.getState().clearAll()
+ }
+
+ const handleSelectedPath = (path: paper.Path) => {
+ console.log("handleSelectedPath", path)
+ if (path.data.type === "rectangle") {
+ path.data.selected = true
+ usePaperSupportStore.getState().handleBufferPaths(path)
+ usePaperSupportStore.getState().handleCircles(path)
+ path.fillColor = path.data.fillColor
+ } else if (path.data.type === "brush") {
+ path.data.selected = true
+ usePaperSupportStore.getState().handleMask(path)
+ usePaperSupportStore.getState().handleBufferPaths(path)
+ usePaperSupportStore.getState().handleCircles(path)
+ } else if (path.data.type === "point") {
+ path.data.selected = true
+ usePaperSupportStore.getState().handleMask(path)
+ usePaperSupportStore.getState().handleText(path)
+ usePaperSupportStore.getState().handleBufferPaths(path)
+ usePaperSupportStore.getState().handleCircles(path)
+ } else if (path.data.type === "polygon") {
+ path.fillColor = path.data.fillColor
+ }
+ path.bringToFront()
+ }
+
+ const cancelSelectedPathColor = (path: paper.Path | paper.CompoundPath) => {
+ if (path.data.type === "rectangle" || path.data.type === "polygon")
+ path.fillColor = path.data.blankColor
+ }
+
+ const handleSelect = useCallback(
+ (id: number, operationId: any) => {
+ if (selectedPath[activeImage]?.length) {
+ if (!selectedPath[activeImage]?.includes(id)) {
+ if (pressShift) {
+ if (getItemsById(id)?.length) {
+ getItemsById(id).forEach((item) => {
+ if (item instanceof paper.Path) {
+ handleSelectedPath(item)
+ } else if (item instanceof paper.CompoundPath) {
+ if (item.data.type === "polygon")
+ item.fillColor = item.data.fillColor
+ else item.data.selected = true
+ }
+ })
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === operationId
+ )
+ const globalPosition = getItemById(id)!.globalMatrix.transform(
+ getItemById(id)!.position
+ )
+ const { top, left } = getShowContextMenuPosition(
+ globalPosition,
+ category!
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ } else {
+ clearSelectedItems()
+ selectedPath[activeImage].forEach((selectedId) => {
+ if (getItemsById(selectedId)?.length) {
+ getItemsById(selectedId).forEach((item) => {
+ cancelSelectedPathColor(item)
+ })
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ })
+ if (getItemsById(id)?.length) {
+ getItemsById(id).forEach((item) => {
+ if (item instanceof paper.Path) {
+ handleSelectedPath(item)
+ } else if (item instanceof paper.CompoundPath) {
+ if (item.data.type === "polygon")
+ item.fillColor = item.data.fillColor
+ else item.data.selected = true
+ }
+ })
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === operationId
+ )
+ const globalPosition = getItemById(id)!.globalMatrix.transform(
+ getItemById(id)!.position
+ )
+ const { top, left } = getShowContextMenuPosition(
+ globalPosition,
+ category!
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ }
+ updateSelectedPath(activeImage, "ADD", id)
+ } else {
+ clearSelectedItems()
+ updateSelectedPath(activeImage, "DELETE", id)
+ if (getItemsById(id)?.length) {
+ getItemsById(id).forEach((item) => {
+ cancelSelectedPathColor(item)
+ })
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ }
+ } else {
+ clearSelectedItems()
+ updateSelectedPath(activeImage, "ADD", id)
+ if (getItemsById(id)?.length) {
+ getItemsById(id).forEach((item) => {
+ if (item instanceof paper.Path) {
+ handleSelectedPath(item)
+ } else if (item instanceof paper.CompoundPath) {
+ if (item.data.type === "polygon")
+ item.fillColor = item.data.fillColor
+ else item.data.selected = true
+ }
+ // if (item.data.type === "rectangle") {
+ // item.data.selected = true;
+ // usePaperSupportStore.getState().handleCircles(item);
+ // }
+ // item.fillColor = item.data?.fillColor;
+ // item.bringToFront();
+ })
+
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === operationId
+ )
+ const globalPosition = getItemById(id)!.globalMatrix.transform(
+ getItemById(id)!.position
+ )
+ const { top, left } = getShowContextMenuPosition(
+ globalPosition,
+ category!
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: activeImage,
+ operationId: operationId,
+ id,
+ })
+ }
+ }
+ },
+ [
+ activeImage,
+ getItemById,
+ getItemsById,
+ pressShift,
+ selectedPath,
+ updateSelectedPath,
+ ]
+ )
+
+ // 根据img operationId 更新操作栏operation状态
+ const checkIfOperationNeedsUpdate = (imgId: string, operationId: any) => {
+ const operationMap = storeLabel.get(imgId)
+ if (operationMap) {
+ const objList = operationMap.get(operationId)
+ if (objList && objList.length) {
+ let hideFlag = true
+ objList.forEach(([id]) => {
+ const objStatus =
+ useObjectStore.getState().pathStatus[`${imgId}${id}`]
+ if (objStatus && !objStatus["HIDE"]) {
+ hideFlag = false
+ }
+ })
+ updateOperationStatus(imgId + operationId, "HIDE", hideFlag)
+ }
+ }
+ }
+
+ // 隐藏时清空当前选中的item选中状态
+ const setSelectedItemFalse = (id: number) => {
+ if (!id) return
+ const sameIdItems = usePaperStore.getState().getItemsById(id)
+ sameIdItems.forEach((i) => {
+ if (i.data.type === "mask" || i.data.type === "text") {
+ i.remove()
+ }
+ if (i.data.selected) i.data.selected = false
+ })
+ }
+
+ const operationHide = useCallback(
+ (operationId: string, hide: boolean) => {
+ updateOperationStatus(activeImage + operationId, "HIDE", hide)
+
+ storeLabel
+ .get(activeImage)
+ ?.get(operationId)
+ ?.map((child) => {
+ updatePathStatus(activeImage + child[0], "HIDE", hide)
+ if (getItemsById(child[0]) && getItemsById(child[0]).length) {
+ getItemsById(child[0]).forEach((item) => {
+ item.visible = !hide
+ })
+ // 隐藏时清空选中状态
+ if (hide) setSelectedItemFalse(child[0])
+ }
+ })
+ },
+ [
+ activeImage,
+ getItemsById,
+ storeLabel,
+ updateOperationStatus,
+ updatePathStatus,
+ ]
+ )
+
+ const objectHide = useCallback(
+ (operationId: string, objectId: number, hide: boolean) => {
+ if (hide) {
+ updatePathStatus(activeImage + objectId, "HIDE", hide)
+ if (getItemsById(objectId) && getItemsById(objectId).length) {
+ getItemsById(objectId).forEach((item) => (item.visible = !hide))
+ // 隐藏时清空选中状态
+ setSelectedItemFalse(objectId)
+ }
+ let childrenHideStatus = storeLabel
+ .get(activeImage)
+ ?.get(operationId)
+ ?.map((child) => {
+ return getItemById(child[0])!.visible
+ })
+ if (childrenHideStatus?.findIndex((bool) => bool === true) === -1) {
+ updateOperationStatus(activeImage + operationId, "HIDE", hide)
+ }
+ } else {
+ updateOperationStatus(activeImage + operationId, "HIDE", hide)
+ updatePathStatus(activeImage + objectId, "HIDE", hide)
+ const configs = useTopToolsStore.getState().showTagsConfigs
+ if (getItemsById(objectId) && getItemsById(objectId).length)
+ getItemsById(objectId).forEach((item) => {
+ let visible = !hide
+ if (item.data.type === "tag") {
+ if (item.data.tag_category === "mask") {
+ visible = configs.includes("外框")
+ } else if (item.data.tag_category === "point_text") {
+ visible = configs.includes("点ID")
+ } else {
+ visible = true
+ }
+ }
+ item.visible = visible
+ })
+ }
+ },
+ [
+ activeImage,
+ getItemById,
+ getItemsById,
+ storeLabel,
+ updateOperationStatus,
+ updatePathStatus,
+ ]
+ )
+
+ // const renderObjectName = useCallback((name: string, input: string) => {
+ // const index = name?.indexOf(input);
+ // const beforeStr = name?.substring(0, index);
+ // const afterStr = name?.slice(index + input.length);
+ // return (
+ //
+ // {beforeStr}
+ // {input}
+ // {afterStr}
+ //
+ // );
+ // }, []);
+
+ // child 是否存在不符合要求的子属性
+ const getOperationSubAttrStatus = useCallback(
+ (operationId: string) => {
+ let bool = storeLabel
+ .get(activeImage)
+ ?.get(operationId)
+ ?.some((child) => {
+ return getSubAttrStatus(
+ getOperationSchema(operationId),
+ child[2]?.sub_attributes
+ )
+ })
+ return bool
+ },
+ [activeImage, getOperationSchema, storeLabel]
+ )
+
+ // 搜索标注对象id
+ const handleSelectedBySearchId = (e: any) => {
+ if (e.key === "Enter") {
+ if (searchId && !isNaN(Number(searchId))) {
+ const id = Number(searchId)
+ const labelObjMap = storeLabel.get(activeImage)
+ Array.from(labelObjMap!.entries()).forEach(([operationId, objList]) => {
+ objList.forEach((obj) => {
+ if (id === obj[0]) {
+ handleSelect(id, operationId)
+ }
+ })
+ })
+ }
+ }
+ }
+
+ // 搜索标注对象子属性
+ const handleSelectedBySearchSubAttr = (e: any, currentOperationId: any) => {
+ if (e.key === "Enter") {
+ Array.from(storeLabel.entries()).forEach(([imgId, objectMap]) => {
+ if (objectMap.get(currentOperationId)) {
+ const list = objectMap.get(currentOperationId)
+ list!.forEach((item) => {
+ const { sub_attributes } = item[2]
+ // 如果没有子属性搜索值,则把对象HIDE设为false
+ if (!searchSubAttrs[currentOperationId]) {
+ updatePathStatus(imgId + item[0], "HIDE", false)
+ updateOperationStatus(imgId + currentOperationId, "HIDE", false)
+ if (getItemsById(item[0]))
+ getItemsById(item[0]).forEach((i) => {
+ i.visible = true
+ })
+ return
+ }
+ if (sub_attributes && Object.entries(sub_attributes).length) {
+ let flag = true
+ Object.entries(sub_attributes).forEach(([_k, value]) => {
+ if (searchSubAttrs[currentOperationId] === value) {
+ flag = false
+ }
+ })
+ if (flag) {
+ updatePathStatus(imgId + item[0], "HIDE", true)
+ if (getItemsById(item[0])) {
+ getItemsById(item[0]).forEach((i) => {
+ i.visible = false
+ })
+ setSelectedItemFalse(item[0])
+ }
+ } else {
+ updatePathStatus(imgId + item[0], "HIDE", false)
+ if (getItemsById(item[0]))
+ getItemsById(item[0]).forEach((i) => {
+ i.visible = true
+ })
+ }
+ } else {
+ // 如果有子属性搜索值,但对象无子属性,则把对象HIDE设为true
+ updatePathStatus(imgId + item[0], "HIDE", true)
+ if (getItemsById(item[0])) {
+ getItemsById(item[0]).forEach((i) => {
+ i.visible = false
+ })
+ setSelectedItemFalse(item[0])
+ }
+ }
+ })
+ checkIfOperationNeedsUpdate(imgId, currentOperationId)
+ }
+ })
+ }
+ }
+
+ return (
+
+
+
+
+ 对象列表
+
+
+ setSearchId(e.target.value)}
+ onKeyDown={handleSelectedBySearchId}
+ />
+
+ {[3, 2, 1, 0].map((type, index) => (
+ {
+ setCheckBoxsChecked((pre) => {
+ const arr = [...pre]
+ arr[index] = e.target.checked
+ return arr
+ })
+ }}
+ size="xs"
+ />
+ ))}
+
+ {
+ updateImageStatus(
+ activeImage,
+ "COLLAPSE",
+ !imageStatus[activeImage]?.["COLLAPSE"]
+ )
+ }}>
+ {!imageStatus[activeImage]?.["COLLAPSE"] ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {labelState.get(activeImage) &&
+ labelState.get(activeImage)?.map((operationId) => {
+ const isCollapsed =
+ operationStatus[activeImage + operationId]?.["COLLAPSE"]
+ const isHide =
+ operationStatus[activeImage + operationId]?.HIDE
+ const hasSubAttrError = getOperationSubAttrStatus(operationId)
+ return (
+
+
+
+
+
+ operationHide(operationId, !isHide)
+ }>
+ {!isHide ? (
+
+ ) : (
+
+ )}
+
+ {renderOperationIcon(
+ getOperationSchema(operationId)
+ )}
+
+ {renderShortcutKey(operationId)}
+
+
+
+ {getOperationSchema(operationId)?.label_class}
+
+
+ {
+ setSearchSubAttrs((searchValues) => ({
+ ...searchValues,
+ [operationId]: e.target.value,
+ }))
+ }}
+ onKeyDown={(e) =>
+ handleSelectedBySearchSubAttr(e, operationId)
+ }
+ />
+
+ {storeLabel.get(activeImage)?.get(operationId)
+ ?.length || 0}
+
+ {
+ updateOperationStatus(
+ activeImage + operationId,
+ "COLLAPSE",
+ !isCollapsed
+ )
+ }}>
+ {!isCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {storeLabel
+ .get(activeImage)
+ ?.get(operationId)
+ ?.map((child) => {
+ const { comment } = child[2]
+ let visibleFlag = true
+ if (comment && comment.length) {
+ const latest_comment =
+ (taskDetail &&
+ taskDetail.label_status === 2) ||
+ comment.length === 1
+ ? comment[comment.length - 1]
+ : comment[comment.length - 2]
+ let type = latest_comment.comment_type
+ if (type === 2) {
+ const { last_modified_timestamp } = child[2]
+ if (
+ last_modified_timestamp &&
+ last_modified_timestamp >
+ latest_comment.date
+ )
+ type = 3
+ }
+ visibleFlag = checkBoxsChecked[3 - type]
+ }
+ if (checkBoxsChecked.every((value) => !value))
+ visibleFlag = true
+
+ const isSelected = selectedPath[
+ activeImage
+ ]?.includes(child[0])
+ const isChildHide =
+ pathStatus[activeImage + child[0]]?.["HIDE"]
+ const hasChildError = getSubAttrStatus(
+ getOperationSchema(operationId),
+ child[2]?.sub_attributes
+ )
+
+ let textColor = undefined
+
+ if (hasChildError) {
+ textColor = "red"
+ } else if (isSelected) {
+ textColor = "white"
+ }
+
+ if (!visibleFlag) return null
+
+ return (
+
+ handleSelect(child[0], operationId)
+ }
+ style={{ cursor: "pointer" }}>
+
+
+ {
+ e.stopPropagation()
+ objectHide(
+ operationId,
+ child[0],
+ !isChildHide
+ )
+ }}>
+ {!isChildHide ? (
+
+ ) : (
+
+ )}
+
+
+ {child[0]?.toString()}
+
+
+
+ 创建:
+
+ {
+ userOpts.find(
+ (item) =>
+ item.value ===
+ child[2]?.uid
+ )?.label
+ }
+
+
+ {dayjs(
+ (child[2]?.create_timestamp ||
+ 0) * 1000
+ ).format("YYYY-MM-DD HH:mm:ss")}
+
+
+ {child[2]?.first_modified_uid && (
+
+
+ 首次修改:
+
+
+ {
+ userOpts.find(
+ (item) =>
+ item.value ===
+ child[2]
+ ?.first_modified_uid
+ )?.label
+ }
+
+
+ {dayjs(
+ (child[2]
+ ?.first_modified_timestamp ||
+ 0) * 1000
+ ).format(
+ "YYYY-MM-DD HH:mm:ss"
+ )}
+
+
+ )}
+ {child[2]?.last_modified_uid && (
+
+
+ 最新修改:
+
+
+ {
+ userOpts.find(
+ (item) =>
+ item.value ===
+ child[2]
+ ?.last_modified_uid
+ )?.label
+ }
+
+
+ {dayjs(
+ (child[2]
+ ?.last_modified_timestamp ||
+ 0) * 1000
+ ).format(
+ "YYYY-MM-DD HH:mm:ss"
+ )}
+
+
+ )}
+
+ }
+ multiline
+ bg="white"
+ c="black"
+ withArrow>
+
+
+
+ {comment ? (
+
+ {comment.map((c: any) => {
+ const count =
+ c.check_box_comments.length +
+ c.text_comments.length
+ const { last_modified_timestamp } =
+ child[2]
+ let flag = false
+ if (
+ count > 0 &&
+ last_modified_timestamp &&
+ last_modified_timestamp > c.date
+ )
+ flag = true
+
+ let badgeColor = "gray"
+ if (c.comment_type === 1)
+ badgeColor = "green"
+ if (c.comment_type === 2)
+ badgeColor = "red"
+ if (flag) badgeColor = "yellow"
+
+ return (
+
+
+
+ {count}
+
+
+
+
+ {`P${child[2].image_id}:${
+ child[2].uid
+ }:${c.check_box_comments.join(
+ ","
+ )},${c.text_comments.join(
+ ","
+ )} ${
+ c.date
+ ? dayjs(
+ c.date * 1000
+ ).format(
+ "YYYY-MM-DD HH:mm:ss"
+ )
+ : ""
+ }`}
+
+
+
+ )
+ })}
+
+ ) : null}
+
+
+ )
+ })}
+
+
+
+ )
+ })}
+
+
+
+
+
+ )
+}
+
+export default RightObjectTools
diff --git a/components/label/components/RightQATools.tsx b/components/label/components/RightQATools.tsx
new file mode 100644
index 0000000..68f1de5
--- /dev/null
+++ b/components/label/components/RightQATools.tsx
@@ -0,0 +1,685 @@
+"use client"
+
+import {
+ Accordion,
+ ActionIcon,
+ Box,
+ Button,
+ Flex,
+ Group,
+ NumberInput,
+ Radio,
+ Stack,
+ Text,
+ TextInput,
+ Tooltip,
+} from "@mantine/core"
+import { useForm } from "@mantine/form"
+import { IconEdit } from "@tabler/icons-react"
+import dayjs from "dayjs"
+import parse from "html-react-parser"
+import { ArrowUpIcon, ArrowUpToLineIcon } from "lucide-react"
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useState,
+} from "react"
+import { getWrongWordList } from "../api/scheme"
+import { Task } from "../api/task/typing"
+import { useKeyEventStore } from "../store"
+import { usePermissionStore } from "../store/auth"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { useDescToolsStore } from "../useDescToolsStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+import { safeClone } from "../utils/clone"
+import CustomModal from "./CustomModal"
+import EditorContainer, { splitWord } from "./EditorContainer"
+
+interface ComponentProps {
+ taskDetail?: Task.DataProps
+ // projectDetail?: Project.DetailResponse;
+}
+
+interface EditFormProps {
+ question_name: string
+ value?: string
+ round_id: number
+}
+
+// 处理转义字符
+const escapeRegExp = (str: string) => {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
+
+const RightQAContent = forwardRef(
+ ({ taskDetail }: ComponentProps, ref: React.Ref) => {
+ const { isView } = useTopToolsStore()
+ const { activeImage } = useBottomToolsStore()
+ const {
+ qaData,
+ setQaData,
+ countAllTurnsAndQaNumber,
+ updateDescDataFlag,
+ updateFlag,
+ } = useDescToolsStore()
+ const currentQaData = qaData.get(activeImage) ?? qaData.get("init") ?? {}
+ const isReview = [4, 6].includes(taskDetail?.label_status || 0)
+ const [selectedQuestion, setSelectedQuestion] = useState(null)
+
+ const form = useForm({
+ initialValues: {
+ value: "",
+ round: 1,
+ tag: 0,
+ },
+ })
+
+ const textForm = useForm({
+ initialValues: {
+ question_name: "",
+ value: "",
+ round_id: 1,
+ },
+ })
+
+ const counts = useMemo(() => {
+ return countAllTurnsAndQaNumber(activeImage)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeImage, qaData, updateDescDataFlag, countAllTurnsAndQaNumber])
+
+ const [open, setOpen] = useState({
+ status: false,
+ label: "",
+ idx: -1,
+ isEdit: false,
+ })
+
+ // 新增问题
+ const onClickAdd = (label: string, index: number) => {
+ setOpen({ status: true, label, idx: index, isEdit: false })
+ }
+
+ // 编辑问题名称
+ const onClickEdit = (label: string, index: number, val: EditFormProps) => {
+ textForm.setValues({
+ question_name: val.question_name,
+ value: val.value || "",
+ round_id: val.round_id,
+ })
+ setOpen({ status: true, label, idx: index, isEdit: true })
+ }
+
+ type QaObj = Record
+
+ const handleFormUpdate = (changeValues: any) => {
+ if (!selectedQuestion) return
+ const keys = Object.keys(changeValues)
+ const hasTagChange = keys.includes("tag")
+ const formData = form.values
+ const { label, type, category } = selectedQuestion
+ const currentQaDataArr = currentQaData[category] ?? []
+ let newArr: { [x: string]: any }[] = []
+ currentQaDataArr.forEach((qaObj: QaObj) => {
+ const [question_name, detail] = Object.entries(qaObj)[0]
+ if (question_name === label) {
+ newArr.push({
+ [question_name]: {
+ value: type === "value" ? formData.value : detail.value,
+ id: formData.round,
+ is_pre: detail.is_pre,
+ tag: formData.tag,
+ uid: detail.uid
+ ? detail.uid
+ : usePermissionStore.getState().user_id,
+ create_timestamp: detail.create_timestamp
+ ? detail.create_timestamp
+ : dayjs().unix(),
+ modify_uid: usePermissionStore.getState().user_id,
+ modify_timestamp: dayjs().unix(),
+ comment: type === "comment" ? formData.value : detail.comment,
+ flag: hasTagChange ? 0 : detail.tag === 2 ? 1 : 0,
+ },
+ })
+ } else {
+ newArr.push(qaObj)
+ }
+ })
+ try {
+ const finalData = safeClone(currentQaData)
+ finalData[category] = newArr
+ qaData.set(activeImage, finalData)
+ setQaData(new Map(qaData)) // Force update
+ updateFlag(true) // Ensure flag is updated to trigger recalculations if needed
+ } catch (error) {
+ console.log(error)
+ }
+ }
+
+ const onFieldChange = (field: string, value: any) => {
+ form.setFieldValue(field, value)
+ handleFormUpdate({ [field]: value })
+ }
+
+ useEffect(() => {
+ if (!selectedQuestion) return
+ if (
+ selectedQuestion.type === "value" ||
+ selectedQuestion.type === "comment"
+ ) {
+ const { value: v } = selectedQuestion
+ let htmlText = ""
+ if (selectedQuestion.type === "value") {
+ htmlText = v.value ? v.value.split(splitWord)[0] : ""
+ } else {
+ htmlText = v.comment ? v.comment.split(splitWord)[0] : ""
+ }
+ const obj = {
+ value: htmlText,
+ round: v.id,
+ tag: v.tag,
+ }
+ form.setValues(obj)
+ }
+ }, [selectedQuestion, form])
+
+ useEffect(() => {
+ if (updateDescDataFlag) {
+ updateFlag(false)
+ }
+ }, [updateDescDataFlag, updateFlag])
+
+ // 处理问题上移
+ const handleQuestionUp = (category: string, index: number) => {
+ const finalData = safeClone(currentQaData)
+ let arr = finalData[category]
+ let temp = arr[index]
+ arr[index] = arr[index - 1]
+ arr[index - 1] = temp
+ finalData[category] = arr
+ qaData.set(activeImage, finalData)
+ setQaData(new Map(qaData))
+ updateFlag(true)
+ }
+
+ // 处理问题置顶
+ const handleQuestionUpToTop = (category: string, index: number) => {
+ const finalData = safeClone(currentQaData)
+ let arr = finalData[category]
+ const element = arr.splice(index, 1)[0]
+ arr.unshift(element)
+ finalData[category] = arr
+ qaData.set(activeImage, finalData)
+ setQaData(new Map(qaData))
+ updateFlag(true)
+ }
+
+ // 错词检测
+ const checkWrongWords = useCallback(async () => {
+ if (useTopToolsStore.getState().isView) return
+ const res = await getWrongWordList()
+ const dictionary = res.data
+ const wrongWords = dictionary.map((item) => item.error)
+ const rightWords = dictionary.map((item) => item.correct)
+ // currentQaData
+ const currentImg = useBottomToolsStore.getState().activeImage
+ const qaData = useDescToolsStore.getState().qaData
+ const currentQaData = qaData.get(currentImg) ?? qaData.get("init") ?? {}
+ const newQaData = safeClone(currentQaData)
+
+ const handleText = (v: string) => {
+ // 提取文本内容
+ const text = v.split(splitWord)[1]
+ let textContent = text.replace(/<[^>]+>/g, "")
+ // 替换错词
+ for (let i = 0; i < wrongWords.length; i++) {
+ const wrong = escapeRegExp(wrongWords[i])
+ const right = rightWords[i]
+ const regex = new RegExp(wrong, "g")
+
+ if (textContent.match(regex)) {
+ textContent = textContent.replace(
+ regex,
+ `${wrong}`
+ )
+ return textContent
+ }
+ }
+ return text
+ }
+ type QaObj = Record
+ Object.values(newQaData).forEach((questionArr: QaObj[]) => {
+ questionArr.forEach((questionObj: QaObj) => {
+ const [name, questionData] = Object.entries(questionObj)[0]
+ const { value, comment } = questionData
+
+ const update = (v: string, key: "value" | "comment") => {
+ const checkValue = v.split(splitWord)[1]
+ const updatedValue = handleText(v)
+
+ if (checkValue !== updatedValue) {
+ let newValue = `${updatedValue}
${splitWord}${updatedValue}`
+ questionData[key] = newValue
+ if (
+ selectedQuestion?.label === name &&
+ selectedQuestion?.type === key
+ )
+ form.setFieldValue("value", `${updatedValue}
`)
+ }
+ }
+
+ if (value) update(value, "value")
+ if (comment) update(comment, "comment")
+ })
+ })
+ qaData.set(currentImg, newQaData)
+ setQaData(new Map(qaData)) // Force update
+ updateFlag(true)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedQuestion, qaData, setQaData, updateFlag])
+
+ useImperativeHandle(ref, () => ({
+ checkWrongWords,
+ }))
+
+ return (
+
+ {`总轮数:${counts.turns}, 总问题数:${counts.question_size},新增单轮数:${counts.new_single_turns},新增多轮数:${counts.new_multiple_turns}`}
+
+
+ {Object.entries(currentQaData).map(([qaCategory, qaDataArr]) => {
+ const counts = useDescToolsStore
+ .getState()
+ .countTurnsAndQaNumber(activeImage, qaCategory)
+ return (
+
+
+ {`${qaCategory} 轮数:${counts.turns},问题数:${counts.question_size}`}
+
+
+
+ {qaDataArr.map((qaObj, index) => {
+ const [question_name, detail] = Object.entries(qaObj)[0]
+ const htmlText = detail.value
+ ? detail.value.split(splitWord)[0]
+ : ""
+ const commentHtmlText = detail.comment
+ ? detail.comment.split(splitWord)[0]
+ : ""
+
+ return (
+
+
+
+ {question_name}
+ {!detail.is_pre && !isView ? (
+ {
+ onClickEdit(qaCategory, index, {
+ question_name,
+ value: htmlText,
+ round_id: detail.id,
+ })
+ }}>
+
+
+ ) : null}
+
+
+ {detail.tag === 2 ? (
+ 错误
+ ) : detail.tag === 1 ? (
+ 正确
+ ) : null}
+
+
+
+ 轮次:{detail.id}
+ {!isView ? (
+
+ {!detail.is_pre ? (
+
+ ) : null}
+
+ {isReview && !detail.comment ? (
+
+ ) : null}
+ {index !== 0 ? (
+ <>
+
+ {
+ handleQuestionUp(qaCategory, index)
+ }}>
+
+
+
+
+ {
+ handleQuestionUpToTop(
+ qaCategory,
+ index
+ )
+ }}>
+
+
+
+ >
+ ) : null}
+
+ ) : null}
+
+
+ {
+ setSelectedQuestion({
+ label: question_name,
+ value: detail,
+ type: "value",
+ category: qaCategory,
+ })
+ console.log("点击批注", htmlText)
+ form.setValues({
+ value: htmlText,
+ round: detail.id,
+ tag: detail.tag,
+ })
+ }}>
+ {parse(htmlText)}
+
+ {commentHtmlText ? (
+ {
+ setSelectedQuestion({
+ label: question_name,
+ value: detail,
+ type: "comment",
+ category: qaCategory,
+ })
+ form.setValues({
+ value: commentHtmlText,
+ round: detail.id,
+ tag: detail.tag,
+ })
+ }}>
+ {parse(commentHtmlText)}
+
+ ) : null}
+
+
+ )
+ })}
+
+
+
+ )
+ })}
+
+
+
+
+
+ 问题
+ {selectedQuestion?.label || ""}
+
+
+ 轮次
+ onFieldChange("round", val)}
+ disabled={
+ !selectedQuestion ||
+ (selectedQuestion && selectedQuestion.value?.is_pre)
+ }
+ />
+ onFieldChange("tag", Number(val))}>
+
+
+
+
+
+
+ onFieldChange("value", val)}
+ disabled={!selectedQuestion}
+ />
+
+
+ {
+ const currentOperation = currentQaData[open.label]
+ // Validate
+ const { hasErrors } = textForm.validate()
+ if (hasErrors) return
+
+ // Duplicate check
+ const formData = textForm.values
+ const arr = useDescToolsStore
+ .getState()
+ .getQuestionList(activeImage)
+ // If adding new, check duplicates
+ if (!open.isEdit && arr.includes(formData.question_name)) {
+ textForm.setFieldError("question_name", "问题名称不能重复!")
+ return
+ }
+ // If editing, if name changed and new name exists, error
+ if (open.isEdit) {
+ const originalName = Object.keys(currentOperation[open.idx])[0]
+ if (
+ originalName !== formData.question_name &&
+ arr.includes(formData.question_name)
+ ) {
+ textForm.setFieldError("question_name", "问题名称不能重复!")
+ return
+ }
+ }
+
+ const finalData = safeClone(currentQaData)
+ const updateData = safeClone(currentOperation)
+ let obj = {
+ value: formData.value ?? "",
+ id: formData.round_id,
+ is_pre: false,
+ tag: 0,
+ uid: 0,
+ create_timestamp: 0,
+ modify_uid: 0,
+ modify_timestamp: 0,
+ comment: "",
+ flag: 0,
+ }
+ if (open.isEdit) {
+ const prevData = Object.values(currentOperation[open.idx])[0]
+ obj.uid = prevData.uid
+ obj.create_timestamp = prevData.create_timestamp
+ obj.modify_uid = usePermissionStore.getState().user_id
+ obj.modify_timestamp = dayjs().unix()
+ obj.comment = prevData.comment
+ obj.flag = prevData.flag
+ updateData[open.idx] = { [formData.question_name]: obj }
+ } else {
+ obj.uid = usePermissionStore.getState().user_id
+ obj.create_timestamp = dayjs().unix()
+ updateData.splice(open.idx + 1, 0, {
+ [formData.question_name]: obj,
+ })
+ }
+ finalData[open.label] = updateData
+ qaData.set(activeImage, finalData)
+ setQaData(new Map(qaData))
+ updateFlag(true)
+ setOpen({ status: false, label: "", idx: -1, isEdit: false })
+ textForm.reset()
+ }}
+ onCancel={() => {
+ textForm.reset()
+ setOpen({ status: false, label: "", idx: -1, isEdit: false })
+ }}>
+
+ {
+ useKeyEventStore.getState().setFocusInput(true)
+ }}
+ onBlur={() => {
+ useKeyEventStore.getState().setFocusInput(false)
+ }}
+ />
+
+
+ 值
+
+ textForm.setFieldValue("value", val)}
+ />
+
+
+
+
+
+ )
+ }
+)
+
+const RightQATools = (
+ { taskDetail }: ComponentProps,
+ ref: React.Ref
+) => {
+ const { activeImage } = useBottomToolsStore()
+ return
+}
+
+export default forwardRef(RightQATools)
diff --git a/components/label/components/RightTaskTools.tsx b/components/label/components/RightTaskTools.tsx
new file mode 100644
index 0000000..1f1d57b
--- /dev/null
+++ b/components/label/components/RightTaskTools.tsx
@@ -0,0 +1,352 @@
+"use client"
+
+import {
+ ActionIcon,
+ Box,
+ Button,
+ Flex,
+ Group,
+ MultiSelect,
+ Pagination,
+ Popover,
+ ScrollArea,
+ Select,
+ Stack,
+ Table,
+ TextInput,
+ Title,
+} from "@mantine/core"
+import { useForm } from "@mantine/form"
+import {
+ IconChevronLeft,
+ IconChevronRight,
+ IconFilter,
+} from "@tabler/icons-react"
+import { useRouter } from "next/navigation"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { Project } from "../api/project/typing"
+import { getTaskList } from "../api/task"
+import { useObjectStore } from "../store"
+import type { Option } from "../store/auth"
+import { useAllUserStore } from "../store/auth"
+import { usePaperStore } from "../usePaperStore"
+import { TaskStatusEnum, TaskStatusOpts } from "../utils/constants"
+
+interface RightTaskToolsComponentProps {
+ projectDetail?: Project.DetailResponse
+ project_id: number
+ task_id: number
+}
+
+interface FilterOptions {
+ all_user: Option[]
+ label_user: Option[]
+ review_user1: Option[]
+ review_user2: Option[]
+}
+
+const RightTaskTools: React.FC = (props) => {
+ const { projectDetail, project_id = -1, task_id = -1 } = props
+ const projectId = useMemo(() => {
+ return project_id
+ }, [project_id])
+ const taskId = useMemo(() => {
+ return task_id
+ }, [task_id])
+ const router = useRouter()
+
+ const { userOpts } = useAllUserStore()
+
+ const [taskList, setTaskList] = useState([])
+ const [activePage, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+
+ const filterForm = useForm({
+ initialValues: {
+ label_status: [],
+ current_uid: [],
+ label_user: [],
+ review_user1: [],
+ review_user2: [],
+ rejected: null,
+ id: "",
+ },
+ })
+ const [filterParams, setFilterParams] = useState({})
+ const isParamsEmpty: boolean = JSON.stringify(filterParams) === "{}"
+
+ const getListData = useCallback(async () => {
+ let pg_num = 0,
+ pg_size = 1000
+ let total = 0
+ let tasksCount = 1000000
+ let finalData: any[] = []
+
+ while (total < tasksCount) {
+ pg_num += 1
+ const params = filterParams
+ ? {
+ project_id: [projectId],
+ get_data: true,
+ page_number: pg_num,
+ page_size: pg_size,
+ ...filterParams,
+ }
+ : {
+ project_id: [projectId],
+ get_data: true,
+ page_number: pg_num,
+ page_size: pg_size,
+ }
+ const res = await getTaskList(params)
+ const { total_items, task_list } = res
+ finalData = [...finalData, ...task_list]
+ total += task_list.length
+ tasksCount = total_items
+ }
+ setTaskList(finalData)
+ }, [filterParams, projectId])
+
+ useEffect(() => {
+ // Defer the call to avoid synchronous setState inside the effect
+ queueMicrotask(() => getListData())
+ }, [getListData])
+
+ const filterUserOpts = useMemo(() => {
+ const opts: FilterOptions = {
+ all_user: [],
+ label_user: [],
+ review_user1: [],
+ review_user2: [],
+ }
+
+ if (!projectDetail) return opts
+ const steps = projectDetail.label_process.step_detail
+ steps.forEach(({ action, user_list }) => {
+ let users: any[] = []
+ user_list.forEach((v) => {
+ userOpts.forEach((item) => {
+ if (item.value === v) {
+ users.push(item)
+ const flag = opts.all_user.find((u) => u.value === v)
+ if (!flag) opts.all_user.push(item)
+ }
+ })
+ })
+ if (action === 1) {
+ opts.label_user.push(...users)
+ } else if (action === 2) {
+ opts.review_user1.push(...users)
+ } else if (action === 3) {
+ opts.review_user2.push(...users)
+ }
+ })
+
+ return opts
+ }, [projectDetail, userOpts])
+
+ const handleTurnPrevious = () => {
+ const index = taskList.findIndex((task) => task.id === taskId)
+ if (index !== -1) {
+ if (index - 1 >= 0) {
+ const previousId = taskList[index - 1].id
+ router.push(`/label?project_id=${projectId}&task_id=${previousId}`)
+ usePaperStore.getState().resetRasterScale()
+ useObjectStore.getState().resetPathAndOperationStatus()
+ }
+ }
+ }
+
+ const handleTurnNext = () => {
+ const index = taskList.findIndex((task) => task.id === taskId)
+ if (index !== -1) {
+ if (index + 1 < taskList.length) {
+ const nextId = taskList[index + 1].id
+ router.push(`/label?project_id=${projectId}&task_id=${nextId}`)
+ usePaperStore.getState().resetRasterScale()
+ useObjectStore.getState().resetPathAndOperationStatus()
+ }
+ }
+ }
+
+ const paginatedData = useMemo(() => {
+ const start = (activePage - 1) * pageSize
+ const end = start + pageSize
+ return taskList.slice(start, end)
+ }, [taskList, activePage, pageSize])
+
+ const filterContent = (
+
+ v !== "全部")
+ .map(([key, value]) => ({
+ label: value,
+ value: key,
+ }))}
+ {...filterForm.getInputProps("label_status")}
+ clearable
+ />
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+ return (
+
+
+
+ 任务列表{`(${taskList.length || 0})`}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {filterContent}
+
+
+
+
+
+
+
+
+ 任务ID
+ 状态
+ 当前负责人
+
+
+
+ {paginatedData.map((task) => (
+ {
+ router.push(
+ `/label?project_id=${projectId}&task_id=${task.id}`
+ )
+ usePaperStore.getState().resetRasterScale()
+ useObjectStore.getState().resetPathAndOperationStatus()
+ }}>
+ {task.id}
+
+ {TaskStatusEnum.get(task.label_status)}
+ {task.rejected ? "返工" : ""}
+
+ {task.current_username}
+
+ ))}
+
+
+
+
+
+ setPageSize(Number(val))}
+ data={["20", "50", "100"]}
+ size="xs"
+ w={70}
+ allowDeselect={false}
+ />
+
+
+
+ )
+}
+
+export default RightTaskTools
diff --git a/components/label/components/ScaleComponent.tsx b/components/label/components/ScaleComponent.tsx
new file mode 100644
index 0000000..8e63a62
--- /dev/null
+++ b/components/label/components/ScaleComponent.tsx
@@ -0,0 +1,153 @@
+"use client"
+
+import React, { useCallback, useEffect, useRef } from "react"
+
+interface ScaleComponentProps {
+ options: {
+ offsetX: number
+ offsetY: number
+ scale: number
+ }
+ mode: "horizontal" | "vertical"
+}
+
+const ScaleComponent: React.FC = (props) => {
+ const { options, mode } = props
+ const canvasRef = useRef(null)
+
+ const getFixed = (sparsity: number) => {
+ const pointIdx = String(sparsity).indexOf(".")
+ const len = String(sparsity).length
+ return pointIdx < 0 ? 0 : len - pointIdx - 1
+ }
+
+ const isCloseToInteger = (num: number) => {
+ return Math.abs(num - Math.round(num)) < 0.0000001
+ }
+ const getSparsity = (scale: number) => {
+ if (scale <= 1) {
+ return Math.round(100 / scale)
+ } else if (scale <= 3) {
+ return 50
+ } else if (scale <= 4) {
+ return 20
+ } else if (scale <= 5) {
+ return 10
+ }
+ return 5
+ }
+
+ const renderWidthWays = useCallback(() => {
+ const canvas = canvasRef.current
+ if (!canvas) {
+ return
+ }
+ const { width, height } = canvas.getBoundingClientRect()
+ const dpi = 2
+ canvas.width = width * dpi
+ canvas.height = height * dpi
+ // canvas.style.width = width + "px";
+ // canvas.style.height = height + "px";
+ let { width: w, height: h } = canvas
+ w /= dpi
+ h /= dpi
+ const ctx = canvas.getContext("2d")!
+ ctx.scale(dpi, dpi)
+ ctx.clearRect(0, 0, w, h)
+ ctx.save()
+ ctx.lineWidth = 1
+ // ctx.strokeStyle = "#d9d9d9";
+ ctx.strokeStyle = "#808080"
+ // ctx.fillStyle = "#232323";
+ ctx.fillStyle = "#808080"
+
+ ctx.font = "12px serif"
+ ctx.beginPath()
+ const { offsetX, offsetY, scale } = options
+ const offset = mode === "horizontal" ? offsetX : offsetY
+ // 间隔
+ const sparsity = getSparsity(scale)
+ // 间隔内有多少条线
+ const part = 10
+ const pixelPerUnit = scale * sparsity
+ const gap = pixelPerUnit / part
+ const fixed = getFixed(sparsity)
+ let index = offset % gap > 0 ? gap - (offset % gap) : -offset % gap
+ if (mode === "horizontal") {
+ ctx.translate(31.5, 0)
+ do {
+ const num = ((offset + index) / pixelPerUnit) * sparsity
+ if (isCloseToInteger(num / sparsity)) {
+ ctx.moveTo(index, h * 0.5)
+ ctx.lineTo(index, h)
+ const text = num.toFixed(fixed)
+ const textWidth = ctx.measureText(text).width
+ ctx.fillText(text, index - textWidth / 2, 10)
+ } else {
+ ctx.moveTo(index, h * 0.7)
+ ctx.lineTo(index, h)
+ }
+ index += gap
+ } while (index < w)
+ } else {
+ ctx.translate(0, 0.5)
+ do {
+ const num = ((offset + index) / pixelPerUnit) * sparsity
+ if (isCloseToInteger(num / sparsity)) {
+ ctx.moveTo(w * 0.5, index)
+ ctx.lineTo(w, index)
+ const text = num.toFixed(fixed)
+ ctx.save()
+ ctx.rotate((-90 * Math.PI) / 180)
+ const textWidth =
+ text === "0"
+ ? ctx.measureText(text).width * 2
+ : ctx.measureText(text).width
+ ctx.fillText(text, -(index + textWidth / 2), 12)
+ ctx.rotate((0 * Math.PI) / 180)
+ ctx.restore()
+ } else {
+ ctx.moveTo(w * 0.7, index)
+ ctx.lineTo(w, index)
+ }
+ index += gap
+ } while (index < h)
+ }
+ ctx.closePath()
+ ctx.stroke()
+ ctx.restore()
+ }, [mode, options])
+
+ const getStyles = (): React.CSSProperties => {
+ const baseStyles: React.CSSProperties = {
+ flex: "none",
+ display: "block",
+ backgroundColor: "transparent",
+ pointerEvents: "none",
+ }
+
+ if (mode === "horizontal") {
+ return {
+ ...baseStyles,
+ width: "100vw",
+ height: "32px",
+ cursor: "col-resize",
+ }
+ }
+
+ return {
+ ...baseStyles,
+ width: "32px",
+ height: "100%",
+ cursor: "row-resize",
+ }
+ }
+
+ useEffect(() => {
+ renderWidthWays()
+ }, [renderWidthWays])
+
+ return
+}
+
+export default ScaleComponent
diff --git a/components/label/components/ScaleToolContainer.tsx b/components/label/components/ScaleToolContainer.tsx
new file mode 100644
index 0000000..780d3d0
--- /dev/null
+++ b/components/label/components/ScaleToolContainer.tsx
@@ -0,0 +1,119 @@
+import { ActionIcon, Flex, NumberInput, Stack, Text } from "@mantine/core"
+import { IconMinus, IconPlus, IconRefresh } from "@tabler/icons-react"
+import paper from "paper"
+import { useMemo } from "react"
+import { useKeyEventStore } from "../store"
+import { usePaperStore } from "../usePaperStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+
+const ScaleToolContainer = () => {
+ const { scale, setScale } = useTopToolsStore()
+ const { setFocusInput } = useKeyEventStore()
+
+ const displayNumber = useMemo(() => {
+ return Number((scale * 100).toFixed(0))
+ }, [scale])
+
+ const setCurrentScale = (n: number) => {
+ const currentScale = n / 100
+ const group = usePaperStore.getState().group
+ if (!group) return
+ group.scaling = new paper.Point(currentScale, currentScale)
+ setScale(currentScale)
+ }
+
+ const handleReset = () => {
+ const group = usePaperStore.getState().group
+ if (!group) return
+ const rasterList = group.getItems({
+ data: {
+ name: "pic",
+ },
+ })
+ if (!rasterList || !rasterList.length) return
+ const currentRaster = rasterList[0]
+ group.position = currentRaster.position
+ setCurrentScale(100)
+ }
+
+ const handleEnter = (e: any) => {
+ const num = Number(e.target.value)
+ if (isNaN(num)) return
+ if (num > 9999 || num < 0) return
+ setCurrentScale(num)
+ }
+
+ return (
+
+
+
+
+ {
+ setCurrentScale(
+ displayNumber + 10 <= 9999 ? displayNumber + 10 : 9999
+ )
+ }}
+ style={{ width: 32, height: 32 }}>
+
+
+
+ {
+ if (e.key === "Enter") {
+ handleEnter(e)
+ }
+ }}
+ onFocus={() => {
+ setFocusInput(true)
+ }}
+ onBlur={() => {
+ setFocusInput(false)
+ }}
+ hideControls
+ />
+ %
+
+ {
+ setCurrentScale(displayNumber - 10 >= 0 ? displayNumber - 10 : 1)
+ }}
+ style={{ width: 32, height: 32 }}>
+
+
+
+ )
+}
+
+export default ScaleToolContainer
diff --git a/components/label/components/TaskCacheModal.tsx b/components/label/components/TaskCacheModal.tsx
new file mode 100644
index 0000000..453d07e
--- /dev/null
+++ b/components/label/components/TaskCacheModal.tsx
@@ -0,0 +1,131 @@
+"use client"
+
+import { Task } from "../api/task/typing"
+import CustomModal from "./CustomModal"
+import { Skeleton, Stack, Flex, Box, Text } from "@mantine/core"
+import { FolderPen, ImageDown, Trash2 } from "lucide-react"
+import { useCallback, useEffect, useState } from "react"
+import { storage, useImagesStore } from "../store"
+import { safeClone } from "../utils/clone"
+
+interface ComponentProps {
+ open: boolean
+ handleClose: () => void
+ taskDetail?: Task.DataProps
+}
+
+const formatSize = (bytes: number) => {
+ const units = ["B", "KB", "MB", "GB", "TB"]
+ let unitIndex = 0
+
+ while (bytes >= 1024 && unitIndex < units.length - 1) {
+ bytes /= 1024
+ unitIndex++
+ }
+
+ return `${bytes.toFixed(2)} ${units[unitIndex]}`
+}
+
+const TaskCacheModal = ({ open, handleClose, taskDetail }: ComponentProps) => {
+ const [loading, setLoading] = useState(false)
+ const [data1, setData1] = useState("0.00 B")
+ const [data2, setData2] = useState("0.00 B")
+
+ const getData = useCallback(async () => {
+ setLoading(true)
+ if (open && taskDetail) {
+ const { id, data_name } = taskDetail
+ // 图片缓存
+ const imgNames = data_name.map((item) => item.name[0])
+ const imageData = useImagesStore.getState().images
+ let size = 0
+ imgNames.forEach((name) => {
+ if (imageData.has(name)) {
+ const base64Text = imageData.get(name)!
+ const text = base64Text.split("data:image/jpeg;base64,")[1]
+ size += (text.length * 3) / 4
+ }
+ })
+ setData1(formatSize(size))
+ // 标注备份
+ let taskLabelData = await storage.getItem(id.toString())
+ const jsonString = JSON.stringify(taskLabelData)
+ const encoder = new TextEncoder()
+ const uint8Array = encoder.encode(jsonString)
+ const byteSize = uint8Array.length
+ setData2(formatSize(byteSize))
+ setLoading(false)
+ }
+ }, [open, taskDetail])
+
+ useEffect(() => {
+ queueMicrotask(() => getData())
+ }, [getData])
+
+ const handleDeleteImageCache = () => {
+ // 删除图片缓存
+ if (data1 === "0.00 B") return
+ const imgNames = taskDetail?.data_name.map((item) => item.name[0]) ?? []
+ const imageData = safeClone(useImagesStore.getState().images)
+ imgNames.forEach((name) => {
+ imageData.delete(name)
+ })
+ useImagesStore.getState().setImages(imageData)
+ setData1("0.00 B")
+ }
+
+ const handleDeleteLabelCache = () => {
+ // 删除标注备份
+ if (data2 === "0.00 B") return
+ storage.removeItem(taskDetail?.id.toString()!)
+ setData2("0.00 B")
+ }
+
+ return (
+
+
+
+
+
+ 图片缓存
+ {!loading ? (
+ {data1}
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ 标注备份
+ {!loading ? (
+ {data2}
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+export default TaskCacheModal
diff --git a/components/label/components/TaskStatusTag.tsx b/components/label/components/TaskStatusTag.tsx
new file mode 100644
index 0000000..fd48241
--- /dev/null
+++ b/components/label/components/TaskStatusTag.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { Box, Text } from "@mantine/core"
+import { useMemo } from "react"
+import { TaskStatusEnum } from "../utils/constants"
+import {
+ TaskIcon1,
+ TaskIcon2,
+ TaskIcon3,
+ TaskIcon4,
+ TaskIcon5,
+ TaskIcon6,
+ TaskIcon7,
+} from "./Icon"
+
+interface ComponentProps {
+ status: number
+ rejected?: boolean
+ center?: boolean
+}
+
+const styleConfig: Record = {
+ 待领取: { color: "#FFB84D", backgroundColor: "#FEF5E7" },
+ 标注中: { color: "#1874FF", backgroundColor: "#E8F0FC" },
+ 标注完成: { color: "#4FD796", backgroundColor: "#E7F9F0" },
+ 审核中: { color: "#1874FF", backgroundColor: "#E8F0FC" },
+ 审核完成: { color: "#4FD796", backgroundColor: "#E7F9F0" },
+ 复审中: { color: "#1874FF", backgroundColor: "#E8F0FC" },
+ 复审完成: { color: "#4FD796", backgroundColor: "#E7F9F0" },
+ 无法标注: { color: "#D9333E", backgroundColor: "#FBEBEC" },
+}
+
+const TaskStatusTag = ({ status, rejected, center }: ComponentProps) => {
+ const text = TaskStatusEnum.get(status) || "默认"
+ const icon = useMemo(() => {
+ switch (status) {
+ case 1:
+ return
+ case 2:
+ return
+ case 3:
+ return
+ case 4:
+ return
+ case 5:
+ return
+ case 6:
+ case 7:
+ return
+ case 8:
+ return
+ default:
+ return <>>
+ }
+ }, [status])
+
+ const getContainerStyle = (): React.CSSProperties => ({
+ display: "flex",
+ gap: "8px",
+ alignItems: "center",
+ justifyContent: center ? "center" : undefined,
+ })
+
+ const getTagStyle = (): React.CSSProperties => {
+ const baseStyle: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ padding: "2px 6px",
+ borderRadius: "2px",
+ }
+ const dynamicStyle = styleConfig[text] || {}
+ return { ...baseStyle, ...dynamicStyle }
+ }
+
+ const getRejectedStyle = (): React.CSSProperties => ({
+ padding: "0 8px",
+ borderRadius: "2px",
+ border: "1px solid #FFB84D",
+ color: "#FFB84D",
+ })
+
+ return (
+
+
+ {icon}
+ {text}
+
+ {rejected ? 返工 : <>>}
+
+ )
+}
+
+export default TaskStatusTag
diff --git a/components/label/components/TopTools.tsx b/components/label/components/TopTools.tsx
new file mode 100644
index 0000000..27d4090
--- /dev/null
+++ b/components/label/components/TopTools.tsx
@@ -0,0 +1,2368 @@
+"use client"
+
+import {
+ ActionIcon,
+ Box,
+ Button,
+ Checkbox,
+ Divider,
+ Flex,
+ Group,
+ HoverCard,
+ NumberInput,
+ Popover,
+ Radio,
+ ScrollArea,
+ Select,
+ Slider,
+ Stack,
+ Switch,
+ Text,
+ TextInput,
+ Tooltip,
+ UnstyledButton,
+} from "@mantine/core"
+import { useForm } from "@mantine/form"
+import { notifications } from "@mantine/notifications"
+import { IconCaretDownFilled, IconChevronDown } from "@tabler/icons-react"
+import dayjs from "dayjs"
+import duration from "dayjs/plugin/duration"
+import {
+ BadgeInfoIcon,
+ Baseline,
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ CirclePlus,
+ ClipboardList,
+ Cloud,
+ CloudCog,
+ CloudDownload,
+ Component,
+ Copy,
+ CopyMinus,
+ CopyPlus,
+ CopySlash,
+ Eclipse,
+ Film,
+ FolderDownIcon,
+ Group as GroupIcon,
+ Locate,
+ LocateFixed,
+ LocateOff,
+ MagnetIcon,
+ MonitorSmartphone,
+ MousePointer,
+ Paintbrush,
+ Pen,
+ SquarePen,
+ Tag,
+ Timer,
+} from "lucide-react"
+import { useRouter } from "next/navigation"
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useState,
+} from "react"
+import { saveLabelResult } from "../api/label"
+import {
+ ImageObjects,
+ LabelResult,
+ RequestLabelResult,
+ WorkLoad,
+} from "../api/label/typing"
+import { Project } from "../api/project/typing"
+import { getNextTaskId, taskCommit } from "../api/task"
+import { Task } from "../api/task/typing"
+import {
+ storage,
+ useKeyEventStore,
+ useLabelStore,
+ useObjectStore,
+ useVideoFrameStore,
+} from "../store"
+import { useBackUrlStore, usePermissionStore } from "../store/auth"
+import { useBottomToolsStore } from "../useBottomToolsStore"
+import { useDescToolsStore } from "../useDescToolsStore"
+import { usePaperStore } from "../usePaperStore"
+import { useRightToolsStore } from "../useRightToolsStore"
+import { useIntervalStore, useLabelTimeStore } from "../useTimerStore"
+import { useTopToolsStore } from "../useTopToolsStore"
+import { getSubAttrStatus } from "../util"
+import { safeClone } from "../utils/clone"
+import { labelTypeMap, TaskStatusEnum } from "../utils/constants"
+import { adjustAllPoints } from "../utils/paperjs"
+import BackConfirmModal from "./BackConfirmModal"
+import BackupModal from "./BackupModal"
+import ConfirmModal from "./ConfirmModal"
+import { splitWord } from "./EditorContainer"
+import RecoverModal from "./RecoverModal"
+import { renderOperationIcon } from "./RightObjectTools"
+import TaskCacheModal from "./TaskCacheModal"
+import TaskStatusTag from "./TaskStatusTag"
+dayjs.extend(duration)
+
+interface TopToolsComponentProps {
+ taskDetail?: Task.DataProps
+ projectDetail?: Project.DetailResponse
+ oldWorkLoad: WorkLoad | null
+ updateOldWorkLoad: (data: WorkLoad) => void
+ isLabelTask: boolean
+ renderPolygons: (
+ operationId: string,
+ imageData: Map | undefined
+ ) => void | null
+}
+
+const initialWorkLoad: WorkLoad = {
+ object_size: {},
+ prelabel_modify_size: {},
+ review_size: 0,
+ comment_size: 0,
+ work_time: 0,
+ dynamic_size: {},
+ static_size: {},
+ review_dynamic_size: {},
+ review_static_size: {},
+ review_question_size: {},
+ key_frame_size: {},
+ review_key_frame_size: {},
+ question_size: {},
+}
+
+const TaskTimerDisplay = ({
+ currentTimeKey,
+}: {
+ currentTimeKey: "label" | "review1" | "review2"
+}) => {
+ const currentTime = useLabelTimeStore(
+ (state) => state.labelTime[currentTimeKey]
+ )
+ return <>{dayjs.duration(currentTime, "seconds").format("HH:mm:ss")}>
+}
+
+const TopTools = (
+ props: TopToolsComponentProps,
+ ref: React.Ref | undefined
+) => {
+ const {
+ taskDetail,
+ projectDetail,
+ oldWorkLoad,
+ updateOldWorkLoad,
+ isLabelTask,
+ renderPolygons,
+ } = props
+ const { backUrl } = useBackUrlStore()
+ const { mode, loadingData } = usePaperStore()
+ const router = useRouter()
+ const user_id = usePermissionStore.getState().user_id
+
+ // const { getAll } = usePaperStore();
+ const [confirmOpen, setConfirmOpen] = useState(false)
+ const [backupOpen, setBackupOpen] = useState(false)
+ const [duplicateConfirmOpen, setDuplicateConfirmOpen] = useState(false)
+ const [duplicateName, setDuplicateName] = useState("")
+ const [recoverOpen, setRecoverOpen] = useState(false)
+ const [taskOpen, setTaskOpen] = useState(false)
+ const labelData = useLabelStore((state) => state.label)
+ const setLabel = useLabelStore((state) => state.setLabel)
+ const pushStateStack = useLabelStore((state) => state.pushStateStack)
+ const setLabelTime = useLabelTimeStore((state) => state.setLabelTime)
+ const { activeImage } = useBottomToolsStore()
+ const {
+ isView,
+ setIsView,
+ saveCurrentScale,
+ setSaveCurrentScale,
+ showMultiFrame,
+ setShowMultiFrame,
+ showObjectList,
+ setShowObjectList,
+ showDescList,
+ setShowDescList,
+ showGroupList,
+ setShowGroupList,
+ showTaskList,
+ setShowTaskList,
+ editMode,
+ setEditMode,
+ pressA,
+ setPressA,
+ activeOperation,
+ setActiveOperation,
+ drawOption,
+ setDrawOption,
+ showTags,
+ setShowTags,
+ showTagsConfigs,
+ setShowTagsConfigs,
+ currentTimeKey,
+ setCurrentTimeKey,
+ subAttrPresetShow,
+ setSubAttrPresetShow,
+ subAttributes,
+ setsubAttributes,
+ objectOperations,
+ imageFilter,
+ setImageFilter,
+ crosshairStatus,
+ setCrosshairStatus,
+ needBackup,
+ setNeedBackup,
+ checkSize,
+ setCheckSize,
+ magnetFlag,
+ setMagnetFlag,
+ } = useTopToolsStore()
+
+ const { pathGroupMap } = useRightToolsStore()
+ const isAutoSave = useIntervalStore((state) => state.isAutoSave)
+ const setIsAutoSave = useIntervalStore((state) => state.setIsAutoSave)
+ const autoSaveGap = useIntervalStore((state) => state.autoSaveGap)
+ const setAutoSaveGap = useIntervalStore((state) => state.setAutoSaveGap)
+
+ // 当前是否可以操作功能按键
+ const currentEditAuth = useMemo(() => {
+ if (!taskDetail) return false
+ if (!taskDetail.current_uid) return false
+ if (user_id !== taskDetail.current_uid) return false
+ const NotAllowEdit = [1, 3, 5, 7, 8]
+ return !NotAllowEdit.includes(taskDetail.label_status)
+ }, [taskDetail, user_id])
+
+ const getOperationSchema = useCallback(
+ (operationId: string) => {
+ return (
+ projectDetail?.label_schema_list?.find(
+ (item) => item.category_id === +operationId
+ ) || null
+ )
+ },
+ [projectDetail?.label_schema_list]
+ )
+
+ const labelCategories = useMemo(() => {
+ return (
+ objectOperations.map((item) => ({
+ id: item.category_id,
+ name: item.label_class,
+ supercategory: item.super_category,
+ })) || []
+ )
+ }, [objectOperations])
+
+ useEffect(() => {
+ if (
+ taskDetail?.label_status &&
+ TaskStatusEnum.get(taskDetail?.label_status)
+ ) {
+ switch (
+ taskDetail?.label_status &&
+ TaskStatusEnum.get(taskDetail?.label_status)
+ ) {
+ case "标注中":
+ setCurrentTimeKey("label")
+ return
+ case "审核中":
+ setCurrentTimeKey("review1")
+ return
+ case "复审中":
+ setCurrentTimeKey("review2")
+ return
+ default:
+ setCurrentTimeKey("label")
+ return
+ }
+ }
+ }, [setCurrentTimeKey, taskDetail?.label_status])
+
+ const calculateMinMaxDifference = (arr: [number, number][]) => {
+ let min1 = arr[0][0]
+ let min2 = arr[0][1]
+ let max1 = arr[0][0]
+ let max2 = arr[0][1]
+
+ for (let i = 1; i < arr.length; i++) {
+ min1 = Math.min(min1, arr[i][0])
+ min2 = Math.min(min2, arr[i][1])
+ max1 = Math.max(max1, arr[i][0])
+ max2 = Math.max(max2, arr[i][1])
+ }
+
+ return [min1, min2, max1 - min1, max2 - min2]
+ }
+
+ const transferPath = useCallback(
+ (
+ img_id: string,
+ category_id: string,
+ path: any[],
+ compoundPath: any[],
+ detail: any
+ ) => {
+ let operationSchema = getOperationSchema(category_id)
+ // 保存后类型为 [[string, number, number]] 保存前为 [SegmentPoint]
+ let flag = !Array.isArray(path?.[0]?.[0])
+ let compoundFlag = !Array.isArray(compoundPath?.[0]?.[0])
+
+ console.log("transferPath", path, flag, compoundFlag)
+
+ // 根据当前图片缩放比例还原各坐标点位置
+ const scale = usePaperStore.getState().rasterScale[img_id] ?? 1
+
+ switch (operationSchema && labelTypeMap.get(operationSchema.label_type)) {
+ case "多边形":
+ return {
+ segmentation: flag
+ ? path.map((child) => {
+ return child.map((item: { x: number; y: number }) => [
+ item.x / scale,
+ item.y / scale,
+ ])
+ })
+ : path.map((child) => {
+ return child.map((item: [number, number]) =>
+ item && item.length
+ ? [item[0] / scale, item[1] / scale]
+ : []
+ )
+ }),
+ hollow_segmentation: compoundFlag
+ ? compoundPath.map((child) => {
+ return child.map((item: { x: number; y: number }) => [
+ item.x / scale,
+ item.y / scale,
+ ])
+ })
+ : compoundPath.map((child) => {
+ return child.map((item: [number, number]) =>
+ item && item.length
+ ? [item[0] / scale, item[1] / scale]
+ : []
+ )
+ }),
+ }
+ case "多线段":
+ return {
+ line_segment: flag
+ ? path.map((child) => {
+ return child.map((item: paper.Segment) => [
+ item.point.x / scale,
+ item.point.y / scale,
+ ])
+ })
+ : path.map((child) => {
+ return child.map((item: [number, number]) => [
+ item[0] / scale,
+ item[1] / scale,
+ ])
+ }),
+ }
+ case "2D框":
+ // 2D框 不存在多区域情况
+ return {
+ rect: flag
+ ? path.map((child) => {
+ return calculateMinMaxDifference(
+ child.map((item: { x: number; y: number }) => [
+ item.x / scale,
+ item.y / scale,
+ ])
+ )
+ })?.[0] || []
+ : path.map((child) => {
+ return calculateMinMaxDifference(
+ child.map((item: [number, number]) => [
+ item[0] / scale,
+ item[1] / scale,
+ ])
+ )
+ })?.[0] || [],
+ }
+ case "关键点":
+ return {
+ key_points: flag
+ ? path.map((child) => {
+ return child.map(
+ (
+ item: { point: { x: number; y: number } },
+ index: number
+ ) => {
+ return {
+ point: [item.point.x / scale, item.point.y / scale],
+ attributes: detail.circles?.[index]?.attributes,
+ }
+ }
+ )
+ })?.[0] || []
+ : path.map((child) => {
+ return child.map((item: [number, number], index: number) => {
+ return {
+ point: [item[0] / scale, item[1] / scale],
+ attributes: detail.circles?.[index]?.attributes,
+ }
+ })
+ })?.[0] || [],
+ }
+ default:
+ return {
+ segmentation: [],
+ }
+ }
+ },
+ [getOperationSchema]
+ )
+
+ const handleSave = useCallback(async () => {
+ if (loadingData) {
+ return
+ }
+
+ if (!oldWorkLoad) {
+ notifications.hide("save")
+ notifications.show({
+ color: "yellow",
+ message: "统计数据生成失败,请联系管理员",
+ })
+ return
+ }
+
+ const group = usePaperStore.getState().group
+
+ if (!group) {
+ notifications.hide("save")
+ return
+ }
+
+ try {
+ // 标注相关数据统计
+ const labelObjectSize = {
+ object_size: 0,
+ valid_size: 0,
+ first_comment_size: 0,
+ second_comment_size: 0,
+ new_size: 0,
+ prelabel_size: 0,
+ prelabel_modify_size: 0,
+ dynamic_size: 0,
+ static_size: 0,
+ review_size: 0,
+ review_dynamic_size: 0,
+ review_static_size: 0,
+ key_frame_size: 0,
+ question_size: 0,
+ total_qa_group_size: 0,
+ single_qa_group_size: 0,
+ multiple_qa_group_size: 0,
+ new_qa_group_size: 0,
+ new_single_qa_group_size: 0,
+ new_multiple_qa_group_size: 0,
+ review_question_size: 0,
+ comment_question_size: 0,
+ }
+
+ const { label_status, data_name } = taskDetail!
+ const frameResults = data_name.map((item, imageId) => {
+ const name = item.name?.[0]
+ let finalData: LabelResult = {
+ data_name: name,
+ }
+ const imageLabelData = useLabelStore.getState().label.get(name)
+ const pathGroupData = pathGroupMap.get(name)
+
+ let image_objects: ImageObjects = {
+ annotations: [],
+ categories: labelCategories,
+ images: {
+ id: imageId + 1,
+ file_name: name,
+ width: usePaperStore.getState().rasterSize[name]?.[0] || 0,
+ height: usePaperStore.getState().rasterSize[name]?.[1] || 0,
+ },
+ image_groups:
+ (pathGroupData && Object.fromEntries(pathGroupData)) || {},
+ }
+ if (imageLabelData)
+ [...imageLabelData.entries()].forEach(([key, labelObjs]) => {
+ let annotations = labelObjs.map(
+ ([id, points, detail, compoundPoints]) => {
+ // 处理统计数据
+ labelObjectSize.object_size++
+ let has_first_review = false
+ let has_second_review = false
+ let reviewed = false
+ const { comment, prelabel, uid } = detail
+ comment.forEach((c: any) => {
+ if (c.comment_type > 0) reviewed = true
+ if (
+ c.turn === 1 &&
+ (c.check_box_comments.length > 0 ||
+ (c.text_comments[0] && c.text_comments[0] !== ""))
+ )
+ has_first_review = true
+ if (
+ c.turn === 2 &&
+ (c.check_box_comments.length > 0 ||
+ (c.text_comments[0] && c.text_comments[0] !== ""))
+ )
+ has_second_review = true
+ })
+ if (has_first_review) labelObjectSize.first_comment_size++
+ if (has_second_review) labelObjectSize.second_comment_size++
+ if (!prelabel || uid > 0) labelObjectSize.valid_size++
+ if (!prelabel) labelObjectSize.new_size++
+ if (prelabel) labelObjectSize.prelabel_size++
+ if (prelabel && uid > 0) labelObjectSize.prelabel_modify_size++
+ if (reviewed) labelObjectSize.review_size++
+
+ // 返回标注对象的annotations
+ return {
+ ...detail,
+ ...transferPath(name, key, points, compoundPoints, detail),
+ id,
+ category_id: Number(key),
+ image_id: imageId + 1,
+ }
+ }
+ )
+ image_objects.annotations = [
+ ...image_objects.annotations,
+ ...annotations,
+ ]
+ })
+ // 处理关键帧相关信息
+ const allKeyFrameData = useBottomToolsStore.getState().keyFrameData
+ const currentKeyFrameData = allKeyFrameData.get(name) || {
+ key_frame: false,
+ label1_ts: 0,
+ label1_uid: 0,
+ review1_ts: 0,
+ review1_uid: 0,
+ review2_ts: 0,
+ review2_uid: 0,
+ }
+ if (currentKeyFrameData.key_frame) labelObjectSize.key_frame_size++
+ image_objects = { ...image_objects, ...currentKeyFrameData }
+ // 基础LLM 文本描述和元操作序列
+ if (projectDetail?.label_type === 5) {
+ const metaData = useDescToolsStore.getState().metaData.get(name) || []
+ const descData =
+ useDescToolsStore.getState().descData.get(name) || new Map()
+ let other_desc: any = {}
+ descData
+ .entries()
+ .forEach(([label_class, { value, is_correct, comment }]) => {
+ let obj = {
+ value:
+ typeof value === "string" ? value.split(splitWord)[0] : value,
+ is_correct:
+ typeof is_correct === "boolean" ? is_correct : undefined,
+ comment:
+ comment &&
+ comment.split(splitWord)[1] &&
+ comment.split(splitWord)[1].trim()
+ ? comment.split(splitWord)[0]
+ : undefined,
+ }
+ other_desc[label_class] = obj
+ })
+ image_objects.desc = {
+ other_desc: JSON.stringify(other_desc),
+ meta_operation: metaData,
+ }
+ }
+ // 处理QA数据
+ if (projectDetail?.label_type === 6) {
+ const currentQaData = safeClone(
+ useDescToolsStore.getState().qaData.get(name) || {}
+ )
+ let other_desc: any = {}
+ // 更新统计数据
+ Object.entries(currentQaData).forEach(
+ ([label_class, questionArr]) => {
+ if (currentKeyFrameData.key_frame)
+ labelObjectSize.question_size += questionArr.length
+ let singleCount = 0
+ let multiCount = 0
+ let newSingleCount = 0
+ let newMultiCount = 0
+ const roundMap = new Map<
+ number,
+ { hasNewQuestion: boolean; count: number }
+ >()
+ let arr: any[] = []
+ questionArr.forEach((q) => {
+ const [name, v] = Object.entries(q)[0]
+ if (!roundMap.has(v.id)) {
+ roundMap.set(v.id, { hasNewQuestion: !v.is_pre, count: 1 })
+ } else {
+ const exist = roundMap.get(v.id)
+ if (!exist?.hasNewQuestion && !v.is_pre)
+ exist!.hasNewQuestion = true
+ exist!.count++
+ }
+ arr.push({
+ [name]: {
+ ...v,
+ value: v.value ? v.value.split(splitWord)[0] : "",
+ comment:
+ v.comment &&
+ v.comment.split(splitWord)[1] &&
+ v.comment.split(splitWord)[1].trim()
+ ? v.comment.split(splitWord)[0]
+ : undefined,
+ },
+ })
+ })
+ other_desc[label_class] = arr
+ arr.forEach((obj: any) => {
+ const v: any = Object.values(obj)[0]
+ if (v.tag && currentKeyFrameData.key_frame) {
+ labelObjectSize.review_question_size++
+ }
+ })
+ roundMap.values().forEach((round) => {
+ if (round.count > 1) {
+ multiCount++
+ if (round.hasNewQuestion) newMultiCount++
+ } else {
+ singleCount++
+ if (round.hasNewQuestion) newSingleCount++
+ }
+ })
+ if (currentKeyFrameData.key_frame) {
+ labelObjectSize.total_qa_group_size += singleCount + multiCount
+ labelObjectSize.single_qa_group_size += singleCount
+ labelObjectSize.multiple_qa_group_size += multiCount
+ labelObjectSize.new_qa_group_size +=
+ newSingleCount + newMultiCount
+ labelObjectSize.new_single_qa_group_size += newSingleCount
+ labelObjectSize.new_multiple_qa_group_size += newMultiCount
+ }
+ }
+ )
+ image_objects.desc = {
+ other_desc: JSON.stringify(other_desc),
+ meta_operation: [],
+ }
+ }
+ finalData.image_objects = image_objects
+ return finalData
+ })
+
+ let saveResults: LabelResult[] = frameResults
+ if (projectDetail?.label_type === 7) {
+ const frameNamesFromTask = data_name
+ .map((item) => item.name?.[0] || "")
+ .filter((name) => !!name)
+ const frameNameSet = new Set(frameNamesFromTask)
+ const frameStore = useVideoFrameStore.getState().videoFrames
+ const taskVideoPrefix = `${taskDetail!.project_id}:${taskDetail!.id}:`
+ const videoFrameEntries = Array.from(frameStore.entries()).filter(
+ ([key]) => key.startsWith(taskVideoPrefix)
+ )
+ if (!videoFrameEntries.length) {
+ throw new Error("视频帧映射缺失,无法保存,请刷新任务后重试")
+ }
+
+ const videoFrameMap = new Map()
+ videoFrameEntries.forEach(([key, frameNames]) => {
+ const videoName = key.slice(taskVideoPrefix.length)
+ if (!videoName) return
+ videoFrameMap.set(
+ videoName,
+ (frameNames || []).filter((frameName) =>
+ frameNameSet.has(frameName)
+ )
+ )
+ })
+ if (!videoFrameMap.size) {
+ throw new Error("视频帧映射无效,无法保存,请刷新任务后重试")
+ }
+
+ const videoNames = Array.from(videoFrameMap.keys())
+ const frameToVideoMap = new Map()
+ videoFrameMap.forEach((frameNames, videoName) => {
+ frameNames.forEach((frameName) => {
+ frameToVideoMap.set(frameName, videoName)
+ })
+ })
+
+ const unresolvedFrames = frameNamesFromTask.filter(
+ (frameName) => !frameToVideoMap.has(frameName)
+ )
+ if (unresolvedFrames.length) {
+ throw new Error(
+ `视频帧映射缺失,无法保存,请刷新任务后重试: ${unresolvedFrames.slice(0, 5).join(", ")}`
+ )
+ }
+
+ const frameResultMap = new Map()
+ frameResults.forEach((item) => {
+ if (item.image_objects) {
+ frameResultMap.set(item.data_name, item.image_objects)
+ }
+ })
+
+ const groupedVideoObjects = new Map<
+ string,
+ { [key: string]: ImageObjects }
+ >()
+ frameResultMap.forEach((imageObjects, frameName) => {
+ const videoName = frameToVideoMap.get(frameName)
+ if (!videoName) return
+ if (!groupedVideoObjects.has(videoName)) {
+ groupedVideoObjects.set(videoName, {})
+ }
+ groupedVideoObjects.get(videoName)![frameName] = imageObjects
+ })
+
+ saveResults = videoNames
+ .map((videoName) => {
+ const videoObjects = groupedVideoObjects.get(videoName)
+ if (!videoObjects || !Object.keys(videoObjects).length) return null
+ return {
+ data_name: videoName,
+ video_objects: videoObjects,
+ } as LabelResult
+ })
+ .filter((item): item is LabelResult => !!item)
+
+ if (!saveResults.length) {
+ throw new Error("视频任务保存结果为空")
+ }
+ }
+
+ const currentLabelTime = useLabelTimeStore.getState().labelTime
+ const workTime = {
+ label_work_time: currentLabelTime.label,
+ first_review_work_time: currentLabelTime.review1,
+ second_review_work_time: currentLabelTime.review2,
+ }
+ // 生成新workload
+ let time = 0
+ if (label_status === 2) {
+ time = workTime.label_work_time
+ } else if (label_status === 4) {
+ time = workTime.first_review_work_time
+ } else if (label_status === 6) {
+ time = workTime.second_review_work_time
+ }
+ const workLoad: WorkLoad = JSON.parse(JSON.stringify(initialWorkLoad))
+ workLoad.work_time = time ? time - oldWorkLoad.work_time : 0
+ frameResults.forEach((item) => {
+ const { image_objects } = item
+ if (image_objects) {
+ const { annotations, key_frame, desc } = image_objects
+ annotations.forEach((annotation) => {
+ let reviewed = false
+ const { comment, prelabel, uid } = annotation
+ if (prelabel)
+ workLoad.prelabel_modify_size[uid]
+ ? workLoad.prelabel_modify_size[uid]++
+ : (workLoad.prelabel_modify_size[uid] = 1)
+ else
+ workLoad.object_size[uid]
+ ? workLoad.object_size[uid]++
+ : (workLoad.object_size[uid] = 1)
+ if (comment) {
+ comment.forEach((c) => {
+ if (c.comment_type > 0 && c.uid === user_id) reviewed = true
+ if (c.comment_type === 2 && c.uid === user_id)
+ workLoad.comment_size++
+ })
+ }
+ if (reviewed) workLoad.review_size++
+ })
+ if (key_frame) {
+ let id = usePermissionStore.getState().user_id
+ if (label_status === 2) {
+ workLoad.key_frame_size[id]
+ ? workLoad.key_frame_size[id]++
+ : (workLoad.key_frame_size[id] = 1)
+ } else if (label_status === 4) {
+ workLoad.review_key_frame_size[id]
+ ? workLoad.review_key_frame_size[id]++
+ : (workLoad.review_key_frame_size[id] = 1)
+ } else if (label_status === 6) {
+ workLoad.review_key_frame_size[id]
+ ? workLoad.review_key_frame_size[id]++
+ : (workLoad.review_key_frame_size[id] = 1)
+ }
+ }
+ if (projectDetail?.label_type === 6) {
+ if (desc) {
+ const { other_desc } = desc
+ const descData = other_desc ? JSON.parse(other_desc) : {}
+ Object.values(descData).forEach((questionArr: any) => {
+ questionArr.forEach((q: any) => {
+ const v: any = Object.values(q)[0]
+ if (v.uid) {
+ workLoad.question_size[v.uid]
+ ? workLoad.question_size[v.uid]++
+ : (workLoad.question_size[v.uid] = 1)
+ }
+ if (v.tag) {
+ const uid = usePermissionStore.getState().user_id
+ workLoad.review_question_size[uid]
+ ? workLoad.review_question_size[uid]++
+ : (workLoad.review_question_size[uid] = 1)
+ }
+ })
+ })
+ }
+ }
+ }
+ })
+ const newWorkLoad: WorkLoad = JSON.parse(JSON.stringify(initialWorkLoad))
+ Object.entries(workLoad).forEach(([key, value]) => {
+ if (key === "work_time") {
+ newWorkLoad.work_time = value
+ return
+ }
+ if (typeof value === "number") {
+ ;(newWorkLoad as any)[key] = value - (oldWorkLoad as any)[key]
+ } else {
+ Object.keys(value).forEach((k) => {
+ if ((oldWorkLoad as any)[key][k]) {
+ ;(newWorkLoad as any)[key][k] =
+ value[k] - (oldWorkLoad as any)[key][k]
+ } else {
+ ;(newWorkLoad as any)[key][k] = value[k]
+ }
+ })
+ }
+ })
+ const defaultObj = {
+ label_object_size: labelObjectSize,
+ work_time: workTime,
+ work_load: newWorkLoad,
+ }
+ let params: RequestLabelResult = {
+ uid: user_id,
+ task_id: taskDetail?.id || 0,
+ results: saveResults,
+ ...defaultObj,
+ }
+ console.log(params, workLoad)
+ await saveLabelResult(params)
+ notifications.show({
+ id: "save",
+ color: "green",
+ message: "保存成功",
+ })
+ updateOldWorkLoad(workLoad)
+ } catch (error: any) {
+ console.log(error)
+ notifications.hide("save")
+ notifications.show({
+ color: "red",
+ message: error?.message || "保存失败",
+ })
+ }
+ }, [
+ labelCategories,
+ oldWorkLoad,
+ pathGroupMap,
+ projectDetail,
+ taskDetail,
+ transferPath,
+ updateOldWorkLoad,
+ user_id,
+ loadingData,
+ ])
+
+ const handleBackup = useCallback(
+ async (name: string) => {
+ let newTaskLabelData = new Map()
+ if (!taskDetail) {
+ return
+ }
+ let textData = new Map()
+ let metaData = new Map()
+ taskDetail.data_name.forEach((item) => {
+ const name = item.name?.[0] ?? ""
+ if (labelData.get(name)) newTaskLabelData.set(name, labelData.get(name))
+ if (projectDetail?.label_type === 5) {
+ const desc = useDescToolsStore.getState().descData.get(name)
+ if (desc) {
+ textData.set(name, desc)
+ }
+ const meta = useDescToolsStore.getState().metaData.get(name)
+ if (meta) metaData.set(name, meta)
+ }
+ if (projectDetail?.label_type === 6) {
+ const qaDesc = useDescToolsStore.getState().qaData.get(name)
+ if (qaDesc) textData.set(name, qaDesc)
+ }
+ })
+ // 调整备份数据结果的点位比例
+ const adjustedData = adjustAllPoints(
+ newTaskLabelData,
+ usePaperStore.getState().reciprocalRasterScale
+ )
+ let taskLabelData = await storage.getItem(taskDetail.id.toString())
+
+ if (taskLabelData) {
+ try {
+ const keys = Object.keys(taskLabelData.state)
+ keys.forEach((item) => {
+ if (item === name && name !== "auto_backup") throw new Error("")
+ })
+ } catch (error) {
+ console.log(error)
+ setDuplicateName(name)
+ setDuplicateConfirmOpen(true)
+ return
+ }
+ let newState = {
+ ...taskLabelData?.state,
+ [name]: adjustedData,
+ [`${name}-text`]: { text: textData, meta: metaData },
+ }
+ storage.setItem(taskDetail.id.toString(), {
+ state: newState,
+ })
+ } else {
+ storage.setItem(taskDetail.id.toString(), {
+ state: {
+ [name]: adjustedData,
+ [`${name}-text`]: { text: textData, meta: metaData },
+ },
+ })
+ }
+ if (name !== "auto_backup")
+ notifications.show({
+ /** Position of the notification, if not set, the position is determined based on `position` prop on Notifications component */
+ position: "top-center",
+ /** Notification message, required for all notifications */
+ message: "备份成功",
+ })
+ setBackupOpen(false)
+ },
+ [labelData, projectDetail?.label_type, taskDetail]
+ )
+
+ const handleRecover = useCallback(
+ async (name: string) => {
+ try {
+ if (!renderPolygons) return
+ let taskLabelData = await storage.getItem(taskDetail!.id.toString())
+ if (taskLabelData) {
+ const finalData = adjustAllPoints(
+ taskLabelData.state[name],
+ usePaperStore.getState().rasterScale
+ )
+ for (const entry of finalData.entries()) {
+ // 赋值用过的id
+ for (const category of entry[1].values()) {
+ category.forEach((item) => {
+ useRightToolsStore.getState().pushPathId(item[0])
+ })
+ }
+ // 绘制数据时先清空当前页所有图形后再进行绘制
+ usePaperStore
+ .getState()
+ .group!.getItems({})
+ .filter((item: any) => item.data.id)
+ .forEach((item: any) => {
+ item.remove()
+ })
+ // 只渲染当前页数据
+ if (entry[0] === activeImage) {
+ const map = entry[1]
+ for (const key of map.keys()) {
+ renderPolygons(key, map)
+ }
+ }
+ }
+ setLabel(finalData)
+ pushStateStack(finalData)
+ // 大语言
+ if ([5, 6].includes(projectDetail?.label_type || 0)) {
+ const data = taskLabelData.state[`${name}-text`]
+ if (projectDetail?.label_type === 5) {
+ const { text, meta } = data
+ useDescToolsStore.getState().setDescData(text)
+ useDescToolsStore.getState().setMetaData(meta)
+ } else if (projectDetail?.label_type === 6) {
+ const { text } = data
+ const updateQaData = safeClone(
+ useDescToolsStore.getState().qaData
+ )
+ text.entries().forEach(([key, value]: any) => {
+ updateQaData.set(key, value)
+ })
+ useDescToolsStore.getState().setQaData(updateQaData)
+ }
+ useDescToolsStore.getState().updateFlag(true)
+ }
+ }
+ notifications.show({
+ color: "green",
+ message: "应用成功",
+ })
+ setRecoverOpen(false)
+ } catch (error) {
+ console.log(error)
+ notifications.show({
+ color: "red",
+ message: "恢复备份失败",
+ })
+ }
+ },
+ [
+ activeImage,
+ projectDetail?.label_type,
+ pushStateStack,
+ renderPolygons,
+ setLabel,
+ taskDetail,
+ ]
+ )
+
+ useEffect(() => {
+ if (needBackup) {
+ handleBackup("auto_backup")
+ setNeedBackup(false)
+ }
+ }, [handleBackup, needBackup, setNeedBackup])
+
+ // 任务跳转
+ const getNextTaskData = async (needSave = false) => {
+ if (loadingData) {
+ notifications.show({
+ color: "yellow",
+ message: "视频帧仍在同步,请稍后再切换任务",
+ })
+ return
+ }
+ if (needSave) {
+ notifications.show({
+ id: "save",
+ loading: true,
+ message: "标注结果自动保存中...",
+ autoClose: false,
+ })
+ try {
+ await handleSave()
+ } catch (error) {
+ console.log(error)
+ return
+ }
+ }
+ const params = {
+ project_id: taskDetail?.project_id || 0,
+ task_status: taskDetail?.label_status || 0,
+ rejected: taskDetail?.rejected || false,
+ last_task_id: taskDetail?.id || 0,
+ }
+ const res = await getNextTaskId(params)
+ if (res.task_id) {
+ router.push(`/label?project_id=${res.project_id}&task_id=${res.task_id}`)
+ // 重置图片比例及选中标注对象
+ usePaperStore.getState().resetRasterScale()
+ useObjectStore.getState().resetPathAndOperationStatus()
+ } else {
+ // 无id返回时跳转回任务列表页
+ const url = backUrl || "/"
+ // router.push(url)
+ window.location.href = url
+ // 重置图片比例及选中标注对象
+ usePaperStore.getState().resetRasterScale()
+ useObjectStore.getState().resetPathAndOperationStatus()
+ }
+ }
+
+ const commitTaskByActionKey = async (key?: number) => {
+ if (loadingData) {
+ notifications.show({
+ color: "yellow",
+ message: "视频帧仍在同步,请稍后再提交",
+ })
+ return
+ }
+ if (!key) {
+ // 提交时对数据进行校验
+ let imgBool = taskDetail!.data_name.some((item) => {
+ let operationBool = objectOperations.some((operation) => {
+ let bool = labelData
+ .get(item.name?.[0])
+ ?.get(operation.category_id.toString())
+ ?.some((child) => {
+ return getSubAttrStatus(
+ getOperationSchema(operation.category_id.toString()),
+ child[2]?.sub_attributes
+ )
+ })
+ return bool
+ })
+ return operationBool
+ })
+ if (imgBool) {
+ notifications.show({
+ color: "yellow",
+ message: "操作失败,存在未设置子属性的对象",
+ })
+ return
+ }
+ // 校验
+ try {
+ const taskNames = taskDetail!.data_name.map(({ name }) => name[0])
+ if (projectDetail && [5, 6].includes(projectDetail.label_type)) {
+ taskNames.forEach((name) => {
+ // const imageLabelData = labelData.get(name);
+ const currentKeyFrameData = useBottomToolsStore
+ .getState()
+ .keyFrameData.get(name) || {
+ key_frame: false,
+ }
+ // console.log(imageLabelData?.values());
+ // let hasLabelObj = false;
+ // imageLabelData?.values().forEach((value) => {
+ // if (value.length) hasLabelObj = true;
+ // });
+ if (currentKeyFrameData.key_frame) {
+ // if (!hasLabelObj) throw new Error("1");
+ if (projectDetail.label_type === 5) {
+ // 元操作序列 校验
+ const metaData =
+ useDescToolsStore.getState().metaData.get(name) || []
+ if (!metaData.length) throw new Error("3")
+ const descData =
+ useDescToolsStore.getState().descData.get(name) || new Map()
+ const operations = useDescToolsStore
+ .getState()
+ .descOperations.map((item) => ({
+ operationName: item.label_class,
+ attrs: item.sub_attributes_describe,
+ }))
+ operations.forEach(({ operationName, attrs }) => {
+ if (!descData.get(operationName)) throw new Error("2")
+ const { value, is_correct } = descData.get(operationName)
+ if (!value) throw new Error("2")
+ if (!attrs.length) {
+ // 无子属性类型
+ const [htmlText, text] = value.split(splitWord)
+ if (!text || !text.trim()) throw new Error("2")
+ if (
+ htmlText.includes("") ||
+ htmlText.includes("") ||
+ htmlText.includes(`"color:`)
+ ) {
+ throw new Error("5")
+ }
+ // 审核时判断问题是否正确
+ if (
+ [4, 6].includes(taskDetail?.label_status || 0) &&
+ !is_correct
+ ) {
+ throw new Error("9")
+ }
+ } else {
+ // 有子属性类型
+ attrs.forEach((attr) => {
+ if (attr.isrequired) {
+ if (!value[attr.chinese_name]) throw new Error("4")
+ }
+ })
+ }
+ })
+ }
+ if (projectDetail.label_type === 6) {
+ const checkData =
+ useDescToolsStore.getState().qaData.get(name) || {}
+
+ if (!Object.values(checkData).length) throw new Error("2")
+ Object.values(checkData).forEach((questionArr) => {
+ questionArr.forEach((q) => {
+ const v = Object.values(q)[0]
+ if (!v.value) throw new Error("2")
+ const [htmlText, text] = v.value.split(splitWord)
+ if (!text || !text.trim()) throw new Error("2")
+ if (
+ htmlText.includes("") ||
+ htmlText.includes("") ||
+ htmlText.includes(`"color:`)
+ ) {
+ throw new Error("5")
+ }
+ // 标注时判断是否问题已被修改
+ if (
+ taskDetail?.label_status === 2 &&
+ v.tag === 2 &&
+ v.flag !== 1
+ ) {
+ throw new Error("10")
+ }
+ // 审核时判断问题是否正确
+ if (
+ [4, 6].includes(taskDetail?.label_status || 0) &&
+ v.tag !== 1
+ ) {
+ throw new Error("9")
+ }
+ })
+ })
+ const counts = useDescToolsStore
+ .getState()
+ .countAllTurnsAndQaNumber(name)
+ if (counts.new_single_turns + counts.new_multiple_turns < 40)
+ throw new Error("6")
+ if (counts.new_multiple_turns < 25) throw new Error("7")
+ }
+ } else {
+ // if (hasLabelObj) throw new Error("8");
+ }
+ })
+ }
+ } catch (error: any) {
+ if (error.message === "1") {
+ notifications.show({
+ color: "yellow",
+ message: "当前任务部分关键帧内无关键目标,请修改后再提交",
+ })
+ } else if (error.message === "2") {
+ notifications.show({
+ color: "yellow",
+ message: "当前任务部分关键帧中的文本描述不全,请修改后再提交",
+ })
+ } else if (error.message === "3") {
+ notifications.show({
+ color: "yellow",
+ message: "当前任务部分关键帧中的元操作序列为空,请修改后再提交",
+ })
+ } else if (error.message === "4") {
+ notifications.show({
+ color: "yellow",
+ message:
+ "当前任务部分关键帧中可选择的描述中,有必填项为空的情况,请修改后再提交",
+ })
+ } else if (error.message === "5") {
+ notifications.show({
+ color: "yellow",
+ message: "当前任务部分关键帧内批注内容未修改,请修改后再提交",
+ })
+ } else if (error.message === "6") {
+ notifications.show({
+ color: "yellow",
+ message: "当前任务部分关键帧内的新增问答组数小于40,请修改后再提交",
+ })
+ } else if (error.message === "7") {
+ notifications.show({
+ color: "yellow",
+ message:
+ "当前任务部分关键帧内的新增多轮问答组数小于25,请修改后再提交",
+ })
+ } else if (error.message === "8") {
+ notifications.show({
+ color: "yellow",
+ message: "当前任务部分非关键帧中有关键目标,请修改后再提交",
+ })
+ } else if (error.message === "9") {
+ notifications.show({
+ color: "yellow",
+ message: "当前任务部分关键帧中有问题未审核正确,请修改后再提交",
+ })
+ } else if (error.message === "10") {
+ notifications.show({
+ color: "yellow",
+ message:
+ "当前任务部分关键帧中有批注的问题未确认修改,请修改后再提交",
+ })
+ }
+ return
+ }
+ }
+
+ try {
+ await handleSave()
+ } catch (error) {
+ console.log(error)
+ return
+ }
+ const currentLabelTime = useLabelTimeStore.getState().labelTime
+ const handleAction = (current_status: number) => {
+ switch (current_status) {
+ case 2:
+ return {
+ commit_action: key || 1,
+ work_time: currentLabelTime.label,
+ }
+ case 4:
+ return {
+ commit_action: key || 2,
+ work_time: currentLabelTime.review1,
+ }
+ case 6:
+ return {
+ commit_action: key || 3,
+ work_time: currentLabelTime.review2,
+ }
+ default:
+ return {
+ commit_action: 0,
+ work_time: 0,
+ }
+ }
+ }
+
+ let size: null | number = null
+ if (key === 4 && projectDetail?.label_type === 6) {
+ // 问答式大语言模型项目 驳回操作
+ size = 0
+ taskDetail?.data_name.forEach((nameObj) => {
+ const name = nameObj.name[0]
+ const currentQaData =
+ useDescToolsStore.getState().qaData.get(name) ?? {}
+ const currentKeyFrameData = useBottomToolsStore
+ .getState()
+ .keyFrameData.get(name) || {
+ key_frame: false,
+ }
+ if (currentKeyFrameData) {
+ Object.entries(currentQaData).forEach(([_key, questionArr]) => {
+ questionArr.forEach((question) => {
+ const v = Object.values(question)[0]
+ if (
+ v.comment &&
+ v.comment.split(splitWord)[1] &&
+ v.comment.split(splitWord)[1].trim()
+ ) {
+ size!++
+ }
+ })
+ })
+ }
+ })
+ }
+ const params =
+ typeof size === "number"
+ ? {
+ task_id: taskDetail?.id || 0,
+ uid: user_id,
+ ...handleAction(taskDetail?.label_status || 0),
+ comment_question_size: size,
+ }
+ : {
+ task_id: taskDetail?.id || 0,
+ uid: user_id,
+ ...handleAction(taskDetail?.label_status || 0),
+ }
+ await taskCommit(params)
+ notifications.show({
+ color: "green",
+ message: "操作成功",
+ })
+ // 提交成功后跳转下一任务
+ getNextTaskData()
+ }
+
+ useEffect(() => {
+ if (objectOperations.length) {
+ setActiveOperation(objectOperations[0].category_id.toString())
+ setPressA(false)
+ // open object list
+ // setShowObjectList(true);
+ }
+ }, [objectOperations, setActiveOperation, setPressA, setShowObjectList])
+
+ useEffect(() => {
+ let timer = setInterval(() => {
+ if (isView) return
+ setLabelTime((prev) => ({
+ ...prev,
+ [currentTimeKey]: prev[currentTimeKey] + 1,
+ }))
+ }, 1000)
+ return () => {
+ clearInterval(timer)
+ }
+ }, [currentTimeKey, isView, setLabelTime])
+
+ const form = useForm({
+ initialValues: {
+ sub_attributes: {} as any,
+ },
+ validate: (values) => {
+ const errors: Record = {}
+ const schema = getOperationSchema(activeOperation)
+ if (!schema) return errors
+ schema.sub_attributes_describe.forEach((item) => {
+ if (item.isrequired) {
+ const val = values.sub_attributes?.[item.chinese_name]
+ if (
+ !val ||
+ (Array.isArray(val) && val.length === 0) ||
+ (typeof val === "string" && !val.trim())
+ ) {
+ let errorKey = `sub_attributes.${item.chinese_name}`
+ errors[errorKey] = `请输入${item.chinese_name}`
+ }
+ }
+ })
+ return errors
+ },
+ })
+
+ useEffect(() => {
+ let operationSchema = getOperationSchema(activeOperation)
+ if (operationSchema?.label_class && subAttrPresetShow) {
+ form.setFieldValue(
+ "sub_attributes",
+ subAttributes[operationSchema?.label_class] || {}
+ )
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeOperation, getOperationSchema, subAttributes, subAttrPresetShow])
+
+ useEffect(() => {
+ usePaperStore.getState().polygonTool?.emit("keydown", { key: "escape" })
+ usePaperStore.getState().rectangleTool?.emit("keydown", { key: "escape" })
+ usePaperStore.getState().brushTool?.emit("keydown", { key: "escape" })
+ usePaperStore.getState().pointTool?.emit("keydown", { key: "escape" })
+ }, [activeOperation, mode])
+
+ const subAttrForm = useMemo(() => {
+ let operationSchema = getOperationSchema(activeOperation)
+
+ return (
+
+
+
+
+ {`${getOperationSchema(activeOperation)?.label_class}`}
+
+
+ 子属性
+
+
+
+
+
+
+
+
+
+ {operationSchema?.sub_attributes_describe.map((item) => {
+ if (item.select_mode === 0) {
+ return (
+
+ )
+ } else if (item.select_mode === 1) {
+ return (
+
+
+ {item.optional_item.map((option) => (
+
+ ))}
+
+
+ )
+ } else if (item.select_mode === 2) {
+ return (
+
+
+ {item.optional_item.map((option) => (
+
+ ))}
+
+
+ )
+ } else {
+ return null
+ }
+ })}
+
+
+
+ )
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeOperation, getOperationSchema, setsubAttributes])
+
+ useImperativeHandle(ref, () => ({
+ handleSave,
+ handleBackup,
+ }))
+
+ const renderEditIcon = useMemo(() => {
+ const iconStyle = { width: 20, height: 20 }
+ if (mode === "support") return
+ if (editMode) {
+ return pressA ? (
+
+ ) : (
+
+ )
+ } else {
+ return
+ }
+ }, [mode, editMode, pressA])
+
+ const showTagConfigContainer = useMemo(() => {
+ const configs = ["ID", "类别", "外框", "点ID", "子属性", "属性值"]
+ return (
+
+ {configs.map((config) => (
+ {
+ let arr = safeClone(showTagsConfigs)
+ const index = arr.findIndex((item) => item === config)
+ if (index !== -1) {
+ arr.splice(index, 1)
+ } else {
+ arr.push(config)
+ }
+ setShowTagsConfigs(arr)
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor =
+ "var(--mantine-color-gray-1)"
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = "transparent"
+ }}>
+ {config}
+
+ ))}
+
+ )
+ }, [setShowTagsConfigs, showTagsConfigs])
+
+ const showSaveConfigContainer = () => {
+ return (
+
+
+ 自动保存
+ setIsAutoSave(event.currentTarget.checked)}
+ />
+
+
+ 自动保存时间间隔
+ setAutoSaveGap(Number(value))}
+ allowDeselect={false}
+ />
+ 分钟
+
+
+ )
+ }
+
+ return (
+
+
+ {
+ console.log(useLabelStore.getState().label)
+ console.log(useRightToolsStore.getState().pathGroupMap)
+ console.log(usePaperStore.getState().group)
+ }}
+ display="none">
+ test
+
+
+ {
+ if (isView) {
+ const url = backUrl || "/"
+ // router.push(url)
+ window.location.href = url
+ // handleBackup("auto_backup");
+ // 重置图片比例及选中标注对象
+ usePaperStore.getState().resetRasterScale()
+ useObjectStore.getState().resetPathAndOperationStatus()
+ } else {
+ setConfirmOpen(true)
+ }
+ }}>
+
+
+ {/* {
+ let url = `${window.location.origin}/component/label/picture/standalone`
+ window.open(url, "_blank")
+ }}>
+
+ */}
+
+
+
+
+ {taskDetail?.label_status && (
+
+ )}
+
+ |
+
+
+
+ {`${projectDetail?.name} - ${taskDetail?.id || 0}`}
+
+
+
+
+
+
+
+
+
+
+ {`${
+ taskDetail?.label_status &&
+ TaskStatusEnum.get(taskDetail?.label_status)
+ }|${taskDetail?.project_name}-${taskDetail?.id}`}
+
+ 图片名称:{activeImage}
+ 标注员:{taskDetail?.label_username}
+
+ 审核员(1):{taskDetail?.review1_username}
+
+
+ 审核员(2):{taskDetail?.review2_username}
+
+ 已确认:
+ 未确认:
+ 正常创建:
+
+
+
+
+
+
+ {activeImage}
+
+
+
+
+ {
+ if (currentEditAuth) setIsView(!isView)
+ }}>
+
+
+ {isLabelTask && (
+ {
+ setIsView(!isView)
+ }}>
+
+
+ )}
+
+
+
+
+
+
+ setTaskOpen(true)}>
+
+
+
+ {isLabelTask && (
+
+
+
+
+ )}
+
+ {
+ if (!isView) commitTaskByActionKey()
+ }}>
+ 提交
+
+ {
+ if (!isView) getNextTaskData(true)
+ }}>
+ 跳过
+
+ {taskDetail?.label_status &&
+ (TaskStatusEnum.get(taskDetail?.label_status) === "审核中" ||
+ TaskStatusEnum.get(taskDetail?.label_status) === "复审中") && (
+ {
+ if (!isView) commitTaskByActionKey(4)
+ }}>
+ 驳回
+
+ )}
+ {
+ if (!isView) commitTaskByActionKey(5)
+ }}>
+ 无法标注
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {imageFilter.brightness}
+ {
+ setImageFilter("brightness", value)
+ }}
+ value={imageFilter.brightness}
+ min={-100}
+ marks={[{ value: 0, label: "0" }]}
+ max={100}
+ />
+ 亮度
+
+
+ {imageFilter.saturate}
+ {
+ setImageFilter("saturate", value)
+ }}
+ value={imageFilter.saturate}
+ min={-100}
+ marks={[{ value: 0, label: "0" }]}
+ max={100}
+ />
+ 饱和度
+
+
+ {imageFilter.contrast}
+ {
+ setImageFilter("contrast", value)
+ }}
+ value={imageFilter.contrast}
+ min={-100}
+ marks={[{ value: 0, label: "0" }]}
+ max={100}
+ />
+ 对比度
+
+
+
+
+
+
+ {crosshairStatus === "hidden" && (
+ {
+ setCrosshairStatus("line")
+ }}
+ />
+ )}
+ {crosshairStatus === "line" && (
+ {
+ setCrosshairStatus("carve")
+ }}
+ />
+ )}
+ {crosshairStatus === "carve" && (
+ {
+ setCrosshairStatus("hidden")
+ }}
+ />
+ )}
+
+
+
+ setShowTags(!showTags)}>
+
+
+
+
+
+
+
+
+ {showTagConfigContainer}
+
+
+
+
+ {
+ setDrawOption("default")
+ }}>
+
+
+ {
+ setDrawOption("intersect")
+ }}>
+
+
+ {
+ setDrawOption("unite")
+ }}>
+
+
+
+
+ {
+ setMagnetFlag(!magnetFlag)
+ }}>
+
+
+
+ {
+ setSaveCurrentScale(event.currentTarget.checked)
+ }}
+ />
+
+ 缓存缩放比例
+
+
+
+
+ {
+ if (v) setCheckSize(Number(v))
+ }}
+ onFocus={() => {
+ useKeyEventStore.getState().setFocusInput(true)
+ }}
+ onBlur={() => {
+ useKeyEventStore.getState().setFocusInput(false)
+ }}
+ />
+ {renderEditIcon}
+
+
+
+ {
+ setSubAttrPresetShow(!subAttrPresetShow)
+ }}>
+ {renderOperationIcon(getOperationSchema(activeOperation))}
+
+ {getOperationSchema(activeOperation)?.label_class || ""}
+
+
+
+ {subAttrForm}
+
+
+
+
+
+
+
+
+ {objectOperations.map((item) => (
+ {
+ setActiveOperation(item.category_id.toString())
+ }}>
+ {renderOperationIcon(
+ getOperationSchema(item.category_id.toString())
+ )}
+
+ {item.label_class || ""}
+
+
+ ))}
+
+
+
+
+ }>
+ 自动标注
+
+
+
+ {!isView ? (
+ <>
+
+ {
+ if (!loadingData) handleSave()
+ }}>
+
+ 保存
+
+
+
+
+
+
+
+
+
+ {showSaveConfigContainer()}
+
+
+
+
+ {
+ setBackupOpen(true)
+ }}>
+
+ 备份
+
+ {
+ setRecoverOpen(true)
+ }}>
+
+ 恢复
+
+ >
+ ) : (
+ <>
+
+
+ 保存
+
+
+
+ 备份
+
+
+
+ 恢复
+
+ >
+ )}
+
+
+
+ {
+ setShowTaskList(!showTaskList)
+ }}>
+
+ 任务
+
+ {
+ setShowMultiFrame(!showMultiFrame)
+ }}>
+
+ 多帧
+
+ {
+ setShowGroupList(!showGroupList)
+ }}>
+
+ 群组
+
+ {projectDetail && [5, 6].includes(projectDetail.label_type) ? (
+ {
+ setShowDescList(!showDescList)
+ }}>
+
+ 描述
+
+ ) : null}
+ {
+ setShowObjectList(!showObjectList)
+ }}>
+
+ 对象
+
+
+
+
+ {confirmOpen && (
+ {
+ try {
+ await handleSave()
+ } catch (error) {
+ console.log(error)
+ return
+ }
+ setConfirmOpen(false)
+ const url = backUrl || "/"
+ // router.push(url)
+ window.location.href = url
+ // 重置图片比例及选中标注对象
+ usePaperStore.getState().resetRasterScale()
+ useObjectStore.getState().resetPathAndOperationStatus()
+ }}
+ handleCancel={async () => {
+ try {
+ setConfirmOpen(false)
+ handleBackup("auto_backup")
+ const url = backUrl || "/"
+ // router.push(url)
+ window.location.href = url
+ // 重置图片比例及选中标注对象
+ usePaperStore.getState().resetRasterScale()
+ useObjectStore.getState().resetPathAndOperationStatus()
+ } catch (error) {
+ console.log(error)
+ }
+ }}
+ onClose={() => {
+ setConfirmOpen(false)
+ }}
+ />
+ )}
+ {backupOpen && (
+ {
+ setBackupOpen(false)
+ }}
+ handleOk={handleBackup}
+ />
+ )}
+ {duplicateConfirmOpen && (
+ {
+ try {
+ let newTaskLabelData = new Map()
+ let textData = new Map()
+ let metaData = new Map()
+ taskDetail!.data_name.forEach((item) => {
+ const name = item.name?.[0] ?? ""
+ if (labelData.get(name))
+ newTaskLabelData.set(name, labelData.get(name))
+ if (projectDetail?.label_type === 5) {
+ const desc = useDescToolsStore.getState().descData.get(name)
+ if (desc) {
+ textData.set(name, desc)
+ }
+ const meta = useDescToolsStore.getState().metaData.get(name)
+ if (meta) metaData.set(name, desc)
+ }
+ if (projectDetail?.label_type === 6) {
+ const qaDesc = useDescToolsStore.getState().qaData.get(name)
+ if (qaDesc) textData.set(name, qaDesc)
+ }
+ })
+ // 调整备份数据结果的点位比例
+ const adjustedData = adjustAllPoints(
+ newTaskLabelData,
+ usePaperStore.getState().reciprocalRasterScale
+ )
+ let taskLabelData = await storage.getItem(
+ taskDetail!.id.toString()
+ )
+ let newState = {
+ ...taskLabelData?.state,
+ [duplicateName]: adjustedData,
+ [`${duplicateName}-text`]: {
+ text: textData,
+ meta: metaData,
+ },
+ }
+ storage.setItem(taskDetail!.id.toString(), {
+ state: newState,
+ })
+ setDuplicateConfirmOpen(false)
+ setDuplicateName("")
+ setBackupOpen(false)
+ } catch (error) {
+ console.log(error)
+ }
+ }}
+ onCancel={() => {
+ setDuplicateName("")
+ setDuplicateConfirmOpen(false)
+ }}
+ />
+ )}
+ {recoverOpen && (
+ {
+ setRecoverOpen(false)
+ }}
+ handleOk={handleRecover}
+ />
+ )}
+ {taskOpen && (
+ {
+ setTaskOpen(false)
+ }}
+ taskDetail={taskDetail}
+ />
+ )}
+
+ )
+}
+
+export default forwardRef(TopTools)
diff --git a/components/label/ffmpeg/ffmpeg.worker.ts b/components/label/ffmpeg/ffmpeg.worker.ts
new file mode 100644
index 0000000..9587690
--- /dev/null
+++ b/components/label/ffmpeg/ffmpeg.worker.ts
@@ -0,0 +1,925 @@
+// Define types for worker messages
+export type WorkerMessage =
+ | { type: "LOAD"; baseURL: string }
+ | {
+ type: "EXEC"
+ file: File
+ args: string[]
+ outputPattern: string
+ quickFirstFrame?: boolean
+ continueAfterQuickFirstFrame?: boolean
+ }
+
+export type WorkerResponse =
+ | { type: "READY" }
+ | { type: "LOG"; message: string }
+ | { type: "PROGRESS"; progress: number }
+ | { type: "DONE" }
+ | { type: "SEGMENT_DATA"; files: { name: string; data: Uint8Array }[] }
+ | { type: "FATAL"; error: string }
+ | { type: "ERROR"; error: string }
+
+import { FFmpeg } from "@ffmpeg/ffmpeg"
+
+const ctx: Worker = self as any
+const ffmpeg = new FFmpeg()
+
+let loaded = false
+let lastBaseURL: string | null = null
+
+// Custom Logger
+ffmpeg.on("log", ({ message }) => {
+ ctx.postMessage({ type: "LOG", message })
+})
+
+ffmpeg.on("progress", ({ progress }) => {
+ ctx.postMessage({ type: "PROGRESS", progress })
+})
+
+ctx.onmessage = async (event: MessageEvent) => {
+ const { type } = event.data
+
+ try {
+ switch (type) {
+ case "LOAD":
+ // Worker 生命周期内只加载一次 wasm,后续任务复用。
+ if (loaded) {
+ ctx.postMessage({ type: "READY" })
+ return
+ }
+ const { baseURL } = event.data
+ lastBaseURL = baseURL
+
+ await ffmpeg.load({
+ coreURL: `${baseURL}/ffmpeg-core.js`,
+ wasmURL: `${baseURL}/ffmpeg-core.wasm`,
+ })
+
+ loaded = true
+ ctx.postMessage({ type: "READY" })
+ break
+
+ case "EXEC":
+ if (!loaded) throw new Error("FFmpeg not loaded")
+ const {
+ file,
+ args,
+ outputPattern,
+ quickFirstFrame,
+ continueAfterQuickFirstFrame,
+ } = event.data
+
+ ctx.postMessage({
+ type: "LOG",
+ message: `[start] outputPattern=${outputPattern}`,
+ })
+
+ const mountDir = "/input_mount"
+ let inputPath = `${mountDir}/${file.name}`
+
+ const mountInput = async () => {
+ // 用 WORKERFS 直接挂载 File,避免先复制到 wasm FS 的额外内存占用。
+ await ffmpeg.createDir(mountDir)
+ await ffmpeg.mount("WORKERFS" as any, { files: [file] }, mountDir)
+ }
+
+ const unmountInput = async () => {
+ await ffmpeg.unmount(mountDir).catch((err) => {
+ console.log("unmounterr", err)
+ })
+ await ffmpeg.deleteDir(mountDir).catch((err) => {
+ console.log("deleteDir", err)
+ })
+ }
+
+ await mountInput()
+
+ const isRawH264 =
+ file.name.toLowerCase().endsWith(".264") ||
+ file.name.toLowerCase().endsWith(".h264")
+
+ if (isRawH264) {
+ ctx.postMessage({
+ type: "LOG",
+ message: "[fix] 检测到 H.264 裸流,正在封装为 MP4 以修复索引...",
+ })
+
+ const remuxedPath = "fixed_container.mp4"
+
+ try {
+ // -f h264: 强制指定输入格式为 h264 裸流
+ // -i ... : 输入
+ // -c copy: 直接复制流,不转码(速度极快)
+ // -f mp4 : 输出为 MP4 容器
+ const ret = await ffmpeg.exec([
+ "-f",
+ "h264",
+ "-i",
+ inputPath,
+ "-c",
+ "copy",
+ remuxedPath,
+ ])
+
+ if (ret === 0) {
+ ctx.postMessage({
+ type: "LOG",
+ message: "[fix] 封装成功,切换输入源",
+ })
+ // 关键:将后续操作的输入路径指向新生成的 MP4
+ inputPath = remuxedPath
+ } else {
+ ctx.postMessage({
+ type: "LOG",
+ message: "[fix] 封装失败,尝试继续使用原始文件...",
+ })
+ }
+ } catch (e) {
+ ctx.postMessage({
+ type: "LOG",
+ message: `[fix] 预处理出错: ${String(e)}`,
+ })
+ }
+ }
+
+ try {
+ const normalizeError = (e: unknown) => {
+ if (typeof e === "string") return e
+ if (e && typeof e === "object" && "message" in e)
+ return String((e as any).message)
+ return String(e)
+ }
+
+ const deleteFileQuietly = async (filePath: string) => {
+ try {
+ await ffmpeg.deleteFile(filePath)
+ } catch (e) {
+ const msg = normalizeError(e)
+ const lower = msg.toLowerCase()
+ // Cleanup path may not exist (e.g. ffprobe output not created).
+ if (
+ lower.includes("no such file") ||
+ lower.includes("enoent") ||
+ (lower.includes("errnoerror") && lower.includes("fs error"))
+ ) {
+ return
+ }
+ ctx.postMessage({
+ type: "LOG",
+ message: `[cleanup] deleteFile failed path=${filePath}: ${msg}`,
+ })
+ }
+ }
+
+ const isOom = (msg: string) =>
+ msg.includes("memory access out of bounds") ||
+ msg.includes("Cannot enlarge memory") ||
+ msg.includes("Aborted")
+
+ const nowMs = () =>
+ typeof performance !== "undefined" ? performance.now() : Date.now()
+
+ const makeThrottledLogger = (intervalMs: number) => {
+ let last = 0
+ return (message: string) => {
+ const t = nowMs()
+ if (t - last < intervalMs) return
+ last = t
+ ctx.postMessage({ type: "LOG", message })
+ }
+ }
+
+ const logThrottled = makeThrottledLogger(1000)
+ const metrics = {
+ startedAtMs: nowMs(),
+ mode: "unknown",
+ segmentAttempts: 0,
+ segmentDurationSec: 0,
+ fallbackToSingle: false,
+ reloadCount: 0,
+ frameCount: 0,
+ execMs: 0,
+ readMs: 0,
+ }
+
+ const fileMB = file.size / 1024 / 1024
+ const filterGuardLevel = (() => {
+ if (isRawH264) return fileMB >= 40 ? 2 : 1
+ if (fileMB >= 60) return 2
+ if (fileMB >= 30) return 1
+ return 0
+ })()
+
+ const runtimeArgs = (() => {
+ const nextArgs = [...args]
+ if (filterGuardLevel <= 0) return nextArgs
+
+ const vfIdx = nextArgs.indexOf("-vf")
+ if (vfIdx >= 0 && typeof nextArgs[vfIdx + 1] === "string") {
+ // 内存守护:高风险场景先去掉 unsharp 并降级 scale flags,减少 wasm 压力。
+ let optimizedVf = nextArgs[vfIdx + 1].replace(
+ /(?:^|,)unsharp=[^,]*/g,
+ ""
+ )
+
+ if (filterGuardLevel >= 2) {
+ const lightScaleFlag =
+ file.size >= 40 * 1024 * 1024 ? "bilinear" : "bicubic"
+ optimizedVf = optimizedVf.replace(
+ /:flags=[^,']+/g,
+ `:flags=${lightScaleFlag}`
+ )
+ }
+
+ optimizedVf = optimizedVf
+ .replace(/,,+/g, ",")
+ .replace(/^,|,$/g, "")
+ nextArgs[vfIdx + 1] = optimizedVf
+ }
+
+ ctx.postMessage({
+ type: "LOG",
+ message: `[oom-guard] apply level=${filterGuardLevel} reason=${
+ isRawH264 ? "raw-h264" : "large-file"
+ }`,
+ })
+ return nextArgs
+ })()
+
+ const findArgValue = (flag: string) => {
+ const idx = runtimeArgs.indexOf(flag)
+ if (idx < 0) return null
+ const value = runtimeArgs[idx + 1]
+ return typeof value === "string" ? value : null
+ }
+
+ const parseFps = () => {
+ const vf = findArgValue("-vf")
+ if (!vf) return null
+ const match = vf.match(/(?:^|,)fps=(\d+(?:\.\d+)?)(?:,|$)/)
+ if (!match) return null
+ const fps = Number(match[1])
+ return Number.isFinite(fps) && fps > 0 ? fps : null
+ }
+
+ const parseScaleWidthFromFilter = () => {
+ const vf = findArgValue("-vf")
+ if (!vf) return null
+ const minMatch = vf.match(/min\(\s*(\d+)\s*,\s*iw\s*\)/i)
+ if (minMatch) {
+ const parsed = Number(minMatch[1])
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
+ }
+ const fixedMatch = vf.match(
+ /scale\s*=\s*'?\s*(\d+)\s*:\s*-?\d+(?::[^,'\s]+=[^,'\s]+)*\s*'?/i
+ )
+ if (!fixedMatch) return null
+ const parsed = Number(fixedMatch[1])
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
+ }
+
+ const outputExt = (() => {
+ for (let i = runtimeArgs.length - 1; i >= 0; i -= 1) {
+ const v = runtimeArgs[i]
+ if (typeof v !== "string") continue
+ const m = v.match(/\.([a-zA-Z0-9]+)$/)
+ if (m) return m[1].toLowerCase()
+ }
+ return "jpg"
+ })()
+
+ let quickFirstFrameSucceeded = false
+ if (quickFirstFrame) {
+ metrics.mode = "first-frame-fast"
+ try {
+ const requestedScaleWidth = parseScaleWidthFromFilter() ?? 640
+ const fastWidth = Math.max(
+ 320,
+ Math.min(requestedScaleWidth, 640)
+ )
+ const vf = findArgValue("-vf")
+ const qv = findArgValue("-q:v")
+ const fastQ = Number.isFinite(Number(qv))
+ ? String(Math.max(Number(qv), 4))
+ : "4"
+
+ const fastFilter = (() => {
+ const baseFilter =
+ vf
+ ?.replace(/(?:^|,)fps=\d+(?:\.\d+)?(?=,|$)/g, "")
+ .replace(/^,|,$/g, "")
+ .replace(/,,+/g, ",") ?? ""
+ let filter = baseFilter || `scale='min(${fastWidth},iw)':-2`
+ filter = filter.replace(
+ /min\(\s*\d+\s*,\s*iw\s*\)/g,
+ `min(${fastWidth},iw)`
+ )
+ filter = filter.replace(/:flags=[^,']+/g, ":flags=bilinear")
+ if (!/:flags=/.test(filter)) filter = `${filter}:flags=bilinear`
+ if (!/(?:^|,)format=/.test(filter))
+ filter = `${filter},format=yuv420p`
+ return filter
+ })()
+
+ const fastOutputName = `frame_fast_000001.${outputExt}`
+ const fastArgs = [
+ "-ss",
+ "0",
+ "-i",
+ inputPath,
+ "-threads",
+ "1",
+ "-an",
+ "-sn",
+ "-dn",
+ "-vf",
+ fastFilter,
+ "-q:v",
+ fastQ,
+ "-pix_fmt",
+ "yuv420p",
+ "-frames:v",
+ "1",
+ fastOutputName,
+ ]
+
+ ctx.postMessage({
+ type: "LOG",
+ message: `[mode] quick-first-frame width=${fastWidth} q=${fastQ}`,
+ })
+
+ const execStartedAt = nowMs()
+ const ret = await ffmpeg.exec(fastArgs)
+ metrics.execMs += nowMs() - execStartedAt
+ if (ret !== 0) {
+ throw new Error(`FAST_FIRST_FRAME_RET_${ret}`)
+ }
+
+ const files = await ffmpeg.listDir(".")
+ const imageFiles = files.filter(
+ (f) =>
+ !f.isDir &&
+ f.name.startsWith("frame_") &&
+ f.name.endsWith(`.${outputExt}`)
+ )
+ const targetName = imageFiles.find(
+ (f) => f.name === fastOutputName
+ )?.name
+ ? fastOutputName
+ : imageFiles[0]?.name
+ if (!targetName) {
+ throw new Error("NO_FAST_FIRST_FRAME")
+ }
+
+ const readStartedAt = nowMs()
+ const raw = (await ffmpeg.readFile(targetName)) as Uint8Array
+ const copy = raw.slice()
+ metrics.readMs += nowMs() - readStartedAt
+ metrics.frameCount = 1
+ quickFirstFrameSucceeded = true
+ await deleteFileQuietly(targetName)
+ for (const imageFile of imageFiles) {
+ if (imageFile.name === targetName) continue
+ await deleteFileQuietly(imageFile.name)
+ }
+
+ ctx.postMessage(
+ {
+ type: "SEGMENT_DATA",
+ files: [{ name: targetName, data: copy }],
+ },
+ [copy.buffer]
+ )
+ } catch (e) {
+ const quickError = normalizeError(e)
+ if (!continueAfterQuickFirstFrame) throw e
+ ctx.postMessage({
+ type: "LOG",
+ message: `[mode] quick-first-frame failed, fallback to full extraction: ${quickError}`,
+ })
+ }
+
+ if (!continueAfterQuickFirstFrame) {
+ ctx.postMessage({ type: "PROGRESS", progress: 1 })
+ const elapsedMs = nowMs() - metrics.startedAtMs
+ ctx.postMessage({
+ type: "LOG",
+ message: `[metrics] phase=done mode=${metrics.mode} frames=1 totalMs=${Math.round(
+ elapsedMs
+ )} execMs=${Math.round(metrics.execMs)} readMs=${Math.round(
+ metrics.readMs
+ )}`,
+ })
+ ctx.postMessage({ type: "DONE" })
+ return
+ }
+
+ if (quickFirstFrameSucceeded) {
+ ctx.postMessage({ type: "PROGRESS", progress: 0.01 })
+ ctx.postMessage({
+ type: "LOG",
+ message:
+ "[mode] quick-first-frame done; continue full extraction in same worker",
+ })
+ }
+ }
+
+ let durationSec = 0
+ const skipDurationProbe = Boolean(
+ quickFirstFrame && continueAfterQuickFirstFrame
+ )
+ const durationProbePath = "__duration.txt"
+ if (!skipDurationProbe) {
+ try {
+ await ffmpeg.ffprobe([
+ "-v",
+ "error",
+ "-show_entries",
+ "format=duration",
+ "-of",
+ "default=noprint_wrappers=1:nokey=1",
+ inputPath,
+ "-o",
+ durationProbePath,
+ ])
+ const txt = (await ffmpeg.readFile(
+ durationProbePath,
+ "utf8"
+ )) as string
+ const parsed = Number.parseFloat(String(txt).trim())
+ if (Number.isFinite(parsed) && parsed > 0) durationSec = parsed
+ } catch (e) {
+ ctx.postMessage({
+ type: "LOG",
+ message: `[probe] ffprobe failed: ${normalizeError(e)}`,
+ })
+ } finally {
+ await deleteFileQuietly(durationProbePath)
+ }
+
+ if (!durationSec) {
+ const logHandler = ({ message }: { message: string }) => {
+ const match = message.match(
+ /Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/
+ )
+ if (match) {
+ const [, h, m, s] = match
+ durationSec =
+ parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s)
+ }
+ }
+
+ ffmpeg.on("log", logHandler)
+ try {
+ await ffmpeg.exec(["-i", inputPath])
+ } catch (e) {
+ void e
+ } finally {
+ ffmpeg.off("log", logHandler)
+ }
+ }
+ } else {
+ ctx.postMessage({
+ type: "LOG",
+ message:
+ "[probe] skip duration probe after quick-first-frame to reduce second-frame latency",
+ })
+ }
+
+ ctx.postMessage({
+ type: "LOG",
+ message: `[probe] durationSec=${durationSec || "unknown"}`,
+ })
+ if (!durationSec) durationSec = 600
+
+ const fps = parseFps() ?? 1
+ const stepSec = 1 / fps
+ const streamStartSec = quickFirstFrameSucceeded ? stepSec : 0
+ const preferSingleFrame = file.size >= 20 * 1024 * 1024
+ const maxFrames = preferSingleFrame ? 30 : 0
+ const reloadEvery = preferSingleFrame ? 10 : 0
+ let sentBytes = 0
+
+ const requestedScaleWidth = parseScaleWidthFromFilter() ?? 720
+ const shouldDownscaleForSafety =
+ filterGuardLevel >= 2 && requestedScaleWidth > 640
+ const maxWidth = shouldDownscaleForSafety
+ ? Math.max(360, Math.round(requestedScaleWidth * 0.78))
+ : requestedScaleWidth
+
+ ctx.postMessage({
+ type: "LOG",
+ message: `[config] fileMB=${Math.round(fileMB)} fps=${fps} requestedWidth=${requestedScaleWidth} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame} filterGuardLevel=${filterGuardLevel} streamStartSec=${streamStartSec}`,
+ })
+ ctx.postMessage({
+ type: "LOG",
+ message: `[metrics] phase=start fileMB=${Math.round(fileMB)} durationSec=${durationSec} fps=${fps} requestedWidth=${requestedScaleWidth} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame} streamStartSec=${streamStartSec}`,
+ })
+
+ const buildSingleFrameArgs = (
+ outputPattern: string,
+ startNumber: number,
+ useBasicFilter = false
+ ) => {
+ const vf = findArgValue("-vf")
+ const qv = findArgValue("-q:v")
+ const out: string[] = []
+ if (useBasicFilter) {
+ const fallbackWidth = Math.max(240, Math.min(maxWidth, 640))
+ out.push(
+ "-vf",
+ `scale='min(${fallbackWidth},iw)':-2:flags=bicubic,format=yuv420p`
+ )
+ } else {
+ const baseFilter =
+ vf
+ ?.replace(/(?:^|,)fps=\d+(?:\.\d+)?(?=,|$)/g, "")
+ .replace(/^,|,$/g, "")
+ .replace(/,,+/g, ",") ?? null
+
+ const patchedFilter = (() => {
+ if (!baseFilter || !baseFilter.trim()) return null
+ let f = baseFilter
+ f = f.replace(
+ /min\(\s*\d+\s*,\s*iw\s*\)/g,
+ `min(${maxWidth},iw)`
+ )
+ f = f.replace(
+ /scale\s*=\s*'?\s*\d+\s*:\s*(-?\d+)((?::[^,'\s]+=[^,'\s]+)*)\s*'?/g,
+ (_match, h: string, opts?: string) =>
+ `scale='min(${maxWidth},iw)':${h}${opts || ""}`
+ )
+ if (!/(?:^|,)format=/.test(f)) f = `${f},format=yuv420p`
+ return f
+ })()
+
+ if (patchedFilter && patchedFilter.trim())
+ out.push("-vf", patchedFilter)
+ }
+ if (qv) {
+ const qNum = Number(qv)
+ if (Number.isFinite(qNum) && preferSingleFrame) {
+ out.push("-q:v", String(Math.max(qNum, useBasicFilter ? 4 : 3)))
+ } else {
+ out.push("-q:v", qv)
+ }
+ }
+ out.push("-pix_fmt", "yuv420p")
+ out.push("-start_number", String(startNumber))
+ out.push("-frames:v", "1", outputPattern)
+ return out
+ }
+
+ const segmentCandidates = preferSingleFrame
+ ? [2, 3, 5].filter((v) => v >= stepSec && v > 0)
+ : [2, 1.2, 1].filter((v) => v >= stepSec && v > 0)
+
+ const trySegmentDuration = async (
+ segmentDurationSec: number,
+ frameLimit = 0
+ ) => {
+ // 主路径:按时间片执行 ffmpeg,分批读取并回传帧,控制内存峰值。
+ metrics.mode = "segment"
+ metrics.segmentDurationSec = segmentDurationSec
+ let t = streamStartSec
+ let emptyStreak = 0
+ let producedAny = false
+ let producedFrames = 0
+
+ while (t < durationSec) {
+ logThrottled(
+ `[exec] mode=segment t=${t.toFixed(3)} dur=${segmentDurationSec}`
+ )
+
+ const segmentArgs = [
+ "-ss",
+ t.toString(),
+ "-t",
+ segmentDurationSec.toString(),
+ "-i",
+ inputPath,
+ "-threads",
+ "1",
+ "-an",
+ "-sn",
+ "-dn",
+ ...runtimeArgs,
+ ]
+
+ const execStartedAt = nowMs()
+ const ret = await ffmpeg.exec(segmentArgs)
+ metrics.execMs += nowMs() - execStartedAt
+ if (ret !== 0) {
+ logThrottled(
+ `[exec] mode=segment ret=${ret} t=${t.toFixed(3)} dur=${segmentDurationSec}`
+ )
+ }
+
+ const files = await ffmpeg.listDir(".")
+ const imageFiles = files.filter(
+ (f) =>
+ !f.isDir &&
+ f.name.startsWith("frame_") &&
+ f.name.endsWith(`.${outputExt}`)
+ )
+
+ if (imageFiles.length === 0) {
+ emptyStreak += 1
+ if (emptyStreak >= Math.ceil(5 / segmentDurationSec)) break
+ } else {
+ emptyStreak = 0
+ producedAny = true
+ }
+
+ const segmentFiles: { name: string; data: Uint8Array }[] = []
+ const transfer: ArrayBuffer[] = []
+ const readStartedAt = nowMs()
+
+ const remaining =
+ frameLimit > 0 ? Math.max(frameLimit - producedFrames, 0) : 0
+ for (let i = 0; i < imageFiles.length; i += 1) {
+ const f = imageFiles[i]
+ if (frameLimit > 0 && i >= remaining) {
+ await deleteFileQuietly(f.name)
+ continue
+ }
+ const raw = (await ffmpeg.readFile(f.name)) as Uint8Array
+ const copy = raw.slice()
+ segmentFiles.push({
+ name: `t_${t.toFixed(3)}_${f.name}`,
+ data: copy,
+ })
+ sentBytes += copy.byteLength
+ transfer.push(copy.buffer)
+ await deleteFileQuietly(f.name)
+ }
+ metrics.readMs += nowMs() - readStartedAt
+
+ if (segmentFiles.length > 0) {
+ metrics.frameCount += segmentFiles.length
+ producedFrames += segmentFiles.length
+ // 通过 transferable 传输二进制,避免主线程和 worker 双份拷贝。
+ ctx.postMessage(
+ { type: "SEGMENT_DATA", files: segmentFiles },
+ transfer
+ )
+ }
+
+ ctx.postMessage({
+ type: "PROGRESS",
+ progress: Math.min((t + segmentDurationSec) / durationSec, 1),
+ })
+
+ if (frameLimit > 0 && producedFrames >= frameLimit) break
+ t += segmentDurationSec
+ }
+
+ if (!producedAny) {
+ throw new Error("NO_FRAMES")
+ }
+ }
+
+ const runSingleFrameMode = async (useBasicFilter = false) => {
+ // 回退路径:逐秒/逐帧抽样,牺牲吞吐换稳定性。
+ metrics.mode = useBasicFilter ? "single-fallback" : "single"
+ let index = streamStartSec > 0 ? 1 : 0
+ let emptyStreak = 0
+ let producedCount = 0
+ const pattern = `frame_%06d.${outputExt}`
+
+ const reloadCore = async (reason: string) => {
+ if (!lastBaseURL) return
+ metrics.reloadCount += 1
+ ctx.postMessage({ type: "LOG", message: `[reload] ${reason}` })
+ // 长任务周期性重载 core,缓解 wasm 内存碎片化。
+ await unmountInput()
+ ffmpeg.terminate()
+ loaded = false
+ await ffmpeg.load({
+ coreURL: `${lastBaseURL}/ffmpeg-core.js`,
+ wasmURL: `${lastBaseURL}/ffmpeg-core.wasm`,
+ })
+ loaded = true
+ await mountInput()
+ }
+
+ for (let t = streamStartSec; t < durationSec; t += stepSec) {
+ if (maxFrames > 0 && index >= maxFrames) break
+ if (reloadEvery > 0 && index > 0 && index % reloadEvery === 0) {
+ await reloadCore(`periodic frame=${index}`)
+ }
+
+ const outputName = `frame_${String(index).padStart(6, "0")}.${outputExt}`
+ const singleFrameArgs = [
+ "-ss",
+ t.toString(),
+ "-i",
+ inputPath,
+ "-threads",
+ "1",
+ "-an",
+ "-sn",
+ "-dn",
+ ...(buildSingleFrameArgs(pattern, index, useBasicFilter) || []),
+ ]
+
+ try {
+ const execStartedAt = nowMs()
+ const ret = await ffmpeg.exec(singleFrameArgs)
+ metrics.execMs += nowMs() - execStartedAt
+ ctx.postMessage({
+ type: "LOG",
+ message: `[exec] mode=single ret=${ret} t=${t.toFixed(3)} out=${outputName}`,
+ })
+ } catch (e) {
+ const msg = normalizeError(e)
+ if (isOom(msg)) throw e
+ ctx.postMessage({
+ type: "LOG",
+ message: `[exec] mode=single error t=${t.toFixed(3)}: ${msg}`,
+ })
+ if (
+ msg.includes("At least one output file must be specified")
+ ) {
+ ctx.postMessage({
+ type: "LOG",
+ message: `[exec] mode=single argsTail=${JSON.stringify(
+ singleFrameArgs.slice(-12)
+ )}`,
+ })
+ }
+ await deleteFileQuietly(outputName)
+ index += 1
+ continue
+ }
+
+ try {
+ const readStartedAt = nowMs()
+ const raw = (await ffmpeg.readFile(outputName)) as Uint8Array
+ const copy = raw.slice()
+ metrics.readMs += nowMs() - readStartedAt
+ sentBytes += copy.byteLength
+ metrics.frameCount += 1
+ producedCount += 1
+ await deleteFileQuietly(outputName)
+
+ ctx.postMessage(
+ {
+ type: "SEGMENT_DATA",
+ files: [{ name: outputName, data: copy }],
+ },
+ [copy.buffer]
+ )
+
+ logThrottled(
+ `[frame] idx=${index} t=${t.toFixed(3)} size=${copy.byteLength} sentMB=${Math.round((sentBytes / 1024 / 1024) * 10) / 10}`
+ )
+ } catch (e) {
+ void e
+ emptyStreak += 1
+ if (emptyStreak >= 3) break
+ index += 1
+ continue
+ }
+ emptyStreak = 0
+
+ ctx.postMessage({
+ type: "PROGRESS",
+ progress: Math.min((t + stepSec) / durationSec, 1),
+ })
+
+ index += 1
+ }
+
+ if (producedCount === 0) {
+ throw new Error(
+ useBasicFilter
+ ? "NO_FRAMES_SINGLE_FALLBACK"
+ : "NO_FRAMES_SINGLE"
+ )
+ }
+ }
+
+ let segmentOk = false
+ for (const seg of segmentCandidates) {
+ metrics.segmentAttempts += 1
+ try {
+ ctx.postMessage({
+ type: "LOG",
+ message: `[mode] trying segmentDuration=${seg}s stepSec=${stepSec}s`,
+ })
+ await trySegmentDuration(seg, maxFrames)
+ segmentOk = true
+ break
+ } catch (e) {
+ const msg = normalizeError(e)
+ if (msg.includes("NO_FRAMES")) break
+ if (
+ msg.includes("Output file is empty") ||
+ msg.includes("nothing was encoded")
+ ) {
+ break
+ }
+ if (!isOom(msg)) throw e
+ }
+ }
+ if (!segmentOk) {
+ metrics.fallbackToSingle = segmentCandidates.length > 0
+ ctx.postMessage({
+ type: "LOG",
+ message: `[mode] segment mode unavailable; fallback to single-frame sampling`,
+ })
+ try {
+ await runSingleFrameMode()
+ } catch (e) {
+ const msg = normalizeError(e)
+ if (!msg.includes("NO_FRAMES_SINGLE")) throw e
+ ctx.postMessage({
+ type: "LOG",
+ message:
+ "[mode] single mode produced no frames; retry with basic filter",
+ })
+ await runSingleFrameMode(true)
+ }
+ }
+
+ const elapsedMs = nowMs() - metrics.startedAtMs
+ const sentMB = Math.round((sentBytes / 1024 / 1024) * 100) / 100
+ const avgFrameKB = metrics.frameCount
+ ? Math.round((sentBytes / metrics.frameCount / 1024) * 10) / 10
+ : 0
+ const throughputFps =
+ elapsedMs > 0
+ ? Math.round((metrics.frameCount / (elapsedMs / 1000)) * 100) /
+ 100
+ : 0
+ ctx.postMessage({
+ type: "LOG",
+ message: `[metrics] phase=done mode=${metrics.mode} frames=${metrics.frameCount} sentMB=${sentMB} avgFrameKB=${avgFrameKB} totalMs=${Math.round(
+ elapsedMs
+ )} execMs=${Math.round(metrics.execMs)} readMs=${Math.round(
+ metrics.readMs
+ )} throughputFps=${throughputFps} segmentAttempts=${
+ metrics.segmentAttempts
+ } segmentDurationSec=${metrics.segmentDurationSec} fallbackToSingle=${
+ metrics.fallbackToSingle
+ } reloadCount=${metrics.reloadCount}`,
+ })
+
+ // 所有帧都已通过 SEGMENT_DATA 流式回传,这里只发完成信号。
+ ctx.postMessage({ type: "DONE" })
+ } catch (e: any) {
+ const errorMessage =
+ typeof e === "string" ? e : e?.message || String(e)
+ if (errorMessage.includes("memory access out of bounds")) {
+ try {
+ ffmpeg.terminate()
+ loaded = false
+ ctx.postMessage({
+ type: "LOG",
+ message: `[fatal] ffmpeg terminated due to: ${errorMessage}`,
+ })
+
+ if (lastBaseURL) {
+ await ffmpeg.load({
+ coreURL: `${lastBaseURL}/ffmpeg-core.js`,
+ wasmURL: `${lastBaseURL}/ffmpeg-core.wasm`,
+ })
+ loaded = true
+ ctx.postMessage({
+ type: "LOG",
+ message: `[fatal] ffmpeg reloaded`,
+ })
+ }
+ } catch (e2) {
+ let flag = typeof e2 === "string"
+ ctx.postMessage({
+ type: "LOG",
+ message: `[fatal] reload failed: ${flag ? e2 : (e2 as any)?.message || String(e2)}`,
+ })
+ }
+
+ ctx.postMessage({ type: "FATAL", error: errorMessage })
+ return
+ }
+
+ throw e
+ } finally {
+ // 始终清理挂载目录,避免残留状态影响下一次任务。
+ try {
+ await unmountInput()
+ } catch (e) {
+ ctx.postMessage({
+ type: "LOG",
+ message: `[cleanup] ${typeof e === "string" ? e : (e as any)?.message || String(e)}`,
+ })
+ }
+ }
+ break
+ }
+ } catch (error: any) {
+ const msg =
+ typeof error === "string" ? error : error?.message || String(error)
+ ctx.postMessage({ type: "ERROR", error: msg })
+ }
+}
diff --git a/components/label/ffmpeg/processVideo.ts b/components/label/ffmpeg/processVideo.ts
new file mode 100644
index 0000000..9662ad5
--- /dev/null
+++ b/components/label/ffmpeg/processVideo.ts
@@ -0,0 +1,339 @@
+"use client"
+
+import type { WorkerMessage, WorkerResponse } from "./ffmpeg.worker"
+
+export interface VideoFrameFile {
+ name: string
+ dataUrl: string
+}
+
+interface ProcessVideoParams {
+ base64Data: string
+ fileName: string
+ namePrefix?: string
+ baseURL?: string
+ args?: string[]
+ timeoutMs?: number
+ firstFrameTimeoutMs?: number
+ quickFirstFrame?: boolean
+ continueAfterQuickFirstFrame?: boolean
+ onFrames?: (frames: VideoFrameFile[]) => void
+ onProgress?: (progress: number) => void
+}
+
+const defaultArgs = [
+ "-vf",
+ // 基线策略:1fps + Lanczos 缩放 + 轻微锐化,兼顾清晰度与处理速度。
+ "fps=1,scale='min(720,iw)':-2:flags=lanczos+accurate_rnd+full_chroma_int,unsharp=5:5:0.5:3:3:0",
+ "-q:v",
+ "2",
+ "frame_%03d.jpg",
+]
+
+const lowMemoryArgs = [
+ "-vf",
+ // OOM 回退:降低分辨率并使用更轻滤镜,优先保稳定输出。
+ "fps=1,scale='min(480,iw)':-2:flags=bilinear,format=yuv420p",
+ "-q:v",
+ "4",
+ "frame_%03d.jpg",
+]
+
+const timeoutFallbackArgs = [
+ "-vf",
+ // 超时回退:进一步降采样,减少单次解码计算量。
+ "fps=1,scale='min(360,iw)':-2:flags=bilinear,format=yuv420p",
+ "-q:v",
+ "5",
+ "frame_%03d.jpg",
+]
+
+const mimeByExt: Record = {
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ png: "image/png",
+ webp: "image/webp",
+ mp4: "video/mp4",
+ mov: "video/quicktime",
+ webm: "video/webm",
+ mkv: "video/x-matroska",
+ avi: "video/x-msvideo",
+ h264: "video/h264",
+ "264": "video/h264",
+}
+
+const getFileExt = (fileName: string) => {
+ const segments = fileName.split(".")
+ if (segments.length <= 1) return ""
+ return segments[segments.length - 1].toLowerCase()
+}
+
+const stripFileExt = (fileName: string) => {
+ const index = fileName.lastIndexOf(".")
+ if (index <= 0) return fileName
+ return fileName.slice(0, index)
+}
+
+const normalizeBase64 = (text: string) => {
+ if (!text) return ""
+ const raw = text.includes(",") ? text.split(",").slice(1).join(",") : text
+ return raw.replace(/\s+/g, "")
+}
+
+const sanitizePrefix = (prefix: string) => {
+ const safe = prefix
+ .replace(/[^a-zA-Z0-9_-]+/g, "_")
+ .replace(/_+/g, "_")
+ .replace(/^_|_$/g, "")
+ return safe || "video"
+}
+
+const uint8ArrayToBase64 = (arr: Uint8Array) => {
+ const chunkSize = 0x8000
+ let binary = ""
+ for (let i = 0; i < arr.length; i += chunkSize) {
+ binary += String.fromCharCode(...arr.subarray(i, i + chunkSize))
+ }
+ return btoa(binary)
+}
+
+const base64ToFile = (base64Data: string, fileName: string) => {
+ const payload = normalizeBase64(base64Data)
+ if (!payload) throw new Error("empty video payload")
+ const binary = atob(payload)
+ const len = binary.length
+ const bytes = new Uint8Array(len)
+ for (let i = 0; i < len; i += 1) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ const fileExt = getFileExt(fileName)
+ return new File([bytes], fileName, {
+ type: mimeByExt[fileExt] || "video/mp4",
+ })
+}
+
+const getOutputExtFromArgs = (args: string[]) => {
+ for (let i = args.length - 1; i >= 0; i -= 1) {
+ const ext = getFileExt(args[i])
+ if (ext) return ext
+ }
+ return "jpg"
+}
+
+export const processVideo = async (
+ params: ProcessVideoParams
+): Promise => {
+ if (typeof window === "undefined") return []
+
+ const args = params.args && params.args.length ? params.args : defaultArgs
+ const fileExt = getFileExt(params.fileName)
+ const prefix = sanitizePrefix(
+ params.namePrefix || stripFileExt(params.fileName)
+ )
+ const baseURL =
+ params.baseURL ||
+ `${window.location.origin}${process.env.NEXT_PUBLIC_BASE_PATH}/wasm`
+ const inputFile = base64ToFile(params.base64Data, params.fileName)
+ const fileMB = inputFile.size / 1024 / 1024
+ const inferredTimeoutMs = (() => {
+ // 大文件和裸流(.h264/.264)给予更长时间,避免误判超时。
+ let ms = 240_000
+ if (fileMB > 30) ms += Math.round((fileMB - 30) * 2_500)
+ if (fileExt === "h264" || fileExt === "264") ms = Math.max(ms, 600_000)
+ return Math.min(ms, 1_200_000)
+ })()
+ const timeoutMs = Number.isFinite(params.timeoutMs)
+ ? Math.max(10_000, Number(params.timeoutMs))
+ : inferredTimeoutMs
+
+ const inferredFirstFrameTimeoutMs = (() => {
+ let ms = 20_000
+ if (fileMB > 30) ms += Math.round((fileMB - 30) * 1_000)
+ if (fileExt === "h264" || fileExt === "264") ms = Math.max(ms, 45_000)
+ return Math.min(ms, 120_000)
+ })()
+ const firstFrameTimeoutMs = Number.isFinite(params.firstFrameTimeoutMs)
+ ? Math.max(8_000, Number(params.firstFrameTimeoutMs))
+ : inferredFirstFrameTimeoutMs
+
+ const runOnce = async (
+ runArgs: string[],
+ runTimeoutMs: number,
+ runFirstFrameTimeoutMs: number
+ ) => {
+ const worker = new Worker(new URL("./ffmpeg.worker.ts", import.meta.url))
+ const outputExt = getOutputExtFromArgs(runArgs)
+ let frameIndex = 0
+ const frames: VideoFrameFile[] = []
+
+ try {
+ return await new Promise((resolve, reject) => {
+ let settled = false
+ let timeoutId: ReturnType | null = null
+ let firstFrameTimeoutId: ReturnType | null = null
+
+ const finish = (next: () => void) => {
+ if (settled) return
+ settled = true
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ timeoutId = null
+ }
+ if (firstFrameTimeoutId) {
+ clearTimeout(firstFrameTimeoutId)
+ firstFrameTimeoutId = null
+ }
+ next()
+ }
+
+ timeoutId = setTimeout(() => {
+ finish(() =>
+ reject(
+ new Error(
+ `video process timeout after ${Math.round(runTimeoutMs / 1000)}s`
+ )
+ )
+ )
+ }, runTimeoutMs)
+ firstFrameTimeoutId = setTimeout(() => {
+ if (frames.length > 0) return
+ finish(() =>
+ reject(
+ new Error(
+ `video process timeout before first frame after ${Math.round(runFirstFrameTimeoutMs / 1000)}s`
+ )
+ )
+ )
+ }, runFirstFrameTimeoutMs)
+
+ worker.onmessage = (event: MessageEvent) => {
+ const message = event.data
+ switch (message.type) {
+ case "READY": {
+ // Worker 加载完 wasm 后才允许真正执行抽帧命令。
+ const runMessage: WorkerMessage = {
+ type: "EXEC",
+ file: inputFile,
+ args: runArgs,
+ outputPattern: "frame_",
+ quickFirstFrame: Boolean(params.quickFirstFrame),
+ continueAfterQuickFirstFrame: Boolean(
+ params.continueAfterQuickFirstFrame
+ ),
+ }
+ worker.postMessage(runMessage)
+ return
+ }
+ case "SEGMENT_DATA": {
+ // 分片回传:边抽边传,降低一次性内存峰值并加快首屏可见时间。
+ const files = message.files || []
+ const batchFrames: VideoFrameFile[] = []
+ files.forEach((file) => {
+ const ext = getFileExt(file.name) || outputExt || "jpg"
+ const frameNo = String(frameIndex).padStart(6, "0")
+ const frameName = `${prefix}_frame_${frameNo}.${ext}`
+ frameIndex += 1
+ const frame = {
+ name: frameName,
+ dataUrl: `data:${mimeByExt[ext] || "image/jpeg"};base64,${uint8ArrayToBase64(file.data)}`,
+ }
+ frames.push(frame)
+ batchFrames.push(frame)
+ })
+ if (batchFrames.length) {
+ if (firstFrameTimeoutId) {
+ clearTimeout(firstFrameTimeoutId)
+ firstFrameTimeoutId = null
+ }
+ try {
+ params.onFrames?.(batchFrames)
+ } catch (callbackError) {
+ console.warn(
+ "[processVideo] onFrames callback failed",
+ callbackError
+ )
+ }
+ }
+ return
+ }
+ case "PROGRESS": {
+ try {
+ params.onProgress?.(message.progress || 0)
+ } catch (callbackError) {
+ console.warn(
+ "[processVideo] onProgress callback failed",
+ callbackError
+ )
+ }
+ return
+ }
+ case "DONE": {
+ finish(() => {
+ if (!frames.length) {
+ reject(new Error("video has no extracted frames"))
+ } else {
+ resolve(frames)
+ }
+ })
+ return
+ }
+ case "FATAL": {
+ finish(() =>
+ reject(new Error(message.error || "video process fatal"))
+ )
+ return
+ }
+ case "ERROR": {
+ finish(() =>
+ reject(new Error(message.error || "video process error"))
+ )
+ return
+ }
+ default:
+ return
+ }
+ }
+
+ worker.onerror = (event) => {
+ finish(() => reject(new Error(event.message || "video worker error")))
+ }
+
+ const loadMessage: WorkerMessage = {
+ type: "LOAD",
+ baseURL,
+ }
+ worker.postMessage(loadMessage)
+ })
+ } finally {
+ worker.terminate()
+ }
+ }
+
+ try {
+ return await runOnce(args, timeoutMs, firstFrameTimeoutMs)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ const isOom =
+ message.includes("memory access out of bounds") ||
+ message.includes("Cannot enlarge memory") ||
+ message.includes("Aborted")
+ const isTimeout = message.includes("video process timeout")
+ if (!isOom && !isTimeout) throw error
+
+ // 仅对可恢复错误做一次降级重试,避免无限重试拖垮页面。
+ const retryArgs = isTimeout ? timeoutFallbackArgs : lowMemoryArgs
+ const retryTimeoutMs = isTimeout
+ ? Math.max(timeoutMs, 600_000)
+ : Math.max(timeoutMs, 300_000)
+ const retryFirstFrameTimeoutMs = Math.max(firstFrameTimeoutMs, 35_000)
+ try {
+ return await runOnce(retryArgs, retryTimeoutMs, retryFirstFrameTimeoutMs)
+ } catch (retryError) {
+ const retryMessage =
+ retryError instanceof Error ? retryError.message : String(retryError)
+ throw new Error(
+ `video process failed after ${isTimeout ? "timeout" : "low-memory"} retry: ${retryMessage}`
+ )
+ }
+ }
+}
diff --git a/components/label/hooks/useAuth.ts b/components/label/hooks/useAuth.ts
new file mode 100644
index 0000000..eb500f8
--- /dev/null
+++ b/components/label/hooks/useAuth.ts
@@ -0,0 +1,120 @@
+"use client"
+
+import { usePermissionStore } from "@/components/label/store/auth"
+import { usePathname } from "next/navigation"
+
+interface PermissionItem {
+ [x: string]: [boolean, null, string]
+}
+
+type PermissionMap = {
+ [x: string]: PermissionItem
+}
+
+const mappingList: { [x: string]: [string, string] } = {
+ "/management/team/employee": ["user", "view"],
+ "/management/team/organization": ["user", "group_view"],
+ "/management/team/dailypaper": ["daily_work", "team_view"],
+ "/management/team/cost": ["settlement_form", "view"],
+ "/management/team/workload": ["workload", "view"],
+ "/management/team/sync_server": ["data_sync", "server_view"],
+ "/management/team/sync_task": ["data_sync", "task_view"],
+ "/management/team/board": ["user", "view"],
+}
+
+export const headerBarCheckItems = (data: PermissionMap) => {
+ const projectTitle = [
+ ["daily_work", "self_view"],
+ ["label_schema", "view"],
+ ["project", "view"],
+ ["task", "view"],
+ ] as const
+
+ let projectVisible = false
+ projectTitle.forEach(([prop, subProp]) => {
+ if (data[prop] && data[prop][subProp] && data[prop][subProp][0]) {
+ projectVisible = true
+ }
+ })
+
+ const staffTitle = [
+ ["daily_work", "team_view"],
+ ["user", "view"],
+ ["user", "group_view"],
+ ] as const
+
+ let staffVisible = false
+ staffTitle.forEach(([prop, subProp]) => {
+ if (data[prop] && data[prop][subProp] && data[prop][subProp][0]) {
+ staffVisible = true
+ }
+ })
+
+ return [projectVisible, staffVisible]
+}
+
+const useAuth = () => {
+ const pathname = usePathname()
+ const detailInfo = usePermissionStore((s) => s.detailInfo)
+ const permission = detailInfo?.permissions as PermissionMap | undefined
+ const roleName: string[] = detailInfo?.role_name ?? []
+
+ return {
+ hasPermission: () => {
+ if (
+ [
+ "/",
+ "/login",
+ "/management",
+ "/management/person/dashboard",
+ "/management/team",
+ ].includes(pathname)
+ ) {
+ return true
+ }
+
+ if (!permission) return false
+ if (mappingList[pathname]) {
+ const [prop, subProp] = mappingList[pathname]
+ return (
+ !!permission[prop] &&
+ !!permission[prop][subProp] &&
+ !!permission[prop][subProp][0]
+ )
+ }
+ return false
+ },
+ // path 跳转是否可见
+ canNv: (path: string, type = "view") => {
+ if (!permission) return false
+ if (mappingList[path]) {
+ const [prop] = mappingList[path]
+ return (
+ !!permission[prop] &&
+ !!permission[prop][type] &&
+ !!permission[prop][type][0]
+ )
+ }
+ return false
+ },
+ // 当前页面是否可见某个操作
+ isShow: (type = "view") => {
+ if (!permission) return false
+ if (mappingList[pathname]) {
+ const [prop] = mappingList[pathname]
+ return (
+ !!permission[prop] &&
+ !!permission[prop][type] &&
+ !!permission[prop][type][0]
+ )
+ }
+ return false
+ },
+ checkRole: (name: string) => {
+ if (!name) return false
+ return roleName.includes(name)
+ },
+ }
+}
+
+export default useAuth
diff --git a/components/label/index.tsx b/components/label/index.tsx
new file mode 100644
index 0000000..cbee5ef
--- /dev/null
+++ b/components/label/index.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import dynamic from "next/dynamic"
+
+const NoSsrLabel = dynamic(() => import("./LabelNossr"), {
+ ssr: false,
+})
+
+interface LabelContentProps {
+ headerHeight?: number
+ leftWidth?: number
+ project_id?: number
+ task_id?: number
+}
+
+const LabelContent = ({
+ headerHeight = 0,
+ leftWidth = 0,
+ project_id = -1,
+ task_id = -1,
+}: LabelContentProps) => {
+ return (
+
+ )
+}
+
+export default LabelContent
diff --git a/components/label/store.ts b/components/label/store.ts
new file mode 100644
index 0000000..a479563
--- /dev/null
+++ b/components/label/store.ts
@@ -0,0 +1,605 @@
+import { del, get, set } from "idb-keyval"
+import superjson from "superjson"
+import { create } from "zustand"
+import { persist, PersistStorage, StorageValue } from "zustand/middleware"
+import { Comment } from "./api/label/typing"
+import { safeClone } from "./utils/clone"
+
+type LabelData = Map>
+type LabelPathTuple = [number, any[], any, any[]]
+type LabelTime = {
+ label: number
+ review1: number
+ review2: number
+}
+
+const isPaperPointLike = (value: any) => {
+ return (
+ value &&
+ typeof value === "object" &&
+ !Array.isArray(value) &&
+ typeof value.x === "number" &&
+ typeof value.y === "number"
+ )
+}
+
+const isPaperSegmentLike = (value: any) => {
+ return (
+ value &&
+ typeof value === "object" &&
+ !Array.isArray(value) &&
+ value.point &&
+ isPaperPointLike(value.point)
+ )
+}
+
+const normalizePointCollection = (value: any): any => {
+ if (Array.isArray(value)) {
+ return value.map((item) => normalizePointCollection(item))
+ }
+ if (isPaperSegmentLike(value)) {
+ return [value.point.x, value.point.y]
+ }
+ if (isPaperPointLike(value)) {
+ return [value.x, value.y]
+ }
+ return value
+}
+
+const normalizeLabelPathTuple = (path: LabelPathTuple): LabelPathTuple => {
+ return [
+ path[0],
+ normalizePointCollection(path[1]),
+ path[2],
+ normalizePointCollection(path[3]),
+ ]
+}
+
+interface LabelState {
+ // pathId pathPoints detail hollowPathPoints
+ label: LabelData
+ labelTime: LabelTime
+ labelDefaultComments: Comment[]
+ setLabel: (label: LabelData) => void
+ stateStack: LabelData[]
+ setStateStack: (data: LabelData[]) => void
+ popStateStack: () => LabelData | null
+ pushStateStack: (newData: LabelData) => void
+ getLabelDetailDataByKeys: (
+ imageId: string,
+ categoryId: string,
+ id: number
+ ) => any
+ updateLabelDetailDataByKeys: (
+ imageId: string,
+ categoryId: string,
+ id: number,
+ newDetail: any
+ ) => void
+ addDetail: (
+ imageId: string,
+ categoryId: string,
+ id: number,
+ obj: {
+ [keys: string]: any
+ }
+ ) => void
+ setLabelTime: (
+ labelTime: LabelTime | ((prev: LabelTime) => LabelTime)
+ ) => void
+ setLabelDefaultComments: (arr: Comment[]) => void
+ setImage: (
+ imageId: string,
+ operation: Map
+ ) => void
+ setOperation: (
+ imageId: string,
+ operationId: string,
+ path: [number, any[], any, any[]]
+ ) => void
+ deleteOperation: (
+ imageId: string,
+ operationId: string,
+ pathId: number
+ ) => void
+ resetLabel: () => void
+}
+
+interface ObjectState {
+ pathStatus: {
+ // key = imageId + pathId
+ [key: string]: {
+ ["HIDE"]?: boolean
+ }
+ }
+ selectedPath: {
+ // key = imageId
+ [key: string]: number[]
+ }
+ operationStatus: {
+ // key = imageId + operationId
+ [key: string]: {
+ ["HIDE"]?: boolean
+ ["INPUT"]?: string
+ ["COLLAPSE"]?: boolean
+ }
+ }
+ groupStatus: {
+ // key = imageId + groupId
+ [key: string]: {
+ ["COLLAPSE"]?: boolean
+ }
+ }
+ imageStatus: {
+ // key = imageId
+ [key: string]: {
+ ["INPUT"]?: string
+ ["COLLAPSE"]?: boolean
+ ["FILTERCOLOR"]?: {
+ [key: string]: boolean
+ }
+ }
+ }
+ updatePathStatus: (
+ id: string,
+ statusKey: "HIDE",
+ statusValue: boolean
+ ) => void
+ updateSelectedPath: (
+ id: string,
+ type: "ADD" | "DELETE" | "ADDSOME",
+ pathValue?: number
+ ) => void
+ resetPathAndOperationStatus: () => void
+ updateOperationStatus: (
+ id: string,
+ statusKey: "HIDE" | "COLLAPSE" | "INPUT",
+ statusValue: string | boolean
+ ) => void
+ updateGroupStatus: (
+ id: string,
+ statusKey: "COLLAPSE",
+ statusValue: string | boolean
+ ) => void
+ updateImageStatus: (
+ id: string,
+ statusKey: "INPUT" | "COLLAPSE" | "FILTERCOLOR",
+ statusValue:
+ | string
+ | boolean
+ | {
+ [key: string]: boolean
+ }
+ ) => void
+}
+
+interface KeyEventState {
+ shift: boolean
+ focusInput: boolean
+ setShift: (value: boolean) => void
+ setFocusInput: (value: boolean) => void
+}
+
+const initialLabelState = {
+ label: new Map(),
+ labelTime: {
+ label: 0,
+ review1: 0,
+ review2: 0,
+ },
+}
+
+export const initialDetail = {
+ comment: [],
+ create_timestamp: 0,
+ first_modified_timestamp: 0,
+ first_modified_uid: 0,
+ last_modified_timestamp: 0,
+ last_modified_uid: 0,
+ sub_attributes: {},
+ prelabel: false,
+}
+
+// 自定义 storage 对象
+export const storage: PersistStorage = {
+ getItem: async (name: string) => {
+ // console.log(name, "has been retrieved");
+ let str = await get(name)
+ if (!str) return null
+ return superjson.parse(str) || null
+ },
+ setItem: async (name: string, value: StorageValue): Promise => {
+ // console.log(name, "with value", value, "has been saved");
+ await set(name, superjson.stringify(value))
+ },
+ removeItem: async (name: string): Promise => {
+ console.log(name, "has been deleted")
+ await del(name)
+ },
+}
+
+// store path data
+export const useLabelStore = create(
+ persist(
+ (storeSet) => ({
+ label: initialLabelState.label,
+ labelTime: initialLabelState.labelTime,
+ labelDefaultComments: [],
+ stateStack: [],
+ setLabel: (label) =>
+ storeSet((state) => {
+ return {
+ ...state,
+ label,
+ }
+ }),
+ setStateStack: (data) =>
+ storeSet((state) => {
+ return {
+ ...state,
+ stateStack: data,
+ }
+ }),
+ popStateStack: () => {
+ const arr: LabelData[] = useLabelStore.getState().stateStack
+ console.log("弹出栈", arr)
+ if (!arr.length) return null
+ if (arr.length === 1) {
+ return arr[0]
+ } else {
+ arr.pop()
+ storeSet((state) => {
+ return {
+ ...state,
+ stateStack: arr,
+ }
+ })
+ return arr[arr.length - 1]
+ }
+ },
+ pushStateStack: (newData) => {
+ const arr: LabelData[] = useLabelStore.getState().stateStack
+ if (arr.length === 10) {
+ arr.shift()
+ }
+ arr.push(safeClone(newData))
+ console.log(arr)
+ return storeSet((state) => {
+ return {
+ ...state,
+ stateStack: arr,
+ }
+ })
+ },
+ getLabelDetailDataByKeys: (
+ imageId: string,
+ categoryId: string,
+ id: number
+ ) => {
+ const data = useLabelStore.getState().label
+ if (!data.has(imageId)) return null
+ const image = data.get(imageId)
+ if (!image?.has(categoryId)) return null
+ const category = image.get(categoryId)
+ let detail = null
+ category?.map((item) => {
+ if (item[0] === id) detail = item[2]
+ })
+ return detail
+ },
+ updateLabelDetailDataByKeys: (imageId, categoryId, id, newData) => {
+ const labelData = useLabelStore.getState().label
+ if (!labelData.has(imageId)) return
+ const image = labelData.get(imageId)
+ if (!image?.has(categoryId)) return
+ const category = image.get(categoryId)
+ category?.map((item) => {
+ if (item[0] === id) item[2] = newData
+ })
+ },
+ addDetail: (imageId, categoryId, id, obj) => {
+ const labelData = useLabelStore.getState().label
+ if (!labelData.has(imageId)) return
+ const image = labelData.get(imageId)
+ if (!image?.has(categoryId)) return
+ const category = image.get(categoryId)
+ category?.map((item) => {
+ if (item[0] === id) item[2] = Object.assign(item[2], obj)
+ })
+ },
+ setLabelTime: (labelTime) =>
+ storeSet((state) => {
+ return {
+ ...state,
+ labelTime:
+ typeof labelTime === "function"
+ ? labelTime(state.labelTime)
+ : labelTime,
+ }
+ }),
+ setLabelDefaultComments: (labelDefaultComments) =>
+ storeSet((state) => {
+ return {
+ ...state,
+ labelDefaultComments,
+ }
+ }),
+ setImage: (imageId, operation) =>
+ storeSet((state) => {
+ state.label.set(imageId, operation)
+ return state
+ }),
+ setOperation: (imageId, operationId, path) =>
+ storeSet((state) => {
+ const normalizedPath = normalizeLabelPathTuple(path)
+ const labelData = useLabelStore.getState().label
+ if (!labelData.has(imageId)) {
+ labelData.set(imageId, new Map())
+ }
+ if (labelData.get(imageId)?.get(operationId)?.length) {
+ let result = [
+ ...labelData.get(imageId)?.get(operationId)!,
+ normalizedPath,
+ ]
+ .reduce((acc, curr) => {
+ const key = curr[0]
+ acc.set(key, curr)
+ return acc
+ }, new Map())
+ .values()
+ labelData.get(imageId)?.set(operationId, Array.from(result))
+ } else {
+ labelData.get(imageId)?.set(operationId, [normalizedPath])
+ }
+ console.log("绘制时", labelData)
+ useLabelStore.getState().pushStateStack(safeClone(labelData))
+ return { ...state, label: safeClone(labelData) }
+ }),
+
+ // 删除path
+ deleteOperation: (imageId, operationId, pathId) =>
+ storeSet((state) => {
+ if (state.label.get(imageId)?.get(operationId)?.length) {
+ let result =
+ state.label
+ .get(imageId)
+ ?.get(operationId)
+ ?.filter((item) => item[0] !== pathId) || []
+ state.label.get(imageId)?.set(operationId, result)
+ }
+ return state
+ }),
+ resetLabel: () =>
+ storeSet((state) => {
+ return {
+ ...state,
+ label: initialLabelState.label,
+ labelTime: initialLabelState.labelTime,
+ }
+ }),
+ }),
+ {
+ name: "label-data-storage",
+ storage: storage,
+ }
+ )
+)
+
+const initialObjectState = {
+ pathStatus: {},
+ selectedPath: {},
+ operationStatus: {},
+ groupStatus: {},
+ imageStatus: {},
+}
+
+// store object list status
+export const useObjectStore = create(
+ persist(
+ (storeSet) => ({
+ pathStatus: initialObjectState.pathStatus,
+ selectedPath: initialObjectState.selectedPath,
+ operationStatus: initialObjectState.operationStatus,
+ groupStatus: initialObjectState.groupStatus,
+ imageStatus: initialObjectState.imageStatus,
+ updatePathStatus: (id: string, statusKey: "HIDE", statusValue: boolean) =>
+ storeSet((state) => {
+ let idStatus = state.pathStatus[id] || {}
+ idStatus = Object.assign(idStatus, {
+ [statusKey]: statusValue,
+ })
+ return {
+ ...state,
+ pathStatus: {
+ ...state.pathStatus,
+ [id]: idStatus,
+ },
+ }
+ }),
+ updateSelectedPath: (
+ id: string,
+ type: "ADD" | "DELETE" | "ADDSOME",
+ pathValue?: number
+ ) =>
+ storeSet((state) => {
+ let idSelectedPath = state.selectedPath[id] || []
+ if (type === "ADD") {
+ if (useKeyEventStore.getState().shift) {
+ idSelectedPath = pathValue
+ ? [...new Set([...idSelectedPath, pathValue])]
+ : idSelectedPath
+ } else {
+ idSelectedPath = pathValue ? [pathValue] : []
+ }
+ } else if (type === "ADDSOME") {
+ idSelectedPath = pathValue
+ ? [...new Set([...idSelectedPath, pathValue])]
+ : idSelectedPath
+ } else if (type === "DELETE") {
+ idSelectedPath =
+ idSelectedPath?.filter((id) => id !== pathValue) || []
+ }
+ return {
+ ...state,
+ selectedPath: {
+ ...state.selectedPath,
+ [id]: idSelectedPath,
+ },
+ }
+ }),
+ resetPathAndOperationStatus: () =>
+ storeSet((state) => {
+ return {
+ ...state,
+ selectedPath: {},
+ pathStatus: {},
+ operationStatus: {},
+ }
+ }),
+ updateOperationStatus: (
+ id: string,
+ statusKey: "HIDE" | "COLLAPSE" | "INPUT",
+ statusValue: string | boolean
+ ) =>
+ storeSet((state) => {
+ let idStatus = state.operationStatus[id] || {}
+ idStatus = Object.assign(idStatus, {
+ [statusKey]: statusValue,
+ })
+ return {
+ ...state,
+ operationStatus: {
+ ...state.operationStatus,
+ [id]: idStatus,
+ },
+ }
+ }),
+ updateGroupStatus: (
+ id: string,
+ statusKey: "COLLAPSE",
+ statusValue: string | boolean
+ ) =>
+ storeSet((state) => {
+ let idStatus = state.groupStatus[id] || {}
+ idStatus = Object.assign(idStatus, {
+ [statusKey]: statusValue,
+ })
+ return {
+ ...state,
+ groupStatus: {
+ ...state.groupStatus,
+ [id]: idStatus,
+ },
+ }
+ }),
+ updateImageStatus: (
+ id: string,
+ statusKey: "INPUT" | "COLLAPSE" | "FILTERCOLOR",
+ statusValue:
+ | string
+ | boolean
+ | {
+ [key: string]: boolean
+ }
+ ) =>
+ storeSet((state) => {
+ let idStatus = state.imageStatus[id] || {}
+ idStatus = Object.assign(idStatus, {
+ [statusKey]: statusValue,
+ })
+ return {
+ ...state,
+ imageStatus: {
+ ...state.imageStatus,
+ [id]: idStatus,
+ },
+ }
+ }),
+ }),
+ {
+ name: "object-data-storage",
+ storage: storage,
+ partialize: (state: ObjectState) =>
+ Object.fromEntries(
+ Object.entries(state).filter(
+ ([key]) => !["selectedPath"].includes(key)
+ )
+ ) as any,
+ }
+ )
+)
+
+const initialKeyEventState = {
+ shift: false,
+ focusInput: false,
+}
+
+// store keyboard and mouse status
+export const useKeyEventStore = create((set) => ({
+ shift: initialKeyEventState.shift,
+ setShift: (value: boolean) =>
+ set((state: KeyEventState) => ({ ...state, shift: value })),
+ focusInput: initialKeyEventState.focusInput,
+ setFocusInput: (value: boolean) =>
+ set((state: KeyEventState) => ({ ...state, focusInput: value })),
+}))
+
+interface ImageState {
+ images: Map
+ setImages: (value: any) => void
+}
+
+interface VideoFrameState {
+ videoFrames: Map
+ setVideoFrames: (key: string, value: string[]) => void
+ removeVideoFrames: (key: string) => void
+}
+
+export const useImagesStore = create(
+ persist(
+ (storeSet) => ({
+ images: new Map(),
+ setImages: (value: any) =>
+ storeSet((state) => {
+ return {
+ ...state,
+ images: value,
+ }
+ }),
+ }),
+ {
+ name: "image-data-storage",
+ storage: storage,
+ }
+ )
+)
+
+export const useVideoFrameStore = create(
+ persist(
+ (storeSet) => ({
+ videoFrames: new Map(),
+ setVideoFrames: (key: string, value: string[]) =>
+ storeSet((state) => {
+ const nextMap = new Map(state.videoFrames)
+ nextMap.set(key, value)
+ return {
+ ...state,
+ videoFrames: nextMap,
+ }
+ }),
+ removeVideoFrames: (key: string) =>
+ storeSet((state) => {
+ const nextMap = new Map(state.videoFrames)
+ nextMap.delete(key)
+ return {
+ ...state,
+ videoFrames: nextMap,
+ }
+ }),
+ }),
+ {
+ name: "video-frame-storage",
+ storage: storage,
+ }
+ )
+)
diff --git a/components/label/store/auth.ts b/components/label/store/auth.ts
new file mode 100644
index 0000000..55e668d
--- /dev/null
+++ b/components/label/store/auth.ts
@@ -0,0 +1,414 @@
+import { ALL_CODE } from "../utils/constants"
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+import { storage } from "../store"
+import dayjs from "dayjs"
+
+const initUserInfo = {
+ user_id: null,
+ user_name: null,
+ user_password: null,
+ detailInfo: null,
+}
+
+export const usePermissionStore = create(
+ persist(
+ (set) => ({
+ user_id: null,
+ user_name: null,
+ user_password: null,
+ detailInfo: null,
+ setUserPermissionInfo: ({ user_id, user_name, detailInfo }: any) =>
+ set({
+ user_id,
+ user_name,
+ detailInfo,
+ user_password: usePermissionStore.getState().user_password,
+ }),
+ setUserPassword: (user_password: string) =>
+ set((state: any) => {
+ return {
+ ...state,
+ user_password,
+ }
+ }),
+ logout: () => set(initUserInfo),
+ }),
+ {
+ name: "label_permission",
+ }
+ )
+)
+
+export interface Option {
+ label: string
+ value: any
+}
+
+interface UserStoreProps {
+ userOpts: Option[]
+ treeData: any[]
+ adminOpts: Option[]
+ needUpdate: boolean
+ setUserOpts: (opts: Option[]) => any
+ setTreeData: (opts: any[]) => any
+ setAdminOpts: (opts: Option[]) => any
+ setFlag: (f: boolean) => void
+}
+
+export const useAllUserStore = create((set) => ({
+ userOpts: [],
+ treeData: [],
+ adminOpts: [],
+ needUpdate: true,
+ setUserOpts: (opts: Option[]) =>
+ set((state: any) => ({ ...state, userOpts: opts })),
+ setTreeData: (opts: any[]) =>
+ set((state: any) => ({ ...state, treeData: opts })),
+ setAdminOpts: (opts: Option[]) =>
+ set((state: any) => ({ ...state, adminOpts: opts })),
+ setFlag: (flag) => set((state: any) => ({ ...state, needUpdate: flag })),
+}))
+
+/* ******************* Project *********************** */
+type paramsType = "all" | "collect" | "dynamic"
+export const initAllParams = {
+ page_number: 1,
+ page_size: 15,
+ status: ALL_CODE,
+}
+export const initCollectParams = {
+ page_number: 1,
+ page_size: 15,
+ status: ALL_CODE,
+ is_collect: 1,
+}
+export const initDynamicParams = {
+ page_number: 1,
+ page_size: 15,
+ status: ALL_CODE,
+ is_dynamic: 1,
+}
+
+export const useProjectStore = create((set) => ({
+ allParams: initAllParams,
+ allTotal: 0,
+ collectParams: initCollectParams,
+ collectTotal: 0,
+ dynamicParams: initDynamicParams,
+ dynamicTotal: 0,
+ setSearchParams: (params: any, type: paramsType) =>
+ set((state: any) => {
+ let obj = { ...state }
+ if (type === "all") {
+ obj.allParams = params
+ } else if (type === "collect") {
+ obj.collectParams = params
+ } else if (type === "dynamic") {
+ obj.dynamicParams = params
+ }
+ return obj
+ }),
+ setTotal: (total: number, type: paramsType) =>
+ set((state: any) => {
+ let obj = { ...state }
+ if (type === "all") {
+ obj.allTotal = total
+ } else if (type === "collect") {
+ obj.collectTotal = total
+ } else if (type === "dynamic") {
+ obj.dynamicTotal = total
+ }
+ return obj
+ }),
+ resetParams: (type: paramsType) =>
+ set((state: any) => {
+ let obj = { ...state }
+ if (type === "all") {
+ obj.allParams = initAllParams
+ } else if (type === "collect") {
+ obj.collectParams = initCollectParams
+ } else if (type === "dynamic") {
+ obj.dynamicParams = initDynamicParams
+ }
+ return obj
+ }),
+}))
+
+/* ******************* Task *********************** */
+interface TaskStore {
+ params: any
+ total: number
+ setParams: (params: any) => void
+ resetParams: () => void
+ setPagination: (page: number, size: number) => void
+ setTotal: (total: number) => void
+}
+
+export const initTaskParams = {
+ page_number: 1,
+ page_size: 20,
+}
+
+export const useOwnTaskStore = create((set) => ({
+ params: initTaskParams,
+ total: 0,
+ setParams: (params: any) => set((state: any) => ({ ...state, params })),
+ resetParams: () =>
+ set((state: any) => ({ ...state, params: initTaskParams })),
+ setPagination: (p, s) =>
+ set((state: any) => ({
+ ...state,
+ params: { ...state.params, page_number: p, page_size: s },
+ })),
+ setTotal: (total: number) => set((state: any) => ({ ...state, total })),
+}))
+
+export const useAllTaskStore = create((set) => ({
+ params: initTaskParams,
+ total: 0,
+ setParams: (params: any) => set((state: any) => ({ ...state, params })),
+ resetParams: () =>
+ set((state: any) => ({ ...state, params: initTaskParams })),
+ setPagination: (p, s) =>
+ set((state: any) => ({
+ ...state,
+ params: { ...state.params, page_number: p, page_size: s },
+ })),
+ setTotal: (total: number) => set((state: any) => ({ ...state, total })),
+}))
+
+/* ******************* Scheme *********************** */
+// 处理标注方案数据结构
+const handlePlanData = (data: any) => {
+ const id = data.length
+ let fieldsObj: any = {}
+ const list: any = []
+ data.forEach((item: any, index: number) => {
+ const { label_class, color, label_type, radius, sub_attributes_describe } =
+ item
+ const singleClass: any = {
+ [`class${index}-label_class`]: label_class,
+ [`class${index}-color`]: color.join("_"),
+ [`class${index}-label_type`]: label_type,
+ [`class${index}-radius`]: radius,
+ }
+ const sub_count = sub_attributes_describe.length
+ const sub_attr: string[] = []
+ sub_attributes_describe.forEach((subItem: any, subIndex: number) => {
+ const { chinese_name, select_mode, round_id, isrequired, optional_item } =
+ subItem
+ sub_attr.push(`sub${subIndex}`)
+ singleClass[`class${index}-sub${subIndex}-sub_attributes`] = chinese_name
+ singleClass[`class${index}-sub${subIndex}-select_mode`] = select_mode
+ singleClass[`class${index}-sub${subIndex}-round_id`] = round_id
+ singleClass[`class${index}-sub${subIndex}-isrequired`] = isrequired
+ singleClass[`class${index}-sub${subIndex}-optional_item`] = optional_item
+ })
+ if (sub_count === 0)
+ list.push({ name: `class${index}`, sub_attr: ["sub0"], sub_count })
+ else list.push({ name: `class${index}`, sub_attr, sub_count })
+ fieldsObj = { ...fieldsObj, ...singleClass }
+ })
+ return { id, fieldsObj, list }
+}
+
+// scheme Store
+export const useSchemeStore = create((set) => ({
+ // 首页搜索
+ searchParams: {
+ current_page: 1,
+ page_size: 20,
+ },
+ total: 0,
+ setSearchParams: (params: any) =>
+ set((state: any) => ({ ...state, searchParams: params })),
+ setTotal: (total: number) =>
+ set((state: any) => ({ ...state, total: total })),
+ // 方案版本数据
+ nowVersionPlan: [],
+ nowVersionFormData: {},
+ nowVersionFormId: 0,
+ nowVersionFormLabel: [
+ { name: `class${0}`, sub_attr: ["sub0"], sub_count: 0 },
+ ],
+ selectPlan: (obj: any, data: any) => {
+ const { id, fieldsObj, list } = handlePlanData(data)
+ set((state: any) => ({
+ ...state,
+ nowVersionPlan: data,
+ nowVersionFormId: id,
+ nowVersionFormData: { ...fieldsObj, ...obj },
+ nowVersionFormLabel: list.length
+ ? list
+ : [{ name: `class${0}`, sub_attr: ["sub0"], sub_count: 0 }],
+ }))
+ },
+ resetPlan: () =>
+ set((state: any) => ({
+ ...state,
+ nowVersionPlan: [],
+ nowVersionFormData: {},
+ nowVersionFormId: 0,
+ nowVersionFormLabel: [
+ { name: `class${0}`, sub_attr: ["sub0"], sub_count: 0 },
+ ],
+ })),
+}))
+
+/* ******************* 标注页面返回跳转 *********************** */
+// 返回跳转
+export const useBackUrlStore = create(
+ persist(
+ (set) => ({
+ backUrl: null,
+ backTitle: null,
+ setBackProps: (url: string, title?: string) =>
+ set({ backUrl: url, backTitle: title || null }),
+ }),
+ {
+ name: "back_url",
+ }
+ )
+)
+
+/* ******************* 数据统计 *********************** */
+interface WorkLoadDataProps {
+ all: any[]
+ order_by_group_name: any[]
+ order_by_project_name: any[]
+ order_by_project_type: any[]
+ order_by_user_name: any[]
+}
+type dimensionType =
+ | "all"
+ | "order_by_group_name"
+ | "order_by_project_name"
+ | "order_by_project_type"
+ | "order_by_user_name"
+interface WorkloadStore {
+ labelData: WorkLoadDataProps
+ reviewData: WorkLoadDataProps
+ review2Data: WorkLoadDataProps
+ filters: {
+ label: [any, any]
+ review1: [any, any]
+ review2: [any, any]
+ }
+ dimensions: {
+ label: dimensionType
+ review1: dimensionType
+ review2: dimensionType
+ }
+ setLabelData: (data: WorkLoadDataProps) => void
+ setReviewData: (data: WorkLoadDataProps) => void
+ setReview2Data: (data: WorkLoadDataProps) => void
+ setFilters: (key: "label" | "review1" | "review2", data: [any, any]) => void
+ setDimensions: (
+ key: "label" | "review1" | "review2",
+ data: dimensionType
+ ) => void
+}
+
+export const dimensionOpts = [
+ { label: "全部", value: "all" },
+ { label: "用户名", value: "order_by_user_name" },
+ { label: "用户组", value: "order_by_group_name" },
+ { label: "项目名称", value: "order_by_project_name" },
+ { label: "项目类型", value: "order_by_project_type" },
+]
+
+export const useWorkloadStore = create(
+ persist(
+ (set) => ({
+ labelData: {
+ all: [],
+ order_by_group_name: [],
+ order_by_project_name: [],
+ order_by_project_type: [],
+ order_by_user_name: [],
+ },
+ reviewData: {
+ all: [],
+ order_by_group_name: [],
+ order_by_project_name: [],
+ order_by_project_type: [],
+ order_by_user_name: [],
+ },
+ review2Data: {
+ all: [],
+ order_by_group_name: [],
+ order_by_project_name: [],
+ order_by_project_type: [],
+ order_by_user_name: [],
+ },
+ filters: {
+ label: [dayjs().subtract(1, "day"), dayjs().subtract(1, "day")],
+ review1: [dayjs().subtract(1, "day"), dayjs().subtract(1, "day")],
+ review2: [dayjs().subtract(1, "day"), dayjs().subtract(1, "day")],
+ },
+ dimensions: {
+ label: "all",
+ review1: "all",
+ review2: "all",
+ },
+ setLabelData: (data) => set((state) => ({ ...state, labelData: data })),
+ setReviewData: (data) => set((state) => ({ ...state, reviewData: data })),
+ setReview2Data: (data) =>
+ set((state) => ({ ...state, review2Data: data })),
+ setFilters: (key, data) =>
+ set((state) => ({
+ ...state,
+ filters: { ...state.filters, [key]: data },
+ })),
+ setDimensions: (key, data) =>
+ set((state) => ({
+ ...state,
+ dimensions: { ...state.dimensions, [key]: data },
+ })),
+ }),
+ {
+ name: "workload",
+ storage: storage,
+ }
+ )
+)
+
+interface TaskDataStore {
+ taskData: any[]
+ filters: {
+ page_size: number
+ project_type?: string[]
+ project_id?: number[]
+ label_status?: number[]
+ current_uid?: number[]
+ label_user?: number[]
+ review_user1?: number[]
+ review_user2?: number[]
+ }
+ totalSize: any
+ total: number | null
+ setTaskData: (data: any[]) => void
+ setFilters: (data: any) => void
+ setTotal: (n: number) => void
+ setTotalSize: (data: any) => void
+}
+
+export const useTaskDataStore = create(
+ persist(
+ (set) => ({
+ taskData: [],
+ filters: { page_size: 1000 },
+ total: null,
+ totalSize: {},
+ setTaskData: (data) => set((state) => ({ ...state, taskData: data })),
+ setFilters: (data) => set((state) => ({ ...state, filters: data })),
+ setTotal: (num) => set((state) => ({ ...state, total: num })),
+ setTotalSize: (data) => set((state) => ({ ...state, totalSize: data })),
+ }),
+ {
+ name: "task-list",
+ storage: storage,
+ }
+ )
+)
diff --git a/components/label/useBottomToolsStore.ts b/components/label/useBottomToolsStore.ts
new file mode 100644
index 0000000..3aaaa29
--- /dev/null
+++ b/components/label/useBottomToolsStore.ts
@@ -0,0 +1,102 @@
+import { create } from "zustand"
+
+interface KeyFrameData {
+ key_frame: boolean
+ label1_ts: number
+ label1_uid: number
+ review1_ts: number
+ review1_uid: number
+ review2_ts: number
+ review2_uid: number
+}
+
+interface BottomToolsState {
+ activeImage: string
+ setActiveImage: (val: string) => void
+ inputNumber: string
+ setInputNumber: (val: string) => void
+ activeImageId: number
+ setActiveImageId: (val: number) => void
+ onlyKeyFrame: boolean
+ setOnlyKeyFrame: (val: boolean) => void
+ keyFrameData: Map
+ setKeyFrameData: (imageId: string, value: KeyFrameData) => void
+ allItems: string[]
+ setAllItems: (arr: string[]) => void
+ selectedItems: string[]
+ setSelectedItems: (
+ imageId: string,
+ ctrlKey: boolean,
+ shiftKey: boolean
+ ) => void
+}
+
+const initialBottomToolsState = {
+ activeImage: "",
+ inputNumber: "",
+}
+
+export const useBottomToolsStore = create((set) => ({
+ activeImage: initialBottomToolsState.activeImage,
+ setActiveImage: (val: string) =>
+ set((state: BottomToolsState) => ({
+ ...state,
+ activeImage: val,
+ })),
+ inputNumber: initialBottomToolsState.inputNumber,
+ setInputNumber: (val: string) =>
+ set((state: BottomToolsState) => ({
+ ...state,
+ inputNumber: val,
+ })),
+ activeImageId: 0,
+ setActiveImageId: (val) =>
+ set((state: BottomToolsState) => ({
+ ...state,
+ activeImageId: val,
+ })),
+ onlyKeyFrame: false,
+ setOnlyKeyFrame: (val) =>
+ set((state) => ({
+ ...state,
+ onlyKeyFrame: val,
+ })),
+ keyFrameData: new Map(),
+ setKeyFrameData: (imageId, value) => {
+ const data = useBottomToolsStore.getState().keyFrameData
+ data.set(imageId, value)
+ return set((state) => ({
+ ...state,
+ keyFrameData: data,
+ }))
+ },
+ allItems: [],
+ setAllItems: (arr) =>
+ set((state) => ({
+ ...state,
+ allItems: arr,
+ })),
+ selectedItems: [],
+ setSelectedItems: (id, ctrlKey, shiftKey) => {
+ let arr = [...useBottomToolsStore.getState().selectedItems]
+ if ((ctrlKey && shiftKey) || (!ctrlKey && !shiftKey)) arr = []
+ else if (ctrlKey && !shiftKey) {
+ arr.push(id)
+ } else if (!ctrlKey && shiftKey) {
+ const allItems = useBottomToolsStore.getState().allItems
+ const prevId = arr.length
+ ? arr[arr.length - 1]
+ : useBottomToolsStore.getState().activeImage
+ const prevIndex = allItems.findIndex((v) => v === prevId)
+ const currentIndex = allItems.findIndex((v) => v === id)
+ arr =
+ prevIndex < currentIndex
+ ? allItems.slice(prevIndex, currentIndex + 1)
+ : allItems.slice(currentIndex, prevIndex + 1)
+ }
+ return set((state) => ({
+ ...state,
+ selectedItems: arr,
+ }))
+ },
+}))
diff --git a/components/label/useDescToolsStore.ts b/components/label/useDescToolsStore.ts
new file mode 100644
index 0000000..5eb1107
--- /dev/null
+++ b/components/label/useDescToolsStore.ts
@@ -0,0 +1,222 @@
+import { Project } from "./api/project/typing"
+import { create } from "zustand"
+import { splitWord } from "./components/EditorContainer"
+
+interface SubAttribute {
+ value?: string | { [x: string]: any }
+ is_correct?: boolean
+ comment?: string
+}
+
+interface QaDataProps {
+ value: string
+ id: number // 轮次
+ is_pre: boolean // 是否是预设问题
+ tag: number // 标识该问题是否被审核过 0-未审核 1-正确 2-错误
+ uid: number // 创建人
+ create_timestamp: number // 创建时间
+ modify_uid: number // 修改人
+ modify_timestamp: number // 修改时间
+ comment: string // 批注内容
+ flag: number // 标识被批注过的问题,标注员是否已修改
+}
+
+interface DescToolsStore {
+ updateDescDataFlag: boolean
+ descOperations: Project.LabelSchemaList[]
+ setDescOperations: (val: Project.LabelSchemaList[]) => void
+ metaOperation: Project.LabelSchemaList | null
+ setMetaOperation: (val: Project.LabelSchemaList | null) => void
+ descData: Map>
+ getFormDataByImageId: (id: string) => { [x: string]: any }
+ getDataByClassName: (id: string, name: string) => SubAttribute | null
+ setDescData: (data: Map>) => void
+ metaData: Map
+ setMetaData: (data: Map) => void
+ qaOperations: Project.LabelSchemaList[]
+ setQaOperations: (val: Project.LabelSchemaList[]) => void
+ qaData: Map }>
+ setQaData: (
+ data: Map }>
+ ) => void
+ getQuestionList: (imageId: string) => string[]
+ countTurnsAndQaNumber: (
+ imageId: string,
+ label: string
+ ) => {
+ turns: number
+ question_size: number
+ }
+ countAllTurnsAndQaNumber: (imageId: string) => {
+ turns: number
+ question_size: number
+ new_single_turns: number
+ new_multiple_turns: number
+ }
+ resetData: () => void
+ updateFlag: (val: boolean) => void
+}
+
+export const useDescToolsStore = create((set) => ({
+ updateDescDataFlag: false,
+ descOperations: [],
+ descData: new Map(),
+ metaOperation: null,
+ metaData: new Map(),
+ qaOperations: [],
+ qaData: new Map(),
+ setDescOperations: (val) =>
+ set((state) => ({
+ ...state,
+ descOperations: val,
+ })),
+ getFormDataByImageId: (id) => {
+ let obj: any = {}
+ const data: Map | undefined = useDescToolsStore
+ .getState()
+ .descData.get(id)
+ if (!data) return {}
+ data.entries().forEach(([label_class, subattr]) => {
+ if (typeof subattr.value === "string") {
+ obj[label_class] = {
+ ...subattr,
+ value: subattr.value.split(splitWord)[0],
+ }
+ } else if (typeof subattr.value === "object") {
+ obj[label_class] = { ...subattr.value }
+ } else {
+ obj[label_class] = {}
+ }
+ })
+ return obj
+ },
+ getDataByClassName: (id, label_class) => {
+ let attr = null
+ const data: any = useDescToolsStore.getState().descData.get(id)
+ if (data && data.has(label_class)) {
+ attr = data.get(label_class)
+ }
+ return attr
+ },
+ setDescData: (data) =>
+ set((state) => ({
+ ...state,
+ descData: data,
+ })),
+ setMetaOperation: (val) =>
+ set((state) => ({
+ ...state,
+ metaOperation: val,
+ })),
+ setMetaData: (data) =>
+ set((state) => ({
+ ...state,
+ metaData: data,
+ })),
+ setQaOperations: (val) =>
+ set((state) => ({
+ ...state,
+ qaOperations: val,
+ })),
+ setQaData: (data) =>
+ set((state) => ({
+ ...state,
+ qaData: data,
+ })),
+ getQuestionList: (imageId) => {
+ let names: string[] = []
+ const data = useDescToolsStore.getState().qaData.get(imageId) ?? {}
+ Object.values(data).forEach((value) => {
+ value.forEach((v) => {
+ const key = Object.keys(v)[0]
+ names.push(key)
+ })
+ })
+ return names
+ },
+ countTurnsAndQaNumber: (imageId, label) => {
+ let obj = {
+ turns: 0,
+ question_size: 0,
+ }
+ const data =
+ useDescToolsStore.getState().qaData.get(imageId) ??
+ useDescToolsStore.getState().qaData.get("init") ??
+ {}
+ const labelArr = data[label]
+ if (labelArr && labelArr.length) {
+ obj.question_size = labelArr.length
+ obj.turns = [
+ ...new Set(
+ labelArr.map((l) => {
+ const v = Object.values(l)[0]
+ return v.id
+ })
+ ),
+ ].length
+ }
+ return obj
+ },
+ countAllTurnsAndQaNumber: (imageId) => {
+ let obj = {
+ turns: 0,
+ question_size: 0,
+ new_single_turns: 0,
+ new_multiple_turns: 0,
+ }
+ const data =
+ useDescToolsStore.getState().qaData.get(imageId) ??
+ useDescToolsStore.getState().qaData.get("init") ??
+ {}
+ Object.values(data).forEach((value) => {
+ obj.question_size += value.length
+ obj.turns += [
+ ...new Set(
+ value.map((d) => {
+ const v = Object.values(d)[0]
+ return v.id
+ })
+ ),
+ ].length
+ let singleRoundCount = 0
+ let multiRoundCount = 0
+ const roundMap = new Map<
+ number,
+ { hasNewQuestion: boolean; count: number }
+ >()
+ value.forEach((d) => {
+ const v = Object.values(d)[0]
+ if (!roundMap.has(v.id)) {
+ roundMap.set(v.id, { hasNewQuestion: !v.is_pre, count: 1 })
+ } else {
+ const exist = roundMap.get(v.id)
+ if (!exist?.hasNewQuestion && !v.is_pre) exist!.hasNewQuestion = true
+ exist!.count++
+ }
+ })
+ roundMap.values().forEach((round) => {
+ if (round.hasNewQuestion) {
+ if (round.count > 1) {
+ multiRoundCount++
+ } else {
+ singleRoundCount++
+ }
+ }
+ })
+ obj.new_single_turns += singleRoundCount
+ obj.new_multiple_turns += multiRoundCount
+ })
+ return obj
+ },
+ resetData: () =>
+ set((state) => ({
+ ...state,
+ descData: new Map(),
+ metaData: new Map(),
+ })),
+ updateFlag: (data) =>
+ set((state) => ({
+ ...state,
+ updateDescDataFlag: data,
+ })),
+}))
diff --git a/components/label/useKeyBoardStore.tsx b/components/label/useKeyBoardStore.tsx
new file mode 100644
index 0000000..8b2f4fe
--- /dev/null
+++ b/components/label/useKeyBoardStore.tsx
@@ -0,0 +1,19 @@
+import { create } from "zustand"
+
+interface KeyboardStore {
+ ctrl: boolean
+ setCtrl: (v: boolean) => void
+ shift: boolean
+ setShift: (v: boolean) => void
+ copyDataIds: number[] // ctrl + c 复制数据
+ setCopyDataIds: (ids: number[]) => void
+}
+
+export const useKeyboardStore = create((set) => ({
+ ctrl: false,
+ shift: false,
+ copyDataIds: [],
+ setCtrl: (v) => set((state) => ({ ...state, ctrl: v })),
+ setShift: (v) => set((state) => ({ ...state, shift: v })),
+ setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })),
+}))
diff --git a/components/label/useOtherToolsStore.ts b/components/label/useOtherToolsStore.ts
new file mode 100644
index 0000000..b8e578a
--- /dev/null
+++ b/components/label/useOtherToolsStore.ts
@@ -0,0 +1,108 @@
+import { Comment } from "./api/label/typing"
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+import { useLabelStore } from "./store"
+import { useTopToolsStore } from "./useTopToolsStore"
+
+interface OtherToolsState {
+ showContextMenu: boolean
+ position: {
+ top: number
+ right: number
+ bottom: number
+ left: number
+ }
+ imageId: string
+ operationId: string
+ id: number
+ setContextMenuId: (id: number) => void
+ setContextMenuOperationId: (operationId: string) => void
+ setShowContextMenu: (props: {
+ showContextMenu: boolean
+ position: {
+ top: number
+ left: number
+ }
+ imageId: string
+ operationId: string
+ id: number
+ }) => void
+}
+
+const initOtherToolsState = {
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ imageId: "",
+ operationId: "",
+ id: 0,
+}
+
+export const useOtherToolsStore = create(
+ persist(
+ (set) => ({
+ showContextMenu: initOtherToolsState.showContextMenu,
+ position: initOtherToolsState.position,
+ imageId: initOtherToolsState.imageId,
+ operationId: initOtherToolsState.operationId,
+ id: initOtherToolsState.id,
+ setContextMenuId: (id) =>
+ set((state: OtherToolsState) => ({
+ ...state,
+ id,
+ })),
+ setContextMenuOperationId: (operationId) =>
+ set((state: OtherToolsState) => ({
+ ...state,
+ operationId,
+ })),
+ setShowContextMenu: ({
+ showContextMenu,
+ position,
+ imageId,
+ operationId,
+ id,
+ }) => {
+ if (
+ showContextMenu &&
+ useTopToolsStore.getState().currentTimeKey !== "label"
+ ) {
+ // 打开子属性及批注弹窗时,更新当前标注对象detail
+ const currentDetail = useLabelStore
+ .getState()
+ .getLabelDetailDataByKeys(imageId, operationId, id)
+ const { comment } = currentDetail
+ const updatedComment = comment.map((item: Comment, index: number) =>
+ index < comment.length - 1
+ ? item
+ : {
+ ...item,
+ comment_type: item.comment_type || 1,
+ }
+ )
+ useLabelStore
+ .getState()
+ .updateLabelDetailDataByKeys(imageId, operationId, id, {
+ ...currentDetail,
+ comment: updatedComment,
+ })
+ }
+ return set((state: OtherToolsState) => ({
+ ...state,
+ showContextMenu,
+ position: { ...position, bottom: 0, right: 0 },
+ imageId,
+ operationId,
+ id,
+ }))
+ },
+ }),
+ {
+ name: "other-tools",
+ }
+ )
+)
diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts
new file mode 100644
index 0000000..0e0e3a7
--- /dev/null
+++ b/components/label/usePaperStore.ts
@@ -0,0 +1,4537 @@
+import { Comment } from "./api/label/typing"
+import { Project } from "./api/project/typing"
+import dayjs from "dayjs"
+import paper from "paper"
+import { DispatchWithoutAction } from "react"
+import { create } from "zustand"
+import { usePermissionStore } from "./store/auth"
+import {
+ initialDetail,
+ useKeyEventStore,
+ useLabelStore,
+ useObjectStore,
+} from "./store"
+import { useBottomToolsStore } from "./useBottomToolsStore"
+import { useOtherToolsStore } from "./useOtherToolsStore"
+import { usePaperSupportStore } from "./usePaperSupportStore"
+import { useRightToolsStore } from "./useRightToolsStore"
+import { useTopToolsStore } from "./useTopToolsStore"
+import { safeClone } from "./utils/clone"
+
+interface PaperState {
+ paperScope: paper.PaperScope | null
+ group: paper.Group | null
+ rasterPath: paper.Path | null
+ el: HTMLCanvasElement | null
+ viewTool: paper.Tool | null
+ panTool: paper.Tool | null
+ toolOption: any
+ polygonTool: paper.Tool | null
+ rectangleTool: paper.Tool | null
+ brushTool: paper.Tool | null
+ pointTool: paper.Tool | null
+ supportTool: paper.Tool | null
+ mode: "pan" | "polygon" | "rectangle" | "brush" | "point" | "support" | null
+ rasterScale: { [x: string]: number }
+ rasterSize: { [x: string]: [number, number] }
+ reciprocalRasterScale: { [x: string]: number }
+ loadingData: boolean
+ init: (
+ initEl: HTMLCanvasElement,
+ initScope: paper.PaperScope,
+ initGroup: paper.Group
+ ) => void
+ setRasterPath: (paperRaster: paper.Path) => void
+ setRasterScale: (id: string, scale: number) => void
+ setRasterSize: (id: string, size: [number, number]) => void
+ resetRasterScale: () => void
+ initViewTool: (paperPanTool: paper.Tool) => void
+ initPanTool: (
+ paperPanTool: paper.Tool,
+ renderRectangle: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.Path.Rectangle,
+ forceUpdate: DispatchWithoutAction
+ ) => void
+ setToolOption: (toolOption: any) => void
+ addPolygon: (
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ points: paper.Point[] | undefined,
+ option: any,
+ data: any
+ ) => paper.Path
+ addCompoundPath: (
+ renderCompoundPath: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.CompoundPath,
+ children: paper.Path[] | undefined,
+ option: any,
+ data: any
+ ) => paper.CompoundPath
+ initPolygonTool: (
+ paperPolygonTool: paper.Tool,
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ renderCompoundPath: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.CompoundPath,
+ forceUpdate: DispatchWithoutAction
+ ) => void
+ addRectangle: (
+ renderRectangle: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.Path.Rectangle,
+ option: any,
+ data: any
+ ) => paper.Path.Rectangle
+ initRectangleTool: (
+ paperRectangleTool: paper.Tool,
+ renderRectangle: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.Path.Rectangle,
+ forceUpdate: DispatchWithoutAction
+ ) => void
+ addBrush: (
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ points: paper.Point[] | undefined,
+ option: any,
+ data: any
+ ) => paper.Path
+ initBrushTool: (
+ paperBrushTool: paper.Tool,
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ renderCompoundPath: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.CompoundPath,
+ forceUpdate: DispatchWithoutAction
+ ) => void
+ addPoint: (
+ renderCircle: (paperGroup: paper.Group, option: any) => paper.Path.Circle,
+ points: paper.Point | undefined,
+ option: any,
+ data: any
+ ) => paper.Path.Circle
+ addPointLine: (
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ points: paper.Point[] | undefined,
+ option: any,
+ data: any
+ ) => paper.Path
+ initPointTool: (
+ paperPointTool: paper.Tool,
+ renderCircle: (paperGroup: paper.Group, option: any) => paper.Path.Circle,
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ forceUpdate: DispatchWithoutAction
+ ) => void
+ getPointCircle: (
+ point: paper.Point,
+ index: number,
+ id: number,
+ color: any
+ ) => paper.Path.Circle
+ initSupportTool: (
+ tool: paper.Tool,
+ renderPath: (
+ points: Array<[number, number]>,
+ tags: number[],
+ flag?: boolean
+ ) => void
+ ) => void
+ getClickedCircle: (
+ point: paper.Point,
+ index: number,
+ id: number,
+ color: any
+ ) => paper.Path.Circle
+ getBuffPath: (
+ item: paper.Segment,
+ index: number,
+ id: number,
+ color: any
+ ) => paper.Path | null
+ setPaperMode: (
+ modeParam:
+ | "pan"
+ | "polygon"
+ | "rectangle"
+ | "brush"
+ | "point"
+ | "support"
+ | null
+ ) => void
+ setGroupScale: (scale: number) => void
+ getAll: () => paper.Path[]
+ getItemById: (id: number) => paper.Path | null
+ getItemsById: (id: number) => Array
+ setLoadingData: (flag: boolean) => void
+}
+
+interface PositionStore {
+ currentMousePosition: [number, number]
+ setCurrentMousePosition: (p: [number, number]) => void
+}
+
+const initialPaperState = {
+ paperScope: null,
+ group: null,
+ rasterPath: null,
+ el: null,
+ mode: null,
+ viewTool: null,
+ panTool: null,
+ toolOption: {
+ strokeColor: "red",
+ strokeWidth: 2,
+ fillColor: "#ff000033",
+ blankColor: "#ff000033",
+ },
+ polygonTool: null,
+ rectangleTool: null,
+ brushTool: null,
+ pointTool: null,
+ rasterScale: {},
+ rasterSize: {},
+ reciprocalRasterScale: {},
+}
+const handleDelete = () => {
+ const group = usePaperStore.getState().group!
+ const imageId = useBottomToolsStore.getState().activeImage
+ let ids = useObjectStore.getState().selectedPath[imageId]
+ let operationIds: any[] = []
+ let parentGroupIds: any[] = []
+
+ ids.forEach((id, index) => {
+ const items = group.getItems({ data: { id } })
+ items.forEach((item: any) => {
+ item.remove()
+ if (item.data.operationId && !operationIds[index])
+ operationIds[index] = item.data.operationId
+ if (item.data.parentGroupId && !parentGroupIds[index])
+ parentGroupIds[index] = item.data.parentGroupId
+ })
+ useObjectStore
+ .getState()
+ .updateSelectedPath(
+ useBottomToolsStore.getState().activeImage,
+ "DELETE",
+ id
+ )
+ })
+
+ // handle group path update
+ if (parentGroupIds.length) {
+ console.log(parentGroupIds)
+ const groupMap = useRightToolsStore.getState().pathGroupMap.get(imageId)
+ parentGroupIds.forEach((parentGroupId, index) => {
+ if (parentGroupId) {
+ const currentGroupIds = groupMap
+ ?.get(parentGroupId)
+ ?.filter((v) => v !== ids[index])
+ if (!currentGroupIds || currentGroupIds?.length <= 1) {
+ groupMap?.delete(parentGroupId)
+ } else {
+ groupMap?.set(parentGroupId, currentGroupIds ?? [])
+ }
+ }
+ })
+ }
+ const currentLabelData = safeClone(useLabelStore.getState().label)
+ operationIds.forEach((operationId, index) => {
+ let result =
+ currentLabelData
+ .get(imageId)
+ ?.get(operationId)
+ ?.filter((item) => item[0] !== ids[index]) ?? []
+ currentLabelData.get(imageId)?.set(operationId, result)
+ })
+ useLabelStore.getState().setLabel(currentLabelData)
+ useLabelStore.getState().pushStateStack(currentLabelData)
+ // ids.forEach((id) => {
+ // usePaperStore
+ // .getState()
+ // .group?.getItems({
+ // data: { id: id },
+ // })
+ // .forEach((item: any) => {
+ // item.remove();
+ // if (item.data.id) {
+ // if (item.data.imageId && item.data.operationId) {
+ // useLabelStore
+ // .getState()
+ // .deleteOperation(
+ // item.data.imageId,
+ // item.data.operationId,
+ // item.data.id
+ // );
+ // }
+ // // tocheck
+ // useObjectStore
+ // .getState()
+ // .updateSelectedPath(
+ // useBottomToolsStore.getState().activeImage,
+ // item.data.id
+ // );
+ // }
+ // });
+ // });
+}
+
+const handleShift = (flag: boolean) => {
+ useKeyEventStore.getState().setShift(flag)
+}
+
+// 通用 鼠标在paperScope上移动时更新当前鼠标位置
+const handleMouseMove = (e: paper.MouseEvent) => {
+ usePositionStore.getState().setCurrentMousePosition([e.point.x, e.point.y])
+}
+
+const { user_id } = usePermissionStore.getState()
+
+type OrderPoint = [number, number]
+const getOrder = (b: OrderPoint[]): number[] => {
+ // 左下角:x最小,y最大
+ // 左上角:x最小,y最小
+ // 右上角:x最大,y最小
+ // 右下角:x最大,y最大
+ const bottomLeft = b.reduce(
+ (acc, point) =>
+ point[0] < acc[0] || (point[0] === acc[0] && point[1] > acc[1])
+ ? point
+ : acc,
+ b[0]
+ )
+ const topLeft = b.reduce(
+ (acc, point) =>
+ point[0] < acc[0] || (point[0] === acc[0] && point[1] < acc[1])
+ ? point
+ : acc,
+ b[0]
+ )
+ const topRight = b.reduce(
+ (acc, point) =>
+ point[0] > acc[0] || (point[0] === acc[0] && point[1] < acc[1])
+ ? point
+ : acc,
+ b[0]
+ )
+ const bottomRight = b.reduce(
+ (acc, point) =>
+ point[0] > acc[0] || (point[0] === acc[0] && point[1] > acc[1])
+ ? point
+ : acc,
+ b[0]
+ )
+
+ return [
+ b.indexOf(bottomLeft),
+ b.indexOf(topLeft),
+ b.indexOf(topRight),
+ b.indexOf(bottomRight),
+ ]
+}
+
+const handleRectangleIntersectRaster = (rect: paper.Path) => {
+ let intersections = rect.getIntersections(
+ usePaperStore.getState().rasterPath!
+ )
+ let validIntersections = intersections.filter((intersection) => {
+ // 检查交点是否在两个路径的可见部分
+ return (
+ rect?.contains(intersection.point) &&
+ usePaperStore.getState().rasterPath!.contains(intersection.point)
+ )
+ })
+
+ // 没有 validIntersections 不进行操作
+ if (validIntersections.length) {
+ let intersectionPath: paper.Path | null = rect.intersect(
+ usePaperStore.getState().rasterPath!
+ ) as paper.Path
+
+ let rectOrder = getOrder(
+ rect.segments.map((segment) => [
+ +segment.point.x.toFixed(2),
+ +segment.point.y.toFixed(2),
+ ])
+ )
+
+ let intersectionOrder = getOrder(
+ intersectionPath.segments.map((segment) => [
+ +segment.point.x.toFixed(2),
+ +segment.point.y.toFixed(2),
+ ])
+ )
+
+ let order = rectOrder.map((rorder) =>
+ intersectionOrder.findIndex((iorder) => iorder === rorder)
+ )
+
+ if (intersectionPath) {
+ rect.segments = order.map((child) => intersectionPath!.segments[child])
+ }
+
+ // rect.segments = [
+ // intersectionPath.segments[1],
+ // intersectionPath.segments[2],
+ // intersectionPath.segments[3],
+ // intersectionPath.segments[0],
+ // ];
+ // rect.segments = intersectionPath.segments;
+ intersectionPath.remove()
+ intersectionPath = null
+ } else {
+ let intersectionPath: paper.Path | null = rect.intersect(
+ usePaperStore.getState().rasterPath!
+ ) as paper.Path
+ rect.segments = intersectionPath.segments
+ intersectionPath.remove()
+ intersectionPath = null
+ }
+}
+
+export const updateObjLatestComment = (
+ imageId: any,
+ operationId: any,
+ id: any
+) => {
+ // 标注中的任务不对批注状态进行更新
+ if (useTopToolsStore.getState().currentTimeKey === "label") return
+ const currentDetail = useLabelStore
+ .getState()
+ .getLabelDetailDataByKeys(imageId, operationId, id)
+ const comment = currentDetail?.comment || []
+ const updatedComment = comment.map((item: Comment, index: number) =>
+ index < comment.length - 1
+ ? item
+ : { ...item, comment_type: item.comment_type || 1 }
+ )
+ useLabelStore
+ .getState()
+ .updateLabelDetailDataByKeys(imageId, operationId, id, {
+ ...currentDetail,
+ comment: updatedComment,
+ })
+}
+
+// 清空辅助标注形成的path
+export const clearSupportAnnotationItem = () => {
+ const needClearItems = usePaperStore
+ .getState()
+ .group!.getItems({ data: { id: "support" } })
+ needClearItems.forEach((item: any) => item.remove())
+}
+
+// 根据点击位置判断弹窗弹出的top、left
+const containerWidth = 244
+const containerHeight = 240
+export const getShowContextMenuPosition = (
+ clickPoint: paper.Point,
+ category: Project.LabelSchemaList,
+ offsetWidth = 0,
+ offsetHeight = 0
+) => {
+ let subAttrHeight = 0
+ if (category) {
+ const { sub_attributes_describe } = category
+ console.log(sub_attributes_describe)
+
+ if (sub_attributes_describe.length) subAttrHeight += 30
+ sub_attributes_describe.forEach((item) => {
+ if (item.chinese_name) subAttrHeight += 32
+ if (item.optional_item && item.optional_item.length)
+ item.optional_item.forEach(() => {
+ subAttrHeight += 22
+ })
+ if (item.select_mode === 0) subAttrHeight += 32
+ })
+ }
+
+ const finalHeight = containerHeight + subAttrHeight
+ const element = document.getElementById("paper-container")
+ const rasterPos = [element!.clientWidth, element!.clientHeight]
+ console.log(rasterPos, finalHeight)
+ let left =
+ clickPoint.x + containerWidth > rasterPos[0]
+ ? clickPoint.x + offsetWidth - containerWidth
+ : clickPoint.x + offsetWidth
+ let top =
+ clickPoint.y + finalHeight > rasterPos[1]
+ ? clickPoint.y + offsetHeight - finalHeight < 0
+ ? 0
+ : clickPoint.y + offsetHeight - finalHeight
+ : clickPoint.y + offsetHeight
+ console.log("转换之后的x、y", left, top)
+
+ return {
+ top,
+ left,
+ }
+}
+
+export const usePositionStore = create((set) => ({
+ currentMousePosition: [0, 0],
+ setCurrentMousePosition: (point) =>
+ set((state) => ({ ...state, currentMousePosition: point })),
+}))
+
+// store paper
+export const usePaperStore = create((set) => ({
+ paperScope: initialPaperState.paperScope,
+ group: initialPaperState.group,
+ rasterPath: initialPaperState.rasterPath,
+ el: initialPaperState.el,
+ mode: initialPaperState.mode,
+ viewTool: initialPaperState.viewTool,
+ panTool: initialPaperState.panTool,
+ toolOption: initialPaperState.toolOption,
+ polygonTool: initialPaperState.polygonTool,
+ rectangleTool: initialPaperState.rectangleTool,
+ brushTool: initialPaperState.brushTool,
+ pointTool: initialPaperState.pointTool,
+ supportTool: null,
+ rasterScale: initialPaperState.rasterScale,
+ rasterSize: initialPaperState.rasterSize,
+ reciprocalRasterScale: initialPaperState.reciprocalRasterScale,
+ currentMousePosition: [0, 0],
+ loadingData: false,
+ init: (
+ initEl: HTMLCanvasElement,
+ initScope: paper.PaperScope,
+ initGroup: paper.Group
+ ) => {
+ set((state: PaperState) => ({
+ ...state,
+ paperScope: initScope,
+ group: initGroup,
+ el: initEl,
+ }))
+ },
+ setRasterPath: (rasterPath) => {
+ set((state: PaperState) => ({
+ ...state,
+ rasterPath: rasterPath,
+ }))
+ },
+ setRasterScale: (id: string, scale: number) => {
+ set((state: PaperState) => ({
+ ...state,
+ rasterScale: { ...state.rasterScale, [id]: scale },
+ reciprocalRasterScale: {
+ ...state.reciprocalRasterScale,
+ [id]: 1 / scale,
+ },
+ }))
+ },
+ setRasterSize: (id: string, size: [number, number]) => {
+ set((state: PaperState) => ({
+ ...state,
+ rasterSize: { ...state.rasterSize, [id]: size },
+ }))
+ },
+ resetRasterScale: () =>
+ set((state: PaperState) => ({
+ ...state,
+ rasterScale: {},
+ reciprocalRasterScale: {},
+ })),
+ setLoadingData: (flag) =>
+ set((state: PaperState) => ({
+ ...state,
+ loadingData: flag,
+ })),
+ setCurrentMousePosition: (point: [number, number]) =>
+ set((state: PaperState) => ({
+ ...state,
+ currentMousePosition: point,
+ })),
+ setToolOption: (toolOption: any) => {
+ set((state: PaperState) => ({
+ ...state,
+ toolOption: toolOption,
+ }))
+ },
+ initViewTool: (paperViewTool: paper.Tool) => {
+ let viewTool = paperViewTool
+ viewTool.onMouseMove = (e: paper.MouseEvent) => {
+ handleMouseMove(e)
+ }
+ set((state: PaperState) => ({
+ ...state,
+ viewTool: viewTool,
+ }))
+ },
+ initPanTool: (
+ paperPanTool: paper.Tool,
+ renderRectangle: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.Path.Rectangle,
+ forceUpdate: DispatchWithoutAction
+ ) => {
+ let panMode = ""
+ let activePath: paper.Path
+ // let compoundPolygon: paper.Path | paper.CompoundPath | null;
+ let hitIndex: number
+ let pressShift: boolean = false
+ let pressCtrl: boolean = false
+ let rect: paper.Path.Rectangle | null
+ let panTool = paperPanTool
+ let lastPoint: paper.Point
+ let isChanged: boolean = false
+ let lastTime: number
+
+ // const handleSelected = (path: paper.Path) => {
+ // // path.blendMode = "subtract";
+ // path.segments.forEach((segment) => {
+ // // console.log(segment.index);
+
+ // // let point = usePaperStore
+ // // .getState()
+ // // .group!.globalToLocal(segment.point);
+ // let circle = usePaperStore
+ // .getState()
+ // .getClickedCircle(
+ // segment.point,
+ // segment.index,
+ // path.data.id,
+ // path.data.blankColor
+ // );
+ // // circle.insertBelow(path);
+ // // path.addChild(circle);
+
+ // selectedCircles.push(circle);
+ // // usePaperStore.getState().group?.addChild(circle);
+ // // circle.bringToFront();
+ // // circle.addTo(path);
+ // });
+ // };
+
+ const handleCtrl = (rect: paper.Path.Rectangle) => {
+ let items = usePaperStore
+ .getState()
+ .group!.getItems({})
+ .filter(
+ (item) =>
+ item.data?.id &&
+ ["rectangle", "polygon", "brush", "point"].includes(item.data.type)
+ )
+ // 清空选中
+ usePaperStore
+ .getState()
+ .group!.getItems({
+ data: { selected: true },
+ })
+ .forEach((item) => {
+ if (item.data.selected) {
+ item.data.selected = false
+ usePaperSupportStore.getState().clearMasks()
+ usePaperSupportStore.getState().clearTexts()
+ }
+ // item.data.selected = false;
+ })
+ handleCancelSelect()
+ for (let i = 0; i < items.length; i++) {
+ let child = items[i]
+ if (child instanceof paper.Path) {
+ if (
+ rect.intersects(child.bounds as any) ||
+ rect.contains(child.bounds)
+ ) {
+ if (rect.data.id !== child.data.id) {
+ if (child.data.type !== "brush" && child.data.type !== "point") {
+ child.fillColor = child.data.fillColor
+ }
+ if (child.data.type === "rectangle") {
+ if (child instanceof paper.Path) child.data.selected = true
+ } else if (child.data.type === "brush") {
+ child.data.selected = true
+ usePaperSupportStore.getState().handleMask(child)
+ } else if (child.data.type === "point") {
+ child.data.selected = true
+ usePaperSupportStore.getState().handleMask(child)
+ usePaperSupportStore.getState().handleText(child as paper.Path)
+ }
+ useObjectStore
+ .getState()
+ .updateSelectedPath(
+ useBottomToolsStore.getState().activeImage,
+ "ADDSOME",
+ child.data?.id
+ )
+ }
+ }
+ } else if (child instanceof paper.CompoundPath) {
+ let bool = child.children.some((item) => {
+ return (
+ rect.intersects(item.bounds as any) || rect.contains(item.bounds)
+ )
+ })
+ if (bool) {
+ if (rect.data.id !== child.data.id) {
+ if (child.data.type !== "brush" && child.data.type !== "point") {
+ child.fillColor = child.data.fillColor
+ }
+ if (child.data.type === "rectangle") {
+ if (child instanceof paper.Path) child.data.selected = true
+ } else if (child.data.type === "brush") {
+ child.data.selected = true
+ usePaperSupportStore.getState().handleMask(child)
+ } else if (child.data.type === "point") {
+ child.data.selected = true
+ usePaperSupportStore.getState().handleMask(child)
+ usePaperSupportStore.getState().handleText(child as paper.Path)
+ }
+ useObjectStore
+ .getState()
+ .updateSelectedPath(
+ useBottomToolsStore.getState().activeImage,
+ "ADDSOME",
+ child.data?.id
+ )
+ }
+ }
+ }
+ }
+ }
+
+ const handleCancelSelect = () => {
+ usePaperStore
+ .getState()
+ .group!.getItems({})
+ .forEach((item) => {
+ // clear color
+ if (
+ !(
+ item.data.type === "mask" ||
+ item.data.type === "text" ||
+ item.data.type === "tag"
+ )
+ )
+ item.fillColor = item?.data?.blankColor
+ })
+
+ // 如果没有按下shift 当前图片SelectedPath被清空 下面从空数组开始push
+ useObjectStore
+ .getState()
+ .updateSelectedPath(useBottomToolsStore.getState().activeImage, "ADD")
+ }
+
+ panTool.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ if (pressCtrl) return
+ const currentTime = Date.now()
+ const timeDelta = currentTime - lastTime
+
+ if (e.event && e.event.button === 0) {
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: useOtherToolsStore.getState().imageId,
+ operationId: useOtherToolsStore.getState().operationId,
+ id: useOtherToolsStore.getState().id,
+ })
+ } else if (e.event && e.event.button === 1) {
+ // 鼠标中键操作图片移动
+ panMode = "item"
+ return
+ }
+ if (useTopToolsStore.getState().isView) {
+ panMode = ""
+ return
+ }
+
+ let point = usePaperStore.getState().group!.globalToLocal(e.point)
+
+ let hitResult = usePaperStore.getState().group?.hitTest(e.point, {
+ segments: true,
+ stroke: true,
+ curves: true,
+ fill: true,
+ guide: false,
+ tolerance: usePaperStore.getState().paperScope
+ ? 8 / useTopToolsStore.getState().scale
+ : 0,
+ })
+ console.log("hitResult", hitResult)
+ let lastTagDataId = -1
+ if (activePath?.data.id) {
+ lastTagDataId = activePath.data.id
+ }
+
+ // 处理相同id标注对象的函数 生成对应activePath 更新rect和polygon的fillColor
+ const handleSameIdItems = (id: number) => {
+ const sameIdItems = usePaperStore
+ .getState()
+ .getItemsById(id)
+ .filter((item) => {
+ return item.data.type !== "tag"
+ })
+ const types = [
+ ...new Set(sameIdItems.map((item) => item.data.type || "")),
+ ]
+ if (types.includes("rectangle")) {
+ activePath = sameIdItems.filter((item) => {
+ if (item.data.type === "rectangle") {
+ item.fillColor = item.data.fillColor
+ return true
+ } else return false
+ })[0] as paper.Path
+ } else if (types.includes("polygon")) {
+ const items = sameIdItems.filter((item) => {
+ if (item.data.type === "polygon") {
+ if (!item.data.isHollowPolygon)
+ item.fillColor = item.data.fillColor
+ return true
+ } else return false
+ })
+ console.log(items)
+ } else if (types.includes("brush")) {
+ const items = sameIdItems.filter((item) => item.data.type === "brush")
+ if (items.length === 1) {
+ // 此时 items[0] 类型只可能为path
+ activePath = items[0] as paper.Path
+ } else if (items.length > 1) {
+ let parent: any = items.filter(
+ (item) => item instanceof paper.CompoundPath
+ )[0]
+ const children = parent.children
+ if (children.length === 1) {
+ activePath = children[0]
+ } else if (children.length > 1) {
+ let activeItem: any
+ let min = 1000000
+ children.forEach((child: any) => {
+ let nearestPoint = child.getNearestPoint(point)
+ let distance = point.getDistance(nearestPoint)
+ if (distance < min) {
+ min = distance
+ activeItem = child
+ }
+ })
+ activePath = activeItem
+ }
+ // 根据点击位置更新实际点击类型
+ let currentHitRes = activePath.hitTest(point, {
+ segments: true,
+ stroke: true,
+ curves: true,
+ fill: true,
+ guide: false,
+ tolerance: usePaperStore.getState().paperScope
+ ? 8 / useTopToolsStore.getState().scale
+ : 0,
+ })
+ if (currentHitRes) {
+ if (currentHitRes.type === "segment") {
+ hitIndex = currentHitRes.segment.index
+ panMode = "segment"
+ } else if (currentHitRes.type === "stroke") {
+ hitIndex = currentHitRes.location.index
+ panMode = "stroke"
+ }
+ }
+ }
+ } else if (types.includes("point")) {
+ const items = sameIdItems.filter((item) => item.data.type === "point")
+ if (items.length === 1) {
+ // 点类型没有单对象多区域的情况
+ activePath = items[0] as paper.Path
+ }
+ }
+ console.log("处理相同id对象后", activePath)
+ if (activePath instanceof paper.CompoundPath) {
+ let selected = (activePath.children as paper.Path[]).find(
+ (child) => child.data.selected
+ )
+ selected && usePaperSupportStore.getState().handleCircles(selected)
+ }
+ }
+
+ // 当前点击结果为遮罩 处理线段和点的相关情况
+ if (hitResult && hitResult.item.data.type === "mask") {
+ panMode = "fill"
+ if (hitResult.item.data.id) {
+ handleSameIdItems(hitResult.item.data.id)
+ }
+ return
+ }
+
+ if (pressShift) {
+ } else {
+ // not press shift
+ let selectedItems = usePaperStore.getState().group!.getItems({
+ data: { selected: true },
+ })
+ if (selectedItems && selectedItems.length) {
+ selectedItems.forEach((item) => {
+ if (item.data.selected) {
+ let itemHitResult = item.hitTest(point, {
+ segments: true,
+ stroke: true,
+ curves: true,
+ fill: true,
+ guide: false,
+ tolerance: usePaperStore.getState().paperScope
+ ? 8 / usePaperStore.getState().paperScope!.view.zoom
+ : 0,
+ })
+
+ if (!itemHitResult) {
+ item.data.selected = false
+ usePaperSupportStore.getState().clearAll()
+
+ handleCancelSelect()
+ }
+ }
+ // item.data.selected = false;
+ })
+ } else {
+ handleCancelSelect()
+ }
+ }
+
+ if (hitResult && hitResult.item.className !== "Raster") {
+ if (hitResult.item.data?.id) {
+ // 处理多边形和矩形填充色
+ usePaperStore
+ .getState()
+ .group!.getItems({
+ data: { id: hitResult.item.data?.id },
+ })
+ .forEach((item) => {
+ // 点击图形本身 fill color
+ if (
+ hitResult.item.data.type !== "brush" &&
+ hitResult.item.data.type !== "point" &&
+ hitResult.item.data.type !== "pathCircle" &&
+ hitResult.item.data.type !== "pathBuff" &&
+ hitResult.item.data.type !== "text" &&
+ item.data.type !== "tag"
+ ) {
+ item.fillColor = hitResult.item.data.fillColor
+ }
+ })
+
+ if (hitResult.item.data.type === "polygon") {
+ if (hitResult.item instanceof paper.CompoundPath) {
+ ;(hitResult.item.children as paper.Path[]).forEach((item) => {
+ const childHitResult = item.hitTest(point, {
+ segments: true,
+ stroke: true,
+ curves: true,
+ fill: true,
+ guide: false,
+ tolerance: 0,
+ })
+
+ if (childHitResult) {
+ if (childHitResult.item.data.isHollowPolygon) return
+ // item.fillColor = new paper.Color("transparent");
+ // item.strokeColor = item.strokeColor || new paper.Color("red");
+ // item.strokeWidth = item.strokeWidth || 1;
+ if (
+ lastPoint &&
+ lastPoint.x === point.x &&
+ lastPoint.y === point.y &&
+ timeDelta < 200
+ ) {
+ console.log("点击位置 多边形子路径", childHitResult)
+ if (childHitResult.item instanceof paper.Path) {
+ // if (!childHitResult.item.data.selected) {
+ // childHitResult.item.data.selected = true;
+ // }
+ let selectedItems = usePaperStore
+ .getState()
+ .group!.getItems({
+ data: { selected: true },
+ })
+ selectedItems.forEach((item) => {
+ item.data.selected = false
+ })
+ childHitResult.item.data.selected = true
+
+ usePaperSupportStore
+ .getState()
+ .handleBufferPaths(childHitResult.item)
+ usePaperSupportStore
+ .getState()
+ .handleCircles(childHitResult.item)
+ }
+ }
+ }
+ })
+ } else if (hitResult.item instanceof paper.Path) {
+ // doubleclick;
+ if (
+ lastPoint &&
+ lastPoint.x === point.x &&
+ lastPoint.y === point.y &&
+ timeDelta < 200
+ ) {
+ if (hitResult.item.data.isHollowPolygon) return
+ let selectedItems = usePaperStore.getState().group!.getItems({
+ data: { selected: true },
+ })
+ selectedItems.forEach((item) => {
+ item.data.selected = false
+ })
+ hitResult.item.data.selected = true
+ usePaperSupportStore
+ .getState()
+ .handleBufferPaths(hitResult.item)
+ usePaperSupportStore.getState().handleCircles(hitResult.item)
+ // if (!hitResult.item.data.selected) {
+ // hitResult.item.data.selected = true;
+ // usePaperSupportStore
+ // .getState()
+ // .handleBufferPaths(hitResult.item);
+ // usePaperSupportStore.getState().handleCircles(hitResult.item);
+ // }
+ }
+ }
+ activePath = hitResult.item as paper.Path
+ } else if (hitResult.item.data.type === "rectangle") {
+ if (hitResult.item instanceof paper.Path) {
+ if (!hitResult.item.data.selected) {
+ hitResult.item.data.selected = true
+ usePaperSupportStore
+ .getState()
+ .handleBufferPaths(hitResult.item)
+ usePaperSupportStore.getState().handleCircles(hitResult.item)
+ }
+ activePath = hitResult.item as paper.Path
+ }
+ } else if (hitResult.item.data.type === "brush") {
+ // hitResult.item.data.selected = true;
+ usePaperSupportStore.getState().handleMask(hitResult.item)
+ // 绘制选中的circle
+ if (hitResult.item instanceof paper.CompoundPath) {
+ ;(hitResult.item.children as paper.Path[]).forEach((item) => {
+ const childHitResult = item.hitTest(point, {
+ segments: true,
+ stroke: true,
+ curves: true,
+ fill: true,
+ guide: false,
+ tolerance: usePaperStore.getState().paperScope
+ ? 8 / usePaperStore.getState().paperScope!.view.zoom
+ : 0,
+ })
+ if (childHitResult) {
+ if (childHitResult.item instanceof paper.Path) {
+ if (!childHitResult.item.data.selected) {
+ childHitResult.item.data.selected = true
+ usePaperSupportStore
+ .getState()
+ .handleBufferPaths(childHitResult.item)
+ usePaperSupportStore
+ .getState()
+ .handleCircles(childHitResult.item)
+ }
+ activePath = childHitResult.item as paper.Path
+ }
+ }
+ })
+ } else if (hitResult.item instanceof paper.Path) {
+ if (!hitResult.item.data.selected) {
+ hitResult.item.data.selected = true
+ usePaperSupportStore
+ .getState()
+ .handleBufferPaths(hitResult.item)
+ usePaperSupportStore.getState().handleCircles(hitResult.item)
+ }
+ activePath = hitResult.item as paper.Path
+ }
+ } else if (hitResult.item.data.type === "point") {
+ // 只有点击圆才视为选中
+ if (hitResult.type === "segment") {
+ // Path 类型
+ if (hitResult.item instanceof paper.Path) {
+ if (!hitResult.item.data.selected) {
+ hitResult.item.data.selected = true
+ // 绘制遮罩
+ usePaperSupportStore.getState().handleMask(hitResult.item)
+ // 绘制点对应的数字
+ usePaperSupportStore
+ .getState()
+ .handleText(hitResult.item as paper.Path)
+ // 绘制选中边
+ usePaperSupportStore
+ .getState()
+ .handleBufferPaths(hitResult.item)
+ // 绘制选中圆
+ usePaperSupportStore.getState().handleCircles(hitResult.item)
+ }
+ activePath = hitResult.item as paper.Path
+ }
+ }
+ } else if (
+ hitResult.item.data.type === "pathBuff" ||
+ hitResult.item.data.type === "pathCircle"
+ ) {
+ handleSameIdItems(hitResult.item.data.id)
+ }
+
+ // // 更新被选中的标注对象detail
+ // updateObjLatestComment(
+ // hitResult.item.data?.imageId,
+ // hitResult.item.data?.operationId,
+ // hitResult.item.data?.id
+ // );
+ useObjectStore
+ .getState()
+ .updateSelectedPath(
+ useBottomToolsStore.getState().activeImage,
+ "ADD",
+ hitResult.item.data?.id
+ )
+
+ // usePaperStore.getState().group?.addChild(activePath); // 放到顶层
+
+ // activePath.bringToFront();
+ }
+ } else {
+ // if Raster
+ if (e.event && e.event.button === 2) {
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: useOtherToolsStore.getState().imageId,
+ operationId: useOtherToolsStore.getState().operationId,
+ id: useOtherToolsStore.getState().id,
+ })
+ }
+ }
+
+ // 禁止右键拖拽
+ if (e.event && e.event.button === 2) {
+ panMode = ""
+ if (
+ hitResult?.item?.data.id &&
+ ["rectangle", "polygon", "brush", "point"].includes(
+ hitResult?.item?.data.type
+ )
+ ) {
+ const data = hitResult?.item?.data
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === data.operationId
+ )
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category!,
+ 5,
+ 5
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: data.imageId,
+ operationId: data.operationId,
+ id: data.id,
+ })
+ }
+ } else if (hitResult && hitResult.type === "fill") {
+ if (
+ hitResult &&
+ hitResult.item?.data?.type === "pathCircle" &&
+ activePath?.data?.type === "rectangle"
+ ) {
+ // 放大后 点击(只考虑了rect) 1204todo
+ hitIndex = hitResult.item.data.index
+ panMode = "segment"
+ } else {
+ panMode = "fill"
+ }
+ } else if (hitResult && hitResult.type === "stroke") {
+ // 镂空区域不可添加点
+ if (activePath && activePath.data.isHollowPolygon) return
+ panMode = "stroke"
+ // hitIndex = hitResult.location.index;
+
+ if (hitResult.item?.data?.type === "pathBuff") {
+ hitIndex = hitResult.item.data.index
+ } else {
+ hitIndex = hitResult.location.index
+ return
+ }
+
+ //点击多边形边,添加一个 端点
+ if (
+ activePath?.data?.type === "polygon" ||
+ activePath?.data?.type === "brush" ||
+ activePath?.data?.type === "point"
+ ) {
+ // doubleclick
+ if (
+ lastPoint &&
+ lastPoint.x === point.x &&
+ lastPoint.y === point.y &&
+ timeDelta < 200
+ ) {
+ if (activePath instanceof paper.CompoundPath) {
+ ;(activePath.children as paper.Path[]).forEach((item) => {
+ const childHitResult = item.hitTest(point, {
+ segments: true,
+ stroke: true,
+ curves: true,
+ fill: true,
+ guide: false,
+ tolerance: usePaperStore.getState().paperScope
+ ? 8 / usePaperStore.getState().paperScope!.view.zoom
+ : 0,
+ })
+ if (childHitResult) {
+ if (childHitResult.location.point) {
+ item.insert(hitIndex + 1, childHitResult.location.point)
+ usePaperSupportStore.getState().handleBufferPaths(item)
+ usePaperSupportStore.getState().handleCircles(item)
+ isChanged = true
+ panMode = "stroke"
+ hitIndex = hitIndex + 1
+ }
+ }
+ })
+ } else {
+ activePath.insert(hitIndex + 1, hitResult.location.point)
+ activePath?.data?.type === "point" &&
+ usePaperStore
+ .getState()
+ .getPointCircle(
+ hitResult.location.point,
+ hitIndex + 1,
+ activePath.data.id,
+ activePath.data.blankColor
+ )
+ activePath.bringToFront()
+ usePaperSupportStore.getState().handleBufferPaths(activePath)
+ usePaperSupportStore.getState().handleCircles(activePath)
+ isChanged = true
+ panMode = "stroke"
+ hitIndex = hitIndex + 1
+ }
+ if (activePath?.data?.type === "brush") {
+ usePaperSupportStore.getState().handleMask(hitResult.item)
+ }
+ if (activePath?.data?.type === "point") {
+ // 绘制点对应的数字
+ usePaperSupportStore.getState().handleText(activePath)
+ let circles = usePaperStore.getState().group!.getItems({
+ data: {
+ id: activePath.data.id,
+ type: "circle",
+ },
+ }) as paper.Path[]
+
+ activePath.segments.forEach((segment, index) => {
+ let findIndex = circles.findIndex((circle) => {
+ return circle.contains(segment.point)
+ })
+ circles[index].data = Object.assign({}, circles[index]?.data, {
+ text: findIndex + 1,
+ })
+ })
+ usePaperSupportStore.getState().handleBufferPaths(activePath)
+ usePaperSupportStore.getState().handleCircles(activePath)
+ }
+ }
+ }
+ } else if (hitResult && hitResult.type === "segment") {
+ // 镂空区域不可删除点
+ if (activePath && activePath.data.isHollowPolygon) return
+ panMode = "segment"
+
+ if (hitResult.item?.data?.type === "pathCircle") {
+ hitIndex = hitResult.item.data.index
+ } else {
+ hitIndex = hitResult.segment.index
+ }
+
+ // shift点击 删除端点
+ // if (e.event.shiftKey) {
+ // activePath.removeSegment(hitIndex);
+ // panMode = "";
+ // }
+ if (
+ activePath?.data?.type === "polygon" ||
+ activePath?.data?.type === "brush" ||
+ activePath?.data?.type === "point"
+ ) {
+ if (
+ lastPoint &&
+ lastPoint.x === point.x &&
+ lastPoint.y === point.y &&
+ timeDelta < 200
+ ) {
+ if (activePath instanceof paper.CompoundPath) {
+ ;(activePath.children as paper.Path[]).forEach((item) => {
+ const childHitResult = item.hitTest(point, {
+ segments: true,
+ stroke: true,
+ curves: true,
+ fill: true,
+ guide: false,
+ tolerance: usePaperStore.getState().paperScope
+ ? 8 / usePaperStore.getState().paperScope!.view.zoom
+ : 0,
+ })
+ if (childHitResult) {
+ let checkLength = 3
+ if (item?.data?.type === "brush") checkLength = 2
+ if (item?.data?.type === "point") checkLength = 1
+ if (item.segments && item.segments.length <= checkLength)
+ return
+ ;(childHitResult.item as paper.Path).removeSegment(hitIndex)
+ usePaperSupportStore
+ .getState()
+ .handleBufferPaths(childHitResult.item as paper.Path)
+ usePaperSupportStore
+ .getState()
+ .handleCircles(childHitResult.item as paper.Path)
+
+ isChanged = true
+ panMode = "segment"
+ }
+ })
+ } else {
+ let checkLength = 3
+ if (activePath?.data?.type === "brush") checkLength = 2
+ if (activePath?.data?.type === "point") checkLength = 1
+ if (
+ activePath.segments &&
+ activePath.segments.length <= checkLength
+ )
+ return
+
+ activePath.removeSegment(hitIndex)
+ usePaperSupportStore.getState().handleBufferPaths(activePath)
+ usePaperSupportStore.getState().handleCircles(activePath)
+ isChanged = true
+ panMode = "segment"
+ }
+ if (activePath?.data?.type === "brush") {
+ usePaperSupportStore.getState().handleMask(hitResult.item)
+ }
+ if (activePath?.data?.type === "point") {
+ let paths = usePaperStore.getState().group!.getItems({
+ data: {
+ id: activePath.data.id,
+ // text: activePath.segments.length - hitIndex + 1,
+ type: "circle",
+ },
+ }) as paper.Path[]
+
+ // paths = paths.filter((item) => {
+ // if (item.data.text === hitIndex + 1) item.remove();
+ // return item.data.text !== hitIndex + 1;
+ // });
+ paths = paths.filter((circle) => {
+ let findIndex = activePath.segments.findIndex((segment) => {
+ return circle.contains(segment.point)
+ })
+ if (findIndex === -1) {
+ circle.remove()
+ return false
+ } else {
+ return true
+ }
+ })
+
+ // 1. 对数组按照 text 属性进行排序
+ let sortedArr = paths
+ .slice()
+ .sort((a, b) => a.data.text - b.data.text)
+
+ // 2. 重新赋值 text 属性为从 1 开始的连续自然数
+ sortedArr.forEach((item, index) => {
+ item.data.text = index + 1
+ })
+
+ // paths.forEach((child) => {
+ // if (
+ // child.data.text ===
+ // activePath.segments.length - hitIndex + 1
+ // ) {
+ // // 删除 circle
+ // child.remove();
+ // } else if (
+ // child.data.text >
+ // activePath.segments.length - hitIndex + 1
+ // ) {
+ // child.data.text = child.data.text - 1;
+ // }
+ // });
+ // usePaperSupportStore.getState().handleMask(hitResult.item);
+ // usePaperSupportStore
+ // .getState()
+ // .handleText(hitResult.item as paper.Path);
+ }
+ }
+ }
+ } else {
+ panMode = ""
+ }
+
+ // 当前选中路径 tags
+ const tags = usePaperStore.getState().group!.getItems({
+ data: {
+ id: activePath?.data?.id,
+ type: "tag",
+ },
+ })
+ // 上一选中路径 tags
+ const lastTags = usePaperStore.getState().group!.getItems({
+ data: {
+ id: lastTagDataId,
+ type: "tag",
+ },
+ })
+ // 标识位为true时:选中路径隐藏tags; 没选中显示tags
+ const configs = useTopToolsStore.getState().showTagsConfigs
+ if (useTopToolsStore.getState().showTags)
+ if (panMode) {
+ lastTags.forEach((tag) => {
+ if (tag.data.tag_category === "mask")
+ tag.visible = configs.includes("外框")
+ else if (tag.data.tag_category === "point_text")
+ tag.visible = configs.includes("点ID")
+ else tag.visible = true
+ })
+ tags.forEach((tag) => {
+ tag.visible = false
+ })
+ } else {
+ tags.forEach((tag) => {
+ if (tag.data.tag_category === "mask")
+ tag.visible = configs.includes("外框")
+ else if (tag.data.tag_category === "point_text")
+ tag.visible = configs.includes("点ID")
+ else tag.visible = true
+ })
+ }
+
+ console.log("鼠标按下 选中路径", activePath, "当前模式", panMode)
+
+ lastPoint = point
+ lastTime = Date.now()
+ }
+
+ panTool.onMouseDrag = (e: {
+ event: MouseEvent
+ point: paper.Point
+ downPoint: paper.Point
+ delta: paper.Point
+ }) => {
+ // 优先判断移动图片
+ if (panMode === "item") {
+ usePaperStore.getState().group!.position = usePaperStore
+ .getState()
+ .group?.position.add(e.delta)!
+ }
+
+ if (useTopToolsStore.getState().isView) return
+
+ if (pressCtrl) {
+ const delta = e.point.subtract(e.downPoint)
+ if (delta.length > 10 && e.event.button === 0) {
+ let temp = renderRectangle(usePaperStore.getState().group!, {})
+ let point = temp.globalToLocal(e.point)
+ let downPoint = temp.globalToLocal(e.downPoint)
+
+ temp.remove()
+ rect = usePaperStore.getState().addRectangle(
+ renderRectangle,
+ {
+ from: downPoint,
+ to: point,
+ // option
+ strokeColor: "#FFF",
+ strokeWidth: 2,
+ fillColor: undefined,
+ },
+ {
+ imageId: useBottomToolsStore.getState().activeImage,
+ operationId: useTopToolsStore.getState().activeOperation,
+ id:
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1,
+ }
+ )
+ rect.removeOnDrag()
+ }
+ } else {
+ let point: paper.Point | undefined = undefined
+ if (activePath) {
+ point = usePaperStore.getState().group!.globalToLocal(e.point)
+ }
+
+ let movePath = (path: paper.Path) => {
+ const currentGroupScaling = usePaperStore.getState().group!.scaling
+ const delta = {
+ x: e.delta.x / currentGroupScaling.x,
+ y: e.delta.y / currentGroupScaling.y,
+ }
+
+ if (panMode === "fill") {
+ if (path.data.type === "brush") {
+ path.position = path.position.add(delta)
+ usePaperSupportStore
+ .getState()
+ .selectedCircles.forEach((item) => {
+ item.position = item.position.add(delta)
+ })
+ usePaperSupportStore.getState().handleMask(path)
+ } else if (path.data.type === "point") {
+ path.position = path.position.add(delta)
+ usePaperSupportStore.getState().handleMask(path)
+ usePaperSupportStore.getState().handleText(path)
+ let circles = usePaperStore.getState().group!.getItems({
+ data: {
+ id: path.data.id,
+ type: "circle",
+ },
+ }) as paper.Path[]
+ if (circles && circles.length) {
+ circles.forEach((circle) => {
+ circle.position = circle.position.add(delta)
+ })
+ }
+ usePaperSupportStore
+ .getState()
+ .selectedCircles.forEach((circle) => {
+ circle.position = circle.position.add(delta)
+ })
+ } else {
+ path.position = path.position.add(delta)
+ // 镂空区域移动不影响选中点移动
+ if (!path.data.isHollowPolygon) {
+ usePaperSupportStore
+ .getState()
+ .selectedCircles.forEach((item) => {
+ item.position = item.position.add(delta)
+ })
+ }
+ usePaperSupportStore.getState().handleBufferPaths(null)
+ }
+ } else if (panMode === "stroke") {
+ if (path.data.type === "rectangle") {
+ usePaperSupportStore.getState().handleBufferPaths(null)
+ let axis: "x" | "y" = hitIndex % 2 ? "y" : "x"
+ path.segments[hitIndex].point[axis] = point![axis]
+ path.segments[hitIndex].next.point[axis] = point![axis]
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position[axis] = point![axis]
+ let next =
+ hitIndex + 1 ===
+ usePaperSupportStore.getState().selectedCircles.length
+ ? 0
+ : hitIndex + 1
+ usePaperSupportStore.getState().selectedCircles[next].position[
+ axis
+ ] = point![axis]
+ } else if (path.data.type === "polygon") {
+ } else if (path.data.type === "brush") {
+ // path.position = path.position.add(e.delta);
+ // handleMask(path);
+ }
+ } else if (panMode === "segment") {
+ if (path.data.type === "rectangle") {
+ usePaperSupportStore.getState().handleBufferPaths(null)
+ path.segments[hitIndex].point.x = point!.x
+ path.segments[hitIndex].point.y = point!.y
+ path.segments[hitIndex].next.point[hitIndex % 2 ? "y" : "x"] =
+ point![hitIndex % 2 ? "y" : "x"]
+ path.segments[hitIndex].previous.point[hitIndex % 2 ? "x" : "y"] =
+ point![hitIndex % 2 ? "x" : "y"]
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position.x = point!.x
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position.y = point!.y
+ let previous =
+ hitIndex + 1 ===
+ usePaperSupportStore.getState().selectedCircles.length
+ ? 0
+ : hitIndex + 1
+ let next =
+ hitIndex - 1 < 0
+ ? usePaperSupportStore.getState().selectedCircles.length - 1
+ : hitIndex - 1
+ usePaperSupportStore.getState().selectedCircles[
+ previous
+ ].position[hitIndex % 2 ? "y" : "x"] =
+ point![hitIndex % 2 ? "y" : "x"]
+ usePaperSupportStore.getState().selectedCircles[next].position[
+ hitIndex % 2 ? "x" : "y"
+ ] = point![hitIndex % 2 ? "x" : "y"]
+ } else if (path.data.type === "polygon") {
+ if (path.data.isHollowPolygon) return
+
+ usePaperSupportStore.getState().clearBufferPaths()
+ path.segments[hitIndex].point.x = point!.x
+ path.segments[hitIndex].point.y = point!.y
+
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position.x = point!.x
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position.y = point!.y
+ } else if (path.data.type === "brush") {
+ path.segments[hitIndex].point.x = point!.x
+ path.segments[hitIndex].point.y = point!.y
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position.x = point!.x
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position.y = point!.y
+ usePaperSupportStore.getState().handleMask(path)
+ } else if (path.data.type === "point") {
+ let circles = usePaperStore.getState().group!.getItems({
+ data: {
+ id: path.data.id,
+ type: "circle",
+ },
+ }) as paper.Path[]
+
+ // let index = path.segments.length - hitIndex - 1
+ // let index = hitIndex;
+
+ // 移动点
+ if (circles && circles.length) {
+ // let circle = circles.find(
+ // (item) => item.data.text === index + 1
+ // );
+
+ let circle = circles.find((circle) => {
+ return circle.contains(path.segments[hitIndex].point)
+ })
+ circle!.position.x = point!.x
+ circle!.position.y = point!.y
+ }
+ // 移动路径
+ path.segments[hitIndex].point.x = point!.x
+ path.segments[hitIndex].point.y = point!.y
+ // 移动选中点
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position.x = point!.x
+ usePaperSupportStore.getState().selectedCircles[
+ hitIndex
+ ]!.position.y = point!.y
+
+ // 移动蒙版
+ usePaperSupportStore.getState().handleMask(path)
+ usePaperSupportStore.getState().handleText(path as paper.Path)
+ }
+ }
+ }
+ // 如果 activePath 被选中了 即可以被移动修改
+ if (activePath?.data.selected) {
+ movePath(activePath)
+ isChanged = true
+ } else {
+ // 没被选中可能是children被选中
+ if (activePath instanceof paper.CompoundPath) {
+ // let selectedItems = activePath.children.filter(
+ // (item) => item.data.selected
+ // );
+ // console.log("selectedItems", selectedItems);
+
+ const selectedItem: paper.Path = (
+ activePath.children as paper.Path[]
+ ).filter((item) => item.data.selected)[0]
+ ;(activePath.children as paper.Path[]).forEach((item) => {
+ if (item && item.data.selected) {
+ movePath(item as paper.Path)
+ isChanged = true
+ } else if (
+ item.data.isHollowPolygon &&
+ selectedItem &&
+ selectedItem.bounds.contains(item.bounds)
+ ) {
+ // 当前被选中路径的镂空区域需要一起移动
+ movePath(item as paper.Path)
+ isChanged = true
+ }
+ })
+ }
+ }
+ }
+ }
+
+ // 改变对象不能用 activeImage activeOperation (因为图形只在对应图片渲染,所以activeImage是一样的)
+ panTool.onMouseUp = (_e: paper.ToolEvent) => {
+ if (useTopToolsStore.getState().isView) return
+
+ let newDrawPath: paper.Path | paper.CompoundPath | null = null
+
+ if (pressCtrl) {
+ if (rect) handleCtrl(rect)
+ rect?.remove()
+ return
+ }
+
+ const handleBrushIntersectRaster = (currentPath: paper.Path) => {
+ const currentSegments = currentPath.segments
+ if (!currentSegments || (currentSegments && !currentSegments.length))
+ return
+ // 设置首尾连线 判断是否跟自身当前有交点
+ let flag: boolean
+ if (currentSegments.length === 2) flag = false
+ else if (currentSegments.length > 2) {
+ const line = new paper.Path([
+ currentSegments[0],
+ currentSegments[currentSegments.length - 1],
+ ])
+ const lineIntersections = line.getIntersections(currentPath)
+ flag = lineIntersections.length > 1 ? false : true
+ } else {
+ flag = false
+ }
+ let intersections = currentPath.getIntersections(
+ usePaperStore.getState().rasterPath!
+ )
+ let validIntersections = intersections.filter((intersection) => {
+ // 检查交点是否在两个路径的可见部分
+ return (
+ currentPath.contains(intersection.point) &&
+ usePaperStore.getState().rasterPath!.contains(intersection.point)
+ )
+ })
+
+ // intersectionPath as paper.CompoundPath | paper.Path | null
+ let intersectionPath: any = currentPath.intersect(
+ usePaperStore.getState().rasterPath!,
+ {
+ insert: false,
+ trace: flag,
+ }
+ )
+
+ // 没有 validIntersections 不进行操作
+ if (validIntersections.length) {
+ // 处理相交部分
+ if (intersectionPath instanceof paper.CompoundPath) {
+ // intersectionPath是CompoundPath
+ let new_segments: any[] = []
+ if (
+ intersectionPath.children &&
+ !intersectionPath.children.length
+ ) {
+ // unconfirm activePath被修改了
+ ;(activePath.children as paper.Path[]).forEach(
+ (activeChild, index) => {
+ if (index !== 0) {
+ new_segments.push(...activeChild.segments)
+ }
+ }
+ )
+ activePath.removeChildren(1)
+ currentPath.segments = new_segments
+ } else {
+ ;(intersectionPath.children as paper.Path[]).forEach((child) => {
+ if (child.segments && child.segments.length) {
+ new_segments.push(...child.segments)
+ }
+ })
+ currentPath.segments = new_segments
+ }
+ } else if (
+ intersectionPath.segments &&
+ intersectionPath.segments.length
+ ) {
+ currentPath.segments = intersectionPath.segments
+ }
+
+ // let oldCompoundPolygon = compoundPolygon;
+ // compoundPolygon = oldCompoundPolygon?.intersect(
+ // usePaperStore.getState().rasterPath!
+ // ) as paper.Path;
+ // compoundPolygon.closed = false;
+ // oldCompoundPolygon?.remove();
+ // usePaperSupportStore.getState().handleMask(currentPath);
+ } else {
+ // path整体移动到图片外 清空path的点
+ if (
+ !usePaperStore
+ .getState()
+ .rasterPath!.bounds.contains(currentPath.bounds)
+ ) {
+ currentPath.segments = []
+ }
+ }
+ intersectionPath.remove()
+ intersectionPath = null
+ usePaperSupportStore.getState().handleMask(currentPath)
+ usePaperSupportStore.getState().handleBufferPaths(currentPath)
+ usePaperSupportStore.getState().handleCircles(currentPath)
+ }
+
+ const handlePolygonIntersectRaster = (path: paper.Path) => {
+ let intersections = path.getIntersections(
+ usePaperStore.getState().rasterPath!
+ )
+ let validIntersections = intersections.filter((intersection) => {
+ // 检查交点是否在两个路径的可见部分
+ return (
+ path.contains(intersection.point) &&
+ usePaperStore.getState().rasterPath!.contains(intersection.point)
+ )
+ })
+
+ if (validIntersections.length) {
+ let oldPolygon: any = path
+ console.log("处理前", path)
+ newDrawPath = oldPolygon.intersect(
+ usePaperStore.getState().rasterPath!
+ ) as any
+ console.log("after", newDrawPath)
+ // if (!newDrawPath?.clockwise) newDrawPath?.reverse();
+ if (newDrawPath instanceof paper.CompoundPath)
+ (newDrawPath.children as paper.Path[]).forEach((child) => {
+ if (
+ !child.data ||
+ (child.data && !Object.keys(child.data).length)
+ )
+ child.data = safeClone(newDrawPath?.data || {})
+ })
+ oldPolygon.remove()
+ oldPolygon = null
+ } else {
+ // path整体移动到图片外 清空path的点
+ if (
+ !usePaperStore
+ .getState()
+ .rasterPath!.bounds.contains(path.bounds) &&
+ newDrawPath instanceof paper.Path
+ ) {
+ newDrawPath.segments = []
+ }
+ }
+ usePaperSupportStore.getState().handleBufferPaths(newDrawPath)
+ usePaperSupportStore.getState().handleCircles(newDrawPath)
+ }
+
+ const handlePointIntersectRaster = (path: paper.Path) => {
+ let circles = usePaperStore.getState().group!.getItems({
+ data: {
+ id: path.data.id,
+ type: "circle",
+ },
+ }) as paper.Path[]
+ // 去除超出图片边界的部分
+ let newCircles = circles.filter((circle) => {
+ if (
+ !usePaperStore
+ .getState()
+ .rasterPath!.bounds.contains(circle.bounds.center)
+ ) {
+ circle.remove()
+ return false
+ } else {
+ return true
+ }
+ })
+
+ // 1. 对数组按照 text 属性进行排序
+ let sortedArr = newCircles
+ .slice()
+ .sort((a, b) => a.data.text - b.data.text)
+
+ // 2. 重新赋值 text 属性为从 1 开始的连续自然数
+ sortedArr.forEach((item, index) => {
+ item.data.text = index + 1
+ })
+
+ // newCircles.forEach((circle, circleIndex) => {
+ // // 重新设置显示数字
+ // circle.data.text = circleIndex + 1;
+ // });
+ path.segments = path.segments.filter((segment) => {
+ return usePaperStore
+ .getState()
+ .rasterPath!.bounds.contains(segment.point)
+ })
+ // 去除超出图片边界的部分 end
+ usePaperSupportStore.getState().handleMask(path)
+ usePaperSupportStore.getState().handleBufferPaths(path)
+ usePaperSupportStore.getState().handleCircles(path)
+ usePaperSupportStore.getState().handleText(path)
+ }
+
+ const handlePolygonIntersect = () => {
+ if (panMode !== "stroke" && newDrawPath) {
+ const handleCurrentPathSub = (eachPath: paper.Path) => {
+ if (newDrawPath) {
+ let oldPolygon: any = newDrawPath
+ let oldParent: any = newDrawPath.parent
+
+ newDrawPath = oldPolygon.subtract(eachPath)
+
+ // if (!newDrawPath?.clockwise) newDrawPath?.reverse();
+ if (newDrawPath instanceof paper.CompoundPath) {
+ ;(newDrawPath.children as paper.Path[]).forEach((child) => {
+ child.data = safeClone(
+ Object.assign({}, newDrawPath?.data || {}, {
+ isHollowPolygon: !child.clockwise,
+ })
+ )
+ })
+ if (oldParent instanceof paper.CompoundPath) {
+ ;(oldParent.children as paper.Path[]).forEach((child) => {
+ child.data = safeClone(
+ Object.assign({}, newDrawPath?.data || {}, {
+ isHollowPolygon: !child.clockwise,
+ })
+ )
+ })
+ }
+ }
+
+ oldPolygon.remove()
+ oldPolygon = null
+ }
+ }
+
+ usePaperStore.getState().group?.children!.forEach((item: any) => {
+ if (
+ !item.data.id ||
+ item.data.type !== "polygon" ||
+ !item.visible ||
+ item.data.id === newDrawPath?.data.id
+ )
+ return
+ if (item instanceof paper.Path) {
+ if (item.segments.length) {
+ handleCurrentPathSub(item)
+ }
+ } else if (item instanceof paper.CompoundPath) {
+ ;(item.children as paper.Path[]).forEach((child) => {
+ if (
+ child?.segments?.length &&
+ child.data.id !== newDrawPath?.data.id
+ ) {
+ handleCurrentPathSub(child)
+ }
+ })
+ }
+ })
+ }
+ }
+
+ const handlePolygonUnite = () => {
+ const handleCurrentPathUnite = (eachPath: paper.Path) => {
+ if (!newDrawPath) return
+ let intersectionPath = newDrawPath.intersect(eachPath, {
+ insert: false,
+ }) as paper.Path
+ if (intersectionPath.segments?.length) {
+ let oldPolygon: any = newDrawPath
+ newDrawPath = oldPolygon.unite(eachPath) as any
+ // if (!newDrawPath?.clockwise) newDrawPath?.reverse();
+ if (newDrawPath instanceof paper.CompoundPath)
+ (newDrawPath.children as paper.Path[]).forEach((child) => {
+ child.data = safeClone(newDrawPath?.data || {})
+ })
+ oldPolygon.remove()
+ oldPolygon = null
+ }
+ }
+ usePaperStore.getState().group?.children!.forEach((item: any) => {
+ if (!item.data.id || item.data.type !== "polygon" || !item.visible)
+ return
+ if (item instanceof paper.CompoundPath) {
+ ;(item.children as paper.Path[]).forEach((child) => {
+ handleCurrentPathUnite(child)
+ })
+ } else if (item instanceof paper.Path) {
+ handleCurrentPathUnite(item)
+ }
+ })
+ }
+
+ const getNewDetail = (detailPathData: {
+ imageId: string
+ operationId: any
+ id: number
+ }) => {
+ const currentDetail = useLabelStore
+ .getState()
+ .getLabelDetailDataByKeys(
+ detailPathData.imageId,
+ detailPathData.operationId,
+ detailPathData.id
+ )
+ const newDetail = currentDetail
+ ? {
+ ...currentDetail,
+ first_modified_timestamp:
+ currentDetail.first_modified_timestamp || dayjs().unix(),
+ first_modified_uid: currentDetail.first_modified_uid || user_id,
+ last_modified_timestamp: dayjs().unix(),
+ last_modified_uid: user_id,
+ }
+ : {
+ ...initialDetail,
+ create_timestamp: dayjs().unix(),
+ category_id: Number(detailPathData.operationId),
+ }
+ return newDetail
+ }
+
+ // 保存矩形类型数据结果
+ const handleSaveRect = (rectPath: any) => {
+ // 去除超出图片边界的部分
+ handleRectangleIntersectRaster(rectPath)
+ usePaperSupportStore.getState().handleBufferPaths(rectPath)
+ usePaperSupportStore.getState().handleCircles(rectPath)
+ const newDetail = getNewDetail(rectPath.data)
+ let pathData = JSON.parse(rectPath.exportJSON({ precision: 20 }))
+
+ if (rectPath.data.id && rectPath.segments?.length) {
+ useLabelStore
+ .getState()
+ .setOperation(rectPath.data.imageId, rectPath.data.operationId, [
+ rectPath.data.id,
+ [pathData[1].segments],
+ newDetail,
+ [],
+ ])
+ } else {
+ useLabelStore
+ .getState()
+ .deleteOperation(
+ rectPath.data.imageId,
+ rectPath.data.operationId,
+ rectPath.data.id
+ )
+ }
+
+ forceUpdate()
+ }
+
+ // 保存多边形类型数据结果
+ const handleSavePolygon = (polygonPath: any) => {
+ newDrawPath = new paper.Path()
+ let parent: any = usePaperStore.getState().group
+ let hollowPaths: Array<{ path: paper.Path; index: number }> = []
+ let area = 0
+ if (polygonPath instanceof paper.CompoundPath) {
+ let oldSelected = (polygonPath.children as paper.Path[]).find(
+ (item) => item.data.selected
+ )!
+ parent = polygonPath
+ newDrawPath.set({
+ segments: oldSelected.segments,
+ data: oldSelected.data,
+ strokeColor: oldSelected.strokeColor,
+ strokeWidth: oldSelected.strokeWidth,
+ fillColor: oldSelected.fillColor,
+ closed: oldSelected.closed,
+ parent: usePaperStore.getState().group,
+ })
+ oldSelected.remove()
+ // newDrawPath = (polygonPath.children as paper.Path[]).find(
+ // (item) => item.data.selected
+ // )!;
+ area = (newDrawPath as paper.Path).area
+ // 设置镂空数组
+ polygonPath.children.forEach((child: any, index: number) => {
+ if (
+ child.data.isHollowPolygon &&
+ newDrawPath?.bounds.contains(child.bounds)
+ ) {
+ hollowPaths.push({ path: child, index })
+ }
+ })
+ } else {
+ newDrawPath = polygonPath
+ area = (newDrawPath as paper.Path).area
+ }
+ console.log("保存当前被选中路径的结果", newDrawPath)
+
+ // 边界裁剪
+ handlePolygonIntersectRaster(newDrawPath as any)
+
+ usePaperSupportStore.getState().handleCircles(newDrawPath)
+ console.log("边界裁剪之后", newDrawPath)
+
+ if (useTopToolsStore.getState().drawOption === "intersect") {
+ // 裁剪
+ handlePolygonIntersect()
+ usePaperSupportStore.getState().handleCircles(newDrawPath)
+ } else if (useTopToolsStore.getState().drawOption === "unite") {
+ // 融合
+ handlePolygonUnite()
+ usePaperSupportStore.getState().handleCircles(newDrawPath)
+ }
+
+ // 路径已发生改变则清空之前存的镂空区域,后续无需处理
+ // if (
+ // newDrawPath instanceof paper.CompoundPath ||
+ // (newDrawPath instanceof paper.Path && newDrawPath.area !== area)
+ // ) {
+ // hollowPaths.forEach((item) => {
+ // item.path?.remove();
+ // });
+ // hollowPaths = [];
+ // }
+
+ console.log("裁剪之后", newDrawPath)
+
+ if (
+ newDrawPath instanceof paper.CompoundPath ||
+ (newDrawPath instanceof paper.Path && newDrawPath.area !== area)
+ ) {
+ hollowPaths.forEach((item) => {
+ item.path?.remove()
+ })
+ hollowPaths = []
+ } else {
+ hollowPaths = hollowPaths.filter((item) => {
+ let subtractPath = newDrawPath!.subtract(item.path!, {
+ insert: false,
+ })
+ if (subtractPath instanceof paper.Path) {
+ item.path?.remove()
+ return false
+ } else {
+ return true
+ }
+ })
+ }
+
+ // 边界裁剪
+ // handlePolygonIntersectRaster(newDrawPath as any);
+
+ // if (
+ // newDrawPath instanceof paper.CompoundPath ||
+ // (newDrawPath instanceof paper.Path && newDrawPath.area !== area)
+ // ) {
+ // hollowPaths.forEach((item) => {
+ // item.path?.remove();
+ // });
+ // hollowPaths = [];
+ // }
+ // usePaperSupportStore.getState().handleCircles(newDrawPath);
+ // console.log("边界裁剪之后", newDrawPath);
+
+ if (newDrawPath instanceof paper.CompoundPath) {
+ let selectedItems = usePaperStore.getState().group!.getItems({
+ data: { selected: true },
+ })
+ selectedItems.forEach((item) => {
+ item.data.selected = false
+ })
+ handleCancelSelect()
+ }
+
+ // 存在镂空区域时,对镂空区域进行边界裁剪
+ if (hollowPaths.length) {
+ hollowPaths.forEach((hollowPath) => {
+ if (
+ !usePaperStore
+ .getState()
+ .rasterPath!.bounds.contains(hollowPath.path.bounds)
+ ) {
+ polygonPath.removeChildren(hollowPath.index, hollowPath.index + 1)
+ return
+ }
+ // hollowPath.path.clockwise = false;
+ hollowPath.path.data = safeClone(
+ Object.assign({}, hollowPath.path?.data || {}, {
+ isHollowPolygon: true,
+ })
+ )
+ // let oldPolygon: any = hollowPath.path;
+ // let finnalPath = oldPolygon.intersect(
+ // usePaperStore.getState().rasterPath!
+ // ) as any;
+ // console.log("finnalPath", finnalPath);
+
+ // // if (hollowPath.clockwise) hollowPath.reverse();
+ // if (finnalPath instanceof paper.CompoundPath) {
+ // (finnalPath.children as paper.Path[]).forEach((child) => {
+ // // child.clockwise = false;
+ // child.data = safeClone(
+ // Object.assign({}, oldPolygon?.data || {}, {
+ // isHollowPolygon: true,
+ // })
+ // );
+ // });
+ // } else if (finnalPath instanceof paper.Path) {
+ // // finnalPath.clockwise = false;
+ // finnalPath.data = safeClone(
+ // Object.assign({}, oldPolygon?.data || {}, {
+ // isHollowPolygon: true,
+ // })
+ // );
+ // }
+ // oldPolygon.remove();
+ // oldPolygon = null;
+
+ // polygonPath.removeChildren(hollowPath.index, hollowPath.index + 1);
+ // polygonPath.addChild(finnalPath);
+ })
+ }
+
+ if (!(parent instanceof paper.Group)) {
+ if (newDrawPath instanceof paper.CompoundPath) {
+ let len = newDrawPath.children.length
+ for (let i = len - 1; i >= 0; i--) {
+ let child = newDrawPath.children[i]
+ child.set({
+ parent: parent,
+ })
+ }
+ } else {
+ newDrawPath?.set({
+ parent: parent,
+ })
+ }
+ }
+
+ // 创建对象 重新渲染对象 都需要添加 imageId operationId
+ if (
+ polygonPath.data.imageId &&
+ polygonPath.data.operationId &&
+ polygonPath.data.id
+ ) {
+ const newDetail = getNewDetail(polygonPath.data)
+ let savedItems = usePaperStore.getState().group!.getItems({
+ data: { id: polygonPath.data.id },
+ }) as paper.Path[]
+
+ console.log(savedItems)
+
+ let saveCompoundItems = savedItems.filter(
+ (item) =>
+ item instanceof paper.Path &&
+ item.data.type === "polygon" &&
+ item.segments.length
+ )
+
+ let handleSavePaths = (paths: paper.Path[]) => {
+ let polygonPoints: any[] = []
+ let polygonHollowPoints: any[] = []
+ let bool = paths.some((child) => {
+ return child.segments.length > 0
+ })
+
+ if (bool) {
+ ;(paths as paper.Path[]).forEach((path) => {
+ if (path.data.type !== "polygon") return
+ let pathData = JSON.parse(path.exportJSON({ precision: 20 }))
+ // 在镂空区域中 镂空区域路径方向为false
+ if (!path.data.isHollowPolygon) {
+ polygonPoints.push(pathData[1]?.segments)
+ } else {
+ if (path.data.id && path.segments?.length) {
+ polygonHollowPoints.push(pathData[1]?.segments)
+ }
+ }
+ })
+ console.log(
+ "编辑后最终保存结果",
+ polygonPoints,
+ polygonHollowPoints
+ )
+ useLabelStore
+ .getState()
+ .setOperation(
+ polygonPath.data.imageId,
+ polygonPath.data.operationId,
+ [
+ polygonPath.data.id,
+ polygonPoints,
+ newDetail,
+ polygonHollowPoints,
+ ]
+ )
+ } else {
+ useLabelStore
+ .getState()
+ .deleteOperation(
+ polygonPath.data.imageId,
+ polygonPath.data.operationId,
+ polygonPath.data.id
+ )
+ }
+ }
+
+ handleSavePaths(saveCompoundItems)
+ activePath =
+ newDrawPath?.parent instanceof paper.CompoundPath
+ ? newDrawPath.parent
+ : (newDrawPath as any)
+ forceUpdate()
+ }
+ }
+
+ // 保存线段类型数据结果
+ const handleSaveBrush = (brushPath: any) => {
+ if (brushPath instanceof paper.CompoundPath) {
+ ;(brushPath.children as paper.Path[]).forEach((path) => {
+ handleBrushIntersectRaster(path)
+ })
+ } else {
+ handleBrushIntersectRaster(brushPath)
+ }
+
+ if (
+ brushPath.data.imageId &&
+ brushPath.data.operationId &&
+ brushPath.data.id
+ ) {
+ const currentDetail = useLabelStore
+ .getState()
+ .getLabelDetailDataByKeys(
+ brushPath.data.imageId,
+ brushPath.data.operationId,
+ brushPath.data.id
+ )
+ const newDetail = currentDetail
+ ? {
+ ...currentDetail,
+ first_modified_timestamp:
+ currentDetail.first_modified_timestamp || dayjs().unix(),
+ first_modified_uid: currentDetail.first_modified_uid || user_id,
+ last_modified_timestamp: dayjs().unix(),
+ last_modified_uid: user_id,
+ }
+ : {
+ ...initialDetail,
+ create_timestamp: dayjs().unix(),
+ category_id: Number(brushPath.data.operationId),
+ }
+ if (brushPath instanceof paper.CompoundPath) {
+ const brushChildren = brushPath.children
+ if (brushChildren && brushChildren.length) {
+ const points: any[] = []
+ ;(brushChildren as paper.Path[]).forEach((childPath) => {
+ if (childPath.segments && childPath.segments.length) {
+ let pathData = JSON.parse(
+ childPath.exportJSON({ precision: 20 })
+ )
+ points.push(pathData[1]?.segments)
+ }
+ })
+ if (points.length)
+ useLabelStore
+ .getState()
+ .setOperation(
+ brushPath.data.imageId,
+ brushPath.data.operationId,
+ [brushPath.data.id, points, newDetail, []]
+ )
+ else {
+ usePaperSupportStore
+ .getState()
+ .removeMaskById(brushPath.data.id)
+ useLabelStore
+ .getState()
+ .deleteOperation(
+ brushPath.data.imageId,
+ brushPath.data.operationId,
+ brushPath.data.id
+ )
+ }
+ }
+ } else if (brushPath instanceof paper.Path) {
+ if (brushPath.segments && brushPath.segments.length) {
+ const pathData = JSON.parse(
+ brushPath.exportJSON({ precision: 20 })
+ )
+ const brushSegments = pathData?.[1]?.segments
+ if (brushSegments?.length) {
+ useLabelStore
+ .getState()
+ .setOperation(
+ brushPath.data.imageId,
+ brushPath.data.operationId,
+ [brushPath.data.id, [brushSegments], newDetail, []]
+ )
+ } else {
+ usePaperSupportStore
+ .getState()
+ .removeMaskById(brushPath.data.id)
+ useLabelStore
+ .getState()
+ .deleteOperation(
+ brushPath.data.imageId,
+ brushPath.data.operationId,
+ brushPath.data.id
+ )
+ }
+ } else {
+ usePaperSupportStore.getState().removeMaskById(brushPath.data.id)
+ useLabelStore
+ .getState()
+ .deleteOperation(
+ brushPath.data.imageId,
+ brushPath.data.operationId,
+ brushPath.data.id
+ )
+ }
+ }
+
+ forceUpdate()
+ }
+ }
+
+ const handleSavePoint = (pointPath: any) => {
+ handlePointIntersectRaster(pointPath)
+ if (
+ pointPath.data.imageId &&
+ pointPath.data.operationId &&
+ pointPath.data.id
+ ) {
+ const currentDetail = useLabelStore
+ .getState()
+ .getLabelDetailDataByKeys(
+ pointPath.data.imageId,
+ pointPath.data.operationId,
+ pointPath.data.id
+ )
+ const newDetail = currentDetail
+ ? {
+ ...currentDetail,
+ first_modified_timestamp:
+ currentDetail.first_modified_timestamp || dayjs().unix(),
+ first_modified_uid: currentDetail.first_modified_uid || user_id,
+ last_modified_timestamp: dayjs().unix(),
+ last_modified_uid: user_id,
+ }
+ : {
+ ...initialDetail,
+ create_timestamp: dayjs().unix(),
+ category_id: Number(pointPath.data.operationId),
+ }
+ if (pointPath instanceof paper.Path) {
+ if (pointPath.segments && pointPath.segments.length) {
+ const pathData = JSON.parse(
+ pointPath.exportJSON({ precision: 20 })
+ )
+ const pointSegments = pathData?.[1]?.segments
+ if (pointSegments?.length) {
+ useLabelStore
+ .getState()
+ .setOperation(
+ pointPath.data.imageId,
+ pointPath.data.operationId,
+ [pointPath.data.id, [pointSegments], newDetail, []]
+ )
+ } else {
+ usePaperSupportStore
+ .getState()
+ .removeMaskById(pointPath.data.id)
+ useLabelStore
+ .getState()
+ .deleteOperation(
+ pointPath.data.imageId,
+ pointPath.data.operationId,
+ pointPath.data.id
+ )
+ }
+ } else {
+ usePaperSupportStore.getState().removeMaskById(pointPath.data.id)
+ useLabelStore
+ .getState()
+ .deleteOperation(
+ pointPath.data.imageId,
+ pointPath.data.operationId,
+ pointPath.data.id
+ )
+ }
+ }
+
+ forceUpdate()
+ }
+ }
+
+ if (isChanged) {
+ if (
+ (activePath && activePath.data.selected) ||
+ (activePath?.children?.length &&
+ activePath?.children?.findIndex((item) => item.data.selected) !==
+ -1)
+ ) {
+ if (
+ panMode === "segment" ||
+ panMode === "fill" ||
+ panMode === "stroke"
+ ) {
+ if (activePath.data.type === "rectangle") {
+ handleSaveRect(activePath)
+ } else if (activePath.data.type === "brush") {
+ handleSaveBrush(activePath)
+ } else if (activePath.data.type === "polygon") {
+ handleSavePolygon(activePath)
+ } else if (activePath.data.type === "point") {
+ handleSavePoint(activePath)
+ }
+ }
+ } else {
+ // 没被选中
+ }
+
+ isChanged = false
+ }
+ }
+ panTool.onKeyDown = (e: paper.KeyEvent) => {
+ if (e.key === "delete") {
+ handleDelete()
+ usePaperSupportStore.getState().clearTexts()
+
+ let ids =
+ useObjectStore.getState().selectedPath[
+ useBottomToolsStore.getState().activeImage
+ ]
+
+ usePaperSupportStore.getState().removeMaskByIds(ids)
+
+ forceUpdate()
+ } else if (e.key === "shift") {
+ pressShift = true
+ useKeyEventStore.getState().setShift(true)
+ } else if (e.key === "control") {
+ pressCtrl = true
+ }
+ }
+ panTool.onKeyUp = (e: paper.KeyEvent) => {
+ if (e.key === "shift") {
+ pressShift = false
+ useKeyEventStore.getState().setShift(false)
+ } else if (e.key === "control") {
+ pressCtrl = false
+ }
+ }
+ panTool.onMouseMove = (e: paper.MouseEvent) => {
+ handleMouseMove(e)
+ }
+
+ set((state: PaperState) => ({
+ ...state,
+ panTool: panTool,
+ }))
+ },
+ addPolygon: (
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ points: paper.Point[] | undefined,
+ option: any,
+ data: any
+ ) => {
+ let polygon = renderPath(
+ usePaperStore.getState().group!,
+ Object.assign(option, {
+ strokeScaling: false,
+ })
+ )
+ polygon.data = Object.assign({ type: "polygon", ...option }, data)
+ points?.forEach((point) => {
+ polygon.add(point)
+ })
+
+ polygon.onMouseEnter = (_e: paper.MouseEvent) => {
+ // if (usePaperStore.getState().mode === "pan") {
+ // e.target.strokeWidth += 2;
+ // if (usePaperStore.getState().el)
+ // usePaperStore.getState().el!.style.cursor = "move";
+ // }
+ }
+ polygon.onMouseLeave = (_e: paper.MouseEvent) => {
+ // if (usePaperStore.getState().mode === "pan") {
+ // // render 结束没有触发 onMouseEnter
+ // if (e.target.strokeWidth - 2 > 0) e.target.strokeWidth -= 2;
+ // if (usePaperStore.getState().el)
+ // usePaperStore.getState().el!.style.cursor = "default";
+ // }
+ }
+
+ polygon.onDoubleClick = (e: paper.MouseEvent) => {
+ console.log("polygon.onDoubleClick", e)
+ }
+ polygon.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ if (e.event && e.event.button === 2) {
+ if (polygon.data.id) {
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === polygon.data.operationId
+ )
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category!,
+ 5,
+ 5
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: polygon.data.imageId,
+ operationId: polygon.data.operationId,
+ id: polygon.data.id,
+ })
+ }
+ }
+ }
+ return polygon
+ },
+ addCompoundPath: (
+ renderCompoundPath: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.CompoundPath,
+ children: paper.Path[] | undefined,
+ option: any,
+ data: any
+ ) => {
+ let compoundPath = renderCompoundPath(
+ usePaperStore.getState().group!,
+ Object.assign(option, {
+ children,
+ strokeScaling: false,
+ })
+ )
+ compoundPath.data = Object.assign({ type: "polygon", ...option }, data)
+ compoundPath.onMouseDown = (e: {
+ event: MouseEvent
+ point: paper.Point
+ }) => {
+ if (e.event && e.event.button === 2) {
+ if (compoundPath.data.id) {
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) =>
+ item.category_id.toString() === compoundPath.data.operationId
+ )
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category!,
+ 5,
+ 5
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: compoundPath.data.imageId,
+ operationId: compoundPath.data.operationId,
+ id: compoundPath.data.id,
+ })
+ }
+ }
+ }
+
+ return compoundPath
+ },
+ initPolygonTool: (
+ paperPolygonTool: paper.Tool,
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ renderCompoundPath: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.CompoundPath,
+ forceUpdate: DispatchWithoutAction
+ ) => {
+ let selectedCircles: paper.Path.Circle[] = []
+ let polygon: paper.Path | paper.CompoundPath | null
+ let compoundPolygon: paper.Path | paper.CompoundPath | null
+ let points: paper.Point[] = []
+ let lastPoint: paper.Point
+ let lastTime: number
+ let isMouseDown: boolean = false
+ let polygonTool = paperPolygonTool
+ let currentMagnetPoint: paper.Point | null = null
+
+ const handleCircles = (path: paper.Path | paper.CompoundPath | null) => {
+ // 更新选中的点
+ if (selectedCircles && selectedCircles.length) {
+ selectedCircles.forEach((item) => {
+ item.remove()
+ })
+ selectedCircles = []
+ }
+ path &&
+ path instanceof paper.Path &&
+ path.segments.forEach((segment) => {
+ let circle = usePaperStore
+ .getState()
+ .getClickedCircle(
+ segment.point,
+ segment.index,
+ path.data.id,
+ path.data.blankColor
+ )
+ selectedCircles.push(circle)
+ })
+ }
+
+ const handlePolygonIntersectRaster = (savePath: paper.Path) => {
+ let oldPolygon: any = savePath
+ polygon = oldPolygon.intersect(
+ usePaperStore.getState().rasterPath!
+ ) as any
+ if (polygon instanceof paper.CompoundPath)
+ (polygon.children as paper.Path[]).forEach((child) => {
+ if (!child.data || (child.data && !Object.keys(child.data).length))
+ child.data = safeClone(
+ Object.assign(
+ { isHollowPolygon: !child.clockwise },
+ polygon?.data || {}
+ )
+ )
+ })
+ oldPolygon.remove()
+ oldPolygon = null
+ }
+
+ const handlePolygonIntersect = (path: paper.Path) => {
+ const handleCurrentPathSub = (eachPath: paper.Path) => {
+ if (!polygon) return
+ let oldPolygon: any = polygon
+ polygon = oldPolygon?.subtract(eachPath) as any
+ if (polygon instanceof paper.CompoundPath)
+ (polygon.children as paper.Path[]).forEach((child) => {
+ child.data = safeClone(
+ Object.assign(
+ { isHollowPolygon: !child.clockwise },
+ polygon?.data || {}
+ )
+ )
+ })
+ oldPolygon?.remove()
+ oldPolygon = null
+ }
+
+ usePaperStore.getState().group?.children!.forEach((item: any) => {
+ if (!item.data.id || item.data.type !== "polygon" || !item.visible)
+ return
+ if (item instanceof paper.Path) {
+ if (item?.segments?.length && item.data.id !== path?.data.id) {
+ handleCurrentPathSub(item)
+ }
+ } else if (
+ item instanceof paper.CompoundPath &&
+ item.data.id !== path.data.id
+ ) {
+ ;(item.children as paper.Path[]).forEach((child) => {
+ if (child?.segments?.length && child.data.id !== path?.data.id) {
+ handleCurrentPathSub(child)
+ }
+ })
+ }
+ })
+ }
+
+ const handlePolygonUnite = () => {
+ const handleCurrentPathUnite = (eachPath: paper.Path) => {
+ if (!polygon) return
+ let intersectionPath = polygon.intersect(eachPath, {
+ insert: false,
+ }) as paper.Path
+ if (intersectionPath.segments?.length) {
+ let oldPolygon: any = polygon
+ polygon = oldPolygon.unite(eachPath) as any
+ if (polygon instanceof paper.CompoundPath)
+ (polygon.children as paper.Path[]).forEach((child) => {
+ child.data = safeClone(polygon?.data || {})
+ })
+ oldPolygon?.remove()
+ oldPolygon = null
+ }
+ }
+
+ usePaperStore.getState().group?.children!.forEach((item: any) => {
+ if (!item.data.id || item.data.type !== "polygon" || !item.visible)
+ return
+ if (item instanceof paper.CompoundPath) {
+ ;(item.children as paper.Path[]).forEach((child) => {
+ handleCurrentPathUnite(child)
+ })
+ } else if (item instanceof paper.Path) {
+ handleCurrentPathUnite(item)
+ }
+ })
+ }
+
+ polygonTool.onMouseDown = (e: {
+ event: MouseEvent
+ point: paper.Point
+ }) => {
+ if (e.event.button !== 0) return
+ isMouseDown = true
+
+ // 绘制时去除选中辅助
+ if (usePaperSupportStore.getState().selectedCircles.length) {
+ usePaperSupportStore.getState().clearAll()
+ }
+
+ // 先判断是否存在吸附点
+ let checkPoint = currentMagnetPoint ? currentMagnetPoint : e.point
+ // doubleclick
+ if (
+ lastPoint &&
+ lastPoint.x === checkPoint.x &&
+ lastPoint.y === checkPoint.y
+ ) {
+ if (polygon) {
+ if ((polygon as paper.Path).segments.length < 3) {
+ polygon.remove()
+ selectedCircles.forEach((item) => {
+ item.remove()
+ })
+ }
+
+ // 调整polygon点位初始顺序
+ if (!polygon.clockwise) polygon.reverse()
+
+ // 初始化 compoundPolygon
+ compoundPolygon = new paper.Path()
+
+ // pressA 模式下添加多边形
+ if (useTopToolsStore.getState().pressA) {
+ let ids =
+ useObjectStore.getState().selectedPath[
+ useBottomToolsStore.getState().activeImage
+ ]
+ let paths = usePaperStore.getState().getItemsById(ids[0])
+ let oldPolygon = polygon
+ // 闭合绘制路径
+ oldPolygon.closePath()
+
+ if (paths.length === 1) {
+ const prevPath = paths[0]
+ polygon = usePaperStore.getState().addCompoundPath(
+ renderCompoundPath,
+ [prevPath, oldPolygon] as paper.Path[],
+ {
+ // option
+ ...usePaperStore.getState().toolOption,
+ },
+ { ...oldPolygon.data, id: prevPath?.data.id }
+ )
+ } else if (paths.length > 1) {
+ const prevPath = paths.filter(
+ (path) => path instanceof paper.CompoundPath
+ )[0]
+ // 直接将当前路径添加到原有父路径
+ prevPath.addChild(oldPolygon)
+ polygon = prevPath
+ }
+ }
+
+ if (useTopToolsStore.getState().drawOption === "intersect") {
+ // 裁剪
+ handlePolygonIntersect(polygon as paper.Path)
+ handleCircles(polygon)
+ } else if (useTopToolsStore.getState().drawOption === "unite") {
+ // 融合
+ handlePolygonUnite()
+ handleCircles(polygon)
+ }
+ console.log("裁剪之后", polygon)
+
+ // if (useTopToolsStore.getState().drawOption === "default") {
+ // if (useTopToolsStore.getState().pressA)
+ // compoundPolygon = polygon.clone();
+ // }
+
+ // 去除超出图片边界的部分
+ handlePolygonIntersectRaster(polygon as any)
+ // 去除超出图片边界的部分
+ console.log("边界裁剪之后", polygon)
+
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) =>
+ item.category_id.toString() ===
+ useTopToolsStore.getState().activeOperation
+ )
+ let label_class = category?.label_class || ""
+ let subAttr = Object.assign(
+ {},
+ useTopToolsStore.getState().subAttributes[label_class]
+ )
+
+ // 镂空或分割的多边形存值
+ if (polygon instanceof paper.CompoundPath) {
+ if (polygon.children && polygon.children.length) {
+ let points: any[] = []
+ let hollowPoints: any[] = []
+ ;(polygon.children as paper.Path[]).forEach((path) => {
+ let pathData = JSON.parse(path.exportJSON({ precision: 20 }))
+ // 在镂空或分割的区域中 路径方向是false
+ if (path.clockwise) {
+ points.push(pathData[1]?.segments)
+ } else {
+ hollowPoints.push(pathData[1]?.segments)
+ }
+ })
+ console.log(
+ "处理完成的点位数据",
+ points,
+ hollowPoints,
+ polygon.data
+ )
+
+ useLabelStore
+ .getState()
+ .setOperation(
+ useBottomToolsStore.getState().activeImage,
+ useTopToolsStore.getState().activeOperation,
+ [
+ polygon.data.id,
+ points,
+ {
+ ...initialDetail,
+ uid: usePermissionStore.getState().user_id,
+ comment: [
+ ...useLabelStore.getState().labelDefaultComments,
+ ],
+ create_timestamp: dayjs().unix(),
+ sub_attributes: subAttr,
+ image_id: useBottomToolsStore.getState().activeImageId,
+ category_id: Number(
+ useTopToolsStore.getState().activeOperation
+ ),
+ },
+ hollowPoints,
+ ]
+ )
+ if (!useTopToolsStore.getState().pressA)
+ useRightToolsStore.getState().pushPathId(polygon.data.id)
+ }
+ } else if (
+ // 未镂空或分割 存值
+ polygon instanceof paper.Path &&
+ polygon.data.id &&
+ polygon.segments?.length
+ ) {
+ let pathData = JSON.parse(polygon.exportJSON({ precision: 20 }))
+ console.log(pathData[1]?.segments)
+ useLabelStore
+ .getState()
+ .setOperation(
+ useBottomToolsStore.getState().activeImage,
+ useTopToolsStore.getState().activeOperation,
+ [
+ polygon.data.id,
+ // todo intersect+a | +a会变成CompoundPath 理论上已解决这个todo
+ [pathData[1]?.segments, ...[]],
+ {
+ ...initialDetail,
+ uid: usePermissionStore.getState().user_id,
+ comment: [...useLabelStore.getState().labelDefaultComments],
+ create_timestamp: dayjs().unix(),
+ sub_attributes: subAttr,
+ image_id: useBottomToolsStore.getState().activeImageId,
+ category_id: Number(
+ useTopToolsStore.getState().activeOperation
+ ),
+ },
+ [],
+ ]
+ )
+ useRightToolsStore.getState().pushPathId(polygon.data.id)
+ }
+
+ // 如果该类别存在子属性,则弹出子属性对话框
+ if (category && category.sub_attributes.length) {
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category!,
+ -30,
+ -30
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: polygon.data.imageId,
+ operationId: polygon.data.operationId,
+ id: polygon.data.id,
+ })
+ }
+
+ // 设置其为selectedPath
+ useObjectStore
+ .getState()
+ .updateSelectedPath(
+ useBottomToolsStore.getState().activeImage,
+ "ADD",
+ polygon.data.id
+ )
+
+ forceUpdate()
+
+ if (compoundPolygon instanceof paper.CompoundPath) {
+ polygon.remove()
+ polygon = null
+ points = []
+ } else {
+ compoundPolygon?.remove()
+ compoundPolygon = null
+ polygon?.closePath()
+ polygon = null
+ points = []
+ }
+ selectedCircles.forEach((item) => {
+ item.remove()
+ })
+ const magnetPaths = usePaperStore
+ .getState()
+ .group!.getItems({ data: { type: "magnet" } })
+ magnetPaths.forEach((item) => {
+ item.remove()
+ })
+ currentMagnetPoint = null
+
+ useTopToolsStore.getState().setPressA(false)
+ return
+ }
+ } else {
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: "",
+ operationId: "",
+ id: 0,
+ })
+ // 清空多边形 fillColor
+ usePaperStore
+ .getState()
+ .group!.getItems({})
+ .forEach((item) => {
+ // clear color
+ item.fillColor = item?.data?.blankColor
+ })
+ }
+
+ let temp = renderPath(usePaperStore.getState().group!, {})
+ let point = currentMagnetPoint
+ ? currentMagnetPoint
+ : temp.globalToLocal(e.point)
+
+ let circle = usePaperStore
+ .getState()
+ .getClickedCircle(
+ point,
+ selectedCircles.length,
+ polygon?.data.id,
+ polygon?.data.blankColor
+ )
+ // add circle
+ selectedCircles.push(circle)
+
+ // add point
+ points.push(point)
+ if (polygon) {
+ polygon.remove()
+ }
+ polygon = usePaperStore.getState().addPolygon(
+ renderPath,
+ points,
+ {
+ // option
+ ...usePaperStore.getState().toolOption,
+ },
+ {
+ imageId: useBottomToolsStore.getState().activeImage,
+ operationId: useTopToolsStore.getState().activeOperation,
+ id:
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1,
+ }
+ )
+
+ lastPoint = checkPoint
+ lastTime = Date.now()
+ }
+ polygonTool.onMouseUp = (_e: paper.ToolEvent) => {
+ isMouseDown = false
+ }
+ polygonTool.onMouseMove = (e: paper.MouseEvent) => {
+ handleMouseMove(e)
+
+ if (polygon) {
+ let temp = renderPath(usePaperStore.getState().group!, {})
+ let point = temp.globalToLocal(e.point)
+ // let point = e.point;
+ // when mousedown then drag draw
+ const paperGroup = usePaperStore.getState().group!
+ const currentPloygons = paperGroup.getItems({
+ data: { type: "polygon" },
+ })
+ // clear prev magnet
+ const prevHitResult = paperGroup.getItems({
+ data: { type: "magnet" },
+ })
+ prevHitResult.forEach((item) => {
+ item.remove()
+ })
+ currentMagnetPoint = null
+ if (useTopToolsStore.getState().magnetFlag)
+ currentPloygons.forEach((imgPolygon) => {
+ if (
+ imgPolygon instanceof paper.Path &&
+ imgPolygon.data.id !== polygon!.data.id
+ ) {
+ const hitResult = imgPolygon.hitTest(point, {
+ stroke: true,
+ tolerance: usePaperStore.getState().paperScope
+ ? 8 / usePaperStore.getState().paperScope!.view.zoom
+ : 0,
+ })
+ if (hitResult) {
+ // 绘制高亮边及其端点
+ const { point, location } = hitResult
+ const magnetPoint = new paper.Path.Circle(point, 4).set({
+ fillColor: "white",
+ strokeScaling: false,
+ data: { type: "magnet" },
+ })
+ magnetPoint.scale(1 / usePaperStore.getState().group!.scaling.x)
+ const magnetLine = new paper.Path.Line(
+ location.curve.segment1.point,
+ location.curve.segment2.point
+ ).set({
+ strokeColor: "white",
+ strokeWidth: 2,
+ strokeScaling: false,
+ data: { type: "magnet" },
+ })
+ paperGroup.addChild(magnetPoint)
+ paperGroup.addChild(magnetLine)
+ currentMagnetPoint = point
+ }
+ }
+ })
+ if (isMouseDown) {
+ const delta = currentMagnetPoint
+ ? (currentMagnetPoint as paper.Point).subtract(lastPoint)
+ : e.point.subtract(lastPoint)
+ const currentTime = Date.now()
+ const timeDelta = currentTime - lastTime
+ if (delta.length > 10 && timeDelta > 50) {
+ // Add a point to the current path if the distance exceeds the threshold
+ if (!currentMagnetPoint) {
+ points.push(point)
+ let circle = usePaperStore
+ .getState()
+ .getClickedCircle(
+ point,
+ selectedCircles.length,
+ polygon.data.id,
+ polygon.data.blankColor
+ )
+ // add circle
+ selectedCircles.push(circle)
+ ;(polygon as paper.Path).add(point)
+ lastPoint = e.point
+ } else {
+ points.push(currentMagnetPoint)
+ let circle = usePaperStore
+ .getState()
+ .getClickedCircle(
+ currentMagnetPoint,
+ selectedCircles.length,
+ polygon.data.id,
+ polygon.data.blankColor
+ )
+ // add circle
+ selectedCircles.push(circle)
+ ;(polygon as paper.Path).add(currentMagnetPoint)
+ lastPoint = currentMagnetPoint
+ }
+ lastTime = currentTime
+ }
+ } else {
+ polygon.remove()
+ polygon = usePaperStore.getState().addPolygon(
+ renderPath,
+ points.concat(point),
+ {
+ // option
+ ...usePaperStore.getState().toolOption,
+ },
+ {}
+ )
+ }
+ }
+ }
+ polygonTool.onKeyDown = (e: paper.KeyEvent) => {
+ if (e.key === "escape" || e.key === "alt") {
+ if (polygon) {
+ polygon.remove()
+ polygon = null
+ points = []
+ usePaperSupportStore.getState().handleBufferPaths(polygon)
+ handleCircles(polygon)
+ }
+ const magnetPaths = usePaperStore
+ .getState()
+ .group!.getItems({ data: { type: "magnet" } })
+ magnetPaths.forEach((item) => {
+ item.remove()
+ })
+ currentMagnetPoint = null
+ } else if (e.key === "delete") {
+ handleDelete()
+ forceUpdate()
+ } else if (e.key === "shift") {
+ handleShift(true)
+ }
+ }
+ polygonTool.onKeyUp = (e: paper.KeyEvent) => {
+ if (e.key === "shift") {
+ handleShift(false)
+ }
+ }
+ set((state: PaperState) => ({
+ ...state,
+ polygonTool: polygonTool,
+ }))
+ },
+ addRectangle: (
+ renderRectangle: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.Path.Rectangle,
+ option: any,
+ data: any
+ ) => {
+ let rect = renderRectangle(
+ usePaperStore.getState().group!,
+ Object.assign(option, { strokeScaling: false })
+ )
+ rect.data = Object.assign({ type: "rectangle", ...option }, data)
+ rect.onMouseEnter = (_e: paper.MouseEvent) => {
+ // if (usePaperStore.getState().mode === "pan") {
+ // e.target.strokeWidth += 2;
+ // if (usePaperStore.getState().el)
+ // usePaperStore.getState().el!.style.cursor = "move";
+ // }
+ }
+ rect.onMouseLeave = (_e: paper.MouseEvent) => {
+ // if (usePaperStore.getState().mode === "pan") {
+ // // render 结束没有触发 onMouseEnter
+ // if (e.target.strokeWidth - 2 > 0) e.target.strokeWidth -= 2;
+ // if (usePaperStore.getState().el)
+ // usePaperStore.getState().el!.style.cursor = "default";
+ // }
+ }
+ rect.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ if (e.event && e.event.button === 2) {
+ if (rect.data.id) {
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === rect.data.operationId
+ )
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category!,
+ 5,
+ 5
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: rect.data.imageId,
+ operationId: rect.data.operationId,
+ id: rect.data.id,
+ })
+ }
+ }
+ }
+ return rect
+ },
+ initRectangleTool: (
+ paperRectangleTool: paper.Tool,
+ renderRectangle: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.Path.Rectangle,
+ forceUpdate: DispatchWithoutAction
+ ) => {
+ let rect: paper.Path.Rectangle | null
+ let rectangleTool = paperRectangleTool
+
+ rectangleTool.onMouseDown = (e: { event: MouseEvent }) => {
+ if (e.event && e.event.button !== 2)
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: "",
+ operationId: "",
+ id: 0,
+ })
+
+ // 绘制时去除选中辅助
+ if (usePaperSupportStore.getState().selectedCircles.length) {
+ usePaperSupportStore.getState().clearAll()
+ }
+ }
+
+ rectangleTool.onMouseDrag = (e: {
+ event: MouseEvent
+ point: paper.Point
+ downPoint: paper.Point
+ }) => {
+ const delta = e.point.subtract(e.downPoint)
+ // 阻止右键移动矩形(e.event.button === 0)
+ if (delta.length > 10 && e.event.button === 0) {
+ let temp = renderRectangle(usePaperStore.getState().group!, {})
+ let point = temp.globalToLocal(e.point)
+ let downPoint = temp.globalToLocal(e.downPoint)
+
+ temp.remove()
+ rect = usePaperStore.getState().addRectangle(
+ renderRectangle,
+ {
+ from: downPoint,
+ to: point,
+ // option
+ ...usePaperStore.getState().toolOption,
+ },
+ {
+ imageId: useBottomToolsStore.getState().activeImage,
+ operationId: useTopToolsStore.getState().activeOperation,
+ id:
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1,
+ }
+ )
+ rect.removeOnDrag()
+
+ // !!! 矩形不进行裁剪
+ // intersect drawOption 舍弃交集
+ // 舍弃交集
+ }
+ }
+ rectangleTool.onMouseUp = (e: {
+ event: MouseEvent
+ point: paper.Point
+ }) => {
+ // add segment judgement
+ if (rect && rect.segments?.length && rect?.data?.id) {
+ // 去除超出图片边界的部分
+ handleRectangleIntersectRaster(rect)
+ // 去除后无点位 则不作后续新增处理
+ if (!rect.segments.length) return
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) =>
+ item.category_id.toString() ===
+ useTopToolsStore.getState().activeOperation
+ )
+ let label_class = category?.label_class || ""
+ let subAttr = Object.assign(
+ {},
+ useTopToolsStore.getState().subAttributes[label_class]
+ )
+ useLabelStore
+ .getState()
+ .setOperation(
+ useBottomToolsStore.getState().activeImage,
+ useTopToolsStore.getState().activeOperation,
+ [
+ rect.data.id,
+ [JSON.parse(rect.exportJSON({ precision: 20 }))[1]?.segments],
+ {
+ ...initialDetail,
+ uid: usePermissionStore.getState().user_id,
+ comment: [...useLabelStore.getState().labelDefaultComments],
+ create_timestamp: dayjs().unix(),
+ sub_attributes: subAttr,
+ image_id: useBottomToolsStore.getState().activeImageId,
+ category_id: Number(
+ useTopToolsStore.getState().activeOperation
+ ),
+ },
+ [],
+ ]
+ )
+ useRightToolsStore.getState().pushPathId(rect.data.id)
+
+ // 设置其为selectedPath
+ useObjectStore
+ .getState()
+ .updateSelectedPath(
+ useBottomToolsStore.getState().activeImage,
+ "ADD",
+ rect.data?.id
+ )
+
+ forceUpdate()
+ // 如果该类别存在子属性,则弹出子属性对话框
+ if (category && category.sub_attributes.length) {
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category,
+ -30,
+ -30
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: rect.data.imageId,
+ operationId: rect.data.operationId,
+ id: rect.data.id,
+ })
+ }
+ }
+ rect = null
+ }
+ rectangleTool.onKeyDown = (e: paper.KeyEvent) => {
+ if (e.key === "escape" || e.key === "alt") {
+ if (rect) {
+ rect.remove()
+ rect = null
+ }
+ } else if (e.key === "delete") {
+ handleDelete()
+ forceUpdate()
+ } else if (e.key === "shift") {
+ handleShift(true)
+ }
+ }
+ rectangleTool.onKeyUp = (e: paper.KeyEvent) => {
+ if (e.key === "shift") {
+ handleShift(false)
+ }
+ }
+ rectangleTool.onMouseMove = (e: paper.MouseEvent) => {
+ handleMouseMove(e)
+ }
+ set((state: PaperState) => ({
+ ...state,
+ rectangleTool: rectangleTool,
+ }))
+ },
+ addBrush: (
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ points: paper.Point[] | undefined,
+ option: any,
+ data: any
+ ) => {
+ let brush = renderPath(
+ usePaperStore.getState().group!,
+ Object.assign(
+ { ...option, fillColor: undefined, closed: false },
+ { segments: points, strokeScaling: false }
+ )
+ )
+ brush.data = Object.assign({ type: "brush", ...option }, data)
+ // points?.forEach((point) => {
+ // brush.add(point);
+ // });
+ brush.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ if (e.event && e.event.button === 2) {
+ // console.log(e.event.clientX, e.event.clientY);
+ if (brush.data.id) {
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === brush.data.operationId
+ )
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category!,
+ 5,
+ 5
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: brush.data.imageId,
+ operationId: brush.data.operationId,
+ id: brush.data.id,
+ })
+ }
+ }
+ }
+ return brush
+ },
+ initBrushTool: (
+ paperBrushTool: paper.Tool,
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ renderCompoundPath: (
+ paperGroup: paper.Group,
+ option: any
+ ) => paper.CompoundPath,
+ forceUpdate: DispatchWithoutAction
+ ) => {
+ let brushCircles: paper.Path.Circle[] = []
+ let myPath: paper.Path | null
+ let compoundPolygon: paper.Path | paper.CompoundPath | null
+ let lastPoint: paper.Point
+ let points: paper.Point[] = []
+
+ let brushTool = paperBrushTool
+
+ const handleBrushIntersectRaster = (savePath: paper.Path) => {
+ let intersections = savePath.getIntersections(
+ usePaperStore.getState().rasterPath!
+ )
+ let validIntersections = intersections.filter((intersection) => {
+ // 检查交点是否在两个路径的可见部分
+ return (
+ savePath.contains(intersection.point) &&
+ usePaperStore.getState().rasterPath!.contains(intersection.point)
+ )
+ })
+
+ // 没有 validIntersections 不进行操作
+ if (validIntersections.length) {
+ // intersectionPath as paper.CompoundPath | paper.Path | null
+ let intersectionPath: any = savePath.intersect(
+ usePaperStore.getState().rasterPath!,
+ {
+ insert: false,
+ trace: false,
+ }
+ )
+
+ // 检测交点并处理
+ if (intersectionPath instanceof paper.CompoundPath) {
+ let new_segments: any[] = []
+ ;(intersectionPath.children as paper.Path[]).forEach((child) => {
+ if (child.segments && child.segments.length) {
+ new_segments.push(...child.segments)
+ }
+ })
+ savePath.segments = new_segments
+ } else if (
+ intersectionPath.segments &&
+ intersectionPath.segments.length
+ ) {
+ // intersectionPath是CompoundPath
+ savePath.segments = intersectionPath.segments
+ } else {
+ }
+ intersectionPath.remove()
+ intersectionPath = null
+
+ let oldCompoundPolygon = compoundPolygon
+ compoundPolygon = oldCompoundPolygon?.intersect(
+ usePaperStore.getState().rasterPath!
+ ) as paper.Path
+ compoundPolygon.closed = false
+ oldCompoundPolygon?.remove()
+ } else {
+ let intersectionPath: paper.Path | null = savePath.intersect(
+ usePaperStore.getState().rasterPath!,
+ {
+ insert: false,
+ trace: false,
+ }
+ ) as paper.Path
+ savePath.segments = intersectionPath.segments
+ intersectionPath.remove()
+ intersectionPath = null
+ }
+ }
+
+ brushTool.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ if (e.event.button !== 0) return
+
+ // 绘制时去除选中辅助
+ if (usePaperSupportStore.getState().selectedCircles.length) {
+ usePaperSupportStore.getState().clearAll()
+ }
+
+ // doubleclick
+ if (lastPoint && lastPoint.x === e.point.x && lastPoint.y === e.point.y) {
+ if (myPath) {
+ // 初始化 compoundPolygon
+ compoundPolygon = new paper.Path()
+ if (useTopToolsStore.getState().pressA)
+ compoundPolygon = myPath.clone()
+
+ // 去除超出图片边界的部分
+ handleBrushIntersectRaster(myPath)
+ // 去除超出图片边界的部分
+
+ // pressA 模式下添加线段
+ if (useTopToolsStore.getState().pressA) {
+ let ids =
+ useObjectStore.getState().selectedPath[
+ useBottomToolsStore.getState().activeImage
+ ]
+ let path = usePaperStore.getState().getItemById(ids[0])
+ let paths =
+ usePaperStore
+ .getState()
+ .getItemsById(ids[0])
+ .filter((item) => item instanceof paper.Path && item.area) || []
+
+ // compoundPolygon === Path
+ let oldCompoundPolygon: any = compoundPolygon
+ let children: paper.Path[] = []
+ if (oldCompoundPolygon instanceof paper.CompoundPath) {
+ children = oldCompoundPolygon.children as paper.Path[]
+ } else if (oldCompoundPolygon instanceof paper.Path) {
+ children = [oldCompoundPolygon]
+ }
+ compoundPolygon = usePaperStore.getState().addCompoundPath(
+ renderCompoundPath,
+ [...paths!, ...children] as paper.Path[],
+ {
+ // option
+ ...usePaperStore.getState().toolOption,
+ fillColor: undefined,
+ closed: false,
+ },
+ { ...oldCompoundPolygon.data, id: path?.data.id, type: "brush" }
+ )
+ // oldCompoundPolygon?.remove();
+ oldCompoundPolygon.data.id = path?.data.id
+ }
+
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) =>
+ item.category_id.toString() ===
+ useTopToolsStore.getState().activeOperation
+ )
+ let label_class = category?.label_class || ""
+ let subAttr = Object.assign(
+ {},
+ useTopToolsStore.getState().subAttributes[label_class]
+ )
+
+ // 多线段存值
+ if (compoundPolygon instanceof paper.CompoundPath) {
+ if (compoundPolygon.children && compoundPolygon.children.length) {
+ let polygonPoints: any[] = []
+ let polygonHollowPoints: any[] = []
+
+ ;(compoundPolygon.children as paper.Path[]).forEach((path) => {
+ let pathData = JSON.parse(path.exportJSON({ precision: 20 }))
+ // 在镂空或分割的区域中 路径方向是false
+ if (path.clockwise) {
+ polygonPoints.push(pathData[1]?.segments)
+ } else {
+ if (path.data.id && path.segments?.length) {
+ polygonHollowPoints.push(pathData[1]?.segments)
+ }
+ }
+ })
+
+ useLabelStore
+ .getState()
+ .setOperation(
+ useBottomToolsStore.getState().activeImage,
+ useTopToolsStore.getState().activeOperation,
+ [
+ useTopToolsStore.getState().pressA
+ ? compoundPolygon.data.id
+ : myPath.data.id,
+ polygonPoints,
+ {
+ ...initialDetail,
+ uid: usePermissionStore.getState().user_id,
+ comment: [
+ ...useLabelStore.getState().labelDefaultComments,
+ ],
+ create_timestamp: dayjs().unix(),
+ sub_attributes: subAttr,
+ image_id: useBottomToolsStore.getState().activeImageId,
+ category_id: Number(
+ useTopToolsStore.getState().activeOperation
+ ),
+ },
+ polygonHollowPoints,
+ ]
+ )
+ useRightToolsStore
+ .getState()
+ .pushPathId(
+ useTopToolsStore.getState().pressA
+ ? compoundPolygon.data.id
+ : myPath.data.id
+ )
+ }
+ } else if (
+ compoundPolygon instanceof paper.Path &&
+ myPath.data.id &&
+ myPath.segments?.length
+ ) {
+ let pathData = JSON.parse(myPath.exportJSON({ precision: 20 }))
+ useLabelStore
+ .getState()
+ .setOperation(
+ useBottomToolsStore.getState().activeImage,
+ useTopToolsStore.getState().activeOperation,
+ [
+ myPath.data.id,
+ // todo intersect+a | +a会变成CompoundPath 理论上已解决这个todo
+ [pathData[1]?.segments, ...[]],
+ {
+ ...initialDetail,
+ uid: usePermissionStore.getState().user_id,
+ comment: [...useLabelStore.getState().labelDefaultComments],
+ create_timestamp: dayjs().unix(),
+ sub_attributes: subAttr,
+ image_id: useBottomToolsStore.getState().activeImageId,
+ category_id: Number(
+ useTopToolsStore.getState().activeOperation
+ ),
+ },
+ [],
+ ]
+ )
+ useRightToolsStore.getState().pushPathId(myPath.data.id)
+ }
+
+ // 如果该类别存在子属性,则弹出子属性对话框
+ if (category && category.sub_attributes.length) {
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category,
+ -30,
+ -30
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: myPath.data.imageId,
+ operationId: myPath.data.operationId,
+ id: myPath.data.id,
+ })
+ }
+
+ // 设置其为selectedPath
+ useObjectStore
+ .getState()
+ .updateSelectedPath(
+ useBottomToolsStore.getState().activeImage,
+ "ADD",
+ useTopToolsStore.getState().pressA
+ ? compoundPolygon.data.id
+ : myPath.data.id
+ )
+
+ forceUpdate()
+
+ if (compoundPolygon instanceof paper.CompoundPath) {
+ myPath.remove()
+ myPath = null
+ points = []
+ } else {
+ compoundPolygon?.remove()
+ compoundPolygon = null
+ myPath = null
+ points = []
+ }
+
+ brushCircles.forEach((item) => {
+ item.remove()
+ })
+
+ useTopToolsStore.getState().setPressA(false)
+ return
+ }
+ } else {
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: "",
+ operationId: "",
+ id: 0,
+ })
+ }
+
+ let temp = renderPath(usePaperStore.getState().group!, {})
+ let point = temp.globalToLocal(e.point)
+
+ // add point
+ points.push(point)
+ if (myPath) {
+ myPath.remove()
+ }
+ myPath = usePaperStore.getState().addBrush(
+ renderPath,
+ points,
+ {
+ // option
+ ...usePaperStore.getState().toolOption,
+ },
+ {
+ imageId: useBottomToolsStore.getState().activeImage,
+ operationId: useTopToolsStore.getState().activeOperation,
+ id:
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1,
+ }
+ )
+ let circle = usePaperStore
+ .getState()
+ .getClickedCircle(
+ point,
+ brushCircles.length,
+ myPath?.data.id,
+ myPath?.data.blankColor
+ )
+ brushCircles.push(circle)
+
+ lastPoint = e.point
+ temp.remove()
+ }
+
+ brushTool.onMouseMove = (e: paper.MouseEvent) => {
+ handleMouseMove(e)
+
+ if (myPath) {
+ let temp = renderPath(usePaperStore.getState().group!, {})
+ let point = temp.globalToLocal(e.point)
+ myPath.remove()
+ myPath = usePaperStore.getState().addBrush(
+ renderPath,
+ points.concat(point),
+ {
+ // option
+ ...usePaperStore.getState().toolOption,
+ fillColor: undefined,
+ closed: false,
+ },
+ {}
+ )
+ }
+ }
+
+ brushTool.onKeyDown = (e: paper.KeyEvent) => {
+ if (e.key === "escape" || e.key === "alt") {
+ if (myPath) {
+ brushCircles.forEach((item) => {
+ item.remove()
+ })
+ myPath.remove()
+ myPath = null
+ points = []
+ }
+ } else if (e.key === "delete") {
+ handleDelete()
+ forceUpdate()
+ } else if (e.key === "shift") {
+ handleShift(true)
+ }
+ }
+ brushTool.onKeyUp = (e: paper.KeyEvent) => {
+ if (e.key === "shift") {
+ handleShift(false)
+ }
+ }
+
+ set((state: PaperState) => ({
+ ...state,
+ brushTool: brushTool,
+ }))
+ },
+ addPoint: (
+ renderCircle: (paperGroup: paper.Group, option: any) => paper.Path.Circle,
+ point: paper.Point | undefined,
+ option: any,
+ data: any
+ ) => {
+ let myCircle = renderCircle(usePaperStore.getState().group!, {
+ center: point,
+ ...option,
+ strokeScaling: false,
+ })
+ const currentScaling = usePaperStore.getState().group!.scaling.x
+ const newScale = useTopToolsStore.getState().scale / currentScaling
+ myCircle.scale(newScale / currentScaling)
+ myCircle.data = Object.assign({ type: "circle", ...option }, data)
+ myCircle.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ if (e.event && e.event.button === 2) {
+ // console.log(e.event.clientX, e.event.clientY);
+ if (myCircle.data.id) {
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) =>
+ item.category_id.toString() === myCircle.data.operationId
+ )
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category!,
+ 5,
+ 5
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: myCircle.data.imageId,
+ operationId: myCircle.data.operationId,
+ id: myCircle.data.id,
+ })
+ }
+ }
+ }
+ myCircle.onMouseEnter = (e: paper.MouseEvent) => {
+ if (usePaperStore.getState().mode === "pan") {
+ e.target.strokeWidth += 2
+ if (usePaperStore.getState().el)
+ usePaperStore.getState().el!.style.cursor = "move"
+ }
+ }
+ myCircle.onMouseLeave = (e: paper.MouseEvent) => {
+ if (usePaperStore.getState().mode === "pan") {
+ e.target.strokeWidth -= 2
+ if (usePaperStore.getState().el)
+ usePaperStore.getState().el!.style.cursor = "default"
+ }
+ }
+ return myCircle
+ },
+ addPointLine: (
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ points: paper.Point[] | undefined,
+ option: any,
+ data: any
+ ) => {
+ let myCircleLine = renderPath(
+ usePaperStore.getState().group!,
+ Object.assign(
+ {
+ ...option,
+ fillColor: undefined,
+ closed: false,
+ dashArray: [4, 4],
+ strokeScaling: false,
+ },
+ { segments: points }
+ )
+ )
+ myCircleLine.data = Object.assign({ type: "point", ...option }, data)
+ return myCircleLine
+ },
+
+ initPointTool: (
+ paperPointTool: paper.Tool,
+ renderCircle: (paperGroup: paper.Group, option: any) => paper.Path.Circle,
+ renderPath: (paperGroup: paper.Group, option: any) => paper.Path,
+ forceUpdate: DispatchWithoutAction
+ ) => {
+ let myCircle: paper.Path.Circle[] = []
+ let myCircleText: paper.PointText[] = []
+ let textNumbers: number[] = []
+ let myPath: paper.Path | null
+ let lastPoint: paper.Point
+ let points: paper.Point[] = []
+
+ let pointTool = paperPointTool
+
+ pointTool.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ if (e.event.button !== 0) return
+
+ // 绘制时去除选中辅助
+ if (usePaperSupportStore.getState().selectedCircles.length) {
+ usePaperSupportStore.getState().clearAll()
+ }
+
+ // doubleclick
+ if (lastPoint && lastPoint.x === e.point.x && lastPoint.y === e.point.y) {
+ if (myPath) {
+ // 去除超出图片边界的部分
+ myCircle = myCircle.filter((circle) => {
+ if (circle.data.id === myPath!.data.id) {
+ if (
+ usePaperStore
+ .getState()
+ .rasterPath!.bounds.contains(circle.bounds.center)
+ ) {
+ return true
+ } else {
+ circle.remove()
+ return false
+ }
+ } else {
+ return false
+ }
+ })
+ myPath.segments = myPath.segments.filter((segment) => {
+ return usePaperStore
+ .getState()
+ .rasterPath!.bounds.contains(segment.point)
+ })
+ // 去除超出图片边界的部分 end
+
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) =>
+ item.category_id.toString() ===
+ useTopToolsStore.getState().activeOperation
+ )
+ let label_class = category?.label_class || ""
+ let subAttr = Object.assign(
+ {},
+ useTopToolsStore.getState().subAttributes[label_class]
+ )
+
+ // 存值
+ // let circles = usePaperStore.getState().group!.getItems({
+ // data: { type: "circle", id: myPath.data.id },
+ // });
+ let circles = myCircle.filter((item) => {
+ return item.data.id === myPath!.data.id
+ })
+ if (circles.length) {
+ let segments = circles.map((circle, circleIndex) => {
+ // 重新设置显示数字
+ circle.data.text = circleIndex + 1
+ return {
+ point: [circle.position.x, circle.position.y],
+ attributes: circle.data.attributes,
+ }
+ })
+ useLabelStore
+ .getState()
+ .setOperation(
+ useBottomToolsStore.getState().activeImage,
+ useTopToolsStore.getState().activeOperation,
+ [
+ myPath.data.id,
+ // 关键点 没有多区域
+ [segments.map((item) => item.point)],
+ {
+ ...initialDetail,
+ uid: usePermissionStore.getState().user_id,
+ comment: [...useLabelStore.getState().labelDefaultComments],
+ create_timestamp: dayjs().unix(),
+ sub_attributes: subAttr,
+ image_id: useBottomToolsStore.getState().activeImageId,
+ category_id: Number(
+ useTopToolsStore.getState().activeOperation
+ ),
+ circles: segments,
+ },
+ [],
+ ]
+ )
+
+ // 如果该类别存在子属性,则弹出子属性对话框
+ if (category && category.sub_attributes.length) {
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category,
+ -30,
+ -30
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: myPath.data.imageId,
+ operationId: myPath.data.operationId,
+ id: myPath.data.id,
+ })
+ }
+
+ // 设置其为selectedPath
+ useObjectStore
+ .getState()
+ .updateSelectedPath(
+ useBottomToolsStore.getState().activeImage,
+ "ADD",
+ myPath.data.id
+ )
+ useRightToolsStore.getState().pushPathId(myPath.data.id)
+
+ forceUpdate()
+ }
+
+ myPath = null
+ points = []
+ textNumbers = []
+ myCircleText.forEach((item) => {
+ item.remove()
+ })
+ myCircleText = []
+ // 避免后续按键删除已绘制的circle
+ myCircle = []
+
+ return
+ }
+ } else {
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: false,
+ position: {
+ top: 0,
+ left: 0,
+ },
+ imageId: "",
+ operationId: "",
+ id: 0,
+ })
+ }
+
+ let temp = renderCircle(usePaperStore.getState().group!, {})
+ let point = temp.globalToLocal(e.point)
+ let text = textNumbers.reduce((a, b) => Math.max(a, b), 0) + 1
+
+ let circle = usePaperStore.getState().addPoint(
+ renderCircle,
+ point,
+ {
+ fillColor: null,
+ // center: point,
+ strokeColor: usePaperStore.getState().toolOption?.strokeColor,
+ radius: 3,
+ strokeScaling: false,
+ },
+ {
+ imageId: useBottomToolsStore.getState().activeImage,
+ operationId: useTopToolsStore.getState().activeOperation,
+ text: text,
+ id:
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1,
+ }
+ )
+ // add circle
+ myCircle.push(circle)
+ let pointText = new paper.PointText({
+ parent: usePaperStore.getState().group!,
+ point: point,
+ content: text.toString(),
+ justification: "right",
+ fillColor: usePaperStore.getState().toolOption?.strokeColor,
+ fontSize: 12,
+ })
+ const currentScaling = usePaperStore.getState().group!.scaling.x
+ const newScale = useTopToolsStore.getState().scale / currentScaling
+ pointText.scale(newScale / currentScaling)
+ textNumbers.push(text)
+ myCircleText.push(pointText)
+ // add point
+ points.push(point)
+
+ if (myPath) {
+ myPath.remove()
+ }
+ myPath = usePaperStore.getState().addPointLine(
+ renderPath,
+ points,
+ {
+ // option
+ ...usePaperStore.getState().toolOption,
+ },
+ {
+ imageId: useBottomToolsStore.getState().activeImage,
+ operationId: useTopToolsStore.getState().activeOperation,
+ id:
+ useRightToolsStore
+ .getState()
+ .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1,
+ }
+ )
+
+ lastPoint = e.point
+
+ // group.current?.addChild(myCircle);
+ temp.remove()
+ }
+ pointTool.onMouseMove = (e: paper.MouseEvent) => {
+ handleMouseMove(e)
+
+ if (myPath) {
+ let temp = renderPath(usePaperStore.getState().group!, {})
+ let point = temp.globalToLocal(e.point)
+ myPath.remove()
+ myPath = usePaperStore.getState().addPointLine(
+ renderPath,
+ points.concat(point),
+ {
+ // option
+ ...usePaperStore.getState().toolOption,
+ fillColor: undefined,
+ closed: false,
+ },
+ {}
+ )
+ }
+ }
+ pointTool.onKeyDown = (e: paper.KeyEvent) => {
+ if (e.key === "escape" || e.key === "alt") {
+ if (myPath) {
+ myPath.remove()
+ myPath = null
+ points = []
+ textNumbers = []
+ myCircleText.forEach((item) => {
+ item.remove()
+ })
+ myCircleText = []
+ myCircle.forEach((item) => {
+ item.remove()
+ })
+ myCircle = []
+ }
+ } else if (e.key === "delete") {
+ handleDelete()
+ forceUpdate()
+ } else if (e.key === "shift") {
+ handleShift(true)
+ }
+ }
+ pointTool.onKeyUp = (e: paper.KeyEvent) => {
+ if (e.key === "shift") {
+ handleShift(false)
+ }
+ }
+ set((state: PaperState) => ({
+ ...state,
+ pointTool: pointTool,
+ }))
+ },
+ getPointCircle: (
+ point: paper.Point,
+ index: number,
+ id: number,
+ color: any
+ ) => {
+ let circle: paper.Path.Circle = new paper.Path.Circle(
+ Object.assign(
+ { parent: usePaperStore.getState().group!, center: point },
+ {
+ fillColor: color,
+ // center: point,
+ strokeColor: usePaperStore.getState().toolOption?.strokeColor,
+ radius: 3,
+ strokeScaling: false,
+ },
+ {
+ data: {
+ type: "circle",
+ fillColor: color,
+ strokeColor: usePaperStore.getState().toolOption?.strokeColor,
+ imageId: useBottomToolsStore.getState().activeImage,
+ // bug
+ operationId: useTopToolsStore.getState().activeOperation,
+ text: +index + 1,
+ id: id,
+ },
+ }
+ )
+ )
+ const currentScaling = usePaperStore.getState().group!.scaling.x
+ const newScale = useTopToolsStore.getState().scale / currentScaling
+ circle.scale(newScale / currentScaling)
+ circle.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ if (e.event && e.event.button === 2) {
+ // console.log(e.event.clientX, e.event.clientY);
+ if (circle.data.id) {
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === circle.data.operationId
+ )
+ const { top, left } = getShowContextMenuPosition(
+ e.point,
+ category!,
+ 5,
+ 5
+ )
+ useOtherToolsStore.getState().setShowContextMenu({
+ showContextMenu: true,
+ position: { top, left },
+ imageId: circle.data.imageId,
+ operationId: circle.data.operationId,
+ id: circle.data.id,
+ })
+ }
+ }
+ }
+ circle.onMouseEnter = (e: paper.MouseEvent) => {
+ if (usePaperStore.getState().mode === "pan") {
+ e.target.strokeWidth += 2
+ if (usePaperStore.getState().el)
+ usePaperStore.getState().el!.style.cursor = "move"
+ }
+ }
+ circle.onMouseLeave = (e: paper.MouseEvent) => {
+ if (usePaperStore.getState().mode === "pan") {
+ e.target.strokeWidth -= 2
+ if (usePaperStore.getState().el)
+ usePaperStore.getState().el!.style.cursor = "default"
+ }
+ }
+ return circle
+ },
+ initSupportTool: (tool, renderPath) => {
+ let points: Array<[number, number]> = []
+ let tags: number[] = []
+
+ tool.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
+ let point = usePaperStore.getState().group!.globalToLocal(e.point)
+ if (e.event.button === 1) return
+ points.push([point.x, point.y])
+ tags.push(e.event.button === 0 ? 1 : 0)
+ renderPath(points, tags, points.length === 1)
+ }
+
+ tool.onKeyDown = (e: paper.KeyEvent) => {
+ if (e.key === "escape") {
+ points = []
+ tags = []
+ clearSupportAnnotationItem()
+ usePaperStore.getState().setPaperMode("pan")
+ }
+ if (e.key === "f1") {
+ points = []
+ tags = []
+ }
+ }
+
+ set((state: PaperState) => ({
+ ...state,
+ supportTool: tool,
+ }))
+ },
+ getClickedCircle: (
+ point: paper.Point,
+ index: number,
+ id: number,
+ color: any
+ ) => {
+ const currentScaling = usePaperStore.getState().group!.scaling.x
+ const newScale = useTopToolsStore.getState().scale / currentScaling
+ let circle: paper.Path.Circle = new paper.Path.Circle(
+ Object.assign(
+ { parent: usePaperStore.getState().group!, center: point },
+ {
+ fillColor: color,
+ // center: point,
+ strokeColor: "#FFF",
+ radius: 3,
+ strokeScaling: false,
+ },
+ {
+ data: { index, type: "pathCircle", id },
+ }
+ )
+ )
+ circle.scale(newScale / currentScaling)
+ circle.onMouseEnter = (_e: any) => {
+ circle.strokeWidth = 4
+ circle.fillColor = new paper.Color("#FFF")
+ }
+ circle.onMouseLeave = (_e: any) => {
+ circle.strokeWidth = 2
+ circle.fillColor = color
+ }
+ return circle
+ },
+ getBuffPath: (item: paper.Segment, index: number, id: number, color: any) => {
+ if (!item.next) return null
+ let bufferPath: paper.Path = new paper.Path(
+ Object.assign(
+ {
+ parent: usePaperStore.getState().group!,
+ segments: [item.point, item.next],
+ strokeColor: color, // 设置为透明,以便不可见
+ strokeScaling: false,
+ strokeWidth: 4, // 设置一个较大的strokeWidth作为缓冲区
+ strokeCap: "butt",
+ onSelect: function (event: { stopPropagation: () => void }) {
+ event.stopPropagation() // 阻止事件冒泡
+ },
+ },
+ {
+ data: { index, type: "pathBuff", id },
+ }
+ )
+ )
+ // 复制段的handleIn和handleOut到缓冲路径
+ // bufferPath.segments[0].handleIn = item.handleIn;
+ // bufferPath.segments[1].handleOut = item.handleOut;
+ let edge: paper.Path | null
+ // 鼠标移入时高亮显示
+ bufferPath.onMouseEnter = function (event: {
+ stopPropagation: () => void
+ }) {
+ if (!edge) {
+ edge = new paper.Path(
+ Object.assign(
+ {
+ parent: usePaperStore.getState().group!,
+ segments: [
+ bufferPath.segments[0].point,
+ bufferPath.segments[1].point,
+ ],
+ strokeColor: "white", // 初始颜色与原始路径相同
+ strokeWidth: 2,
+ strokeScaling: false,
+ strokeCap: "butt",
+ },
+ {
+ data: { index, type: "pathBuff" },
+ }
+ )
+ )
+ // edge.bringToFront();
+ event.stopPropagation() // 阻止事件冒泡
+ }
+ }
+
+ // 鼠标移出时取消高亮
+ bufferPath.onMouseLeave = function (event: {
+ stopPropagation: () => void
+ }) {
+ edge?.remove()
+ edge = null
+ event.stopPropagation() // 阻止事件冒泡
+ }
+ return bufferPath
+ },
+ setPaperMode: (modeParam) => {
+ set((state: PaperState) => ({
+ ...state,
+ mode: modeParam,
+ }))
+
+ switch (modeParam) {
+ case "pan":
+ usePaperStore.getState().panTool?.activate()
+ if (usePaperStore.getState().el) {
+ usePaperStore.getState().el!.style.cursor = "default"
+ }
+ break
+ case "polygon":
+ usePaperStore.getState().polygonTool?.activate()
+ if (usePaperStore.getState().el) {
+ usePaperStore.getState().el!.style.cursor = "crosshair"
+ }
+ break
+ case "rectangle":
+ usePaperStore.getState().rectangleTool?.activate()
+ if (usePaperStore.getState().el) {
+ usePaperStore.getState().el!.style.cursor = "crosshair"
+ }
+ break
+ case "brush":
+ usePaperStore.getState().brushTool?.activate()
+ if (usePaperStore.getState().el) {
+ usePaperStore.getState().el!.style.cursor = "crosshair"
+ }
+ break
+ case "point":
+ usePaperStore.getState().pointTool?.activate()
+ if (usePaperStore.getState().el) {
+ usePaperStore.getState().el!.style.cursor = "crosshair"
+ }
+ break
+ case "support":
+ usePaperStore.getState().supportTool?.activate()
+ if (usePaperStore.getState().el) {
+ usePaperStore.getState().el!.style.cursor = "crosshair"
+ }
+ break
+ default:
+ usePaperStore.getState().viewTool?.activate()
+ break
+ }
+ },
+ setGroupScale: (scale: number) => {
+ if (usePaperStore.getState().group) {
+ const currentScaling = usePaperStore.getState().group!.scaling.x
+ const newScale = scale / currentScaling
+ const point = usePositionStore.getState().currentMousePosition
+
+ if (point[0] !== 0 || point[1] !== 0)
+ usePaperStore.getState().group!.scale(newScale, new paper.Point(point))
+ else usePaperStore.getState().group!.scale(newScale)
+
+ let texts = usePaperStore.getState().group!.getItems({
+ data: { type: "text" },
+ }) as paper.Path[]
+ texts.forEach((text) => {
+ text.scale(1 / newScale)
+ })
+
+ let pathCircles = usePaperStore.getState().group!.getItems({
+ data: { type: "pathCircle" },
+ }) as paper.Path[]
+ pathCircles.forEach((pathCircle) => {
+ pathCircle.scale(1 / newScale)
+ })
+ let circles = usePaperStore.getState().group!.getItems({
+ data: { type: "circle" },
+ }) as paper.Path[]
+ circles.forEach((circle) => {
+ circle.scale(1 / newScale)
+ })
+ }
+ },
+ getAll: () => {
+ let polygon = usePaperStore.getState().group!.getItems({
+ data: { type: "polygon" },
+ }) as paper.Path[]
+ return polygon
+ },
+ getItemById: (id: number) => {
+ // let item = usePaperStore.getState().group!.getItems({
+ // id: id,
+ // }) as paper.Path[];
+ let savedItem: paper.Path[] = (
+ usePaperStore.getState().group!.getItems({
+ data: { id: id },
+ }) as paper.Path[]
+ ).filter((item) =>
+ ["rectangle", "polygon", "brush", "point"].includes(item.data.type)
+ )
+ if (savedItem?.[0]) {
+ return savedItem[0]
+ } else {
+ return null
+ }
+ },
+ getItemsById: (id: number) => {
+ // let item = usePaperStore.getState().group!.getItems({
+ // id: id,
+ // }) as paper.Path[];
+ let savedItem = usePaperStore.getState().group!.getItems({
+ data: { id: id },
+ }) as paper.Path[]
+ if (savedItem?.length) {
+ return savedItem
+ } else {
+ return []
+ }
+ },
+}))
diff --git a/components/label/usePaperSupportStore.ts b/components/label/usePaperSupportStore.ts
new file mode 100644
index 0000000..157a7cd
--- /dev/null
+++ b/components/label/usePaperSupportStore.ts
@@ -0,0 +1,234 @@
+import paper from "paper"
+import { create } from "zustand"
+import { usePaperStore } from "./usePaperStore"
+import { useTopToolsStore } from "./useTopToolsStore"
+
+interface SupportStore {
+ masks: paper.Path.Rectangle[]
+ pointTexts: paper.PointText[]
+ selectedCircles: paper.Path.Circle[]
+ bufferPaths: paper.Path[]
+ handleMask: (path: paper.Path | paper.Item | paper.CompoundPath) => void
+ removeMaskById: (id: number) => void
+ removeMaskByIds: (id: number[]) => void
+ clearMasks: () => void
+ handleText: (path: paper.Path | paper.Item | paper.CompoundPath) => void
+ clearTexts: () => void
+ handleCircles: (path: paper.Path | paper.CompoundPath | null) => void
+ clearCircles: () => void
+ handleBufferPaths: (path: paper.Path | paper.CompoundPath | null) => void
+ clearBufferPaths: () => void
+ clearAll: () => void
+}
+
+export const usePaperSupportStore = create((set) => ({
+ masks: [],
+ pointTexts: [],
+ selectedCircles: [],
+ bufferPaths: [],
+ handleMask: (path) =>
+ set((state) => {
+ let newMasks = state.masks
+ if (newMasks.length) {
+ newMasks = newMasks.filter((mask) => {
+ if (mask.data.id !== path.data.id) {
+ return true
+ } else {
+ mask.remove()
+ return false
+ }
+ })
+ }
+
+ let bounds = path.bounds
+ let mask = new paper.Path.Rectangle({
+ parent: usePaperStore.getState().group!,
+ point: [bounds.left, bounds.top],
+ size: [bounds.width, bounds.height],
+ fillColor: path.data.fillColor,
+ strokeColor: path.data.strokeColor,
+ strokeWidth: path.data.strokeWidth || 1,
+ // opacity: 0.3,
+ strokeScaling: false,
+ })
+ mask.data = {
+ type: "mask",
+ id: path.data.id,
+ operationId: path.data.operationId,
+ }
+ newMasks.push(mask)
+ return {
+ ...state,
+ masks: newMasks,
+ }
+ }),
+ removeMaskById: (id) =>
+ set((state) => {
+ let newMasks = state.masks.filter((mask) => {
+ if (mask.data.id !== id) {
+ return true
+ } else {
+ mask.remove()
+ return false
+ }
+ })
+ return {
+ ...state,
+ masks: newMasks,
+ }
+ }),
+ removeMaskByIds: (ids) =>
+ set((state) => {
+ let newMasks = state.masks.filter((mask) => {
+ if (!ids.includes(mask.data.id)) {
+ return true
+ } else {
+ mask.remove()
+ return false
+ }
+ })
+ return {
+ ...state,
+ masks: newMasks,
+ }
+ }),
+ clearMasks: () =>
+ set((state) => {
+ if (state.masks.length) {
+ state.masks.forEach((mask) => mask.remove())
+ }
+ return {
+ ...state,
+ masks: [],
+ }
+ }),
+ handleText: (path) =>
+ set((state) => {
+ if (state.pointTexts.length) {
+ state.pointTexts.forEach((item) => item.remove())
+ }
+ let newTexts: any[] = []
+ // let circleItems = usePaperStore.getState().group!.getItems({
+ // data: { type: "circle" },
+ // });
+ const currentScaling = usePaperStore.getState().group!.scaling.x
+ const newScale = useTopToolsStore.getState().scale / currentScaling
+
+ let currentPointTexts = (path as paper.Path)?.segments.map(
+ (item, index) => {
+ // let text = circleItems.find((circle) => circle.contains(item.point))
+ // ?.data.text;
+ let text = index + 1
+ let pointText = new paper.PointText({
+ parent: usePaperStore.getState().group!,
+ point: item.point,
+ content: text?.toString(),
+ justification: "right",
+ fillColor: path?.data?.strokeColor,
+ // fontSize: 12,
+ data: {
+ type: "text",
+ id: path.data.id,
+ operationId: path.data.operationId,
+ },
+ strokeScaling: false,
+ // scaling: usePaperStore.getState().group!.scaling,
+ })
+
+ pointText.scale(newScale / currentScaling)
+ return pointText
+ }
+ )
+ currentPointTexts.forEach((currentPointText) => {
+ newTexts.push(currentPointText)
+ })
+ return {
+ ...state,
+ pointTexts: newTexts,
+ }
+ }),
+ clearTexts: () =>
+ set((state) => {
+ if (state.pointTexts.length) {
+ state.pointTexts.forEach((pointText) => pointText.remove())
+ }
+ return {
+ ...state,
+ pointTexts: [],
+ }
+ }),
+ handleCircles: (path) =>
+ set((state) => {
+ if (state.selectedCircles.length) {
+ state.selectedCircles.forEach((circle) => circle.remove())
+ }
+ let newCircles: any[] = []
+ path &&
+ path instanceof paper.Path &&
+ path.segments.forEach((segment) => {
+ let circle = usePaperStore
+ .getState()
+ .getClickedCircle(
+ segment.point,
+ segment.index,
+ path.data.id,
+ path.data.blankColor
+ )
+ newCircles.push(circle)
+ })
+ return {
+ ...state,
+ selectedCircles: newCircles,
+ }
+ }),
+ clearCircles: () =>
+ set((state) => {
+ if (state.selectedCircles.length) {
+ state.selectedCircles.forEach((circle) => circle.remove())
+ }
+ return {
+ ...state,
+ selectedCircles: [],
+ }
+ }),
+ handleBufferPaths: (path) =>
+ set((state) => {
+ if (state.bufferPaths.length) {
+ state.bufferPaths.forEach((bufferPath) => bufferPath.remove())
+ }
+ let newPaths: any[] = []
+ path &&
+ path instanceof paper.Path &&
+ path.segments.forEach((segment) => {
+ let bufferPath = usePaperStore
+ .getState()
+ .getBuffPath(
+ segment,
+ segment.index,
+ path.data.id,
+ path.data.blankColor
+ )
+ if (bufferPath) newPaths.push(bufferPath)
+ })
+ return {
+ ...state,
+ bufferPaths: newPaths,
+ }
+ }),
+ clearBufferPaths: () =>
+ set((state) => {
+ if (state.bufferPaths.length) {
+ state.bufferPaths.forEach((bufferPath) => bufferPath.remove())
+ }
+ return {
+ ...state,
+ bufferPaths: [],
+ }
+ }),
+ clearAll: () => {
+ usePaperSupportStore.getState().clearMasks()
+ usePaperSupportStore.getState().clearTexts()
+ usePaperSupportStore.getState().clearCircles()
+ usePaperSupportStore.getState().clearBufferPaths()
+ },
+}))
diff --git a/components/label/useRenderGroupPath.ts b/components/label/useRenderGroupPath.ts
new file mode 100644
index 0000000..ea07593
--- /dev/null
+++ b/components/label/useRenderGroupPath.ts
@@ -0,0 +1,88 @@
+import paper from "paper"
+import { useBottomToolsStore } from "./useBottomToolsStore"
+import { usePaperStore } from "./usePaperStore"
+import { useRightToolsStore } from "./useRightToolsStore"
+
+const clearGroupPath = () => {
+ const group = usePaperStore.getState().group
+ if (!group) return
+ const paths = group.getItems({ data: { type: "groupPath" } })
+ paths.forEach((path) => {
+ path.remove()
+ })
+}
+
+export const renderGroupPath = () => {
+ const group = usePaperStore.getState().group
+ const visible = useRightToolsStore.getState().groupPathVisible
+ const imageId = useBottomToolsStore.getState().activeImage
+ const groupMap =
+ useRightToolsStore.getState().pathGroupMap.get(imageId) ??
+ new Map()
+
+ console.log("render group", group, groupMap)
+
+ if (!group) return
+ clearGroupPath()
+ if (!Array.from(groupMap.values()).length) return
+
+ for (let [groupId, pathIds] of groupMap) {
+ let boundsArr: any[] = []
+ pathIds.forEach((pathId) => {
+ const path = usePaperStore.getState().getItemById(pathId)!
+ console.log(path)
+ if (path) {
+ path.data = { ...path.data, parentGroupId: groupId }
+ boundsArr.push(path.bounds)
+ }
+ })
+ console.log(boundsArr)
+ if (!boundsArr.length) continue
+ // pathIds.length 必大于等于2
+ let uniteBounds = boundsArr[0].unite(boundsArr[1])
+ for (let i = 2; i < boundsArr.length; i++) {
+ uniteBounds = uniteBounds.unite(boundsArr[i])
+ }
+ // draw groupPath
+ let groupPath = new paper.Path.Rectangle(uniteBounds)
+ groupPath.set({
+ data: { type: "groupPath", groupId, childPathIds: pathIds },
+ strokeColor: "white",
+ strokeWidth: 2,
+ strokeScaling: false,
+ parent: group,
+ visible,
+ })
+ const text = `@${groupId}`
+ const pos = [
+ groupPath.bounds.left - text.length * 15,
+ groupPath.bounds.top + 18,
+ ]
+ const textPath = new paper.PointText(pos)
+ textPath.set({
+ content: text,
+ fontSize: 18,
+ fontWeight: "bold",
+ fillColor: "white",
+ data: {
+ groupId,
+ type: "groupPath",
+ blankColor: "white",
+ },
+ parent: group,
+ visible,
+ })
+ const textMask = new paper.Path.Rectangle(textPath.bounds)
+ textMask.set({
+ strokeColor: "white",
+ strokeWidth: 2,
+ strokeScaling: false,
+ data: {
+ groupId,
+ type: "groupPath",
+ },
+ parent: group,
+ visible,
+ })
+ }
+}
diff --git a/components/label/useRenderTag.ts b/components/label/useRenderTag.ts
new file mode 100644
index 0000000..04c2420
--- /dev/null
+++ b/components/label/useRenderTag.ts
@@ -0,0 +1,165 @@
+import paper from "paper"
+import { useLabelStore } from "./store"
+import { useBottomToolsStore } from "./useBottomToolsStore"
+import { usePaperStore } from "./usePaperStore"
+import { useTopToolsStore } from "./useTopToolsStore"
+
+export const removeTagTexts = (id?: number) => {
+ const group = usePaperStore.getState().group
+ if (!group) return
+ const items = id
+ ? group.getItems({ data: { id, type: "tag" } })
+ : group.getItems({ data: { type: "tag" } })
+ items.forEach((item) => item.remove())
+}
+
+const useRenderTag = () => {
+ const renderTag = () => {
+ const dataMap = useLabelStore.getState().label
+ const activeImage = useBottomToolsStore.getState().activeImage
+ const group = usePaperStore.getState().group
+ const visible = useTopToolsStore.getState().showTags
+ const configs = useTopToolsStore.getState().showTagsConfigs
+
+ // 清空之前渲染的标签
+ removeTagTexts()
+
+ for (const [imageId, categoryMap] of dataMap) {
+ if (imageId === activeImage) {
+ for (const [categoryId, labelList] of categoryMap) {
+ let category = useTopToolsStore
+ .getState()
+ .objectOperations.find(
+ (item) => item.category_id.toString() === categoryId
+ )!
+ const { label_class } = category
+ labelList.forEach(([id, _d, detail]) => {
+ const path = usePaperStore.getState().getItemById(id)
+ if (path) {
+ const bounds = path.bounds
+ let pos = [bounds.left, bounds.top - 6]
+ let pathData = path.data
+ let content = ``
+ if (configs.includes("ID"))
+ content += `${id}${
+ path.data.parentGroupId ? `@${path.data.parentGroupId}` : ""
+ }`
+ if (configs.includes("类别")) content += ` ${label_class}`
+
+ if (
+ detail.sub_attributes &&
+ Object.keys(detail.sub_attributes).length
+ ) {
+ Object.keys(detail.sub_attributes).forEach((key) => {
+ if (configs.includes("子属性")) content += ` ${key}:`
+ if (configs.includes("属性值"))
+ content += `${detail.sub_attributes[key]};`
+ })
+ }
+ // 文本
+ const text = new paper.PointText(pos)
+ text.set({
+ content,
+ fontSize: 12,
+ fontWeight: "bold",
+ fillColor: "black",
+ data: {
+ id,
+ type: "tag",
+ tag_category: "text",
+ },
+ parent: group,
+ visible,
+ })
+ // 文本框
+ const textMask = new paper.Path.Rectangle(text.bounds)
+ textMask.set({
+ fillColor: pathData.fillColor,
+ strokeColor: pathData.strokeColor,
+ strokeWidth: 1,
+ strokeScaling: false,
+ data: {
+ id,
+ type: "tag",
+ tag_category: "text_mask",
+ },
+ parent: group,
+ visible,
+ })
+ // 标注对象外接框
+ const mask = new paper.Path.Rectangle(bounds)
+ mask.set({
+ strokeColor: pathData.strokeColor,
+ strokeWidth: 2,
+ strokeScaling: false,
+ data: {
+ id,
+ type: "tag",
+ tag_category: "mask",
+ },
+ parent: group,
+ visible: visible && configs.includes("外框"),
+ })
+ // 标注对象点id
+ if (path instanceof paper.CompoundPath) {
+ let index = 1
+ path.children.forEach((child) => {
+ const segments = (child as paper.Path).segments
+ segments.forEach((segment) => {
+ let pointTextPosition = [
+ segment.point.x - 10,
+ segment.point.y + 5,
+ ]
+ const pointText = new paper.PointText(pointTextPosition)
+ pointText.set({
+ content: index,
+ fontSize: 12,
+ fontWeight: "bold",
+ fillColor: pathData.strokeColor,
+ data: {
+ id,
+ type: "tag",
+ tag_category: "point_text",
+ },
+ parent: group,
+ visible: visible && configs.includes("点ID"),
+ })
+ index++
+ })
+ })
+ } else {
+ const segments = (path as paper.Path).segments
+ segments.forEach((segment, index) => {
+ let pointTextPosition = [
+ segment.point.x - 10,
+ segment.point.y + 5,
+ ]
+ const pointText = new paper.PointText(pointTextPosition)
+ pointText.set({
+ content: index + 1,
+ fontSize: 12,
+ fontWeight: "bold",
+ fillColor: pathData.strokeColor,
+ data: {
+ id,
+ type: "tag",
+ tag_category: "point_text",
+ },
+ parent: group,
+ visible: visible && configs.includes("点ID"),
+ })
+ })
+ }
+ }
+ })
+ }
+ }
+ }
+ }
+
+ return {
+ renderTag,
+ }
+}
+
+export default useRenderTag
diff --git a/components/label/useRightToolsStore.ts b/components/label/useRightToolsStore.ts
new file mode 100644
index 0000000..6c30ebe
--- /dev/null
+++ b/components/label/useRightToolsStore.ts
@@ -0,0 +1,172 @@
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+import { storage, useLabelStore } from "./store"
+import { usePaperStore } from "./usePaperStore"
+import { safeClone } from "./utils/clone"
+
+interface RightToolsState {
+ pathIds: number[]
+ setPathIds: (pathIds: number[]) => void
+ pushPathId: (pathId: number) => void
+ pathGroupIds: number[]
+ setPathGroupIds: (groupIds: number[]) => void
+ pushPathGroupId: (groupId: number) => void
+ pathGroupMap: Map>
+ setPathGroupMap: (pathGroupMap: Map>) => void
+ groupPathVisible: boolean
+ setGroupPathVisible: (visible: boolean) => void
+ addPathGroupInMap: (
+ imageId: string,
+ groupId: number,
+ pathIds: number[]
+ ) => void
+ removePathGroupInMap: (imageId: string, groupId: number) => void
+}
+
+const initRightToolsState = {
+ pathIds: [],
+ pathGroupIds: [],
+ pathGroupMap: new Map(),
+}
+
+export const useRightToolsStore = create(
+ persist(
+ (set) => ({
+ pathIds: initRightToolsState.pathIds,
+ setPathIds: (pathIds) =>
+ set((state: RightToolsState) => ({
+ ...state,
+ pathIds,
+ })),
+ pushPathId: (pathId) => {
+ let newIds = useRightToolsStore.getState().pathIds
+ newIds.push(pathId)
+ set((state: RightToolsState) => ({
+ ...state,
+ pathIds: newIds,
+ }))
+ },
+ pathGroupIds: initRightToolsState.pathGroupIds,
+ setPathGroupIds: (pathGroupIds) =>
+ set((state: RightToolsState) => ({
+ ...state,
+ pathGroupIds,
+ })),
+ pushPathGroupId: (pathGroupId) => {
+ let newIds = useRightToolsStore.getState().pathGroupIds
+ newIds.push(pathGroupId)
+ set((state: RightToolsState) => ({
+ ...state,
+ pathGroupIds: [...new Set(newIds)],
+ }))
+ },
+ pathGroupMap: initRightToolsState.pathGroupMap,
+ setPathGroupMap: (value) =>
+ set((state: RightToolsState) => ({
+ ...state,
+ pathGroupMap: value,
+ })),
+ groupPathVisible: false,
+ setGroupPathVisible: (value) =>
+ set((state) => ({ ...state, groupPathVisible: value })),
+ addPathGroupInMap: (
+ imageId: string,
+ groupId: number,
+ pathIds: number[]
+ ) =>
+ set((state) => {
+ if (!state.pathGroupMap?.has(imageId))
+ state.pathGroupMap.set(imageId, new Map())
+ let groupMap = state.pathGroupMap.get(imageId)
+ let keys = groupMap && Array.from(groupMap!.keys())
+ let deleteIds: number[] = []
+ if (keys && keys.length) {
+ keys.forEach((key) => {
+ let newPathIds = groupMap
+ ?.get(key)
+ ?.filter((child) => !pathIds.includes(child))
+ if (newPathIds && newPathIds.length > 1) {
+ groupMap?.set(key, newPathIds)
+ } else {
+ deleteIds.push(...(groupMap?.get(key) ?? []))
+ groupMap?.delete(key)
+ }
+ })
+ }
+ state.pathGroupMap.get(imageId)?.set(groupId, pathIds)
+ // update LabelData
+ const data = safeClone(useLabelStore.getState().label)
+ // handle delete
+ if (deleteIds.length) {
+ let operationIds: any[] = []
+ deleteIds.forEach((childId) => {
+ const childPath = usePaperStore.getState().getItemById(childId)!
+ childPath.data.parentGroupId = null
+ operationIds.push(childPath.data.operationId)
+ })
+ operationIds.forEach((operationId, index) => {
+ if (data.get(imageId)?.get(operationId)?.length) {
+ let result =
+ data
+ .get(imageId)
+ ?.get(operationId)
+ ?.map((item) =>
+ item[0] !== deleteIds[index]
+ ? item
+ : [
+ item[0],
+ item[1],
+ { ...item[2], parentGroupId: null },
+ item[3],
+ ]
+ ) || []
+ data.get(imageId)?.set(operationId, result as any)
+ }
+ })
+ }
+ for (let [currentGroupId, currentPathIds] of state.pathGroupMap.get(
+ imageId
+ )!) {
+ let needUpdateOperationIds: any[] = []
+ currentPathIds.forEach((childId) => {
+ const childPath = usePaperStore.getState().getItemById(childId)!
+ childPath.data.parentGroupId = currentGroupId
+ needUpdateOperationIds.push(childPath.data.operationId)
+ })
+ needUpdateOperationIds.forEach((operationId, index) => {
+ if (data.get(imageId)?.get(operationId)?.length) {
+ let result =
+ data
+ .get(imageId)
+ ?.get(operationId)
+ ?.map((item) =>
+ item[0] !== currentPathIds[index]
+ ? item
+ : [
+ item[0],
+ item[1],
+ { ...item[2], parentGroupId: currentGroupId },
+ item[3],
+ ]
+ ) || []
+ data.get(imageId)?.set(operationId, result as any)
+ }
+ })
+ }
+ useLabelStore.getState().setLabel(data)
+ return state
+ }),
+ removePathGroupInMap: (imageId, groupId) =>
+ set((state) => {
+ let groupMap = state.pathGroupMap.get(imageId)
+ if (!groupMap) return state
+ groupMap.delete(groupId)
+ return state
+ }),
+ }),
+ {
+ name: "right-tools",
+ storage: storage,
+ }
+ )
+)
diff --git a/components/label/useTimerStore.ts b/components/label/useTimerStore.ts
new file mode 100644
index 0000000..0471920
--- /dev/null
+++ b/components/label/useTimerStore.ts
@@ -0,0 +1,104 @@
+import { create } from "zustand"
+
+interface TimerStore {
+ timer: NodeJS.Timeout | null
+ startTimer: (fn: Function, delay: number) => void
+ clearTimer: () => void
+}
+
+export const useTimerStore = create((set, get) => ({
+ timer: null,
+ startTimer: (fn, delay) => {
+ const { timer } = get()
+ if (timer !== null) {
+ clearTimeout(timer)
+ }
+ const t = setTimeout(() => {
+ fn()
+ }, delay)
+ set({ timer: t })
+ },
+ clearTimer: () => {
+ const { timer } = get()
+ if (timer !== null) {
+ clearTimeout(timer)
+ set({ timer: null })
+ }
+ },
+}))
+
+interface IntervalStore {
+ timer: NodeJS.Timeout | null
+ startTimer: (fn: Function, delay: number) => void
+ clearTimer: () => void
+ isAutoSave: boolean
+ setIsAutoSave: (isAutoSave: boolean) => void
+ autoSaveGap: number
+ setAutoSaveGap: (autoSaveGap: number) => void
+}
+
+export const useIntervalStore = create((set, get) => ({
+ timer: null,
+ isAutoSave: false,
+ autoSaveGap: 5,
+ startTimer: (fn, delay) => {
+ const { timer } = get()
+ if (timer !== null) {
+ clearInterval(timer)
+ }
+ const t = setInterval(() => {
+ fn()
+ }, delay)
+ set((state) => ({ ...state, timer: t }))
+ },
+ clearTimer: () => {
+ const { timer } = get()
+ if (timer !== null) {
+ clearInterval(timer)
+ set((state) => ({ ...state, timer: null }))
+ }
+ },
+ setIsAutoSave: (flag: boolean) => {
+ set((state) => ({ ...state, isAutoSave: flag }))
+ },
+ setAutoSaveGap: (value: number) => {
+ set((state) => ({ ...state, autoSaveGap: value }))
+ },
+}))
+
+export interface LabelTimeState {
+ label: number
+ review1: number
+ review2: number
+}
+
+interface LabelTimeStore {
+ labelTime: LabelTimeState
+ setLabelTime: (
+ labelTime: LabelTimeState | ((prev: LabelTimeState) => LabelTimeState)
+ ) => void
+ resetLabelTime: () => void
+}
+
+const initialLabelTimeState: LabelTimeState = {
+ label: 0,
+ review1: 0,
+ review2: 0,
+}
+
+export const useLabelTimeStore = create((set) => ({
+ labelTime: initialLabelTimeState,
+ setLabelTime: (labelTime) =>
+ set((state) => ({
+ ...state,
+ labelTime:
+ typeof labelTime === "function"
+ ? labelTime(state.labelTime)
+ : labelTime,
+ })),
+ resetLabelTime: () =>
+ set((state) => ({
+ ...state,
+ labelTime: initialLabelTimeState,
+ })),
+}))
diff --git a/components/label/useTopToolsStore.ts b/components/label/useTopToolsStore.ts
new file mode 100644
index 0000000..13de684
--- /dev/null
+++ b/components/label/useTopToolsStore.ts
@@ -0,0 +1,240 @@
+import { Project } from "./api/project/typing"
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+import { usePaperStore } from "./usePaperStore"
+import { useObjectStore } from "./store"
+import { useBottomToolsStore } from "./useBottomToolsStore"
+
+interface TopToolsState {
+ // 标注页面是否仅查看
+ isView: boolean
+ setIsView: (val: boolean) => void
+ // 绘制模式
+ editMode: boolean
+ setEditMode: (val: boolean) => void
+ // 是否在相交模式下press A键
+ pressA: boolean
+ setPressA: (val: boolean) => void
+ // 亮度 饱和度 对比度
+ imageFilter: {
+ saturate: number
+ contrast: number
+ brightness: number
+ }
+ setImageFilter: (
+ key: "saturate" | "contrast" | "brightness",
+ val: number
+ ) => void
+ crosshairStatus: "hidden" | "line" | "carve"
+ setCrosshairStatus: (val: "hidden" | "line" | "carve") => void
+ showTags: boolean
+ setShowTags: (val: boolean) => void
+ showTagsConfigs: string[]
+ setShowTagsConfigs: (val: string[]) => void
+ // drawOption 图形叠加选项
+ drawOption: "default" | "intersect" | "unite"
+ setDrawOption: (val: "default" | "intersect" | "unite") => void
+ magnetFlag: boolean
+ setMagnetFlag: (val: boolean) => void
+ saveCurrentScale: boolean
+ setSaveCurrentScale: (val: boolean) => void
+ scale: number
+ setScale: (val: number) => void
+ activeOperation: string
+ setActiveOperation: (val: string) => void
+ showObjectList: boolean
+ setShowObjectList: (val: boolean) => void
+ showDescList: boolean
+ setShowDescList: (val: boolean) => void
+ showGroupList: boolean
+ setShowGroupList: (val: boolean) => void
+ showTaskList: boolean
+ setShowTaskList: (val: boolean) => void
+ showMultiFrame: boolean
+ setShowMultiFrame: (val: boolean) => void
+ currentTimeKey: "label" | "review1" | "review2"
+ setCurrentTimeKey: (val: "label" | "review1" | "review2") => void
+ subAttrPresetShow: boolean
+ setSubAttrPresetShow: (val: boolean) => void
+ subAttributes: {
+ // key: label_class
+ [key: string]: {
+ [key: string]: string
+ }
+ }
+ setsubAttributes: (key: string, val: { [key: string]: string }) => void
+ objectOperations: Project.LabelSchemaList[]
+ setObjectOperations: (val: Project.LabelSchemaList[]) => void
+ needBackup: boolean
+ setNeedBackup: (flag: boolean) => void
+ checkSize: number
+ setCheckSize: (v: number) => void
+}
+
+const initialTopToolsState = {
+ isView: false,
+ editMode: false,
+ pressA: false,
+ imageFilter: {
+ saturate: 0,
+ contrast: 0,
+ brightness: 0,
+ },
+ crosshairStatus: "hidden" as "hidden" | "line" | "carve",
+ drawOption: "default" as "default" | "intersect" | "unite",
+ saveCurrentScale: false,
+ scale: 1,
+ activeOperation: "",
+ showObjectList: false,
+ showGroupList: false,
+ showTaskList: false,
+ showMultiFrame: true,
+ currentTimeKey: "label" as "label" | "review1" | "review2",
+ subAttrPresetShow: false,
+ subAttributes: {},
+ objectOperations: [],
+}
+
+export const useTopToolsStore = create()(
+ persist(
+ (set) => ({
+ isView: initialTopToolsState.isView,
+ setIsView: (val: boolean) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ isView: val,
+ })),
+ editMode: initialTopToolsState.editMode,
+ setEditMode: (val: boolean) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ editMode: val,
+ })),
+ pressA: initialTopToolsState.pressA,
+ setPressA: (val: boolean) => {
+ if (!val) {
+ set((state: TopToolsState) => ({
+ ...state,
+ pressA: false,
+ }))
+ return
+ }
+ let ids =
+ useObjectStore.getState().selectedPath[
+ useBottomToolsStore.getState().activeImage
+ ]
+ if (!ids || ids.length !== 1) {
+ set((state: TopToolsState) => ({
+ ...state,
+ pressA: false,
+ }))
+ return
+ }
+ let path = usePaperStore.getState().getItemById(ids[0])
+ if (path && path.data.type !== "rectangle") {
+ let flag = val && usePaperStore.getState().mode !== "rectangle"
+
+ set((state: TopToolsState) => ({
+ ...state,
+ pressA: flag ? true : false,
+ }))
+ }
+ },
+ imageFilter: initialTopToolsState.imageFilter,
+ setImageFilter: (key, val) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ imageFilter: Object.assign(state.imageFilter, { [key]: val }),
+ })),
+ crosshairStatus: initialTopToolsState.crosshairStatus,
+ setCrosshairStatus: (val) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ crosshairStatus: val,
+ })),
+ showTags: false,
+ setShowTags: (val) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ showTags: val,
+ })),
+ showTagsConfigs: [],
+ setShowTagsConfigs: (val) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ showTagsConfigs: val,
+ })),
+ drawOption: initialTopToolsState.drawOption,
+ setDrawOption: (val) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ drawOption: val,
+ })),
+ magnetFlag: false,
+ setMagnetFlag: (val) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ magnetFlag: val,
+ })),
+ saveCurrentScale: initialTopToolsState.saveCurrentScale,
+ setSaveCurrentScale: (val: boolean) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ saveCurrentScale: val,
+ })),
+ scale: initialTopToolsState.scale,
+ setScale: (val: number) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ scale: val,
+ })),
+ activeOperation: initialTopToolsState.activeOperation,
+ setActiveOperation: (val: string) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ activeOperation: val,
+ })),
+ showObjectList: initialTopToolsState.showObjectList,
+ setShowObjectList: (val: boolean) =>
+ set((state: TopToolsState) => ({ ...state, showObjectList: val })),
+ showDescList: false,
+ setShowDescList: (val: boolean) =>
+ set((state: TopToolsState) => ({ ...state, showDescList: val })),
+ showGroupList: initialTopToolsState.showGroupList,
+ setShowGroupList: (val: boolean) =>
+ set((state: TopToolsState) => ({ ...state, showGroupList: val })),
+ showTaskList: initialTopToolsState.showTaskList,
+ setShowTaskList: (val: boolean) =>
+ set((state: TopToolsState) => ({ ...state, showTaskList: val })),
+ showMultiFrame: initialTopToolsState.showMultiFrame,
+ setShowMultiFrame: (val: boolean) =>
+ set((state: TopToolsState) => ({ ...state, showMultiFrame: val })),
+ currentTimeKey: initialTopToolsState.currentTimeKey,
+ setCurrentTimeKey: (val) =>
+ set((state: TopToolsState) => ({ ...state, currentTimeKey: val })),
+ subAttrPresetShow: initialTopToolsState.subAttrPresetShow,
+ setSubAttrPresetShow: (val) =>
+ set((state: TopToolsState) => ({ ...state, subAttrPresetShow: val })),
+ subAttributes: initialTopToolsState.subAttributes,
+ setsubAttributes: (key, val) =>
+ set((state: TopToolsState) => ({
+ ...state,
+ subAttributes: Object.assign(
+ useTopToolsStore.getState().subAttributes,
+ { [key]: val }
+ ),
+ })),
+ objectOperations: initialTopToolsState.objectOperations,
+ setObjectOperations: (val) =>
+ set((state: TopToolsState) => ({ ...state, objectOperations: val })),
+ needBackup: false,
+ setNeedBackup: (val) =>
+ set((state: TopToolsState) => ({ ...state, needBackup: val })),
+ checkSize: 5,
+ setCheckSize: (v) => set((state) => ({ ...state, checkSize: v })),
+ }),
+ {
+ name: "top-tools",
+ }
+ )
+)
diff --git a/components/label/util.ts b/components/label/util.ts
new file mode 100644
index 0000000..8cd3855
--- /dev/null
+++ b/components/label/util.ts
@@ -0,0 +1,63 @@
+import { Project } from "./api/project/typing"
+
+// 是否存在不符合要求的子属性
+export const getSubAttrStatus = (
+ operationSchema: Project.LabelSchemaList | null,
+ detail: {
+ [key: string]: string
+ }
+) => {
+ if (!operationSchema?.sub_attributes_describe?.length) return false
+ let bool = operationSchema?.sub_attributes_describe?.some((item) => {
+ if (item.isrequired === 1) {
+ if (!detail || !detail?.[item.chinese_name]) {
+ return true
+ } else {
+ return false
+ }
+ }
+ })
+ return bool
+}
+
+//
+export const checkCommentsIsSame: (
+ a: any[] | null,
+ b: any[] | null,
+ c: any[] | null,
+ d: any[] | null
+) => boolean = (
+ detailCheckboxComments,
+ detailTextComments,
+ checkboxComments,
+ textComments
+) => {
+ if (detailCheckboxComments?.length !== checkboxComments?.length) {
+ return false
+ }
+ if (detailTextComments?.length !== textComments?.length) {
+ return false
+ }
+ const areCheckboxCommentsSame =
+ detailCheckboxComments?.every((comment, index) => {
+ return comment === checkboxComments?.[index]
+ }) ?? true
+ const areTextCommentsSame =
+ detailTextComments?.every((comment, index) => {
+ return comment === textComments?.[index]
+ }) ?? true
+ return areCheckboxCommentsSame && areTextCommentsSame
+}
+
+// find group key
+export const findGroupKey: (
+ group: { [x: number]: number[] },
+ id: number
+) => number = (group, id) => {
+ for (const key in group) {
+ if (group[key].includes(id)) {
+ return Number(key)
+ }
+ }
+ return -1
+}
diff --git a/components/label/utils/clone.ts b/components/label/utils/clone.ts
new file mode 100644
index 0000000..ca8d4c9
--- /dev/null
+++ b/components/label/utils/clone.ts
@@ -0,0 +1,49 @@
+const deepCloneFallback = (value: T, cache = new WeakMap()): T => {
+ if (value === null || typeof value !== "object") return value
+ if (cache.has(value as object)) return cache.get(value as object)
+
+ if (value instanceof Date) return new Date(value.getTime()) as T
+ if (value instanceof Map) {
+ const clonedMap = new Map()
+ cache.set(value as object, clonedMap)
+ value.forEach((mapValue, mapKey) => {
+ clonedMap.set(
+ deepCloneFallback(mapKey, cache),
+ deepCloneFallback(mapValue, cache)
+ )
+ })
+ return clonedMap as T
+ }
+ if (value instanceof Set) {
+ const clonedSet = new Set()
+ cache.set(value as object, clonedSet)
+ value.forEach((setValue) => {
+ clonedSet.add(deepCloneFallback(setValue, cache))
+ })
+ return clonedSet as T
+ }
+ if (Array.isArray(value)) {
+ const clonedArr: any[] = []
+ cache.set(value as object, clonedArr)
+ value.forEach((item, index) => {
+ clonedArr[index] = deepCloneFallback(item, cache)
+ })
+ return clonedArr as T
+ }
+
+ const clonedObj: Record = {}
+ cache.set(value as object, clonedObj)
+ Object.keys(value as object).forEach((key) => {
+ clonedObj[key] = deepCloneFallback((value as any)[key], cache)
+ })
+ return clonedObj as T
+}
+
+export const safeClone = (value: T): T => {
+ try {
+ return structuredClone(value)
+ } catch (error) {
+ console.warn("structuredClone failed, fallback to deep clone", error)
+ return deepCloneFallback(value)
+ }
+}
diff --git a/components/label/utils/constants.ts b/components/label/utils/constants.ts
new file mode 100644
index 0000000..4a8e7e7
--- /dev/null
+++ b/components/label/utils/constants.ts
@@ -0,0 +1,248 @@
+"use client"
+
+export const dateFormat = "YYYY-MM-DD HH:mm:ss"
+export const weekDayText = (data: number) => {
+ switch (data) {
+ case 0:
+ return "星期日"
+ case 1:
+ return "星期一"
+ case 2:
+ return "星期二"
+ case 3:
+ return "星期三"
+ case 4:
+ return "星期四"
+ case 5:
+ return "星期五"
+ case 6:
+ return "星期六"
+ default:
+ return "-"
+ }
+}
+
+// 标注任务工序
+export const processOpts = [
+ { label: "标注", value: "标注" },
+ { label: "审核1", value: "审核1" },
+ { label: "审核2", value: "审核2" },
+]
+
+// 状态码
+export const STATUS_CODE = new Map([
+ [1, "已创建"],
+ [101, "已创建"], // 审核
+ [102, "已创建"], // 复审
+ [103, "已创建"], // 审核3
+ [180, "数据同步中"],
+ [190, "Embedding生成中"],
+ [191, "Embedding生成失败"],
+ [199, "审核失败"],
+ [201, "试标阶段"], // 试标中
+ [202, "试标阶段"], // 试标完成
+ [203, "试标阶段"], // 试标确认
+ [500, "标注中"],
+ [580, "待验收"],
+ [600, "已完成"],
+ [700, "已终止"],
+])
+
+// 状态码
+export const projectStatusOpts = [
+ { label: "试标阶段", value: "201,202,203" },
+ { label: "标注中", value: 500 },
+ { label: "待验收", value: 580 },
+ { label: "已完成", value: 600 },
+ // { label: "审核失败", value: 199 },
+ { label: "已终止", value: 700 },
+]
+
+export const CREATED_CODE = [1, 101, 102, 103, 190, 191, 199] // 已创建的状态码数组
+export const ALL_CODE = [201, 202, 203, 500, 580, 600, 700] // 其他的状态码数组
+export const PRELABEL_CODE = [201, 202, 203]
+export const LABEL_CODE = [500, 580]
+
+export const projectLabelTypeOpts = [
+ { label: "点云框", value: 1 },
+ { label: "点云分割", value: 2 },
+ { label: "点云分类", value: 3 },
+ { label: "图片", value: 4 },
+ { label: "大语言基础", value: 5 },
+ { label: "大语言QA", value: 6 },
+ { label: "视频", value: 7 },
+]
+
+// 项目列表中状态码对应值及style
+export const statusStyles = {
+ 1: {
+ label: "已创建",
+ styleText: "text-[#1874FF] bg-[#E8F0FC]",
+ },
+ 101: {
+ label: "已创建",
+ styleText: "text-[#1874FF] bg-[#E8F0FC]",
+ },
+ 102: {
+ label: "已创建",
+ styleText: "text-[#1874FF] bg-[#E8F0FC]",
+ },
+ 103: {
+ label: "已创建",
+ styleText: "text-[#1874FF] bg-[#E8F0FC]",
+ },
+ 190: {
+ label: "Embedding生成中",
+ styleText: "text-[#1874FF] bg-[#E8F0FC]",
+ },
+ 191: {
+ label: "Embedding生成失败",
+ styleText: "text-[#D9333E] bg-[#FBEBEC]",
+ },
+ 199: {
+ label: "审核失败",
+ styleText: "text-[#D9333E] bg-[#FBEBEC]",
+ },
+ 201: {
+ label: "试标阶段",
+ styleText: "text-[#21D3ED] bg-[#EEFDFF]",
+ },
+ 202: {
+ label: "试标阶段",
+ styleText: "text-[#21D3ED] bg-[#EEFDFF]",
+ },
+ 203: {
+ label: "试标阶段",
+ styleText: "text-[#21D3ED] bg-[#EEFDFF]",
+ },
+ 500: {
+ label: "标注中",
+ styleText: "text-[#1874FF] bg-[#E8F0FC]",
+ },
+ 580: {
+ label: "待验收",
+ styleText: "text-[#FFB84D] bg-[#FEF5E7]",
+ },
+ 600: {
+ label: "已完成",
+ styleText: "text-[#4FD796] bg-[#E7F9F0]",
+ },
+ 700: {
+ label: "已终止",
+ styleText: "text-[#34425F] bg-[#F7F8F9]",
+ },
+}
+
+export const staticsColor = [
+ { "8_151_156": 1 },
+ { "9_109_217": 2 },
+ { "15_15_118": 3 },
+ { "28_27_175": 4 },
+ { "35_84_116": 5 },
+ { "45_69_235": 6 },
+ { "53_127_127": 7 },
+ { "54_124_171": 8 },
+ { "54_207_201": 9 },
+ { "56_158_13": 10 },
+ { "63_18_119": 11 },
+ { "69_151_223": 12 },
+ { "81_188_187": 13 },
+ { "83_29_171": 14 },
+ { "84_119_48": 15 },
+ { "95_33_176": 16 },
+ { "100_16_23": 17 },
+ { "113_249_251": 18 },
+ { "116_78_41": 19 },
+ { "125_179_75": 20 },
+ { "127_48_236": 21 },
+ { "127_127_54": 22 },
+ { "132_133_239": 23 },
+ { "147_210_239": 24 },
+ { "151_29_36": 25 },
+ { "155_125_62": 26 },
+ { "156_239_102": 27 },
+ { "161_98_48": 28 },
+ { "178_253_253": 29 },
+ { "188_136_242": 30 },
+ { "189_191_84": 31 },
+ { "191_49_47": 32 },
+ { "196_29_127": 33 },
+ { "202_247_138": 34 },
+ { "203_164_81": 35 },
+ { "207_19_34": 36 },
+ { "212_107_8": 37 },
+ { "212_177_6": 38 },
+ { "223_134_144": 39 },
+ { "233_157_84": 40 },
+ { "244_206_159": 41 },
+ { "252_202_0": 42 },
+ { "252_250_147": 43 },
+ { "253_247_87": 44 },
+ { "255_191_120": 45 },
+ { "255_236_61": 46 },
+]
+
+export const labelTypeOpts = [
+ { label: "点云框", value: 1 },
+ { label: "点云分割", value: 2 },
+ // { label: '点云分类', value: '点云分类' },
+ { label: "2D框", value: 101 },
+ { label: "多边形", value: 102 },
+ { label: "关键点", value: 103 },
+ { label: "多线段", value: 104 },
+ { label: "文本描述", value: 201 },
+]
+
+export const labelTypeMap = new Map([
+ [1, "点云框"],
+ [2, "点云分割"],
+ [101, "2D框"],
+ [102, "多边形"],
+ [103, "关键点"],
+ [104, "多线段"],
+ [201, "文本描述"],
+])
+
+export const selectModeOpts = [
+ { label: "输入", value: 0 },
+ { label: "单选", value: 1 },
+ { label: "多选", value: 2 },
+]
+
+export const selectModeMap = new Map([
+ [0, "输入"],
+ [1, "单选"],
+ [2, "多选"],
+])
+
+// 标注任务状态码
+export const TaskStatusEnum = new Map([
+ // [0, "None"],
+ [1, "待领取"],
+ [2, "标注中"],
+ [3, "标注完成"],
+ [4, "审核中"],
+ [5, "审核完成"],
+ [6, "复审中"],
+ [7, "复审完成"],
+ [8, "无法标注"],
+])
+
+export const OwnTaskStatusOpts = {
+ 0: "全部",
+ 2: "标注中",
+ 4: "审核中",
+ 6: "复审中",
+}
+
+export const TaskStatusOpts = {
+ 0: "全部",
+ 1: "待领取",
+ 2: "标注中",
+ 3: "标注完成",
+ 4: "审核中",
+ 5: "审核完成",
+ 6: "复审中",
+ 7: "复审完成",
+ 8: "无法标注",
+}
diff --git a/components/label/utils/paperjs.ts b/components/label/utils/paperjs.ts
new file mode 100644
index 0000000..7172799
--- /dev/null
+++ b/components/label/utils/paperjs.ts
@@ -0,0 +1,164 @@
+import paper from "paper"
+
+const scaleFlatPoint = (point: any, scale: number): [number, number] => {
+ return [point[0] * scale, point[1] * scale]
+}
+
+const scaleSegmentToPoint = (seg: any, scale: number): any => {
+ if (seg instanceof paper.Point) {
+ return [seg.x * scale, seg.y * scale]
+ }
+ if (seg instanceof paper.Segment) {
+ return [seg.point.x * scale, seg.point.y * scale]
+ }
+ if (
+ Array.isArray(seg) &&
+ seg.length >= 2 &&
+ typeof seg[0] === "number" &&
+ typeof seg[1] === "number"
+ ) {
+ return scaleFlatPoint(seg, scale)
+ }
+ if (
+ Array.isArray(seg) &&
+ Array.isArray(seg[0]) &&
+ seg[0].length >= 2 &&
+ typeof seg[0][0] === "number" &&
+ typeof seg[0][1] === "number"
+ ) {
+ // Support paper JSON segment format: [[x,y], [handleInX, handleInY], [handleOutX, handleOutY]]
+ return scaleFlatPoint(seg[0], scale)
+ }
+ if (
+ seg?.point &&
+ typeof seg.point.x === "number" &&
+ typeof seg.point.y === "number"
+ ) {
+ return [seg.point.x * scale, seg.point.y * scale]
+ }
+ if (
+ seg?.point &&
+ Array.isArray(seg.point) &&
+ seg.point.length >= 2 &&
+ typeof seg.point[0] === "number" &&
+ typeof seg.point[1] === "number"
+ ) {
+ return [seg.point[0] * scale, seg.point[1] * scale]
+ }
+ return seg
+}
+
+// 调整原始比例点位到当前图片的缩放比例
+export const adjustPoints: (
+ id: string,
+ nestedMap: Map>,
+ scale: number
+) => Map> = (
+ id,
+ nestedMap,
+ scale
+) => {
+ const adjustedMap = new Map<
+ string,
+ Map
+ >()
+ nestedMap.forEach((innerMap, outerKey) => {
+ if (id === outerKey) {
+ const adjustedInnerMap = new Map<
+ string,
+ Array<[number, any[], any, any[]]>
+ >()
+ innerMap.forEach((array, innerKey) => {
+ const adjustedArray = array.map(
+ ([id, points, detail, hollowPoints]) => {
+ // let adjustedPoints = points.map((point) => {
+ // let flag = point instanceof paper.Point;
+ // if (flag) {
+ // return [point.x * scale, point.y * scale];
+ // } else {
+ // return point.map((item: any) => item * scale);
+ // }
+ // });
+
+ let adjustedPoints = points.map((child) => {
+ return child.map((seg: any) => {
+ return scaleSegmentToPoint(seg, scale)
+ })
+ })
+
+ let adjustedHollowPoints = hollowPoints.map((child) => {
+ return child.map((seg: any) => {
+ return scaleSegmentToPoint(seg, scale)
+ })
+ })
+
+ return [id, adjustedPoints, detail, adjustedHollowPoints]
+ }
+ )
+ adjustedInnerMap.set(innerKey, adjustedArray as any)
+ })
+ adjustedMap.set(outerKey, adjustedInnerMap)
+ } else {
+ adjustedMap.set(outerKey, innerMap)
+ }
+ })
+
+ return adjustedMap
+}
+
+// 根据比例调整不同图片的点位数据
+export const adjustAllPoints: (
+ nestedMap: Map>,
+ allScale: { [x: string]: number }
+) => Map> = (
+ nestedMap,
+ allScale
+) => {
+ const adjustedMap = new Map<
+ string,
+ Map
+ >()
+ nestedMap.forEach((innerMap, outerKey) => {
+ const scale = allScale[outerKey]
+ if (scale) {
+ const adjustedInnerMap = new Map<
+ string,
+ Array<[number, any[], any, any[]]>
+ >()
+ innerMap.forEach((array, innerKey) => {
+ const adjustedArray = array.map(
+ ([id, points, detail, hollowPoints]) => {
+ // let adjustedPoints = points.map((point) => {
+ // let flag = point instanceof paper.Point;
+ // if (flag) {
+ // return [point.x * scale, point.y * scale];
+ // } else {
+ // return point.map((item: any) => item * scale);
+ // }
+ // });
+
+ let adjustedPoints = points.map((child) => {
+ return child.map((seg: any) => {
+ return scaleSegmentToPoint(seg, scale)
+ })
+ })
+
+ let adjustedHollowPoints = hollowPoints.map((child) => {
+ return child.map((seg: any) => {
+ return scaleSegmentToPoint(seg, scale)
+ })
+ })
+
+ return [id, adjustedPoints, detail, adjustedHollowPoints]
+ }
+ )
+ adjustedInnerMap.set(innerKey, adjustedArray as any)
+ })
+ adjustedMap.set(outerKey, adjustedInnerMap)
+ } else {
+ adjustedMap.set(outerKey, innerMap)
+ }
+ })
+
+ return adjustedMap
+}
diff --git a/components/label/utils/util.ts b/components/label/utils/util.ts
new file mode 100644
index 0000000..3a21fa9
--- /dev/null
+++ b/components/label/utils/util.ts
@@ -0,0 +1,15 @@
+export const enterFullscreen = (element: any) => {
+ if (element?.requestFullscreen) {
+ element.requestFullscreen()
+ } else if (element?.webkitRequestFullscreen) {
+ element.webkitRequestFullscreen()
+ } else if (element?.msRequestFullscreen) {
+ element.msRequestFullscreen()
+ }
+}
+
+export const exitFullscreen = () => {
+ if (document.fullscreenElement) {
+ document.exitFullscreen()
+ }
+}