2991 lines
96 KiB
TypeScript
2991 lines
96 KiB
TypeScript
"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 } 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,
|
||
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 drawShortcutSnapshotRef = useRef<{
|
||
activeOperation: string
|
||
drawOption: "default" | "intersect" | "unite"
|
||
editMode: boolean
|
||
} | null>(null)
|
||
|
||
const handleShortcut = useCallback(
|
||
(key: string, ctrl: boolean) => {
|
||
let checkOperation = getOperationSchema(key)
|
||
const mode = usePaperStore.getState().mode
|
||
// 标注方案里面存在的非文本类型标注才能切换
|
||
if (checkOperation) {
|
||
// 辅助标注模式下非多边形类型不能切换
|
||
if (mode === "support" && checkOperation.label_type !== 102) return
|
||
if (ctrl) {
|
||
setActiveOperation((+key + 10).toString())
|
||
} else {
|
||
setActiveOperation(key)
|
||
}
|
||
}
|
||
},
|
||
[getOperationSchema, setActiveOperation]
|
||
)
|
||
|
||
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 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 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<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) 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()
|
||
handleToggleHideAllObjects()
|
||
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,
|
||
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])
|
||
|
||
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<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}
|
||
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
|