Merge branch 'zh' into 'main'

Zh

See merge request prism/lable/labelimage!12
This commit is contained in:
刘耀勇
2026-04-15 18:39:36 +08:00
10 changed files with 874 additions and 421 deletions

View File

@@ -3,15 +3,7 @@
import { taskDispatch } from "@/components/label/api/task" import { taskDispatch } from "@/components/label/api/task"
import { Task } from "@/components/label/api/task/typing" import { Task } from "@/components/label/api/task/typing"
import { usePermissionStore } from "@/components/label/store/auth" import { usePermissionStore } from "@/components/label/store/auth"
import { import { Badge, Button, Group, Modal, Select, Stack, Text } from "@mantine/core"
Button,
Group,
Modal,
Select,
Stack,
Switch,
Text,
} from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications" import { notifications } from "@mantine/notifications"
import { useEffect } from "react" import { useEffect } from "react"
@@ -30,7 +22,6 @@ export default function DispatchModal(props: {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
reject: false,
task_status_dst: "", task_status_dst: "",
uid_dst: "", uid_dst: "",
}, },
@@ -41,7 +32,6 @@ export default function DispatchModal(props: {
const resetFormState = () => { const resetFormState = () => {
form.setValues({ form.setValues({
reject: false,
task_status_dst: "", task_status_dst: "",
uid_dst: "", uid_dst: "",
}) })
@@ -61,6 +51,7 @@ export default function DispatchModal(props: {
const taskStatusSrc = dispatchData?.status ?? 0 const taskStatusSrc = dispatchData?.status ?? 0
const taskStatusSrcLabel = dispatchOpts[taskStatusSrc]?.label ?? "-" const taskStatusSrcLabel = dispatchOpts[taskStatusSrc]?.label ?? "-"
const taskRejected = Boolean(dispatchData?.reject)
const dstOptions = const dstOptions =
dispatchOpts[taskStatusSrc]?.opts?.map((o) => ({ dispatchOpts[taskStatusSrc]?.opts?.map((o) => ({
label: o.label, label: o.label,
@@ -116,7 +107,7 @@ export default function DispatchModal(props: {
try { try {
await taskDispatch({ await taskDispatch({
operation_uid: opUid, operation_uid: opUid,
reject: values.reject, reject: taskRejected,
task_ids: task_ids.map((item) => item.id), task_ids: task_ids.map((item) => item.id),
task_status_src: taskStatusSrc, task_status_src: taskStatusSrc,
task_status_dst: taskStatusDst, task_status_dst: taskStatusDst,
@@ -143,9 +134,16 @@ export default function DispatchModal(props: {
{task_ids.map((item) => item.project_name).join("、") ?? "-"} {task_ids.map((item) => item.project_name).join("、") ?? "-"}
</Text> </Text>
<Group gap={6}>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{taskStatusSrcLabel} {taskStatusSrcLabel}
</Text> </Text>
{taskRejected ? (
<Badge variant="outline" color="yellow" size="sm">
</Badge>
) : null}
</Group>
<Select <Select
label="目标状态" label="目标状态"
@@ -168,14 +166,6 @@ export default function DispatchModal(props: {
onChange={(v) => form.setFieldValue("uid_dst", v ?? "")} onChange={(v) => form.setFieldValue("uid_dst", v ?? "")}
/> />
<Switch
label="是否返工"
checked={form.values.reject}
onChange={(e) =>
form.setFieldValue("reject", e.currentTarget.checked)
}
/>
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button <Button
variant="default" variant="default"

View File

@@ -55,7 +55,7 @@ import {
useTimerStore, useTimerStore,
} from "./useTimerStore" } from "./useTimerStore"
import { useTopToolsStore } from "./useTopToolsStore" import { useTopToolsStore } from "./useTopToolsStore"
import { findGroupKey } from "./util" import { findGroupKey, isEditableKeyboardTarget } from "./util"
import { safeClone } from "./utils/clone" import { safeClone } from "./utils/clone"
import { labelTypeMap } from "./utils/constants" import { labelTypeMap } from "./utils/constants"
import { toggleObjectHideByShortcut } from "./utils/objectVisibility" import { toggleObjectHideByShortcut } from "./utils/objectVisibility"
@@ -83,6 +83,11 @@ let latestTaskDetailRequestSeq = 0
const RIGHT_PANEL_TRANSITION = const RIGHT_PANEL_TRANSITION =
"width 160ms ease, min-width 160ms ease, opacity 160ms ease" "width 160ms ease, min-width 160ms ease, opacity 160ms ease"
const RIGHT_PANEL_WILL_CHANGE = "width, min-width, opacity" 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 // utils
const getRectPoints = ([sx, sy, w, h]: number[]) => { const getRectPoints = ([sx, sy, w, h]: number[]) => {
@@ -111,6 +116,51 @@ const getRightPanelViewportStyle = (width: number) => {
} }
} }
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 videoExtPattern = /\.(mp4|mov|avi|mkv|webm|h264|264|ts|m4v)$/i
const GLOBAL_LOADING_Z_INDEX = 900 const GLOBAL_LOADING_Z_INDEX = 900
const videoMimeByExt: Record<string, string> = { const videoMimeByExt: Record<string, string> = {
@@ -233,7 +283,7 @@ const LabelPage = ({
const qaToolContainerRef = useRef<any>(null) const qaToolContainerRef = useRef<any>(null)
const [isConfirmSave, setIsConfirmSave] = useState(false) const [isConfirmSave, setIsConfirmSave] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [sizes, setSizes] = useState<number[]>([]) const [sizes, setSizes] = useState<number[]>([0, 0, 0, 0, 0, 0])
const [videoFetchPendingCount, setVideoFetchPendingCount] = useState(0) const [videoFetchPendingCount, setVideoFetchPendingCount] = useState(0)
const [hasReadyVideoFrame, setHasReadyVideoFrame] = useState(false) const [hasReadyVideoFrame, setHasReadyVideoFrame] = useState(false)
const [sourceVideoNames, setSourceVideoNames] = useState<string[]>([]) const [sourceVideoNames, setSourceVideoNames] = useState<string[]>([])
@@ -2114,7 +2164,7 @@ const LabelPage = ({
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
// console.log(e, e.key); // console.log(e, e.key);
if (focusInput) return if (focusInput || isEditableKeyboardTarget(e.target)) return
console.log(e.key) console.log(e.key)
const mode = usePaperStore.getState().mode const mode = usePaperStore.getState().mode
@@ -2479,51 +2529,35 @@ const LabelPage = ({
// 更新Splitter状态 // 更新Splitter状态
const updateSplitterSizes = useCallback(() => { const updateSplitterSizes = useCallback(() => {
console.log( const availableWidth = Math.max(
"document.documentElement.clientWidth", document.documentElement.clientWidth - leftWidth - 4,
document.documentElement.clientWidth 0
) )
const flags: boolean[] = [
const len = document.documentElement.clientWidth - 4
let arr = [len, 0, 0, 0, 0, 0]
const flags = [
showTaskList, showTaskList,
showGroupList, showGroupList,
showDescList && projectDetail && projectDetail.label_type === 5, (showDescList && projectDetail && projectDetail.label_type === 5) ||
showDescList && projectDetail && projectDetail.label_type === 6, false,
(showDescList && projectDetail && projectDetail.label_type === 6) ||
false,
showObjectList, showObjectList,
] ]
let count = 0 setSizes(allocateRightPanelSizes(availableWidth, flags))
flags.forEach((flag) => { }, [
if (flag) count++ leftWidth,
}) projectDetail,
if (count === 1 || count === 2) { showDescList,
arr[0] = count === 1 ? len * 0.8 : len * 0.6 showGroupList,
flags.forEach((flag, index) => { showObjectList,
if (flag) { showTaskList,
arr[index + 1] = len * 0.2 ])
}
})
} else if (count === 3) {
arr[0] = len * 0.55
flags.forEach((flag, index) => {
if (flag) {
arr[index + 1] = len * 0.15
}
})
} else if (count === 4) {
arr[0] = len * 0.6
flags.forEach((flag, index) => {
if (flag) {
arr[index + 1] = len * 0.1
}
})
}
setSizes(arr)
}, [projectDetail, showDescList, showGroupList, showObjectList, showTaskList])
useEffect(() => { useEffect(() => {
updateSplitterSizes() updateSplitterSizes()
window.addEventListener("resize", updateSplitterSizes)
return () => {
window.removeEventListener("resize", updateSplitterSizes)
}
}, [updateSplitterSizes]) }, [updateSplitterSizes])
useEffect(() => { useEffect(() => {
@@ -2702,7 +2736,7 @@ const LabelPage = ({
</Box> </Box>
<Flex w="calc(100% - 4px)" h={mainBoxHeight}> <Flex w="calc(100% - 4px)" h={mainBoxHeight}>
{/* <Flex h="100%" w={"100%"}> */} {/* <Flex h="100%" w={"100%"}> */}
<Box w={sizes[0] - leftWidth} style={{ flexShrink: 0 }}> <Box w={sizes[0]} style={{ flexShrink: 0, minWidth: 0 }}>
<Box h="100%" id="resize-container"> <Box h="100%" id="resize-container">
<Flex h="100%" w="100%"> <Flex h="100%" w="100%">
<Flex <Flex
@@ -2780,7 +2814,7 @@ const LabelPage = ({
</Box> </Box>
<Box <Box
w={sizes[1]} w={sizes[1]}
miw={showTaskList ? 300 : 0} miw={showTaskList ? sizes[1] : 0}
style={{ style={{
position: "relative", position: "relative",
flexShrink: 0, flexShrink: 0,
@@ -2788,10 +2822,11 @@ const LabelPage = ({
opacity: showTaskList ? 1 : 0, opacity: showTaskList ? 1 : 0,
visibility: showTaskList ? "visible" : "hidden", visibility: showTaskList ? "visible" : "hidden",
pointerEvents: showTaskList ? "auto" : "none", pointerEvents: showTaskList ? "auto" : "none",
borderLeft: showTaskList ? RIGHT_PANEL_SEPARATOR : "none",
transition: RIGHT_PANEL_TRANSITION, transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE, willChange: RIGHT_PANEL_WILL_CHANGE,
}}> }}>
<Box style={getRightPanelViewportStyle(Math.max(sizes[1], 300))}> <Box style={getRightPanelViewportStyle(sizes[1])}>
<RightTaskTools <RightTaskTools
projectDetail={projectDetail} projectDetail={projectDetail}
project_id={projectId} project_id={projectId}
@@ -2801,7 +2836,7 @@ const LabelPage = ({
</Box> </Box>
<Box <Box
w={sizes[2]} w={sizes[2]}
miw={showGroupList ? 200 : 0} miw={showGroupList ? sizes[2] : 0}
style={{ style={{
position: "relative", position: "relative",
flexShrink: 0, flexShrink: 0,
@@ -2809,10 +2844,11 @@ const LabelPage = ({
opacity: showGroupList ? 1 : 0, opacity: showGroupList ? 1 : 0,
visibility: showGroupList ? "visible" : "hidden", visibility: showGroupList ? "visible" : "hidden",
pointerEvents: showGroupList ? "auto" : "none", pointerEvents: showGroupList ? "auto" : "none",
borderLeft: showGroupList ? RIGHT_PANEL_SEPARATOR : "none",
transition: RIGHT_PANEL_TRANSITION, transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE, willChange: RIGHT_PANEL_WILL_CHANGE,
}}> }}>
<Box style={getRightPanelViewportStyle(Math.max(sizes[2], 200))}> <Box style={getRightPanelViewportStyle(sizes[2])}>
<RightGroupTools <RightGroupTools
labelState={labelState} labelState={labelState}
projectDetail={projectDetail} projectDetail={projectDetail}
@@ -2823,7 +2859,7 @@ const LabelPage = ({
w={sizes[3]} w={sizes[3]}
miw={ miw={
showDescList && projectDetail && projectDetail.label_type === 5 showDescList && projectDetail && projectDetail.label_type === 5
? 350 ? sizes[3]
: 0 : 0
} }
style={{ style={{
@@ -2842,10 +2878,14 @@ const LabelPage = ({
showDescList && projectDetail && projectDetail.label_type === 5 showDescList && projectDetail && projectDetail.label_type === 5
? "auto" ? "auto"
: "none", : "none",
borderLeft:
showDescList && projectDetail && projectDetail.label_type === 5
? RIGHT_PANEL_SEPARATOR
: "none",
transition: RIGHT_PANEL_TRANSITION, transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE, willChange: RIGHT_PANEL_WILL_CHANGE,
}}> }}>
<Box style={getRightPanelViewportStyle(Math.max(sizes[3], 350))}> <Box style={getRightPanelViewportStyle(sizes[3])}>
<RightDescTools taskDetail={taskDetail} /> <RightDescTools taskDetail={taskDetail} />
</Box> </Box>
</Box> </Box>
@@ -2853,7 +2893,7 @@ const LabelPage = ({
w={sizes[4]} w={sizes[4]}
miw={ miw={
showDescList && projectDetail && projectDetail.label_type === 6 showDescList && projectDetail && projectDetail.label_type === 6
? 350 ? sizes[4]
: 0 : 0
} }
style={{ style={{
@@ -2872,16 +2912,20 @@ const LabelPage = ({
showDescList && projectDetail && projectDetail.label_type === 6 showDescList && projectDetail && projectDetail.label_type === 6
? "auto" ? "auto"
: "none", : "none",
borderLeft:
showDescList && projectDetail && projectDetail.label_type === 6
? RIGHT_PANEL_SEPARATOR
: "none",
transition: RIGHT_PANEL_TRANSITION, transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE, willChange: RIGHT_PANEL_WILL_CHANGE,
}}> }}>
<Box style={getRightPanelViewportStyle(Math.max(sizes[4], 350))}> <Box style={getRightPanelViewportStyle(sizes[4])}>
<RightQATools ref={qaToolContainerRef} taskDetail={taskDetail} /> <RightQATools ref={qaToolContainerRef} taskDetail={taskDetail} />
</Box> </Box>
</Box> </Box>
<Box <Box
w={sizes[5]} w={sizes[5]}
miw={showObjectList ? 350 : 0} miw={showObjectList ? sizes[5] : 0}
style={{ style={{
position: "relative", position: "relative",
flexShrink: 0, flexShrink: 0,
@@ -2889,10 +2933,11 @@ const LabelPage = ({
opacity: showObjectList ? 1 : 0, opacity: showObjectList ? 1 : 0,
visibility: showObjectList ? "visible" : "hidden", visibility: showObjectList ? "visible" : "hidden",
pointerEvents: showObjectList ? "auto" : "none", pointerEvents: showObjectList ? "auto" : "none",
borderLeft: showObjectList ? RIGHT_PANEL_SEPARATOR : "none",
transition: RIGHT_PANEL_TRANSITION, transition: RIGHT_PANEL_TRANSITION,
willChange: RIGHT_PANEL_WILL_CHANGE, willChange: RIGHT_PANEL_WILL_CHANGE,
}}> }}>
<Box style={getRightPanelViewportStyle(Math.max(sizes[5], 350))}> <Box style={getRightPanelViewportStyle(sizes[5])}>
<RightObjectTools <RightObjectTools
taskDetail={taskDetail} taskDetail={taskDetail}
labelState={labelState} labelState={labelState}

View File

@@ -222,6 +222,7 @@ const PaperContainer = (
} }
if ( if (
!useTopToolsStore.getState().saveCurrentScale &&
RESIZE_INTERPOLATION_DURATION_MS > 0 && RESIZE_INTERPOLATION_DURATION_MS > 0 &&
firstRender && firstRender &&
lastSize?.clientWidth && lastSize?.clientWidth &&
@@ -1022,9 +1023,6 @@ const PaperContainer = (
parent: usePaperStore.getState().group, parent: usePaperStore.getState().group,
strokeScaling: false, strokeScaling: false,
}) })
if (Math.abs(path.area) < useTopToolsStore.getState().checkSize) {
path.remove()
} else {
if (hollows[index]) { if (hollows[index]) {
path.data.isHollowPolygon = true path.data.isHollowPolygon = true
innerPaths.push(path) innerPaths.push(path)
@@ -1033,7 +1031,6 @@ const PaperContainer = (
outerPaths.push(path) outerPaths.push(path)
} }
} }
}
}) })
const compoundPath = new paper.CompoundPath({ const compoundPath = new paper.CompoundPath({
children: [...outerPaths, ...innerPaths], children: [...outerPaths, ...innerPaths],
@@ -3136,6 +3133,8 @@ const PaperContainer = (
const activeImageChanged = prevActiveImageRef.current !== activeImage const activeImageChanged = prevActiveImageRef.current !== activeImage
prevActiveImageRef.current = activeImage prevActiveImageRef.current = activeImage
const hasPicRaster = getPicRasters(currentGroup).length > 0 const hasPicRaster = getPicRasters(currentGroup).length > 0
const preserveViewportOnLayoutResize =
useTopToolsStore.getState().saveCurrentScale
if (!useTopToolsStore.getState().saveCurrentScale) { if (!useTopToolsStore.getState().saveCurrentScale) {
currentGroup.scaling = new paper.Point(1, 1) currentGroup.scaling = new paper.Point(1, 1)
@@ -3147,6 +3146,12 @@ const PaperContainer = (
} }
if (!activeImageChanged) { if (!activeImageChanged) {
if (
preserveViewportOnLayoutResize &&
(hasPicRaster || rasterLoadingImageRef.current === activeImage)
) {
return
}
if (resizeCurrentImage()) { if (resizeCurrentImage()) {
return return
} }

View File

@@ -30,7 +30,7 @@ import {
Spline, Spline,
} from "lucide-react" } from "lucide-react"
import paper from "paper" import paper from "paper"
import { useCallback, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { Project } from "../api/project/typing" import { Project } from "../api/project/typing"
import { Task } from "../api/task/typing" import { Task } from "../api/task/typing"
import { LabelState } from "../LabelNossr" import { LabelState } from "../LabelNossr"
@@ -151,6 +151,7 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
continuePolygonTarget?.imageId === activeImage continuePolygonTarget?.imageId === activeImage
? continuePolygonTarget.objectId ? continuePolygonTarget.objectId
: null : null
const objectOperations = useTopToolsStore((state) => state.objectOperations)
const getOperationChildren = useCallback( const getOperationChildren = useCallback(
(imageId: string, operationId: string) => { (imageId: string, operationId: string) => {
@@ -415,9 +416,13 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
[activeImage] [activeImage]
) )
const activeImageOperations = (labelState.get(activeImage) || []).filter( const activeImageOperations = useMemo(() => {
(operationId) => getOperationChildren(activeImage, operationId).length const schemaOperationIds = objectOperations.map((item) =>
item.category_id.toString()
) )
const imageOperationIds = labelState.get(activeImage) || []
return Array.from(new Set([...schemaOperationIds, ...imageOperationIds]))
}, [activeImage, labelState, objectOperations])
const isObjectHidden = (imageId: string, objectId: number) => { const isObjectHidden = (imageId: string, objectId: number) => {
return pathStatus[imageId + objectId]?.["HIDE"] ?? false return pathStatus[imageId + objectId]?.["HIDE"] ?? false
@@ -558,9 +563,17 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
return ( return (
<Box h="100%" w="100%"> <Box h="100%" w="100%">
<Flex direction="column" h="100%" gap="xs"> <Flex direction="column" h="100%" gap="xs">
<Flex justify="space-between" align="center" px="md" py="xs"> <Flex
<Group gap="xs"> justify="space-between"
align="center"
wrap="nowrap"
gap={6}
px="md"
py="xs"
style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0, flexShrink: 0 }}>
<ActionIcon <ActionIcon
size="sm"
variant="subtle" variant="subtle"
aria-label={areAllObjectsHidden ? "显示全部对象" : "隐藏全部对象"} aria-label={areAllObjectsHidden ? "显示全部对象" : "隐藏全部对象"}
onClick={() => { onClick={() => {
@@ -571,20 +584,23 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
}}> }}>
{areAllObjectsHidden ? <EyeOff size={16} /> : <Eye size={16} />} {areAllObjectsHidden ? <EyeOff size={16} /> : <Eye size={16} />}
</ActionIcon> </ActionIcon>
<Title order={4} size="h4"> <Title
order={5}
size="h5"
style={{ whiteSpace: "nowrap", lineHeight: 1.1 }}>
</Title> </Title>
</Group> </Group>
<Group gap="xs"> <Group gap={6} wrap="nowrap" style={{ minWidth: 0, flexShrink: 0 }}>
<TextInput <TextInput
w={96} w={72}
size="xs" size="xs"
placeholder="输入ID" placeholder="输入ID"
value={searchId} value={searchId}
onChange={(e) => setSearchId(e.target.value)} onChange={(e) => setSearchId(e.target.value)}
onKeyDown={handleSelectedBySearchId} onKeyDown={handleSelectedBySearchId}
/> />
<Group gap={4}> <Group gap={2} wrap="nowrap">
{[3, 2, 1, 0].map((type, index) => ( {[3, 2, 1, 0].map((type, index) => (
<Checkbox <Checkbox
key={type} key={type}
@@ -603,6 +619,7 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
))} ))}
</Group> </Group>
<ActionIcon <ActionIcon
size="sm"
variant="subtle" variant="subtle"
onClick={() => { onClick={() => {
updateImageStatus( updateImageStatus(

View File

@@ -54,8 +54,8 @@ import {
MousePointer, MousePointer,
Paintbrush, Paintbrush,
Pen, Pen,
SquarePen,
SquareDashedMousePointer, SquareDashedMousePointer,
SquarePen,
Tag, Tag,
Timer, Timer,
} from "lucide-react" } from "lucide-react"
@@ -92,7 +92,12 @@ import { useKeyboardStore } from "../useKeyBoardStore"
import { usePaperStore } from "../usePaperStore" import { usePaperStore } from "../usePaperStore"
import { useRightToolsStore } from "../useRightToolsStore" import { useRightToolsStore } from "../useRightToolsStore"
import { useIntervalStore, useLabelTimeStore } from "../useTimerStore" import { useIntervalStore, useLabelTimeStore } from "../useTimerStore"
import { useTopToolsStore } from "../useTopToolsStore" import {
DEFAULT_NODE_SIZE,
NODE_SIZE_MAX,
NODE_SIZE_MIN,
useTopToolsStore,
} from "../useTopToolsStore"
import { getSubAttrStatus } from "../util" import { getSubAttrStatus } from "../util"
import { safeClone } from "../utils/clone" import { safeClone } from "../utils/clone"
import { labelTypeMap, TaskStatusEnum } from "../utils/constants" import { labelTypeMap, TaskStatusEnum } from "../utils/constants"
@@ -222,13 +227,14 @@ const TopTools = (
setAssistToolEnabled, setAssistToolEnabled,
assistToolSize, assistToolSize,
setAssistToolSize, setAssistToolSize,
nodeSize,
setNodeSize,
needBackup, needBackup,
setNeedBackup, setNeedBackup,
checkSize,
setCheckSize,
magnetFlag, magnetFlag,
setMagnetFlag, setMagnetFlag,
} = useTopToolsStore() } = useTopToolsStore()
const [nodeSizeInput, setNodeSizeInput] = useState<number | string>(nodeSize)
const { pathGroupMap } = useRightToolsStore() const { pathGroupMap } = useRightToolsStore()
const isAutoSave = useIntervalStore((state) => state.isAutoSave) const isAutoSave = useIntervalStore((state) => state.isAutoSave)
@@ -247,19 +253,25 @@ const TopTools = (
].filter(Boolean).length ].filter(Boolean).length
}, [projectDetail, showDescList, showGroupList, showObjectList, showTaskList]) }, [projectDetail, showDescList, showGroupList, showObjectList, showTaskList])
useEffect(() => {
setNodeSizeInput(nodeSize)
usePaperStore.getState().refreshNodeVisualSize()
}, [nodeSize])
const toggleRightPanel = useCallback( const toggleRightPanel = useCallback(
(visible: boolean, setter: (val: boolean) => void) => { (visible: boolean, setter: (val: boolean) => void) => {
const nextVisible = !visible const nextVisible = !visible
const nextCount = visibleRightPanelCount + (nextVisible ? 1 : -1) const nextCount = visibleRightPanelCount + (nextVisible ? 1 : -1)
if ( if (
(visibleRightPanelCount === 0 && nextCount === 1) || !saveCurrentScale &&
(visibleRightPanelCount === 1 && nextCount === 0) ((visibleRightPanelCount === 0 && nextCount === 1) ||
(visibleRightPanelCount === 1 && nextCount === 0))
) { ) {
requestResizeGhost() requestResizeGhost()
} }
setter(nextVisible) setter(nextVisible)
}, },
[requestResizeGhost, visibleRightPanelCount] [requestResizeGhost, saveCurrentScale, visibleRightPanelCount]
) )
// 当前是否可以操作功能按键 // 当前是否可以操作功能按键
@@ -1107,6 +1119,7 @@ const TopTools = (
}) })
try { try {
await handleSave() await handleSave()
notifications.hide("save")
} catch (error) { } catch (error) {
console.log(error) console.log(error)
return return
@@ -2188,23 +2201,39 @@ const TopTools = (
</Flex> </Flex>
</Flex> </Flex>
<Flex justify="space-between" align="center" px="xs" gap="xs"> <Flex justify="space-between" align="center" px="xs" gap="xs">
<Tooltip label={`节点直径范围 ${NODE_SIZE_MIN}-${NODE_SIZE_MAX}`}>
<Flex align="center" gap={4}>
<Text span size="xs" style={{ minWidth: "fit-content" }}>
</Text>
<NumberInput <NumberInput
value={checkSize} value={nodeSizeInput}
w={50} w={50}
min={1} min={NODE_SIZE_MIN}
max={NODE_SIZE_MAX}
step={1}
allowDecimal={false}
size="xs" size="xs"
radius="sm" radius="sm"
hideControls hideControls
onChange={(v) => { onChange={(value) => {
if (v) setCheckSize(Number(v)) setNodeSizeInput(value)
}} }}
onFocus={() => { onFocus={() => {
useKeyEventStore.getState().setFocusInput(true) useKeyEventStore.getState().setFocusInput(true)
}} }}
onBlur={() => { onBlur={(event) => {
useKeyEventStore.getState().setFocusInput(false) useKeyEventStore.getState().setFocusInput(false)
const rawValue = event.currentTarget.value.trim()
const nextValue =
rawValue === "" ? DEFAULT_NODE_SIZE : Number(rawValue)
setNodeSize(
Number.isFinite(nextValue) ? nextValue : DEFAULT_NODE_SIZE
)
}} }}
/> />
</Flex>
</Tooltip>
{renderEditIcon} {renderEditIcon}
<Button <Button
w="fit-content" w="fit-content"

View File

@@ -18,7 +18,7 @@ import { useKeyboardStore } from "./useKeyBoardStore"
import { useOtherToolsStore } from "./useOtherToolsStore" import { useOtherToolsStore } from "./useOtherToolsStore"
import { usePaperSupportStore } from "./usePaperSupportStore" import { usePaperSupportStore } from "./usePaperSupportStore"
import { useRightToolsStore } from "./useRightToolsStore" import { useRightToolsStore } from "./useRightToolsStore"
import { useTopToolsStore } from "./useTopToolsStore" import { DEFAULT_NODE_SIZE, useTopToolsStore } from "./useTopToolsStore"
import { safeClone } from "./utils/clone" import { safeClone } from "./utils/clone"
interface ContinuePolygonTarget { interface ContinuePolygonTarget {
@@ -183,6 +183,7 @@ interface PaperState {
setContinuePolygonTarget: (target: ContinuePolygonTarget | null) => void setContinuePolygonTarget: (target: ContinuePolygonTarget | null) => void
beginContinuePolygonDraft: () => boolean beginContinuePolygonDraft: () => boolean
setLoadingData: (flag: boolean) => void setLoadingData: (flag: boolean) => void
refreshNodeVisualSize: () => void
resizeGhostRequestToken: number resizeGhostRequestToken: number
requestResizeGhost: () => void requestResizeGhost: () => void
} }
@@ -298,6 +299,39 @@ const handleDelete = () => {
// }); // });
} }
const cleanupDeletedObjectItems = (
imageId: string,
pathId?: number,
parentGroupId?: number
) => {
if (!pathId) return
usePaperStore
.getState()
.group?.getItems({
data: { id: pathId },
})
.forEach((item) => {
item.remove()
})
useObjectStore.getState().updateSelectedPath(imageId, "DELETE", pathId)
if (!parentGroupId) return
const groupMap = useRightToolsStore.getState().pathGroupMap.get(imageId)
const currentGroupIds = groupMap
?.get(parentGroupId)
?.filter((id) => id !== pathId)
if (!currentGroupIds || currentGroupIds.length <= 1) {
groupMap?.delete(parentGroupId)
return
}
groupMap?.set(parentGroupId, currentGroupIds)
}
const handleShift = (flag: boolean) => { const handleShift = (flag: boolean) => {
useKeyEventStore.getState().setShift(flag) useKeyEventStore.getState().setShift(flag)
} }
@@ -328,6 +362,7 @@ const PAPER_CONTEXT_HIT_TYPES = [
"pathBuff", "pathBuff",
"pathCircle", "pathCircle",
] as const ] as const
const LEGACY_PAPER_NODE_DIAMETER = 6
const isPaperObjectType = (type: unknown) => const isPaperObjectType = (type: unknown) =>
typeof type === "string" && typeof type === "string" &&
@@ -339,6 +374,39 @@ const isPaperContextHitType = (type: unknown) =>
type as (typeof PAPER_CONTEXT_HIT_TYPES)[number] type as (typeof PAPER_CONTEXT_HIT_TYPES)[number]
) )
const getPaperNodeDiameter = () => {
const configuredDiameter = useTopToolsStore.getState().nodeSize
return Number.isFinite(configuredDiameter)
? Math.round(configuredDiameter)
: DEFAULT_NODE_SIZE
}
const getPaperNodeRadius = () => getPaperNodeDiameter() / 2
const getPaperNodeScaleFactor = () => {
const currentScaling = usePaperStore.getState().group?.scaling.x || 1
const newScale = useTopToolsStore.getState().scale / currentScaling
return newScale / currentScaling
}
const syncPaperCircleDiameter = (circle: paper.Path) => {
const nextDiameter = getPaperNodeDiameter()
const currentDiameter =
Number(circle.data?.diameter) || LEGACY_PAPER_NODE_DIAMETER
if (
Number.isFinite(currentDiameter) &&
currentDiameter > 0 &&
Math.abs(currentDiameter - nextDiameter) > 0.001
) {
circle.scale(nextDiameter / currentDiameter)
}
circle.data = Object.assign({}, circle.data, {
diameter: nextDiameter,
})
}
const getPaperCursorByMode = ( const getPaperCursorByMode = (
mode: PaperState["mode"] = usePaperStore.getState().mode mode: PaperState["mode"] = usePaperStore.getState().mode
) => { ) => {
@@ -462,6 +530,124 @@ const getPaperSelectionPath = (
return nearestPath return nearestPath
} }
const getNearestPaperPathSegmentIndex = (
path: paper.Path,
localPoint: paper.Point
) => {
let nearestIndex = 0
let nearestDistance = Infinity
path.segments.forEach((segment, index) => {
const distance = segment.point.getDistance(localPoint)
if (distance < nearestDistance) {
nearestDistance = distance
nearestIndex = index
}
})
return nearestIndex
}
const normalizePointPanHitResult = (
hitResult: paper.HitResult | null,
localPoint: paper.Point
) => {
if (!hitResult?.item?.data?.id || hitResult.item.data?.type !== "circle") {
return hitResult
}
const selectedPath = getPaperSelectionPath(hitResult.item.data.id, localPoint)
if (
!(selectedPath instanceof paper.Path) ||
selectedPath.data?.type !== "point" ||
!selectedPath.segments.length
) {
return hitResult
}
const segmentIndex = getNearestPaperPathSegmentIndex(selectedPath, localPoint)
return {
...hitResult,
item: selectedPath,
type: "segment",
segment: selectedPath.segments[segmentIndex],
} as paper.HitResult
}
const getPointCirclesById = (id: number) =>
(usePaperStore.getState().group?.getItems({
data: {
id,
type: "circle",
},
}) as paper.Path.Circle[]) || []
const createPointCircleForPath = (
path: paper.Path,
point: paper.Point,
index: number
) => {
const circle = usePaperStore
.getState()
.getPointCircle(point, index, path.data.id, path.data.blankColor)
circle.data = Object.assign({}, circle.data, {
id: path.data.id,
imageId: path.data.imageId,
operationId: path.data.operationId,
text: index + 1,
diameter: getPaperNodeDiameter(),
fillColor: path.data.blankColor,
strokeColor: path.data.strokeColor,
})
circle.fillColor = path.data.blankColor || circle.fillColor || null
circle.strokeColor = path.data.strokeColor || circle.strokeColor || null
return circle
}
const syncPointCirclesWithPath = (path: paper.Path) => {
if (path.data?.type !== "point" || !path.data?.id) return []
const unusedCircles = getPointCirclesById(path.data.id).slice()
const orderedCircles = path.segments.map((segment, index) => {
const matchedCircleIndex = unusedCircles.findIndex((circle) => {
return (
circle.position.getDistance(segment.point) <= 0.01 ||
circle.contains(segment.point)
)
})
const circle =
matchedCircleIndex >= 0
? unusedCircles.splice(matchedCircleIndex, 1)[0]
: createPointCircleForPath(path, segment.point, index)
circle.position.x = segment.point.x
circle.position.y = segment.point.y
circle.data = Object.assign({}, circle.data, {
id: path.data.id,
imageId: path.data.imageId,
operationId: path.data.operationId,
text: index + 1,
diameter: getPaperNodeDiameter(),
fillColor: path.data.blankColor,
strokeColor: path.data.strokeColor,
})
circle.fillColor = path.data.blankColor || circle.fillColor || null
circle.strokeColor = path.data.strokeColor || circle.strokeColor || null
return circle
})
unusedCircles.forEach((circle) => {
circle.remove()
})
return orderedCircles
}
const trySelectPaperObjectByPoint = (clickPoint: paper.Point) => { const trySelectPaperObjectByPoint = (clickPoint: paper.Point) => {
const group = usePaperStore.getState().group const group = usePaperStore.getState().group
if (!group) return false if (!group) return false
@@ -898,6 +1084,19 @@ export const usePaperStore = create<PaperState>((set) => ({
...state, ...state,
loadingData: flag, loadingData: flag,
})), })),
refreshNodeVisualSize: () => {
const group = usePaperStore.getState().group
if (!group) return
;(
group.getItems({
match: (item: paper.Item) =>
item instanceof paper.Path &&
["circle", "pathCircle"].includes(item.data?.type || ""),
}) as paper.Path[]
).forEach((circle) => {
syncPaperCircleDiameter(circle)
})
},
requestResizeGhost: () => requestResizeGhost: () =>
set((state: PaperState) => ({ set((state: PaperState) => ({
...state, ...state,
@@ -1090,8 +1289,6 @@ export const usePaperStore = create<PaperState>((set) => ({
? baseTolerance / useTopToolsStore.getState().scale ? baseTolerance / useTopToolsStore.getState().scale
: 0 : 0
false && getPanHitTolerance
const createPanHitOptions = ( const createPanHitOptions = (
tolerance: number, tolerance: number,
match: (hit: paper.HitResult) => boolean match: (hit: paper.HitResult) => boolean
@@ -1115,7 +1312,7 @@ export const usePaperStore = create<PaperState>((set) => ({
if (hoveredCircleInfo) { if (hoveredCircleInfo) {
const pointHit = group.hitTest( const pointHit = group.hitTest(
projectPoint, projectPoint,
createPanHitOptions(0, (hit) => { createPanHitOptions(getPanHitTolerance(), (hit) => {
return ( return (
hit.item.data?.type === "pathCircle" && hit.item.data?.type === "pathCircle" &&
hit.item.data?.id === hoveredCircleInfo.id && hit.item.data?.id === hoveredCircleInfo.id &&
@@ -1132,7 +1329,7 @@ export const usePaperStore = create<PaperState>((set) => ({
if (hoveredBufferInfo) { if (hoveredBufferInfo) {
const edgeHit = group.hitTest( const edgeHit = group.hitTest(
projectPoint, projectPoint,
createPanHitOptions(0, (hit) => { createPanHitOptions(getPanHitTolerance(), (hit) => {
return ( return (
hit.item.data?.type === "pathBuff" && hit.item.data?.type === "pathBuff" &&
hit.item.data?.id === hoveredBufferInfo.id && hit.item.data?.id === hoveredBufferInfo.id &&
@@ -1145,8 +1342,8 @@ export const usePaperStore = create<PaperState>((set) => ({
return group.hitTest( return group.hitTest(
projectPoint, projectPoint,
createPanHitOptions(0, (hit) => createPanHitOptions(getPanHitTolerance(), (hit) =>
["rectangle", "polygon", "brush", "point"].includes( ["rectangle", "polygon", "brush", "point", "circle"].includes(
hit.item.data?.type || "" hit.item.data?.type || ""
) )
) )
@@ -1197,6 +1394,7 @@ export const usePaperStore = create<PaperState>((set) => ({
hasExceededPanDragThreshold = false hasExceededPanDragThreshold = false
let hitResult = resolvePanHitResult(e.point) let hitResult = resolvePanHitResult(e.point)
hitResult = normalizePointPanHitResult(hitResult, point)
console.log("hitResult", hitResult) console.log("hitResult", hitResult)
const hoveredBufferInfo = hoveredBufferPathInfo?.path.parent const hoveredBufferInfo = hoveredBufferPathInfo?.path.parent
? hoveredBufferPathInfo ? hoveredBufferPathInfo
@@ -1475,10 +1673,10 @@ export const usePaperStore = create<PaperState>((set) => ({
activePath = hitResult.item as paper.Path activePath = hitResult.item as paper.Path
} }
} else if (hitResult.item.data.type === "point") { } else if (hitResult.item.data.type === "point") {
// 只有点击圆才视为选中 if (
if (hitResult.type === "segment") { ["segment", "stroke"].includes(hitResult.type) &&
// Path 类型 hitResult.item instanceof paper.Path
if (hitResult.item instanceof paper.Path) { ) {
if (!hitResult.item.data.selected) { if (!hitResult.item.data.selected) {
hitResult.item.data.selected = true hitResult.item.data.selected = true
// 绘制遮罩 // 绘制遮罩
@@ -1496,7 +1694,6 @@ export const usePaperStore = create<PaperState>((set) => ({
} }
activePath = hitResult.item as paper.Path activePath = hitResult.item as paper.Path
} }
}
} else if ( } else if (
hitResult.item.data.type === "pathBuff" || hitResult.item.data.type === "pathBuff" ||
hitResult.item.data.type === "pathCircle" hitResult.item.data.type === "pathCircle"
@@ -1598,21 +1795,7 @@ export const usePaperStore = create<PaperState>((set) => ({
} }
if (currentPath.data.type === "point") { if (currentPath.data.type === "point") {
usePaperSupportStore.getState().handleText(currentPath) usePaperSupportStore.getState().handleText(currentPath)
let circles = usePaperStore.getState().group!.getItems({ syncPointCirclesWithPath(currentPath)
data: {
id: currentPath.data.id,
type: "circle",
},
}) as paper.Path[]
currentPath.segments.forEach((segment, index) => {
let findIndex = circles.findIndex((circle) => {
return circle.contains(segment.point)
})
circles[index].data = Object.assign({}, circles[index]?.data, {
text: findIndex + 1,
})
})
usePaperSupportStore.getState().handleBufferPaths(currentPath) usePaperSupportStore.getState().handleBufferPaths(currentPath)
usePaperSupportStore.getState().handleCircles(currentPath) usePaperSupportStore.getState().handleCircles(currentPath)
} }
@@ -1677,15 +1860,9 @@ export const usePaperStore = create<PaperState>((set) => ({
} }
} else { } else {
activePath.insert(hitIndex + 1, hitResult.location.point) activePath.insert(hitIndex + 1, hitResult.location.point)
activePath?.data?.type === "point" && if (activePath?.data?.type === "point") {
usePaperStore syncPointCirclesWithPath(activePath)
.getState() }
.getPointCircle(
hitResult.location.point,
hitIndex + 1,
activePath.data.id,
activePath.data.blankColor
)
activePath.bringToFront() activePath.bringToFront()
usePaperSupportStore.getState().handleBufferPaths(activePath) usePaperSupportStore.getState().handleBufferPaths(activePath)
usePaperSupportStore.getState().handleCircles(activePath) usePaperSupportStore.getState().handleCircles(activePath)
@@ -1699,21 +1876,7 @@ export const usePaperStore = create<PaperState>((set) => ({
if (activePath?.data?.type === "point") { if (activePath?.data?.type === "point") {
// 绘制点对应的数字 // 绘制点对应的数字
usePaperSupportStore.getState().handleText(activePath) usePaperSupportStore.getState().handleText(activePath)
let circles = usePaperStore.getState().group!.getItems({ syncPointCirclesWithPath(activePath)
data: {
id: activePath.data.id,
type: "circle",
},
}) as paper.Path[]
activePath.segments.forEach((segment, index) => {
let findIndex = circles.findIndex((circle) => {
return circle.contains(segment.point)
})
circles[index].data = Object.assign({}, circles[index]?.data, {
text: findIndex + 1,
})
})
usePaperSupportStore.getState().handleBufferPaths(activePath) usePaperSupportStore.getState().handleBufferPaths(activePath)
usePaperSupportStore.getState().handleCircles(activePath) usePaperSupportStore.getState().handleCircles(activePath)
} }
@@ -1807,39 +1970,7 @@ export const usePaperStore = create<PaperState>((set) => ({
usePaperSupportStore.getState().handleMask(hitResult.item) usePaperSupportStore.getState().handleMask(hitResult.item)
} }
if (activePath?.data?.type === "point") { if (activePath?.data?.type === "point") {
let paths = usePaperStore.getState().group!.getItems({ syncPointCirclesWithPath(activePath)
data: {
id: activePath.data.id,
// text: activePath.segments.length - hitIndex + 1,
type: "circle",
},
}) as paper.Path[]
// paths = paths.filter((item) => {
// if (item.data.text === hitIndex + 1) item.remove();
// return item.data.text !== hitIndex + 1;
// });
paths = paths.filter((circle) => {
let findIndex = activePath.segments.findIndex((segment) => {
return circle.contains(segment.point)
})
if (findIndex === -1) {
circle.remove()
return false
} else {
return true
}
})
// 1. 对数组按照 text 属性进行排序
let sortedArr = paths
.slice()
.sort((a, b) => a.data.text - b.data.text)
// 2. 重新赋值 text 属性为从 1 开始的连续自然数
sortedArr.forEach((item, index) => {
item.data.text = index + 1
})
// paths.forEach((child) => { // paths.forEach((child) => {
// if ( // if (
@@ -2083,27 +2214,11 @@ export const usePaperStore = create<PaperState>((set) => ({
]!.position.y = point!.y ]!.position.y = point!.y
usePaperSupportStore.getState().handleMask(path) usePaperSupportStore.getState().handleMask(path)
} else if (path.data.type === "point") { } else if (path.data.type === "point") {
let circles = usePaperStore.getState().group!.getItems({ const circles = syncPointCirclesWithPath(path)
data: {
id: path.data.id,
type: "circle",
},
}) as paper.Path[]
// let index = path.segments.length - hitIndex - 1 if (circles[hitIndex]) {
// let index = hitIndex; circles[hitIndex].position.x = point!.x
circles[hitIndex].position.y = point!.y
// 移动点
if (circles && circles.length) {
// let circle = circles.find(
// (item) => item.data.text === index + 1
// );
let circle = circles.find((circle) => {
return circle.contains(path.segments[hitIndex].point)
})
circle!.position.x = point!.x
circle!.position.y = point!.y
} }
// 移动路径 // 移动路径
path.segments[hitIndex].point.x = point!.x path.segments[hitIndex].point.x = point!.x
@@ -2315,45 +2430,12 @@ export const usePaperStore = create<PaperState>((set) => ({
} }
const handlePointIntersectRaster = (path: paper.Path) => { const handlePointIntersectRaster = (path: paper.Path) => {
let circles = usePaperStore.getState().group!.getItems({
data: {
id: path.data.id,
type: "circle",
},
}) as paper.Path[]
// 去除超出图片边界的部分
let newCircles = circles.filter((circle) => {
if (
!usePaperStore
.getState()
.rasterPath!.bounds.contains(circle.bounds.center)
) {
circle.remove()
return false
} else {
return true
}
})
// 1. 对数组按照 text 属性进行排序
let sortedArr = newCircles
.slice()
.sort((a, b) => a.data.text - b.data.text)
// 2. 重新赋值 text 属性为从 1 开始的连续自然数
sortedArr.forEach((item, index) => {
item.data.text = index + 1
})
// newCircles.forEach((circle, circleIndex) => {
// // 重新设置显示数字
// circle.data.text = circleIndex + 1;
// });
path.segments = path.segments.filter((segment) => { path.segments = path.segments.filter((segment) => {
return usePaperStore return usePaperStore
.getState() .getState()
.rasterPath!.bounds.contains(segment.point) .rasterPath!.bounds.contains(segment.point)
}) })
syncPointCirclesWithPath(path)
// 去除超出图片边界的部分 end // 去除超出图片边界的部分 end
usePaperSupportStore.getState().handleMask(path) usePaperSupportStore.getState().handleMask(path)
usePaperSupportStore.getState().handleBufferPaths(path) usePaperSupportStore.getState().handleBufferPaths(path)
@@ -2507,6 +2589,11 @@ export const usePaperStore = create<PaperState>((set) => ({
rectPath.data.operationId, rectPath.data.operationId,
rectPath.data.id rectPath.data.id
) )
cleanupDeletedObjectItems(
rectPath.data.imageId,
rectPath.data.id,
rectPath.data.parentGroupId
)
} }
forceUpdate() forceUpdate()
@@ -2758,6 +2845,11 @@ export const usePaperStore = create<PaperState>((set) => ({
polygonPath.data.operationId, polygonPath.data.operationId,
polygonPath.data.id polygonPath.data.id
) )
cleanupDeletedObjectItems(
polygonPath.data.imageId,
polygonPath.data.id,
polygonPath.data.parentGroupId
)
} }
} }
@@ -2837,6 +2929,11 @@ export const usePaperStore = create<PaperState>((set) => ({
brushPath.data.operationId, brushPath.data.operationId,
brushPath.data.id brushPath.data.id
) )
cleanupDeletedObjectItems(
brushPath.data.imageId,
brushPath.data.id,
brushPath.data.parentGroupId
)
} }
} }
} else if (brushPath instanceof paper.Path) { } else if (brushPath instanceof paper.Path) {
@@ -2864,6 +2961,11 @@ export const usePaperStore = create<PaperState>((set) => ({
brushPath.data.operationId, brushPath.data.operationId,
brushPath.data.id brushPath.data.id
) )
cleanupDeletedObjectItems(
brushPath.data.imageId,
brushPath.data.id,
brushPath.data.parentGroupId
)
} }
} else { } else {
usePaperSupportStore.getState().removeMaskById(brushPath.data.id) usePaperSupportStore.getState().removeMaskById(brushPath.data.id)
@@ -2874,6 +2976,11 @@ export const usePaperStore = create<PaperState>((set) => ({
brushPath.data.operationId, brushPath.data.operationId,
brushPath.data.id brushPath.data.id
) )
cleanupDeletedObjectItems(
brushPath.data.imageId,
brushPath.data.id,
brushPath.data.parentGroupId
)
} }
} }
@@ -2934,6 +3041,11 @@ export const usePaperStore = create<PaperState>((set) => ({
pointPath.data.operationId, pointPath.data.operationId,
pointPath.data.id pointPath.data.id
) )
cleanupDeletedObjectItems(
pointPath.data.imageId,
pointPath.data.id,
pointPath.data.parentGroupId
)
} }
} else { } else {
usePaperSupportStore.getState().removeMaskById(pointPath.data.id) usePaperSupportStore.getState().removeMaskById(pointPath.data.id)
@@ -2944,6 +3056,11 @@ export const usePaperStore = create<PaperState>((set) => ({
pointPath.data.operationId, pointPath.data.operationId,
pointPath.data.id pointPath.data.id
) )
cleanupDeletedObjectItems(
pointPath.data.imageId,
pointPath.data.id,
pointPath.data.parentGroupId
)
} }
} }
@@ -5428,6 +5545,7 @@ export const usePaperStore = create<PaperState>((set) => ({
const newScale = useTopToolsStore.getState().scale / currentScaling const newScale = useTopToolsStore.getState().scale / currentScaling
myCircle.scale(newScale / currentScaling) myCircle.scale(newScale / currentScaling)
myCircle.data = Object.assign({ type: "circle", ...option }, data) myCircle.data = Object.assign({ type: "circle", ...option }, data)
syncPaperCircleDiameter(myCircle)
myCircle.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => { myCircle.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
if (e.event && e.event.button === 2) { if (e.event && e.event.button === 2) {
if ( if (
@@ -5845,6 +5963,7 @@ export const usePaperStore = create<PaperState>((set) => ({
id: number, id: number,
color: any color: any
) => { ) => {
const nodeDiameter = getPaperNodeDiameter()
let circle: paper.Path.Circle = new paper.Path.Circle( let circle: paper.Path.Circle = new paper.Path.Circle(
Object.assign( Object.assign(
{ parent: usePaperStore.getState().group!, center: point }, { parent: usePaperStore.getState().group!, center: point },
@@ -5852,12 +5971,13 @@ export const usePaperStore = create<PaperState>((set) => ({
fillColor: color, fillColor: color,
// center: point, // center: point,
strokeColor: usePaperStore.getState().toolOption?.strokeColor, strokeColor: usePaperStore.getState().toolOption?.strokeColor,
radius: 3, radius: getPaperNodeRadius(),
strokeScaling: false, strokeScaling: false,
}, },
{ {
data: { data: {
type: "circle", type: "circle",
diameter: nodeDiameter,
fillColor: color, fillColor: color,
strokeColor: usePaperStore.getState().toolOption?.strokeColor, strokeColor: usePaperStore.getState().toolOption?.strokeColor,
imageId: useBottomToolsStore.getState().activeImage, imageId: useBottomToolsStore.getState().activeImage,
@@ -5869,9 +5989,7 @@ export const usePaperStore = create<PaperState>((set) => ({
} }
) )
) )
const currentScaling = usePaperStore.getState().group!.scaling.x circle.scale(getPaperNodeScaleFactor())
const newScale = useTopToolsStore.getState().scale / currentScaling
circle.scale(newScale / currentScaling)
circle.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => { circle.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
if (e.event && e.event.button === 2) { if (e.event && e.event.button === 2) {
if ( if (
@@ -6052,8 +6170,7 @@ export const usePaperStore = create<PaperState>((set) => ({
id: number, id: number,
color: any color: any
) => { ) => {
const currentScaling = usePaperStore.getState().group!.scaling.x const nodeDiameter = getPaperNodeDiameter()
const newScale = useTopToolsStore.getState().scale / currentScaling
let circle: paper.Path.Circle = new paper.Path.Circle( let circle: paper.Path.Circle = new paper.Path.Circle(
Object.assign( Object.assign(
{ parent: usePaperStore.getState().group!, center: point }, { parent: usePaperStore.getState().group!, center: point },
@@ -6061,15 +6178,16 @@ export const usePaperStore = create<PaperState>((set) => ({
fillColor: color, fillColor: color,
// center: point, // center: point,
strokeColor: "#FFF", strokeColor: "#FFF",
radius: 3, strokeWidth: 2,
radius: getPaperNodeRadius(),
strokeScaling: false, strokeScaling: false,
}, },
{ {
data: { index, type: "pathCircle", id }, data: { index, type: "pathCircle", id, diameter: nodeDiameter },
} }
) )
) )
circle.scale(newScale / currentScaling) circle.scale(getPaperNodeScaleFactor())
circle.onMouseEnter = (_e: any) => { circle.onMouseEnter = (_e: any) => {
hoveredPathCircleInfo = { hoveredPathCircleInfo = {
id, id,

View File

@@ -5,6 +5,145 @@ import { useBottomToolsStore } from "./useBottomToolsStore"
import { usePaperStore } from "./usePaperStore" import { usePaperStore } from "./usePaperStore"
import { useTopToolsStore } from "./useTopToolsStore" import { useTopToolsStore } from "./useTopToolsStore"
const TAG_FONT_SIZE = 12
const TAG_VERTICAL_GAP = 8
const TAG_TEXT_PADDING_X = 6
const TAG_TEXT_PADDING_Y = 4
const TAG_MAX_LINE_LENGTH = 18
const wrapLongToken = (token: string, maxLineLength: number) => {
if (token.length <= maxLineLength) return [token]
const parts: string[] = []
for (let index = 0; index < token.length; index += maxLineLength) {
parts.push(token.slice(index, index + maxLineLength))
}
return parts
}
const wrapTagContent = (
content: string,
maxLineLength = TAG_MAX_LINE_LENGTH
) => {
const normalized = content.trim().replace(/\s+/g, " ")
if (!normalized) return ""
const lines: string[] = []
let currentLine = ""
normalized.split(" ").forEach((token) => {
if (!token) return
const wrappedTokens = wrapLongToken(token, maxLineLength)
wrappedTokens.forEach((wrappedToken) => {
const nextLine = currentLine
? `${currentLine} ${wrappedToken}`
: wrappedToken
if (nextLine.length <= maxLineLength) {
currentLine = nextLine
return
}
if (currentLine) lines.push(currentLine)
currentLine = wrappedToken
})
})
if (currentLine) lines.push(currentLine)
return lines.join("\n")
}
const getTagAnchorPoint = (item: paper.Item) => {
const segmentPoints: paper.Point[] = []
if (item instanceof paper.CompoundPath) {
item.children.forEach((child) => {
if (!(child instanceof paper.Path)) return
child.segments.forEach((segment) => {
segmentPoints.push(segment.point.clone())
})
})
} else if (item instanceof paper.Path) {
item.segments.forEach((segment) => {
segmentPoints.push(segment.point.clone())
})
}
if (!segmentPoints.length) {
return new paper.Point(item.bounds.left, item.bounds.top)
}
return segmentPoints.reduce((highestPoint, point) =>
point.y < highestPoint.y ? point : highestPoint
)
}
const getTextMaskBounds = (textBounds: paper.Rectangle) =>
new paper.Rectangle(
new paper.Point(
textBounds.left - TAG_TEXT_PADDING_X,
textBounds.top - TAG_TEXT_PADDING_Y
),
new paper.Size(
textBounds.width + TAG_TEXT_PADDING_X * 2,
textBounds.height + TAG_TEXT_PADDING_Y * 2
)
)
const toPaperColor = (color: paper.Color | string | null | undefined) => {
if (color instanceof paper.Color) return color.clone()
if (!color) return null
try {
return new paper.Color(color)
} catch {
return null
}
}
const getTagBackgroundColor = (
fillColor: paper.Color | string | null | undefined,
strokeColor: paper.Color | string | null | undefined
) => {
const resolvedColor = toPaperColor(strokeColor) ?? toPaperColor(fillColor)
if (!resolvedColor) return new paper.Color(1, 1, 1, 0.9)
const tagBackgroundColor = resolvedColor.clone()
tagBackgroundColor.alpha = 0.9
return tagBackgroundColor
}
const getRelativeLuminance = (
color: paper.Color | string | null | undefined
) => {
const resolvedColor = toPaperColor(color)
if (!resolvedColor) return 1
const red = resolvedColor.red
const green = resolvedColor.green
const blue = resolvedColor.blue
const toLinear = (channel: number) =>
channel <= 0.03928
? channel / 12.92
: Math.pow((channel + 0.055) / 1.055, 2.4)
return (
0.2126 * toLinear(red) + 0.7152 * toLinear(green) + 0.0722 * toLinear(blue)
)
}
const getContrastTextColor = (
backgroundColor: paper.Color | string | null | undefined
) => {
const backgroundLuminance = getRelativeLuminance(backgroundColor)
const whiteContrast = 1.05 / (backgroundLuminance + 0.05)
const blackContrast = (backgroundLuminance + 0.05) / 0.05
return whiteContrast > blackContrast ? "#ffffff" : "#000000"
}
export const removeTagTexts = (id?: number) => { export const removeTagTexts = (id?: number) => {
const group = usePaperStore.getState().group const group = usePaperStore.getState().group
if (!group) return if (!group) return
@@ -26,7 +165,7 @@ const useRenderTag = () => {
Number.isFinite(group.scaling?.x) && group.scaling.x !== 0 Number.isFinite(group.scaling?.x) && group.scaling.x !== 0
? group.scaling.x ? group.scaling.x
: 1 : 1
const applyTagCompensation = ( const applyPointTagCompensation = (
item: paper.Item, item: paper.Item,
anchor: [number, number] anchor: [number, number]
) => { ) => {
@@ -37,8 +176,9 @@ const useRenderTag = () => {
// 清空之前渲染的标签 // 清空之前渲染的标签
removeTagTexts() removeTagTexts()
for (const [imageId, categoryMap] of dataMap) { const categoryMap = dataMap.get(activeImage)
if (imageId === activeImage) { if (!categoryMap) return
for (const [categoryId, labelList] of categoryMap) { for (const [categoryId, labelList] of categoryMap) {
let category = useTopToolsStore let category = useTopToolsStore
.getState() .getState()
@@ -48,16 +188,24 @@ const useRenderTag = () => {
const { label_class } = category const { label_class } = category
labelList.forEach(([id, _d, detail]) => { labelList.forEach(([id, _d, detail]) => {
const path = usePaperStore.getState().getItemById(id) const path = usePaperStore.getState().getItemById(id)
if (path) { if (!path) return
const bounds = path.bounds const bounds = path.bounds
const tagAnchor: [number, number] = [bounds.left, bounds.top - 6] const pathData = path.data
const pos = [...tagAnchor] const anchorPoint = getTagAnchorPoint(path)
let pathData = path.data const tagAnchor: [number, number] = [anchorPoint.x, anchorPoint.y]
const tagBackgroundColor = getTagBackgroundColor(
pathData.fillColor,
pathData.strokeColor
)
const tagTextColor = getContrastTextColor(tagBackgroundColor)
let content = `` let content = ``
if (configs.includes("ID"))
if (configs.includes("ID")) {
content += `${id}${ content += `${id}${
path.data.parentGroupId ? `@${path.data.parentGroupId}` : "" path.data.parentGroupId ? `@${path.data.parentGroupId}` : ""
}` }`
}
if (configs.includes("类别")) content += ` ${label_class}` if (configs.includes("类别")) content += ` ${label_class}`
if ( if (
@@ -70,13 +218,16 @@ const useRenderTag = () => {
content += `${detail.sub_attributes[key]};` content += `${detail.sub_attributes[key]};`
}) })
} }
// 文本
const text = new paper.PointText(pos) const wrappedContent = wrapTagContent(content)
if (wrappedContent) {
const text = new paper.PointText(anchorPoint)
text.set({ text.set({
content, content: wrappedContent,
fontSize: 12, fontSize: TAG_FONT_SIZE,
fontWeight: "bold", fontWeight: "bold",
fillColor: "black", fillColor: tagTextColor,
justification: "center",
data: { data: {
id, id,
type: "tag", type: "tag",
@@ -86,12 +237,18 @@ const useRenderTag = () => {
parent: group, parent: group,
visible, visible,
}) })
applyTagCompensation(text, tagAnchor) text.translate(
new paper.Point(
anchorPoint.x - text.bounds.center.x,
anchorPoint.y - TAG_VERTICAL_GAP - text.bounds.bottom
)
)
// 文本框 const textMask = new paper.Path.Rectangle(
const textMask = new paper.Path.Rectangle(text.bounds) getTextMaskBounds(text.bounds)
)
textMask.set({ textMask.set({
fillColor: pathData.fillColor, fillColor: tagBackgroundColor.clone(),
strokeColor: pathData.strokeColor, strokeColor: pathData.strokeColor,
strokeWidth: 1, strokeWidth: 1,
strokeScaling: false, strokeScaling: false,
@@ -104,7 +261,9 @@ const useRenderTag = () => {
parent: group, parent: group,
visible, visible,
}) })
applyTagCompensation(textMask, tagAnchor) text.bringToFront()
}
// 标注对象外接框 // 标注对象外接框
const mask = new paper.Path.Rectangle(bounds) const mask = new paper.Path.Rectangle(bounds)
mask.set({ mask.set({
@@ -144,7 +303,7 @@ const useRenderTag = () => {
parent: group, parent: group,
visible: visible && configs.includes("点ID"), visible: visible && configs.includes("点ID"),
}) })
applyTagCompensation(pointText, pointTextPosition) applyPointTagCompensation(pointText, pointTextPosition)
index++ index++
}) })
}) })
@@ -170,14 +329,11 @@ const useRenderTag = () => {
parent: group, parent: group,
visible: visible && configs.includes("点ID"), visible: visible && configs.includes("点ID"),
}) })
applyTagCompensation(pointText, pointTextPosition) applyPointTagCompensation(pointText, pointTextPosition)
}) })
} }
}
}) })
} }
}
}
}, []) }, [])
return { return {

View File

@@ -6,6 +6,17 @@ import { useObjectStore } from "./store"
import { useBottomToolsStore } from "./useBottomToolsStore" import { useBottomToolsStore } from "./useBottomToolsStore"
import { safeClone } from "./utils/clone" import { safeClone } from "./utils/clone"
export const NODE_SIZE_MIN = 6
export const NODE_SIZE_MAX = 24
export const DEFAULT_NODE_SIZE = 10
const clampNodeSize = (value: number) => {
const nextValue = Number.isFinite(value)
? Math.round(value)
: DEFAULT_NODE_SIZE
return Math.min(NODE_SIZE_MAX, Math.max(NODE_SIZE_MIN, nextValue))
}
interface TopToolsState { interface TopToolsState {
// 标注页面是否仅查看 // 标注页面是否仅查看
isView: boolean isView: boolean
@@ -34,6 +45,8 @@ interface TopToolsState {
setAssistToolEnabled: (val: boolean) => void setAssistToolEnabled: (val: boolean) => void
assistToolSize: number assistToolSize: number
setAssistToolSize: (val: number) => void setAssistToolSize: (val: number) => void
nodeSize: number
setNodeSize: (val: number) => void
showTags: boolean showTags: boolean
setShowTags: (val: boolean) => void setShowTags: (val: boolean) => void
showTagsConfigs: string[] showTagsConfigs: string[]
@@ -94,6 +107,7 @@ const initialTopToolsState = {
crosshairStatus: "hidden" as "hidden" | "line" | "carve", crosshairStatus: "hidden" as "hidden" | "line" | "carve",
assistToolEnabled: false, assistToolEnabled: false,
assistToolSize: 10, assistToolSize: 10,
nodeSize: DEFAULT_NODE_SIZE,
drawOption: "default" as "default" | "intersect" | "unite", drawOption: "default" as "default" | "intersect" | "unite",
saveCurrentScale: false, saveCurrentScale: false,
scale: 1, scale: 1,
@@ -183,6 +197,12 @@ export const useTopToolsStore = create<TopToolsState>()(
...state, ...state,
assistToolSize: Math.max(1, val), assistToolSize: Math.max(1, val),
})), })),
nodeSize: initialTopToolsState.nodeSize,
setNodeSize: (val) =>
set((state: TopToolsState) => ({
...state,
nodeSize: clampNodeSize(val),
})),
showTags: false, showTags: false,
setShowTags: (val) => setShowTags: (val) =>
set((state: TopToolsState) => ({ set((state: TopToolsState) => ({

View File

@@ -1,5 +1,71 @@
import { Project } from "./api/project/typing" import { Project } from "./api/project/typing"
const nonEditableInputTypes = new Set([
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
])
const editableRoles = new Set([
"textbox",
"searchbox",
"combobox",
"spinbutton",
])
const resolveTargetElement = (target: EventTarget | null) => {
if (target instanceof Element) return target
if (target instanceof Node) return target.parentElement
return null
}
export const isEditableKeyboardTarget = (target: EventTarget | null) => {
const element = resolveTargetElement(target)
if (!element) return false
const editableElement = element.closest(
[
"input",
"textarea",
"select",
"[contenteditable='']",
"[contenteditable='true']",
"[role='textbox']",
"[role='searchbox']",
"[role='combobox']",
"[role='spinbutton']",
].join(",")
)
if (!editableElement) return false
if (editableElement instanceof HTMLInputElement) {
return !nonEditableInputTypes.has(editableElement.type.toLowerCase())
}
if (
editableElement instanceof HTMLTextAreaElement ||
editableElement instanceof HTMLSelectElement
) {
return true
}
if (editableElement instanceof HTMLElement) {
if (editableElement.isContentEditable) return true
const role = editableElement.getAttribute("role")
return role ? editableRoles.has(role) : false
}
return false
}
// 是否存在不符合要求的子属性 // 是否存在不符合要求的子属性
export const getSubAttrStatus = ( export const getSubAttrStatus = (
operationSchema: Project.LabelSchemaList | null, operationSchema: Project.LabelSchemaList | null,

View File

@@ -23,6 +23,17 @@ const currentComponentList: MenuItem[] = [
}, },
] ]
const lidarList: MenuItem[] =
process.env.NEXT_PUBLIC_ENV === "production"
? []
: [
{
title: "点云标注",
url: "lidar",
icon: "LidarAnnotationIcon",
},
]
export const componentList: MenuItem[] = [ export const componentList: MenuItem[] = [
{ {
title: "个人中心", title: "个人中心",
@@ -35,11 +46,7 @@ export const componentList: MenuItem[] = [
url: "video", url: "video",
icon: "VideoAnnotationIcon", icon: "VideoAnnotationIcon",
}, },
{ ...lidarList,
title: "点云标注",
url: "lidar",
icon: "LidarAnnotationIcon",
},
{ {
title: "管理中心", title: "管理中心",
url: "mgt", url: "mgt",