Files
labelimage/components/label/LabelNossr.tsx
2026-04-17 11:43:19 +08:00

2852 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, string[]>
// 定义操作的类型
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<number[]>) => {
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<number[]>((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<string, string> = {
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<Project.DetailResponse>()
const [taskDetail, setTaskDetail] = useState<Task.DataProps>()
const [oldWorkLoad, setOldWorkLoad] = useState<WorkLoad | null>(null)
// const [objectOperations, setObjectOperations] = useState<
// Project.LabelSchemaList[]
// >([]);
const topRef = useRef<any>(null)
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const autoSaveInProgressRef = useRef(false)
const paperContainerRef = useRef<any>(null)
const qaToolContainerRef = useRef<any>(null)
const [isConfirmSave, setIsConfirmSave] = useState(false)
const [loading, setLoading] = useState(false)
const [sizes, setSizes] = useState<number[]>([0, 0, 0, 0, 0, 0])
const [videoFetchPendingCount, setVideoFetchPendingCount] = useState(0)
const [hasReadyVideoFrame, setHasReadyVideoFrame] = useState(false)
const [sourceVideoNames, setSourceVideoNames] = useState<string[]>([])
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<string, string[]>()
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<ReturnType<typeof processVideo>> = []
const noticeId = `video-process-${cacheKey}`
const fetchStartedAt = Date.now()
let decodeStartedAt: number | null = null
let fetchNoticeTicker: ReturnType<typeof setInterval> | null = null
let progress = 0
let firstFrameDelaySec: string | null = null
const streamFrameNames: string[] = []
const streamFrameNameSet = new Set<string>()
let lastNoticeUpdateAt = 0
let noticeTicker: ReturnType<typeof setInterval> | 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<ReturnType<typeof processVideo>>
) => {
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<typeof setInterval> | 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<boolean>(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<number | null>(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<string, [number, any[], any, any[]][]>,
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 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 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
}
usePaperStore.getState().applySelectedPolygonBooleanOperation(mode)
},
[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<number, paper.Path[]>()
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<number>()
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 = 5 * 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<HTMLDivElement>(null)
return (
<Stack w="100%" h="100vh" pos="relative" ref={fullscreenRef} gap={0}>
<LoadingOverlay
// 视频场景仅阻塞到首帧可见;后续抽帧在后台继续,页面保持可操作。
visible={loading || (videoFetchPendingCount > 0 && !hasReadyVideoFrame)}
zIndex={GLOBAL_LOADING_Z_INDEX}
overlayProps={{ radius: "sm", blur: 2 }}
/>
<Box w="100%" h={80} pos="relative">
<TopTools
ref={topRef}
taskDetail={taskDetail}
projectDetail={projectDetail}
oldWorkLoad={oldWorkLoad}
updateOldWorkLoad={updateOldWorkLoad}
isLabelTask={isLabelTask}
onOperationChange={handleSelectOperation}
renderPolygons={
paperContainerRef.current
? paperContainerRef.current.renderPolygons
: null
}
/>
{sourceVideoNames.length > 0 && (
<Flex
pos="absolute"
top={8}
right={12}
style={{ zIndex: 2101 }}
gap="xs">
<Button
size="xs"
variant="light"
// 当前仅下载第一个视频,便于快速核对源视频与抽帧质量。
loading={downloadingVideoName === sourceVideoNames[0]}
onClick={handleDownloadSourceVideo}>
{sourceVideoNames.length > 1
? `下载源视频(1/${sourceVideoNames.length})`
: "下载源视频"}
</Button>
</Flex>
)}
</Box>
<Flex w="calc(100% - 4px)" h={mainBoxHeight}>
{/* <Flex h="100%" w={"100%"}> */}
<Box w={sizes[0]} style={{ flexShrink: 0, minWidth: 0 }}>
<Box h="100%" id="resize-container">
<Flex h="100%" w="100%">
<Flex
direction="column"
h="100%"
w={`calc(100% - 64px)`}
pos="relative"
style={{ overflow: "hidden" }}>
<ScaleComponent
options={{
offsetX: 0,
offsetY: 0,
scale: scale,
}}
mode="horizontal"
/>
<Flex h="calc(100% - 32px)">
<ScaleComponent
options={{
offsetX: 0,
offsetY: 0,
scale: scale,
}}
mode="vertical"
/>
<Box pos="relative" style={{ flex: 1, minWidth: 0 }}>
<PaperContainer
imgSrc={"/test.jpeg"}
ref={paperContainerRef}
forceUpdate={forceUpdate}
labelState={labelState}
projectDetail={projectDetail}
updateLoadingFlag={setGlobalLoading}
/>
{selectedTagPreviewList.length > 0 && (
<Box
pos="absolute"
top={8}
right={8}
p="xs"
maw={360}
miw={200}
style={{
zIndex: 100,
pointerEvents: "none",
borderRadius: 8,
background: "rgba(255, 255, 255, 0.9)",
border:
"1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
}}>
<Box fz={12} fw={600} mb={4}>
</Box>
<Stack gap={4}>
{selectedTagPreviewList.map((item) => (
<Box
key={item.id}
fz={12}
style={{
lineHeight: 1.4,
wordBreak: "break-word",
}}>
{item.content}
</Box>
))}
</Stack>
</Box>
)}
</Box>
</Flex>
</Flex>
<ScaleToolContainer />
</Flex>
</Box>
</Box>
<Box
w={sizes[1]}
miw={showTaskList ? sizes[1] : 0}
style={{
position: "relative",
flexShrink: 0,
overflow: "hidden",
opacity: showTaskList ? 1 : 0,
visibility: showTaskList ? "visible" : "hidden",
pointerEvents: showTaskList ? "auto" : "none",
borderLeft: showTaskList ? RIGHT_PANEL_SEPARATOR : "none",
transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE,
}}>
<Box style={getRightPanelViewportStyle(sizes[1])}>
<RightTaskTools
projectDetail={projectDetail}
project_id={projectId}
task_id={taskId}
/>
</Box>
</Box>
<Box
w={sizes[2]}
miw={showGroupList ? sizes[2] : 0}
style={{
position: "relative",
flexShrink: 0,
overflow: "hidden",
opacity: showGroupList ? 1 : 0,
visibility: showGroupList ? "visible" : "hidden",
pointerEvents: showGroupList ? "auto" : "none",
borderLeft: showGroupList ? RIGHT_PANEL_SEPARATOR : "none",
transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE,
}}>
<Box style={getRightPanelViewportStyle(sizes[2])}>
<RightGroupTools
labelState={labelState}
projectDetail={projectDetail}
/>
</Box>
</Box>
<Box
w={sizes[3]}
miw={
showDescList && projectDetail && projectDetail.label_type === 5
? sizes[3]
: 0
}
style={{
position: "relative",
flexShrink: 0,
overflow: "hidden",
opacity:
showDescList && projectDetail && projectDetail.label_type === 5
? 1
: 0,
visibility:
showDescList && projectDetail && projectDetail.label_type === 5
? "visible"
: "hidden",
pointerEvents:
showDescList && projectDetail && projectDetail.label_type === 5
? "auto"
: "none",
borderLeft:
showDescList && projectDetail && projectDetail.label_type === 5
? RIGHT_PANEL_SEPARATOR
: "none",
transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE,
}}>
<Box style={getRightPanelViewportStyle(sizes[3])}>
<RightDescTools taskDetail={taskDetail} />
</Box>
</Box>
<Box
w={sizes[4]}
miw={
showDescList && projectDetail && projectDetail.label_type === 6
? sizes[4]
: 0
}
style={{
position: "relative",
flexShrink: 0,
overflow: "hidden",
opacity:
showDescList && projectDetail && projectDetail.label_type === 6
? 1
: 0,
visibility:
showDescList && projectDetail && projectDetail.label_type === 6
? "visible"
: "hidden",
pointerEvents:
showDescList && projectDetail && projectDetail.label_type === 6
? "auto"
: "none",
borderLeft:
showDescList && projectDetail && projectDetail.label_type === 6
? RIGHT_PANEL_SEPARATOR
: "none",
transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE,
}}>
<Box style={getRightPanelViewportStyle(sizes[4])}>
<RightQATools ref={qaToolContainerRef} taskDetail={taskDetail} />
</Box>
</Box>
<Box
w={sizes[5]}
miw={showObjectList ? sizes[5] : 0}
style={{
position: "relative",
flexShrink: 0,
overflow: "hidden",
opacity: showObjectList ? 1 : 0,
visibility: showObjectList ? "visible" : "hidden",
pointerEvents: showObjectList ? "auto" : "none",
borderLeft: showObjectList ? RIGHT_PANEL_SEPARATOR : "none",
transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE,
}}>
<Box style={getRightPanelViewportStyle(sizes[5])}>
<RightObjectTools
taskDetail={taskDetail}
labelState={labelState}
projectDetail={projectDetail}
/>
</Box>
</Box>
{/* </Flex> */}
</Flex>
<Box
w="100%"
h={bottomToolsHeight}
style={{
overflow: "hidden",
opacity: showMultiFrame ? 1 : 0,
pointerEvents: showMultiFrame ? "auto" : "none",
transition: "opacity 160ms ease",
}}>
<BottomTools
labelState={labelState}
projectDetail={projectDetail}
taskDetail={taskDetail}
renderPolygons={
paperContainerRef.current
? paperContainerRef.current.renderPolygons
: null
}
/>
</Box>
{isConfirmSave && (
<ConfirmModal
open={isConfirmSave}
title="保存标注结果"
content="是否确定保存当前标注结果?"
onOk={async () => {
try {
await topRef.current?.handleSave()
setIsConfirmSave(false)
} catch {}
}}
onCancel={() => {
setIsConfirmSave(false)
}}
/>
)}
</Stack>
)
}
export default LabelPage