From 014c34ab765a1557477a505eefceadff9ab36157 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Tue, 10 Mar 2026 18:20:50 +0800 Subject: [PATCH] feat(label): add label page --- app/label/page.tsx | 36 + components/label/LabelNossr.tsx | 1910 +++++++ components/label/OptStore.tsx | 114 + components/label/api/const.ts | 4 + components/label/api/daily/index.ts | 49 + components/label/api/daily/typing.ts | 84 + components/label/api/label/index.ts | 142 + components/label/api/label/typing.ts | 171 + components/label/api/project/index.ts | 198 + components/label/api/project/typing.ts | 474 ++ components/label/api/scheme/index.ts | 61 + components/label/api/scheme/typing.ts | 45 + components/label/api/sync/index.ts | 66 + components/label/api/sync/typing.ts | 64 + components/label/api/task/index.ts | 77 + components/label/api/task/typing.ts | 179 + components/label/api/user/index.ts | 98 + components/label/api/user/typing.ts | 76 + components/label/api/workload/index.ts | 65 + components/label/api/workload/typing.ts | 107 + .../label/components/BackConfirmModal.tsx | 49 + components/label/components/BackupModal.tsx | 48 + components/label/components/BottomTools.tsx | 734 +++ components/label/components/ConfirmModal.tsx | 37 + .../label/components/CrosshairComponent.tsx | 175 + components/label/components/CustomModal.tsx | 67 + .../label/components/EditorContainer.md | 32 + .../label/components/EditorContainer.tsx | 148 + components/label/components/Icon.tsx | 946 ++++ .../label/components/MetaOperationTool.tsx | 204 + .../label/components/PaperContainer.tsx | 2233 ++++++++ components/label/components/RecoverModal.tsx | 69 + .../label/components/RightDescTools.tsx | 306 ++ .../label/components/RightGroupEditModal.tsx | 117 + .../label/components/RightGroupTools.tsx | 471 ++ .../label/components/RightObjectTools.tsx | 955 ++++ components/label/components/RightQATools.tsx | 685 +++ .../label/components/RightTaskTools.tsx | 352 ++ .../label/components/ScaleComponent.tsx | 153 + .../label/components/ScaleToolContainer.tsx | 119 + .../label/components/TaskCacheModal.tsx | 131 + components/label/components/TaskStatusTag.tsx | 94 + components/label/components/TopTools.tsx | 2368 +++++++++ components/label/ffmpeg/ffmpeg.worker.ts | 925 ++++ components/label/ffmpeg/processVideo.ts | 339 ++ components/label/hooks/useAuth.ts | 120 + components/label/index.tsx | 31 + components/label/store.ts | 605 +++ components/label/store/auth.ts | 414 ++ components/label/useBottomToolsStore.ts | 102 + components/label/useDescToolsStore.ts | 222 + components/label/useKeyBoardStore.tsx | 19 + components/label/useOtherToolsStore.ts | 108 + components/label/usePaperStore.ts | 4537 +++++++++++++++++ components/label/usePaperSupportStore.ts | 234 + components/label/useRenderGroupPath.ts | 88 + components/label/useRenderTag.ts | 165 + components/label/useRightToolsStore.ts | 172 + components/label/useTimerStore.ts | 104 + components/label/useTopToolsStore.ts | 240 + components/label/util.ts | 63 + components/label/utils/clone.ts | 49 + components/label/utils/constants.ts | 248 + components/label/utils/paperjs.ts | 164 + components/label/utils/util.ts | 15 + 65 files changed, 23477 insertions(+) create mode 100644 app/label/page.tsx create mode 100644 components/label/LabelNossr.tsx create mode 100644 components/label/OptStore.tsx create mode 100644 components/label/api/const.ts create mode 100644 components/label/api/daily/index.ts create mode 100644 components/label/api/daily/typing.ts create mode 100644 components/label/api/label/index.ts create mode 100644 components/label/api/label/typing.ts create mode 100644 components/label/api/project/index.ts create mode 100644 components/label/api/project/typing.ts create mode 100644 components/label/api/scheme/index.ts create mode 100644 components/label/api/scheme/typing.ts create mode 100644 components/label/api/sync/index.ts create mode 100644 components/label/api/sync/typing.ts create mode 100644 components/label/api/task/index.ts create mode 100644 components/label/api/task/typing.ts create mode 100644 components/label/api/user/index.ts create mode 100644 components/label/api/user/typing.ts create mode 100644 components/label/api/workload/index.ts create mode 100644 components/label/api/workload/typing.ts create mode 100644 components/label/components/BackConfirmModal.tsx create mode 100644 components/label/components/BackupModal.tsx create mode 100644 components/label/components/BottomTools.tsx create mode 100644 components/label/components/ConfirmModal.tsx create mode 100644 components/label/components/CrosshairComponent.tsx create mode 100644 components/label/components/CustomModal.tsx create mode 100644 components/label/components/EditorContainer.md create mode 100644 components/label/components/EditorContainer.tsx create mode 100644 components/label/components/Icon.tsx create mode 100644 components/label/components/MetaOperationTool.tsx create mode 100644 components/label/components/PaperContainer.tsx create mode 100644 components/label/components/RecoverModal.tsx create mode 100644 components/label/components/RightDescTools.tsx create mode 100644 components/label/components/RightGroupEditModal.tsx create mode 100644 components/label/components/RightGroupTools.tsx create mode 100644 components/label/components/RightObjectTools.tsx create mode 100644 components/label/components/RightQATools.tsx create mode 100644 components/label/components/RightTaskTools.tsx create mode 100644 components/label/components/ScaleComponent.tsx create mode 100644 components/label/components/ScaleToolContainer.tsx create mode 100644 components/label/components/TaskCacheModal.tsx create mode 100644 components/label/components/TaskStatusTag.tsx create mode 100644 components/label/components/TopTools.tsx create mode 100644 components/label/ffmpeg/ffmpeg.worker.ts create mode 100644 components/label/ffmpeg/processVideo.ts create mode 100644 components/label/hooks/useAuth.ts create mode 100644 components/label/index.tsx create mode 100644 components/label/store.ts create mode 100644 components/label/store/auth.ts create mode 100644 components/label/useBottomToolsStore.ts create mode 100644 components/label/useDescToolsStore.ts create mode 100644 components/label/useKeyBoardStore.tsx create mode 100644 components/label/useOtherToolsStore.ts create mode 100644 components/label/usePaperStore.ts create mode 100644 components/label/usePaperSupportStore.ts create mode 100644 components/label/useRenderGroupPath.ts create mode 100644 components/label/useRenderTag.ts create mode 100644 components/label/useRightToolsStore.ts create mode 100644 components/label/useTimerStore.ts create mode 100644 components/label/useTopToolsStore.ts create mode 100644 components/label/util.ts create mode 100644 components/label/utils/clone.ts create mode 100644 components/label/utils/constants.ts create mode 100644 components/label/utils/paperjs.ts create mode 100644 components/label/utils/util.ts 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 ( + + + + ({ + label: operation.label_class, + value: operation.category_id.toString(), + }))} + leftSection={ + form.values.operation && + renderOperationIcon( + getOperationSchema(form.values.operation.toString()) + ) + } + required + {...form.getInputProps("operation")} + onChange={(value) => { + form.setFieldValue("operation", value) + if (value && value !== operationId) { + handleChangeContextMenuOperation(value) + } + }} + /> + + + 批注 + + + + + {comments.map((item) => ( + + ))} + + + +