Files
labelimage/components/label/components/BottomTools.tsx
2026-04-08 15:14:17 +08:00

855 lines
27 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 {
ActionIcon,
Box,
Button,
Flex,
Group,
ScrollArea,
Text,
TextInput,
} from "@mantine/core"
import { showNotification } from "@mantine/notifications"
import {
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconFlag,
IconPlayerTrackNext,
IconPlayerTrackNextFilled,
IconPlayerTrackPrev,
IconPlayerTrackPrevFilled,
} from "@tabler/icons-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Project } from "../api/project/typing"
import { Task } from "../api/task/typing"
import { LabelState } from "../LabelNossr"
import { useLabelStore } from "../store"
import { usePermissionStore } from "../store/auth"
import { useBottomToolsStore } from "../useBottomToolsStore"
import { useDescToolsStore } from "../useDescToolsStore"
import { useKeyboardStore } from "../useKeyBoardStore"
import { useOtherToolsStore } from "../useOtherToolsStore"
import { usePaperStore } from "../usePaperStore"
import { useLabelTimeStore } from "../useTimerStore"
import { useTopToolsStore } from "../useTopToolsStore"
import { getSubAttrStatus } from "../util"
import { safeClone } from "../utils/clone"
import CustomModal from "./CustomModal"
import MetaOperationTool from "./MetaOperationTool"
interface BottomToolsComponentProps {
labelState: LabelState
taskDetail?: Task.DataProps
projectDetail?: Project.DetailResponse
renderPolygons: (
operationId: string,
imageData: Map<string, [number, any[], any, any[]][]> | undefined
) => void | null
}
type PlaybackDirection = "backward" | "forward"
const BottomTools: React.FC<BottomToolsComponentProps> = (props) => {
const { labelState, projectDetail, taskDetail, renderPolygons } = props
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({})
const storeLabel = useLabelStore((state) => state.label)
const setLabel = useLabelStore((state) => state.setLabel)
const pushStateStack = useLabelStore((state) => state.pushStateStack)
const {
inputNumber,
setInputNumber,
activeImage,
pendingImage,
frameSwitchStatus,
setActiveImage,
setActiveImageId,
requestFrameSwitch,
onlyKeyFrame,
setOnlyKeyFrame,
keyFrameData,
setKeyFrameData,
selectedItems,
setSelectedItems,
} = useBottomToolsStore()
const { metaOperation } = useDescToolsStore()
const { ctrl: ctrlKey, shift: shiftKey } = useKeyboardStore()
const [open, setOpen] = useState(false)
const [conflictOpen, setConflictOpen] = useState(false)
const [playDirection, setPlayDirection] = useState<PlaybackDirection | null>(
null
)
const allKeys = useMemo(() => Array.from(labelState.keys()), [labelState])
const isShowKeyFrame =
projectDetail &&
[5, 6].includes(projectDetail.label_type) &&
allKeys.some(
(k) => keyFrameData.has(k) && keyFrameData.get(k)?.key_frame
) &&
onlyKeyFrame
const currentKeys = useMemo(
() =>
allKeys.filter((k) =>
isShowKeyFrame
? keyFrameData.has(k) && keyFrameData.get(k)?.key_frame
: true
),
[allKeys, isShowKeyFrame, keyFrameData]
)
// 上一帧图片id
const previousFrameKey = useMemo(() => {
const currentIndex = currentKeys.findIndex((k) => k === activeImage)
return currentIndex > 0 ? currentKeys[currentIndex - 1] : ""
}, [activeImage, currentKeys])
useEffect(() => {
if (!currentKeys.length) {
setActiveImage("")
setInputNumber("")
setActiveImageId(0)
return
}
const currentIndex = activeImage
? currentKeys.findIndex((k) => k === activeImage)
: -1
if (currentIndex >= 0) {
setInputNumber(String(currentIndex + 1))
const absoluteIndex = allKeys.findIndex((k) => k === activeImage)
setActiveImageId(absoluteIndex >= 0 ? absoluteIndex + 1 : 0)
return
}
const firstKey = currentKeys[0]
const absoluteIndex = allKeys.findIndex((k) => k === firstKey)
setActiveImage(firstKey)
setInputNumber("1")
setActiveImageId(absoluteIndex >= 0 ? absoluteIndex + 1 : 1)
}, [
activeImage,
allKeys,
currentKeys,
setActiveImage,
setActiveImageId,
setInputNumber,
])
const scrollToIndex = useCallback(
(index: number) => {
const key = currentKeys[index]
if (key && itemRefs.current[key]) {
itemRefs.current[key]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
})
}
},
[currentKeys]
)
const closeRightContext = useCallback(() => {
useOtherToolsStore.getState().setShowContextMenu({
showContextMenu: false,
position: {
top: 0,
left: 0,
},
imageId: useOtherToolsStore.getState().imageId,
operationId: useOtherToolsStore.getState().operationId,
id: useOtherToolsStore.getState().id,
})
}, [])
const goToVisibleIndex = useCallback(
(index: number) => {
if (index < 0 || index >= currentKeys.length) return false
const nextImage = currentKeys[index]
if (!nextImage) return false
scrollToIndex(index)
if (
frameSwitchStatus === "preparing" &&
pendingImage &&
pendingImage === nextImage
) {
closeRightContext()
return true
}
if (nextImage !== activeImage) {
requestFrameSwitch(nextImage)
}
closeRightContext()
return true
},
[
activeImage,
closeRightContext,
currentKeys,
frameSwitchStatus,
pendingImage,
requestFrameSwitch,
scrollToIndex,
]
)
const stepVisibleImage = useCallback(
(direction: PlaybackDirection) => {
const currentIndex = currentKeys.findIndex((k) => k === activeImage)
if (currentIndex < 0) return false
const nextIndex =
direction === "backward" ? currentIndex - 1 : currentIndex + 1
return goToVisibleIndex(nextIndex)
},
[activeImage, currentKeys, goToVisibleIndex]
)
const togglePlayback = useCallback(
(direction: PlaybackDirection) => {
if (currentKeys.length < 2) return
setPlayDirection((current) => (current === direction ? null : direction))
},
[currentKeys.length]
)
useEffect(() => {
if (!playDirection) return
if (currentKeys.length < 2) {
setPlayDirection(null)
return
}
const timer = window.setInterval(() => {
const moved = stepVisibleImage(playDirection)
if (!moved) {
setPlayDirection(null)
}
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [currentKeys.length, playDirection, stepVisibleImage])
const getOperationSchema = useCallback(
(operationId: string) => {
return (
projectDetail?.label_schema_list?.find(
(item) => item.category_id === +operationId
) || null
)
},
[projectDetail?.label_schema_list]
)
// child 是否存在不符合要求的子属性
const getOperationSubAttrStatus = useCallback(
(imageId: string) => {
let parentBool = labelState.get(imageId)?.some((operationId) => {
let bool = storeLabel
.get(imageId)
?.get(operationId)
?.some((child) => {
return getSubAttrStatus(
getOperationSchema(operationId),
child[2]?.sub_attributes
)
})
return bool
})
return parentBool
},
[getOperationSchema, labelState, storeLabel]
)
// 获取上一帧和当前帧标注对象数组
const getLabelObjectsData = () => {
let map1 =
storeLabel.get(previousFrameKey) ??
new Map<string, [number, any[], any, any[]][]>()
let arr1 = []
for (const value of map1.values()) arr1.push(...value)
let map2 =
storeLabel.get(activeImage) ??
new Map<string, [number, any[], any, any[]][]>()
let arr2 = []
for (const value of map2.values()) arr2.push(...value)
return [arr1, arr2]
}
// 上一帧是否有文本描述
const prevFramehasTextDesc = () => {
if (projectDetail?.label_type === 5) {
let flag = false
const currentDescData = safeClone(useDescToolsStore.getState().descData)
const prevData1 = currentDescData.get(previousFrameKey) ?? new Map()
flag = prevData1.size ? true : false
if (flag) return true
const currentMetaData = safeClone(useDescToolsStore.getState().metaData)
const prevData2 = currentMetaData.get(previousFrameKey) ?? []
flag = prevData2.length ? true : false
return flag
} else if (projectDetail?.label_type === 6) {
const currentQaData = safeClone(useDescToolsStore.getState().qaData)
const prevData = currentQaData.get(previousFrameKey) ?? {}
return JSON.stringify(prevData) === "{}" ? false : true
}
return false
}
// 更新数据绘制
const updateDataRender = (
data: Map<string, [number, any[], any, any[]][]>
) => {
if (!renderPolygons) return
const list = usePaperStore.getState().group?.getItems({})
list?.forEach((item) => {
if (item.data.id) {
item.remove()
}
})
useTopToolsStore.getState().objectOperations?.forEach((item) => {
renderPolygons(item.category_id.toString(), data)
})
}
// 大语言标注继承上一帧时数据处理
const handleLLMData = () => {
// LLM
if (projectDetail?.label_type === 5) {
// 文本描述
const currentDescData = safeClone(useDescToolsStore.getState().descData)
const prevData1 = currentDescData.get(previousFrameKey) ?? new Map()
currentDescData.set(activeImage, prevData1)
useDescToolsStore.getState().setDescData(currentDescData)
useDescToolsStore.getState().updateFlag(true)
// 元操作序列
const currentMetaData = safeClone(useDescToolsStore.getState().metaData)
const prevData2 = currentMetaData.get(previousFrameKey) ?? []
currentMetaData.set(activeImage, prevData2)
useDescToolsStore.getState().setMetaData(currentMetaData)
}
// QA
if (projectDetail?.label_type === 6) {
const currentQaData = safeClone(useDescToolsStore.getState().qaData)
const prevData = currentQaData.get(previousFrameKey) ?? {}
currentQaData.set(activeImage, prevData)
useDescToolsStore.getState().setQaData(currentQaData)
useDescToolsStore.getState().updateFlag(true)
}
}
// 继承上一帧的数据
const handleCopyData = () => {
// 图形绘制
const [perviousData] = getLabelObjectsData()
let map =
storeLabel.get(activeImage) ??
new Map<string, [number, any[], any, any[]][]>()
perviousData.forEach((item) => {
const id = item[2].category_id.toString()
if (map.has(id)) {
let data = map.get(id)
data!.push(item)
map.set(id, data as any)
} else {
map.set(id, [item])
}
})
let updateData = new Map()
for (const [key, value] of storeLabel) {
if (key === activeImage) updateData.set(key, map)
else updateData.set(key, value)
}
if (!storeLabel.has(activeImage)) updateData.set(activeImage, map)
updateDataRender(updateData.get(activeImage)!)
setLabel(updateData)
pushStateStack(updateData)
handleLLMData()
setOpen(false)
}
const handleCopyConflictData = (flag: boolean) => {
const [perviousData, nowData] = getLabelObjectsData()
let map = new Map<string, [number, any[], any, any[]][]>()
let arr = flag
? [
...perviousData,
...nowData.filter(
(item) => !perviousData.map((p) => p[0]).includes(item[0])
),
]
: [
...perviousData.filter(
(item) => !nowData.map((d) => d[0]).includes(item[0])
),
...nowData,
]
arr.forEach((item) => {
const id = item[2].category_id.toString()
if (map.has(id)) {
let data = map.get(id)
data!.push(item)
map.set(id, data as any)
} else {
map.set(id, [item])
}
})
let updateData = new Map()
for (const [key, value] of storeLabel) {
if (key === activeImage) updateData.set(key, map)
else updateData.set(key, value)
}
updateDataRender(updateData.get(activeImage)!)
setLabel(updateData)
pushStateStack(updateData)
handleLLMData()
setConflictOpen(false)
}
return (
<Box w="100%" h="100%">
{projectDetail?.label_type === 5 && metaOperation ? (
<Flex
h={48}
w="100%"
align="center"
style={{
borderTop:
"1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
}}>
<Box px="md" fw={600}>
</Box>
<Box
style={{
maxWidth: "calc(100% - 98px)",
overflowX: "auto",
height: "100%",
}}>
<MetaOperationTool />
</Box>
</Flex>
) : null}
<Box
h={28}
style={{
border:
"1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
}}>
<Flex justify="space-between" align="center" h="100%">
<Box flex={1} />
<Flex flex={1} align="center" justify="center" gap="xs">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
goToVisibleIndex(0)
}}>
<IconChevronsLeft size={16} />
</ActionIcon>
<ActionIcon
variant={playDirection === "backward" ? "filled" : "subtle"}
color={playDirection === "backward" ? "blue" : "gray"}
size="sm"
disabled={currentKeys.length < 2}
aria-label="向前播放"
title="向前播放"
onClick={() => {
togglePlayback("backward")
}}>
{playDirection === "backward" ? (
<IconPlayerTrackPrevFilled size={16} />
) : (
<IconPlayerTrackPrev size={16} />
)}
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
stepVisibleImage("backward")
}}>
<IconChevronLeft size={16} />
</ActionIcon>
<TextInput
w={42}
size="compact-xs"
radius={"sm"}
styles={{
input: {
height: 20,
fontSize: "14px",
textAlign: "center",
},
}}
value={inputNumber}
onChange={(e) => {
setInputNumber(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const targetIndex = Number(inputNumber)
if (
Number.isInteger(targetIndex) &&
targetIndex >= 1 &&
targetIndex <= currentKeys.length
) {
goToVisibleIndex(targetIndex - 1)
} else {
const currentIndex = currentKeys.findIndex(
(key) => key === activeImage
)
setInputNumber(
currentIndex >= 0 ? String(currentIndex + 1) : ""
)
}
}
}}
/>
<Text size="xs">/ {currentKeys.length}</Text>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
stepVisibleImage("forward")
}}>
<IconChevronRight size={16} />
</ActionIcon>
<ActionIcon
variant={playDirection === "forward" ? "filled" : "subtle"}
color={playDirection === "forward" ? "blue" : "gray"}
size="sm"
disabled={currentKeys.length < 2}
aria-label="向后播放"
title="向后播放"
onClick={() => {
togglePlayback("forward")
}}>
{playDirection === "forward" ? (
<IconPlayerTrackNextFilled size={16} />
) : (
<IconPlayerTrackNext size={16} />
)}
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
goToVisibleIndex(currentKeys.length - 1)
}}>
<IconChevronsRight size={16} />
</ActionIcon>
</Flex>
<Flex flex={1} justify="flex-end" align="center" gap="xs" pr="xs">
{projectDetail && [5, 6].includes(projectDetail.label_type) ? (
<>
{isShowKeyFrame ? (
<Button
variant="subtle"
size="compact-xs"
radius={"sm"}
onClick={() => {
const [firstKey] = labelState.keys()
setActiveImage(firstKey)
setInputNumber("1")
setActiveImageId(1)
setOnlyKeyFrame(false)
}}>
</Button>
) : (
<Button
variant="subtle"
size="compact-xs"
radius={"sm"}
onClick={() => {
let flag = false
keyFrameData
.entries()
.forEach(
([key, value]: [string, { key_frame?: boolean }]) => {
if (labelState.has(key) && value?.key_frame)
flag = true
}
)
if (!flag) {
showNotification({
title: "警告",
message: "当前任务暂未设定关键帧",
color: "yellow",
})
return
}
const filterKeys = Array.from(labelState.keys()).filter(
(key) =>
keyFrameData.has(key) &&
keyFrameData.get(key)?.key_frame
)
setActiveImage(filterKeys[0])
setInputNumber("1")
setActiveImageId(1)
setOnlyKeyFrame(true)
}}>
</Button>
)}
{keyFrameData.has(activeImage) &&
keyFrameData.get(activeImage)?.key_frame ? (
<Button
variant="subtle"
size="compact-xs"
radius={"sm"}
disabled={
!taskDetail ||
![2, 4, 6].includes(taskDetail.label_status) ||
useTopToolsStore.getState().isView
}
onClick={() => {
if (!taskDetail) return
const data = keyFrameData.get(activeImage)!
data.key_frame = false
if (taskDetail.label_status === 2) {
data.label1_ts = 0
data.label1_uid = 0
} else if (taskDetail.label_status === 4) {
data.review1_ts = 0
data.review1_uid = 0
} else if (taskDetail.label_status === 6) {
data.review2_ts = 0
data.review2_uid = 0
}
setKeyFrameData(activeImage, data)
}}>
</Button>
) : (
<Button
variant="subtle"
size="compact-xs"
radius={"sm"}
disabled={
!taskDetail ||
![2, 4, 6].includes(taskDetail.label_status) ||
useTopToolsStore.getState().isView
}
onClick={() => {
if (!taskDetail) return
const data = keyFrameData.get(activeImage) || {
key_frame: false,
label1_ts: 0,
label1_uid: 0,
review1_ts: 0,
review1_uid: 0,
review2_ts: 0,
review2_uid: 0,
}
data.key_frame = true
const currentLabelTime =
useLabelTimeStore.getState().labelTime
if (taskDetail.label_status === 2) {
data.label1_ts = currentLabelTime.label
data.label1_uid = usePermissionStore.getState().user_id
} else if (taskDetail.label_status === 4) {
data.review1_ts = currentLabelTime.review1
data.review1_uid = usePermissionStore.getState().user_id
} else if (taskDetail.label_status === 6) {
data.review2_ts = currentLabelTime.review2
data.review2_uid = usePermissionStore.getState().user_id
}
setKeyFrameData(activeImage, data)
}}>
</Button>
)}
</>
) : null}
<Button
variant="subtle"
size="compact-xs"
radius={"sm"}
disabled={
(!getLabelObjectsData()[0].length && !prevFramehasTextDesc()) ||
useTopToolsStore.getState().isView
}
onClick={() => {
const [perviousData, nowData] = getLabelObjectsData()
// check merge conflict
if (!nowData.length) {
setOpen(true)
return
}
let flag = false
const perviousIds = perviousData.map((item) => item[0])
const nowIds = nowData.map((item) => item[0])
perviousIds.forEach((id) => {
if (nowIds.includes(id)) flag = true
})
if (flag) setConflictOpen(true)
else setOpen(true)
}}>
</Button>
</Flex>
</Flex>
</Box>
<Box
h={
projectDetail?.label_type === 5 && metaOperation
? "calc(100% - 76px)"
: "calc(100% - 28px)"
}
style={{
border:
"1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
}}>
<ScrollArea.Autosize
mah="100%"
h={"100%"}
w="100%"
scrollbars="x"
style={{ display: "flex", alignItems: "center" }}>
<Flex gap={0} w="max-content" h="100%" align="center">
{currentKeys.map((key, index) => {
const isActive = activeImage === key
const isPending =
frameSwitchStatus === "preparing" &&
pendingImage === key &&
!isActive
const isSelected =
selectedItems.length && selectedItems.includes(key)
const isHighlight = isActive || isSelected
let textColor = "inherit"
if (getOperationSubAttrStatus(key)) {
textColor = "red"
} else if (isHighlight) {
textColor = "white"
}
return (
<Box
key={key}
w={120}
h="100%"
p={6}
ref={(el) => {
itemRefs.current[key] = el
}}>
<Box
style={{
border: isHighlight
? "1px solid var(--mantine-color-blue-6)"
: isPending
? "1px dashed var(--mantine-color-blue-4)"
: "1px solid var(--mantine-color-gray-3)",
borderRadius: "6px",
backgroundColor: isHighlight
? "var(--mantine-color-blue-6)"
: isPending
? "rgba(34, 139, 230, 0.08)"
: undefined,
color: isHighlight ? "white" : undefined,
opacity: isPending ? 0.85 : 1,
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={(e) => {
if (e.shiftKey) {
e.preventDefault()
}
if (ctrlKey || shiftKey) {
setSelectedItems(key, ctrlKey, shiftKey)
} else {
goToVisibleIndex(index)
setSelectedItems("", false, false)
}
closeRightContext()
}}>
{keyFrameData.has(key) &&
keyFrameData.get(key)?.key_frame ? (
<Box
pos="absolute"
top={0}
right={8}
c={isHighlight ? "white" : "gray"}>
<IconFlag size={16} />
</Box>
) : null}
<Text fw={600} size="lg" c={textColor}>
{index + 1}
</Text>
</Box>
</Box>
)
})}
</Flex>
</ScrollArea.Autosize>
</Box>
<CustomModal
title="提示"
open={open}
centered
onCancel={() => {
setOpen(false)
}}
onOk={handleCopyData}>
</CustomModal>
<CustomModal
title="提示"
open={conflictOpen}
centered
onCancel={() => {
setConflictOpen(false)
}}
footer={
<Group justify="flex-end" gap="xs" mt="md">
<Button
variant="default"
onClick={() => {
setConflictOpen(false)
}}>
</Button>
<Button
color="red"
onClick={() => {
handleCopyConflictData(false)
}}>
</Button>
<Button
onClick={() => {
handleCopyConflictData(true)
}}>
</Button>
</Group>
}>
</CustomModal>
</Box>
)
}
export default BottomTools