"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 paper from "paper" 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, isEditableKeyboardTarget } from "./util" import { safeClone } from "./utils/clone" import { labelTypeMap } from "./utils/constants" import { toggleObjectHideByShortcut } from "./utils/objectVisibility" // 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 const RIGHT_PANEL_TRANSITION = "width 160ms ease, min-width 160ms ease, opacity 160ms ease" const RIGHT_PANEL_WILL_CHANGE = "width, min-width, opacity" const RIGHT_PANEL_SEPARATOR = "1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))" const RIGHT_PANEL_SOFT_MAIN_MIN_WIDTH = 420 const RIGHT_PANEL_HARD_MAIN_MIN_WIDTH = 260 const RIGHT_PANEL_TARGET_WIDTH = 350 // 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 getRightPanelViewportStyle = (width: number) => { const stableWidth = Math.max(0, Math.round(width)) return { position: "absolute" as const, top: 0, left: 0, width: stableWidth, minWidth: stableWidth, height: "100%", contain: "layout paint", } } const allocateRightPanelSizes = ( availableWidth: number, visibleFlags: boolean[] ) => { const resolvedWidth = Math.max(0, Math.round(availableWidth)) const nextSizes = [resolvedWidth, 0, 0, 0, 0, 0] const activeIndexes = visibleFlags.reduce((result, flag, index) => { if (flag) result.push(index + 1) return result }, []) if (!activeIndexes.length) return nextSizes const activeCount = activeIndexes.length const softPanelBudget = Math.max( 0, resolvedWidth - RIGHT_PANEL_SOFT_MAIN_MIN_WIDTH ) const hardPanelBudget = Math.max( 0, resolvedWidth - RIGHT_PANEL_HARD_MAIN_MIN_WIDTH ) let panelWidth = Math.min( RIGHT_PANEL_TARGET_WIDTH, Math.floor(softPanelBudget / activeCount) ) if (panelWidth < RIGHT_PANEL_TARGET_WIDTH) { panelWidth = Math.min( RIGHT_PANEL_TARGET_WIDTH, Math.floor(hardPanelBudget / activeCount) ) } panelWidth = Math.max(0, panelWidth) activeIndexes.forEach((index) => { nextSizes[index] = panelWidth }) const rightPanelTotalWidth = panelWidth * activeCount nextSizes[0] = Math.max(0, resolvedWidth - rightPanelTotalWidth) return nextSizes } 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([0, 0, 0, 0, 0, 0]) 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 selectedPathMap = useObjectStore((state) => state.selectedPath) const setLabelTime = useLabelTimeStore((state) => state.setLabelTime) const isAutoSave = useIntervalStore((state) => state.isAutoSave) const autoSaveGap = useIntervalStore((state) => state.autoSaveGap) const { editMode, setEditMode, pressA, setPressA, activeOperation, setActiveOperation, setDrawOption, setSubAttrPresetShow, showObjectList, showGroupList, showTaskList, showDescList, showTags, showTagsConfigs, showMultiFrame, setShowMultiFrame, isView, setIsView, renderMode, 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) usePaperStore.getState().resetRasterScale() setLabel(new Map()) setStateStack([]) useRightToolsStore.getState().setPathGroupMap(new Map()) useObjectStore.getState().resetPathAndOperationStatus() 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 || renderMode) { setPaperMode(null) } 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, renderMode, setPaperMode, setToolOption, ]) useEffect(() => { setDrawType() }, [editMode, setDrawType]) const { setShift, focusInput } = useKeyEventStore() const drawAction = useKeyboardStore((state) => state.drawAction) const initializedDrawActionTaskIdRef = useRef(null) const handleSelectOperation = useCallback( (operationId: string) => { const operationSchema = getOperationSchema(operationId) if (!operationSchema) return false setActiveOperation(operationId) setSubAttrPresetShow(!!operationSchema.sub_attributes_describe?.length) if (isLabelTask) { setEditMode(true) } return true }, [ getOperationSchema, isLabelTask, setActiveOperation, setEditMode, setSubAttrPresetShow, ] ) const drawShortcutSnapshotRef = useRef<{ activeOperation: string drawOption: "default" | "intersect" | "unite" editMode: boolean } | null>(null) const handleShortcut = useCallback( (key: string, ctrl: boolean) => { const mode = usePaperStore.getState().mode const nextOperationId = ctrl ? (+key + 10).toString() : key const checkOperation = getOperationSchema(nextOperationId) // 标注方案里面存在的非文本类型标注才能切换 if (checkOperation) { // 辅助标注模式下非多边形类型不能切换 if (mode === "support" && checkOperation.label_type !== 102) return handleSelectOperation(nextOperationId) } }, [getOperationSchema, handleSelectOperation] ) const resolveSelectedObjectContext = useCallback(() => { const imageId = useBottomToolsStore.getState().activeImage const selectedIds = useObjectStore.getState().selectedPath[imageId] || [] if (selectedIds.length !== 1) return null const objectId = selectedIds[0] const imageLabel = useLabelStore.getState().label.get(imageId) if (!imageLabel) return null let operationId = "" for (const [categoryId, paths] of imageLabel.entries()) { if (paths.some((path) => path[0] === objectId)) { operationId = categoryId break } } if (!operationId) return null const selectedItem = usePaperStore.getState().getItemById(objectId) if (!selectedItem?.data?.type) return null return { imageId, objectId, operationId, objectType: selectedItem.data.type as string, } }, []) const restoreDrawShortcutSnapshot = useCallback(() => { useKeyboardStore.getState().setDrawAction("none") const snapshot = drawShortcutSnapshotRef.current if (!snapshot) return setActiveOperation(snapshot.activeOperation) setDrawOption(snapshot.drawOption) setEditMode(snapshot.editMode) drawShortcutSnapshotRef.current = null }, [setActiveOperation, setDrawOption, setEditMode]) const enterSelectedObjectDrawMode = useCallback( (mode: "add" | "subtract") => { const selectedContext = resolveSelectedObjectContext() if (!selectedContext) { notifications.show({ color: "yellow", message: "请先选中单个对象", }) return } if ( mode === "add" && !["polygon", "brush"].includes(selectedContext.objectType) ) { notifications.show({ color: "yellow", message: "新增区域仅支持 polygon/brush 类型对象", }) return } if (mode === "subtract" && selectedContext.objectType !== "polygon") { notifications.show({ color: "yellow", message: "删除区域仅支持 polygon 类型对象", }) return } if (!drawShortcutSnapshotRef.current) { const topState = useTopToolsStore.getState() drawShortcutSnapshotRef.current = { activeOperation: topState.activeOperation, drawOption: topState.drawOption, editMode: topState.editMode, } } setActiveOperation(selectedContext.operationId) usePaperStore.getState().setContinuePolygonTarget(null) setEditMode(true) setDrawOption("default") useKeyboardStore.getState().setDrawAction(mode) setPressA(true) if (!useTopToolsStore.getState().pressA) { notifications.show({ color: "yellow", message: "当前对象无法进入区域编辑模式", }) restoreDrawShortcutSnapshot() } }, [ resolveSelectedObjectContext, restoreDrawShortcutSnapshot, setActiveOperation, setDrawOption, setEditMode, setPressA, ] ) const handleToggleHideAllObjects = useCallback(() => { const activeImageLabel = labelData.get(activeImage) if (!activeImageLabel?.size) return const activeImageOperations = Array.from(activeImageLabel.keys()) const totalObjectCount = activeImageOperations.reduce( (count, operationId) => { return count + (activeImageLabel.get(operationId)?.length || 0) }, 0 ) if (!totalObjectCount) return const pathStatus = useObjectStore.getState().pathStatus const areAllObjectsHidden = activeImageOperations.every((operationId) => { const operationChildren = activeImageLabel.get(operationId) || [] return operationChildren.every( ([objectId]) => pathStatus[activeImage + objectId]?.HIDE ?? false ) }) const nextHide = !areAllObjectsHidden const { updateOperationStatus } = useObjectStore.getState() activeImageOperations.forEach((operationId) => { updateOperationStatus(activeImage + operationId, "HIDE", nextHide) activeImageLabel.get(operationId)?.forEach(([objectId]) => { toggleObjectHideByShortcut(activeImage, operationId, objectId, nextHide) }) }) }, [activeImage, labelData]) const handleToggleHideSelectionOrAll = useCallback(() => { const selectedIds = useObjectStore.getState().selectedPath[activeImage] || [] if (!selectedIds.length) { handleToggleHideAllObjects() return } const activeImageLabel = labelData.get(activeImage) if (!activeImageLabel?.size) { return } const selectedIdSet = new Set(selectedIds) const selectedObjects: Array<{ operationId: string objectId: number }> = [] activeImageLabel.forEach((paths, operationId) => { paths.forEach(([objectId]) => { if (!selectedIdSet.has(objectId)) return selectedObjects.push({ operationId, objectId, }) }) }) if (!selectedObjects.length) { return } const { pathStatus } = useObjectStore.getState() const areAllSelectedObjectsHidden = selectedObjects.every( ({ objectId }) => pathStatus[activeImage + objectId]?.HIDE ?? false ) const nextHide = !areAllSelectedObjectsHidden selectedObjects.forEach(({ operationId, objectId }) => { toggleObjectHideByShortcut(activeImage, operationId, objectId, nextHide) }) }, [activeImage, handleToggleHideAllObjects, labelData]) const rerenderActiveImageByLabel = useCallback( ( imageId: string, imageLabel: Map, selectedIds: number[] ) => { const paperGroup = usePaperStore.getState().group if (paperGroup) { paperGroup .getItems({}) .filter((item) => item.data.id) .forEach((item) => { item.remove() }) for (const [categoryId] of imageLabel.entries()) { paperContainerRef.current?.renderPolygons?.(categoryId, imageLabel) } } useObjectStore.setState((state) => ({ ...state, selectedPath: { ...state.selectedPath, [imageId]: selectedIds, }, })) }, [] ) const removeObjectFromGroupMap = useCallback( (imageId: string, id: number) => { const nextPathGroupMap = safeClone( useRightToolsStore.getState().pathGroupMap ) const imageGroupMap = nextPathGroupMap.get(imageId) if (!imageGroupMap) return for (let [groupId, pathIds] of imageGroupMap.entries()) { const nextPathIds = pathIds.filter((pathId) => pathId !== id) if (nextPathIds.length > 1) { imageGroupMap.set(groupId, nextPathIds) } else { imageGroupMap.delete(groupId) } } if (!imageGroupMap.size) nextPathGroupMap.delete(imageId) useRightToolsStore.getState().setPathGroupMap(nextPathGroupMap) }, [] ) const handleContinueSelectedPolygon = useCallback(() => { const selectedContext = resolveSelectedObjectContext() if (!selectedContext) { notifications.show({ color: "yellow", message: "请先选中单个对象", }) return } if (selectedContext.objectType !== "polygon") { notifications.show({ color: "yellow", message: "继续标注快捷键仅支持 polygon 类型对象", }) return } setEditMode(true) setDrawOption("default") setPressA(false) setActiveOperation(selectedContext.operationId) useKeyboardStore.getState().setDrawAction("none") usePaperStore.getState().setContinuePolygonTarget({ imageId: selectedContext.imageId, operationId: selectedContext.operationId, objectId: selectedContext.objectId, }) usePaperStore.getState().setPaperMode("polygon") const started = usePaperStore.getState().beginContinuePolygonDraft() if (!started) return notifications.show({ color: "green", message: "已进入继续标注模式", }) }, [ resolveSelectedObjectContext, setActiveOperation, setDrawOption, setEditMode, setPressA, ]) const hasShapeSegments = useCallback((shape: any) => { if (!shape) return false if (shape instanceof paper.CompoundPath) { return (shape.children as paper.Path[]).some( (child) => child.segments?.length ) } if (shape instanceof paper.Path) { return !!shape.segments?.length } return false }, []) const createWorkingPolygonShape = useCallback((items: any[]) => { const compound = items.find((item) => item instanceof paper.CompoundPath) if (compound) { return (compound as paper.CompoundPath).clone({ insert: false }) as | paper.Path | paper.CompoundPath } const paths = items.filter( (item) => item instanceof paper.Path ) as paper.Path[] if (!paths.length) return null if (paths.length === 1) { return paths[0].clone({ insert: false }) as | paper.Path | paper.CompoundPath } const merged = new paper.CompoundPath({ insert: false }) paths.forEach((path) => { merged.addChild(path.clone({ insert: false })) }) return merged as paper.Path | paper.CompoundPath }, []) const collectPolygonPoints = useCallback( (shape: paper.Path | paper.CompoundPath) => { const polygonPoints: any[] = [] const polygonHollowPoints: any[] = [] const collectPath = (path: paper.Path) => { if (!path.segments?.length) return const pathData = JSON.parse(path.exportJSON({ precision: 20 })) if (path.clockwise) { polygonPoints.push(pathData[1]?.segments) } else { polygonHollowPoints.push(pathData[1]?.segments) } } if (shape instanceof paper.CompoundPath) { ;(shape.children as paper.Path[]).forEach((path) => collectPath(path)) } else { collectPath(shape) } return { polygonPoints, polygonHollowPoints } }, [] ) const applySelectedPolygonOverlap = useCallback( (mode: "subtract" | "unite") => { const selectedContext = resolveSelectedObjectContext() if (!selectedContext) { notifications.show({ color: "yellow", message: "请先选中单个对象", }) return } if (selectedContext.objectType !== "polygon") { notifications.show({ color: "yellow", message: mode === "subtract" ? "镂空快捷键仅支持 polygon 类型对象" : "融合快捷键仅支持 polygon/brush 类型对象", }) return } const group = usePaperStore.getState().group if (!group) return const targetItems = group .getItems({ data: { id: selectedContext.objectId } }) .filter((item) => item.data?.type === "polygon") if (!targetItems.length) return let workingShape = createWorkingPolygonShape(targetItems) if (!workingShape || !hasShapeSegments(workingShape)) return const otherPolygonPaths: paper.Path[] = [] group.children.forEach((item: any) => { if ( !item.visible || !item.data?.id || item.data.id === selectedContext.objectId || item.data.type !== "polygon" ) return if (item instanceof paper.Path) { if (item.segments?.length) otherPolygonPaths.push(item) } else if (item instanceof paper.CompoundPath) { ;(item.children as paper.Path[]).forEach((child) => { if (child.segments?.length) otherPolygonPaths.push(child) }) } }) let changed = false for (const eachPath of otherPolygonPaths) { if (!workingShape || !hasShapeSegments(workingShape)) break const eachPathClone = eachPath.clone({ insert: false }) as paper.Path const intersectionPath = (workingShape as any).intersect( eachPathClone, { insert: false, } ) as paper.Path | paper.CompoundPath const hasIntersection = hasShapeSegments(intersectionPath) intersectionPath?.remove() if (!hasIntersection) { eachPathClone.remove() continue } const nextShape: paper.Path | paper.CompoundPath = mode === "subtract" ? ((workingShape as any).subtract(eachPathClone, { insert: false, }) as paper.Path | paper.CompoundPath) : ((workingShape as any).unite(eachPathClone, { insert: false, }) as paper.Path | paper.CompoundPath) eachPathClone.remove() workingShape?.remove() workingShape = nextShape changed = true } if (!changed) { workingShape?.remove() return } if (!workingShape) return const raster = usePaperStore.getState().rasterPath if (raster && hasShapeSegments(workingShape)) { const clippedShape = (workingShape as any).intersect(raster, { insert: false, trace: false, }) as paper.Path | paper.CompoundPath workingShape?.remove() workingShape = clippedShape } if (!workingShape) return const { polygonPoints, polygonHollowPoints } = collectPolygonPoints(workingShape) workingShape?.remove() const nextLabel = safeClone(useLabelStore.getState().label) const imageLabel = nextLabel.get(selectedContext.imageId) if (!imageLabel) return const operationPaths = safeClone( imageLabel.get(selectedContext.operationId) || [] ) const selectedIndex = operationPaths.findIndex( (item) => item[0] === selectedContext.objectId ) if (selectedIndex === -1) return const currentTuple = operationPaths[selectedIndex] if (polygonPoints.length) { operationPaths[selectedIndex] = [ selectedContext.objectId, polygonPoints, safeClone(currentTuple?.[2] || {}), polygonHollowPoints, ] imageLabel.set(selectedContext.operationId, operationPaths) } else { imageLabel.set( selectedContext.operationId, operationPaths.filter((item) => item[0] !== selectedContext.objectId) ) removeObjectFromGroupMap( selectedContext.imageId, selectedContext.objectId ) } useLabelStore.getState().setLabel(nextLabel) useLabelStore.getState().pushStateStack(nextLabel) rerenderActiveImageByLabel( selectedContext.imageId, imageLabel, polygonPoints.length ? [selectedContext.objectId] : [] ) }, [ collectPolygonPoints, createWorkingPolygonShape, hasShapeSegments, removeObjectFromGroupMap, rerenderActiveImageByLabel, resolveSelectedObjectContext, ] ) const applySelectedBrushIntersectUnite = useCallback(() => { const selectedContext = resolveSelectedObjectContext() if (!selectedContext) { notifications.show({ color: "yellow", message: "请先选中单个对象", }) return } if (selectedContext.objectType !== "brush") { notifications.show({ color: "yellow", message: "融合快捷键仅支持 polygon/brush 类型对象", }) return } const group = usePaperStore.getState().group if (!group) return const selectedBrushPaths = group .getItems({ data: { id: selectedContext.objectId } }) .filter((item) => item.data?.type === "brush") .flatMap((item) => { if (item instanceof paper.CompoundPath) return item.children as paper.Path[] if (item instanceof paper.Path) return [item] return [] }) .filter((path) => path.segments?.length) if (!selectedBrushPaths.length) return const otherBrushPathMap = new Map() group.children.forEach((item: any) => { if ( !item.visible || !item.data?.id || item.data.id === selectedContext.objectId || item.data.type !== "brush" ) return const targetId = Number(item.data.id) const currentPaths = otherBrushPathMap.get(targetId) || [] if (item instanceof paper.CompoundPath) { otherBrushPathMap.set(targetId, [ ...currentPaths, ...(item.children as paper.Path[]), ]) } else if (item instanceof paper.Path) { otherBrushPathMap.set(targetId, [...currentPaths, item]) } }) const intersectingIds = new Set() for (const [otherId, paths] of otherBrushPathMap.entries()) { const hasIntersection = paths.some((otherPath) => { return selectedBrushPaths.some((selectedPath) => { if (!selectedPath.bounds.intersects(otherPath.bounds)) return false return selectedPath.getIntersections(otherPath).length > 0 }) }) if (hasIntersection) intersectingIds.add(otherId) } if (!intersectingIds.size) return const nextLabel = safeClone(useLabelStore.getState().label) const imageLabel = nextLabel.get(selectedContext.imageId) if (!imageLabel) return const selectedOperationPaths = safeClone( imageLabel.get(selectedContext.operationId) || [] ) const selectedIndex = selectedOperationPaths.findIndex( (item) => item[0] === selectedContext.objectId ) if (selectedIndex === -1) return const selectedTuple = selectedOperationPaths[selectedIndex] const mergedPoints = safeClone(selectedTuple?.[1] || []) const pointSet = new Set( mergedPoints.map((pointItem) => JSON.stringify(pointItem)) ) let appendedCount = 0 imageLabel.forEach((paths) => { paths.forEach((item) => { if (!intersectingIds.has(item[0])) return ;(item[1] || []).forEach((pathPoints: any) => { const key = JSON.stringify(pathPoints) if (pointSet.has(key)) return pointSet.add(key) mergedPoints.push(safeClone(pathPoints)) appendedCount++ }) }) }) if (!appendedCount) return selectedOperationPaths[selectedIndex] = [ selectedContext.objectId, mergedPoints, safeClone(selectedTuple?.[2] || {}), safeClone(selectedTuple?.[3] || []), ] imageLabel.set(selectedContext.operationId, selectedOperationPaths) useLabelStore.getState().setLabel(nextLabel) useLabelStore.getState().pushStateStack(nextLabel) rerenderActiveImageByLabel(selectedContext.imageId, imageLabel, [ selectedContext.objectId, ]) }, [rerenderActiveImageByLabel, resolveSelectedObjectContext]) const handleMergeSelectedObjects = useCallback(() => { const activeImage = useBottomToolsStore.getState().activeImage const selectedIds = useObjectStore.getState().selectedPath[activeImage] || [] if (selectedIds.length <= 1) { notifications.show({ color: "yellow", message: "请先选中同类别且数量大于1的对象", }) return } const nextLabel = safeClone(useLabelStore.getState().label) const imageLabel = nextLabel.get(activeImage) if (!imageLabel) return const selectedIdSet = new Set(selectedIds) const selectedByOperation = Array.from(imageLabel.entries()) .map(([operationId, paths]) => { const selectedPaths = paths.filter((item) => selectedIdSet.has(item[0])) return { operationId, selectedPaths } }) .filter((item) => item.selectedPaths.length > 0) if ( selectedByOperation.length !== 1 || selectedByOperation[0].selectedPaths.length <= 1 || selectedByOperation[0].selectedPaths.length !== selectedIds.length ) { notifications.show({ color: "yellow", message: "仅支持合并同类别且数量大于1的选中对象", }) return } const { operationId, selectedPaths } = selectedByOperation[0] const selectedTypeList = selectedIds .map((id) => usePaperStore.getState().getItemById(id)?.data?.type) .filter((type): type is string => typeof type === "string") const selectedTypeSet = new Set(selectedTypeList) const selectedType = Array.from(selectedTypeSet)[0] const operationSchema = getOperationSchema(operationId) const operationType = operationSchema ? labelTypeMap.get(operationSchema.label_type) : "" if ( selectedTypeList.length !== selectedIds.length || selectedTypeSet.size !== 1 || !["polygon", "brush"].includes(selectedType) || !["多边形", "多线段"].includes(operationType || "") ) { notifications.show({ color: "yellow", message: "仅支持合并 polygon/brush 类型对象", }) return } const sortedSelectedPaths = [...selectedPaths].sort((a, b) => a[0] - b[0]) const targetId = sortedSelectedPaths[0][0] const mergedPaths = sortedSelectedPaths.flatMap((item) => safeClone(item[1] || []) ) const mergedCompoundPaths = sortedSelectedPaths.flatMap((item) => safeClone(item[3] || []) ) const mergedDetail = { ...(safeClone(sortedSelectedPaths[0][2]) || {}), parentGroupId: null, } const mergedData: [number, any[], any, any[]] = [ targetId, mergedPaths, mergedDetail, mergedCompoundPaths, ] const restPaths = (imageLabel.get(operationId) || []).filter( (item) => !selectedIdSet.has(item[0]) ) imageLabel.set( operationId, [...restPaths, mergedData].sort((a, b) => a[0] - b[0]) ) const nextPathGroupMap = safeClone( useRightToolsStore.getState().pathGroupMap ) const imageGroupMap = nextPathGroupMap.get(activeImage) if (imageGroupMap) { for (let [groupId, pathIds] of imageGroupMap.entries()) { const nextPathIds = pathIds.filter((id) => !selectedIdSet.has(id)) if (nextPathIds.length > 1) { imageGroupMap.set(groupId, nextPathIds) } else { imageGroupMap.delete(groupId) } } if (!imageGroupMap.size) nextPathGroupMap.delete(activeImage) useRightToolsStore.getState().setPathGroupMap(nextPathGroupMap) } useLabelStore.getState().setLabel(nextLabel) useLabelStore.getState().pushStateStack(nextLabel) useObjectStore.setState((state) => ({ ...state, selectedPath: { ...state.selectedPath, [activeImage]: [targetId], }, })) const paperGroup = usePaperStore.getState().group if (paperGroup) { paperGroup .getItems({}) .filter((item) => item.data.id) .forEach((item) => { item.remove() }) for (const [categoryId] of imageLabel.entries()) { paperContainerRef.current?.renderPolygons?.(categoryId, imageLabel) } } notifications.show({ color: "green", message: `已合并${selectedIds.length}个对象`, }) }, [getOperationSchema]) useEffect(() => { const down = (e: KeyboardEvent) => { // console.log(e, e.key); if (focusInput || isEditableKeyboardTarget(e.target)) return console.log(e.key) const mode = usePaperStore.getState().mode const lowerKey = e.key.toLowerCase() const commandKey = e.ctrlKey || e.metaKey if (e.repeat && ["a", "b", "d", "h", "m", ",", "."].includes(lowerKey)) return if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "m") { e.preventDefault() const topState = useTopToolsStore.getState() topState.setMagnetFlag(!topState.magnetFlag) return } if (isView || renderMode) return if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "h") { e.preventDefault() handleToggleHideSelectionOrAll() return } if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "a") { e.preventDefault() enterSelectedObjectDrawMode("add") return } if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "d") { e.preventDefault() enterSelectedObjectDrawMode("subtract") return } if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "b") { e.preventDefault() handleContinueSelectedPolygon() return } if ( !e.ctrlKey && !e.metaKey && !e.altKey && [",", ","].includes(e.key) ) { e.preventDefault() applySelectedPolygonOverlap("subtract") return } if ( !e.ctrlKey && !e.metaKey && !e.altKey && [".", "。"].includes(e.key) ) { e.preventDefault() const selectedContext = resolveSelectedObjectContext() if (!selectedContext) { notifications.show({ color: "yellow", message: "请先选中单个对象", }) return } if (selectedContext.objectType === "polygon") { applySelectedPolygonOverlap("unite") return } if (selectedContext.objectType === "brush") { applySelectedBrushIntersectUnite() return } notifications.show({ color: "yellow", message: "融合快捷键仅支持 polygon/brush 类型对象", }) return } if (e.altKey && e.key.toLowerCase() === "x") { e.preventDefault() if (!isView) { if (topRef.current?.requestSubmitTaskConfirm) { topRef.current.requestSubmitTaskConfirm() } else { topRef.current?.commitTaskByActionKey?.() } } return } if (commandKey && lowerKey === "f") { e.preventDefault() handleMergeSelectedObjects() return } 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" && /^[0-9]$/.test(e.key)) { if (commandKey) e.preventDefault() if (e.key === "0") { handleShortcut("10", commandKey) } else { handleShortcut(e.key, commandKey) } } if (lowerKey === "s" && commandKey) { e.preventDefault() setIsConfirmSave(true) } if (lowerKey === "g" && commandKey) { e.preventDefault() paperContainerRef.current.handleCreatePathGroup() } if (e.key === "Control" || e.key === "Meta") { 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() // sam2 暂未有图片接口 paperContainerRef.current.renderTrackingAnnotation() } // 复制 if (lowerKey === "c" && commandKey) { e.preventDefault() useKeyboardStore .getState() .setCopyDataIds( useObjectStore.getState().selectedPath[ useBottomToolsStore.getState().activeImage ] ) } // 粘贴 if (lowerKey === "v" && commandKey) { e.preventDefault() paperContainerRef.current.copyAnnotationDataToMultiFrame() } // 撤回 if (lowerKey === "z" && commandKey) { 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" || e.key === "Meta") { useKeyboardStore.getState().setCtrl(false) } } const wheel = (e: WheelEvent) => { const target = e.target as HTMLElement | null if (target?.closest('[data-label-context-menu="true"]')) return 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) } }, [ applySelectedBrushIntersectUnite, applySelectedPolygonOverlap, editMode, enterSelectedObjectDrawMode, focusInput, getOperationSchema, handleToggleHideAllObjects, handleToggleHideSelectionOrAll, handleMergeSelectedObjects, handleContinueSelectedPolygon, handleShortcut, isView, renderMode, projectDetail?.label_type, setEditMode, setGroupScale, setDrawOption, setPressA, setScale, setShift, resolveSelectedObjectContext, ]) useEffect(() => { if (drawAction === "none" || pressA) return restoreDrawShortcutSnapshot() }, [drawAction, pressA, restoreDrawShortcutSnapshot]) const setGlobalLoading = useCallback((flag: boolean) => { setLoading(flag) }, []) // 切换图片时清空复制保存的数组 useEffect(() => { useKeyboardStore.getState().setCopyDataIds([]) useKeyboardStore.getState().setDrawAction("none") usePaperStore.getState().setContinuePolygonTarget(null) drawShortcutSnapshotRef.current = null }, [activeImage]) useEffect(() => { if (!taskDetail?.id || !activeImage || !isLabelTask) return if (initializedDrawActionTaskIdRef.current === taskDetail.id) return useKeyboardStore.getState().setDrawAction("subtract") initializedDrawActionTaskIdRef.current = taskDetail.id }, [activeImage, isLabelTask, taskDetail?.id]) const handleBeforeUnload = (e: any) => { e.preventDefault() // 备份 topRef.current.handleBackup("auto_backup") // 清空操作栏状态 useObjectStore.getState().resetPathAndOperationStatus() } // 更新Splitter状态 const updateSplitterSizes = useCallback(() => { const availableWidth = Math.max( document.documentElement.clientWidth - leftWidth - 4, 0 ) const flags: boolean[] = [ showTaskList, showGroupList, (showDescList && projectDetail && projectDetail.label_type === 5) || false, (showDescList && projectDetail && projectDetail.label_type === 6) || false, showObjectList, ] setSizes(allocateRightPanelSizes(availableWidth, flags)) }, [ leftWidth, projectDetail, showDescList, showGroupList, showObjectList, showTaskList, ]) useEffect(() => { updateSplitterSizes() window.addEventListener("resize", updateSplitterSizes) return () => { window.removeEventListener("resize", 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 bottomToolsHeight = useMemo(() => { if (projectDetail?.label_type === 5 && metaOperation) { return 128 } return 80 }, [metaOperation, projectDetail?.label_type]) const mainBoxHeight = useMemo(() => { return `calc(100% - ${headerHeight}px - ${70 + bottomToolsHeight}px)` }, [bottomToolsHeight, headerHeight]) const selectedTagPreviewList = useMemo(() => { if (!showTags) return [] const selectedIds = selectedPathMap[activeImage] || [] if (!selectedIds.length) return [] const imageLabel = labelData.get(activeImage) if (!imageLabel) return [] const selectedIdSet = new Set(selectedIds) const rawItems: Array<{ id: number; content: string }> = [] imageLabel.forEach((labelList, categoryId) => { const category = objectOperations.find( (item) => item.category_id.toString() === categoryId ) const labelClass = category?.label_class || "" labelList.forEach(([id, _points, detail]) => { if (!selectedIdSet.has(id)) return const parentGroupId = usePaperStore.getState().getItemById(id)?.data?.parentGroupId ?? detail?.parentGroupId let content = "" if (showTagsConfigs.includes("ID")) { content += `${id}${parentGroupId ? `@${parentGroupId}` : ""}` } if (showTagsConfigs.includes("类别")) { content += `${content ? " " : ""}${labelClass}` } if ( detail?.sub_attributes && Object.keys(detail.sub_attributes).length ) { Object.keys(detail.sub_attributes).forEach((key) => { if (showTagsConfigs.includes("子属性")) content += ` ${key}:` if (showTagsConfigs.includes("属性值")) content += `${detail.sub_attributes[key]};` }) } const normalized = content.trim() if (!normalized) return rawItems.push({ id, content: normalized }) }) }) return selectedIds .map((id) => rawItems.find((item) => item.id === id)) .filter((item): item is { id: number; content: string } => !!item) }, [ activeImage, labelData, objectOperations, selectedPathMap, showTags, showTagsConfigs, ]) const fullscreenRef = useRef(null) return ( 0 && !hasReadyVideoFrame)} zIndex={GLOBAL_LOADING_Z_INDEX} overlayProps={{ radius: "sm", blur: 2 }} /> {sourceVideoNames.length > 0 && ( )} {/* */} {selectedTagPreviewList.length > 0 && ( 选中对象标签 {selectedTagPreviewList.map((item) => ( {item.content} ))} )} {/* */} {isConfirmSave && ( { try { await topRef.current?.handleSave() setIsConfirmSave(false) } catch {} }} onCancel={() => { setIsConfirmSave(false) }} /> )} ) } export default LabelPage