diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx index 93cb40d..bf63be2 100644 --- a/components/label/LabelNossr.tsx +++ b/components/label/LabelNossr.tsx @@ -49,16 +49,13 @@ import { useDescToolsStore } from "./useDescToolsStore" import { useKeyboardStore } from "./useKeyBoardStore" import { usePaperStore } from "./usePaperStore" import { useRightToolsStore } from "./useRightToolsStore" -import { - useIntervalStore, - useLabelTimeStore, - useTimerStore, -} from "./useTimerStore" +import { useIntervalStore, useLabelTimeStore } from "./useTimerStore" import { useTopToolsStore } from "./useTopToolsStore" import { findGroupKey, isEditableKeyboardTarget } from "./util" import { safeClone } from "./utils/clone" import { labelTypeMap } from "./utils/constants" import { toggleObjectHideByShortcut } from "./utils/objectVisibility" +import { getDisplayedImageScale } from "./utils/scale" // Splitter component - will need custom implementation or alternative @@ -88,6 +85,8 @@ const RIGHT_PANEL_SEPARATOR = const RIGHT_PANEL_SOFT_MAIN_MIN_WIDTH = 420 const RIGHT_PANEL_HARD_MAIN_MIN_WIDTH = 260 const RIGHT_PANEL_TARGET_WIDTH = 350 +const INACTIVITY_VIEW_DELAY_MS = 5 * 60 * 1000 +const INACTIVITY_ACTIVITY_THROTTLE_MS = 1000 // utils const getRectPoints = ([sx, sy, w, h]: number[]) => { @@ -279,6 +278,8 @@ const LabelPage = ({ const topRef = useRef(null) const autoSaveTimerRef = useRef | null>(null) const autoSaveInProgressRef = useRef(false) + const inactivityTimerRef = useRef | null>(null) + const lastInactivityResetAtRef = useRef(0) const paperContainerRef = useRef(null) const qaToolContainerRef = useRef(null) const [isConfirmSave, setIsConfirmSave] = useState(false) @@ -325,6 +326,9 @@ const LabelPage = ({ setObjectOperations, } = useTopToolsStore() const { activeImage } = useBottomToolsStore() + const activeRasterScale = usePaperStore( + (state) => state.rasterScale[activeImage] ?? 1 + ) const { setDescOperations, metaOperation, @@ -333,6 +337,12 @@ const LabelPage = ({ setQaData, resetData, } = useDescToolsStore() + const displayedImageScale = useMemo(() => { + return getDisplayedImageScale({ + rasterScale: activeRasterScale, + viewScale: scale, + }) + }, [activeRasterScale, scale]) const asyncGetProjectDetail = useCallback(async () => { if (projectId) { @@ -2404,10 +2414,9 @@ const LabelPage = ({ const handleBeforeUnload = (e: any) => { e.preventDefault() + e.returnValue = "" // 备份 - topRef.current.handleBackup("auto_backup") - // 清空操作栏状态 - useObjectStore.getState().resetPathAndOperationStatus() + void topRef.current?.handleBackup?.("auto_backup") } // 更新Splitter状态 @@ -2443,22 +2452,86 @@ const LabelPage = ({ } }, [updateSplitterSizes]) + const clearInactivityTimer = useCallback(() => { + if (inactivityTimerRef.current !== null) { + clearTimeout(inactivityTimerRef.current) + inactivityTimerRef.current = null + } + }, []) + + const scheduleInactivityViewMode = useCallback(() => { + clearInactivityTimer() + inactivityTimerRef.current = setTimeout(() => { + const topToolsState = useTopToolsStore.getState() + if (!topToolsState.isView) { + topToolsState.setIsView(true) + } + }, INACTIVITY_VIEW_DELAY_MS) + }, [clearInactivityTimer]) + + const reportUserActivity = useCallback( + (force = false) => { + if (useTopToolsStore.getState().isView) return + + const now = Date.now() + if ( + !force && + now - lastInactivityResetAtRef.current < INACTIVITY_ACTIVITY_THROTTLE_MS + ) { + return + } + + lastInactivityResetAtRef.current = now + scheduleInactivityViewMode() + }, + [scheduleInactivityViewMode] + ) + useEffect(() => { - if (!isView) { - const delay = 5 * 60000 - // 编辑模式下,修改labelData时启动或更新定时器 - useTimerStore.getState().startTimer(() => { - useTopToolsStore.getState().setIsView(true) - }, delay) - } else { - useTimerStore.getState().clearTimer() + if (isView) { + lastInactivityResetAtRef.current = 0 + clearInactivityTimer() + return } - // 组件卸载时清理定时器 - return () => { - useTimerStore.getState().clearTimer() + // 使用真实输入事件追踪活跃度,避免进行中的标注因 labelData 未变化被误判为空闲。 + reportUserActivity(true) + + const handlePointerDown = () => { + reportUserActivity(true) } - }, [isView, labelData]) + const handlePointerMove = (event: PointerEvent) => { + if (event.buttons !== 0) { + reportUserActivity() + } + } + // const handleWheel = () => { + // reportUserActivity() + // } + const handleKeyDown = () => { + reportUserActivity(true) + } + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + reportUserActivity(true) + } + } + + window.addEventListener("pointerdown", handlePointerDown, true) + window.addEventListener("pointermove", handlePointerMove, true) + // window.addEventListener("wheel", handleWheel, true) + window.addEventListener("keydown", handleKeyDown, true) + document.addEventListener("visibilitychange", handleVisibilityChange) + + return () => { + window.removeEventListener("pointerdown", handlePointerDown, true) + window.removeEventListener("pointermove", handlePointerMove, true) + // window.removeEventListener("wheel", handleWheel, true) + window.removeEventListener("keydown", handleKeyDown, true) + document.removeEventListener("visibilitychange", handleVisibilityChange) + clearInactivityTimer() + } + }, [clearInactivityTimer, isView, reportUserActivity]) // auto save useEffect(() => { @@ -2634,18 +2707,14 @@ const LabelPage = ({ style={{ overflow: "hidden" }}> diff --git a/components/label/components/CrosshairComponent.tsx b/components/label/components/CrosshairComponent.tsx index e569746..d859bfb 100644 --- a/components/label/components/CrosshairComponent.tsx +++ b/components/label/components/CrosshairComponent.tsx @@ -4,9 +4,13 @@ import React, { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, } from "react" +import { useBottomToolsStore } from "../useBottomToolsStore" +import { usePaperStore } from "../usePaperStore" import { useTopToolsStore } from "../useTopToolsStore" +import { getDisplayedImageScale } from "../utils/scale" interface CrosshairComponentProps { isCarved: boolean @@ -18,7 +22,17 @@ const CrosshairComponent = ( ) => { const { isCarved } = props - const { scale } = useTopToolsStore() + const scale = useTopToolsStore((state) => state.scale) + const activeImage = useBottomToolsStore((state) => state.activeImage) + const rasterScale = usePaperStore( + (state) => state.rasterScale[activeImage] ?? 1 + ) + const displayedScale = useMemo(() => { + return getDisplayedImageScale({ + rasterScale, + viewScale: scale, + }) + }, [rasterScale, scale]) const canvasRef = useRef(null) const lastPointerRef = useRef<{ x: number; y: number } | null>(null) @@ -82,9 +96,9 @@ const CrosshairComponent = ( ctx.lineWidth = carveLineWidth // 间隔 - const sparsity = getSparsity(scale) + const sparsity = getSparsity(displayedScale) const part = 10 - const pixelPerUnit = scale * sparsity + const pixelPerUnit = displayedScale * sparsity const gap = pixelPerUnit / part const fixed = getFixed(sparsity) @@ -136,7 +150,7 @@ const CrosshairComponent = ( } } }, - [isCarved, scale] + [displayedScale, isCarved] ) useEffect(() => { diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx index a3f4a9f..452740f 100644 --- a/components/label/components/PaperContainer.tsx +++ b/components/label/components/PaperContainer.tsx @@ -66,6 +66,10 @@ import { paperShapeToMultiPolygon, } from "../utils/geometry/booleanAdapter" import { adjustPoints } from "../utils/paperjs" +import { + getDisplayedImageScale, + getViewScaleFromDisplayedScale, +} from "../utils/scale" import AssistShapeComponent from "./AssistShapeComponent" import CrosshairComponent from "./CrosshairComponent" import { renderOperationIcon } from "./RightObjectTools" @@ -2923,6 +2927,80 @@ const PaperContainer = ( } }, [stopMiddleMousePan]) + const getPicRasters = useCallback((group: paper.Group) => { + return group + .getItems({ data: { name: "pic" } }) + .filter((item): item is paper.Raster => item instanceof paper.Raster) + }, []) + + const getCachedDisplayedScale = useCallback( + (group: paper.Group | null | undefined) => { + if (!group || !saveCurrentScale) return null + const persistedDisplayedScale = useTopToolsStore.getState().displayedScale + + const rasterItems = getPicRasters(group) + if (!rasterItems.length) { + return Number.isFinite(persistedDisplayedScale) && + persistedDisplayedScale > 0 + ? persistedDisplayedScale + : null + } + const currentRaster = rasterItems.length + ? rasterItems[rasterItems.length - 1] + : null + const imageId = + typeof currentRaster?.data?.imageId === "string" + ? currentRaster.data.imageId + : useBottomToolsStore.getState().activeImage + + if (!imageId) { + return Number.isFinite(persistedDisplayedScale) && + persistedDisplayedScale > 0 + ? persistedDisplayedScale + : null + } + + const viewScale = + Number.isFinite(group.scaling?.x) && group.scaling.x > 0 + ? group.scaling.x + : useTopToolsStore.getState().scale + + return getDisplayedImageScale({ + rasterScale: usePaperStore.getState().rasterScale[imageId], + viewScale, + }) + }, + [getPicRasters, saveCurrentScale] + ) + + const getTargetGroupScale = useCallback( + ({ + rasterScale, + cachedDisplayedScale, + fallbackScale, + }: { + rasterScale: number + cachedDisplayedScale?: number | null + fallbackScale?: number | null + }) => { + if ( + saveCurrentScale && + Number.isFinite(cachedDisplayedScale) && + Number(cachedDisplayedScale) > 0 + ) { + return getViewScaleFromDisplayedScale({ + displayedScale: cachedDisplayedScale, + rasterScale, + }) + } + + return Number.isFinite(fallbackScale) && Number(fallbackScale) > 0 + ? Number(fallbackScale) + : 1 + }, + [saveCurrentScale] + ) + const getTargetGroupPosition = useCallback( ({ defaultPosition, @@ -2958,6 +3036,7 @@ const PaperContainer = ( clearPendingFrameSwitch(token) return } + const cachedDisplayedScale = getCachedDisplayedScale(currentVisibleGroup) const preserveGroupPosition = saveCurrentScale && currentVisibleGroup.getItems({ data: { name: "pic" } }).length > 0 @@ -3034,6 +3113,11 @@ const PaperContainer = ( const scaleX = canvasWidth / raster.bounds.width const scaleY = canvasHeight / raster.bounds.height const scale = Math.min(scaleX, scaleY) + const targetGroupScale = getTargetGroupScale({ + rasterScale: scale, + cachedDisplayedScale, + fallbackScale: currentVisibleGroup.scaling.x, + }) if (!Number.isFinite(scale) || scale <= 0) { cleanupStagingGroup() @@ -3048,6 +3132,10 @@ const PaperContainer = ( raster.bounds.width / 2, raster.bounds.height / 2 ) + stagingGroup.scaling = new paper.Point( + targetGroupScale, + targetGroupScale + ) const centeredGroupPosition = new paper.Point( raster.bounds.width / 2, raster.bounds.height / 2 @@ -3074,6 +3162,7 @@ const PaperContainer = ( const previousGroup = usePaperStore.getState().group setGroup(stagingGroup) + useTopToolsStore.getState().setScale(targetGroupScale) setRasterPath(new paper.Path.Rectangle(raster.bounds)) renderedImageRef.current = imageName commitFrameSwitch(imageName, token) @@ -3101,6 +3190,8 @@ const PaperContainer = ( [ clearPendingFrameSwitch, commitFrameSwitch, + getCachedDisplayedScale, + getTargetGroupScale, getTargetGroupPosition, imgSize?.clientHeight, imgSize?.clientWidth, @@ -3166,12 +3257,6 @@ const PaperContainer = ( ) decodeFrameImageRef.current = decodeFrameImage - const getPicRasters = useCallback((group: paper.Group) => { - return group - .getItems({ data: { name: "pic" } }) - .filter((item): item is paper.Raster => item instanceof paper.Raster) - }, []) - const cancelRasterFadeTransition = useCallback(() => { rasterFadeTransitionIdRef.current += 1 if ( @@ -3194,6 +3279,7 @@ const PaperContainer = ( if (activeImage && projectDetail?.id) { const requestId = ++rasterLoadRequestIdRef.current const requestImage = activeImage + const cachedDisplayedScale = getCachedDisplayedScale(paperGroup) const preserveGroupPosition = saveCurrentScale && getPicRasters(paperGroup).length > 0 const preservedGroupPosition = preserveGroupPosition @@ -3275,6 +3361,11 @@ const PaperContainer = ( ]) let scale = Math.min(scaleX, scaleY) // Maintain aspect ratio + const targetGroupScale = getTargetGroupScale({ + rasterScale: scale, + cachedDisplayedScale, + fallbackScale: paperGroup.scaling.x, + }) if (!Number.isFinite(scale) || scale <= 0) { console.warn( "[PaperContainer] skip raster scale: invalid scale", @@ -3357,6 +3448,10 @@ const PaperContainer = ( raster.bounds.width / 2, raster.bounds.height / 2 ) + paperGroup.scaling = new paper.Point( + targetGroupScale, + targetGroupScale + ) const centeredGroupPosition = new paper.Point( raster.bounds.width / 2, raster.bounds.height / 2 @@ -3422,6 +3517,7 @@ const PaperContainer = ( setRasterPath(new paper.Path.Rectangle(raster.bounds)) raster.sendToBack() renderedImageRef.current = requestImage + useTopToolsStore.getState().setScale(targetGroupScale) setRasterInited(true) usePaperStore.getState().group?.bringToFront() } @@ -3439,7 +3535,9 @@ const PaperContainer = ( activeImage, cancelRasterFadeTransition, decodeFrameImage, + getCachedDisplayedScale, getPicRasters, + getTargetGroupScale, getTargetGroupPosition, imgSize?.clientHeight, imgSize?.clientWidth, diff --git a/components/label/components/RepairScaleModal.tsx b/components/label/components/RepairScaleModal.tsx new file mode 100644 index 0000000..5a08669 --- /dev/null +++ b/components/label/components/RepairScaleModal.tsx @@ -0,0 +1,99 @@ +"use client" + +import { Button, Group, NumberInput, Stack, Text } from "@mantine/core" +import { useEffect, useMemo, useState } from "react" +import CustomModal from "./CustomModal" + +interface ComponentProps { + open: boolean + defaultFactor: number + selectedObjectCount: number + canRepairImage: boolean + handleCancel: () => void + handleRepairObject: (factor: number) => void + handleRepairImage: (factor: number) => void +} + +const RepairScaleModal = ({ + open, + defaultFactor, + selectedObjectCount, + canRepairImage, + handleCancel, + handleRepairObject, + handleRepairImage, +}: ComponentProps) => { + const [factorInput, setFactorInput] = useState(1) + + useEffect(() => { + if (!open) return + const nextFactor = + Number.isFinite(defaultFactor) && defaultFactor > 0 + ? Number(defaultFactor.toFixed(6)) + : 1 + setFactorInput(nextFactor) + }, [defaultFactor, open]) + + const normalizedFactor = useMemo(() => { + if (typeof factorInput === "number") return factorInput + return Number(factorInput) + }, [factorInput]) + + const factorError = + Number.isFinite(normalizedFactor) && normalizedFactor > 0 + ? null + : "请输入大于 0 的修复因子" + + return ( + + + + + + }> + + + 该工具会将当前页面里的标注几何点统一除以修复因子,默认值取当前图片比例 + `rasterScale`。修复后请先检查显示效果,再决定是否保存。 + + + + 当前已选对象数:{selectedObjectCount}。修复当前对象需要恰好选中 1 + 个对象。 + + + 当前图片{canRepairImage ? "已有" : "暂无"}可修复标注数据。 + + + + ) +} + +export default RepairScaleModal diff --git a/components/label/components/ScaleComponent.tsx b/components/label/components/ScaleComponent.tsx index 8e63a62..425a1f3 100644 --- a/components/label/components/ScaleComponent.tsx +++ b/components/label/components/ScaleComponent.tsx @@ -4,8 +4,6 @@ import React, { useCallback, useEffect, useRef } from "react" interface ScaleComponentProps { options: { - offsetX: number - offsetY: number scale: number } mode: "horizontal" | "vertical" @@ -14,6 +12,7 @@ interface ScaleComponentProps { const ScaleComponent: React.FC = (props) => { const { options, mode } = props const canvasRef = useRef(null) + const FLOAT_EPSILON = 0.0000001 const getFixed = (sparsity: number) => { const pointIdx = String(sparsity).indexOf(".") @@ -22,8 +21,13 @@ const ScaleComponent: React.FC = (props) => { } const isCloseToInteger = (num: number) => { - return Math.abs(num - Math.round(num)) < 0.0000001 + return Math.abs(num - Math.round(num)) < FLOAT_EPSILON } + + const normalizeScaleValue = (num: number) => { + return Math.abs(num) < FLOAT_EPSILON ? 0 : num + } + const getSparsity = (scale: number) => { if (scale <= 1) { return Math.round(100 / scale) @@ -63,20 +67,30 @@ const ScaleComponent: React.FC = (props) => { ctx.font = "12px serif" ctx.beginPath() - const { offsetX, offsetY, scale } = options - const offset = mode === "horizontal" ? offsetX : offsetY + const { scale } = options + if (!Number.isFinite(scale) || scale <= 0) { + ctx.restore() + return + } // 间隔 const sparsity = getSparsity(scale) // 间隔内有多少条线 const part = 10 const pixelPerUnit = scale * sparsity const gap = pixelPerUnit / part + const unitGap = sparsity / part const fixed = getFixed(sparsity) - let index = offset % gap > 0 ? gap - (offset % gap) : -offset % gap + if (mode === "horizontal") { ctx.translate(31.5, 0) - do { - const num = ((offset + index) / pixelPerUnit) * sparsity + const maxWidth = Math.max(0, w - 32) + for ( + let tickIndex = 0; + tickIndex * gap <= maxWidth + FLOAT_EPSILON; + tickIndex += 1 + ) { + const index = tickIndex * gap + const num = normalizeScaleValue(tickIndex * unitGap) if (isCloseToInteger(num / sparsity)) { ctx.moveTo(index, h * 0.5) ctx.lineTo(index, h) @@ -87,12 +101,16 @@ const ScaleComponent: React.FC = (props) => { ctx.moveTo(index, h * 0.7) ctx.lineTo(index, h) } - index += gap - } while (index < w) + } } else { ctx.translate(0, 0.5) - do { - const num = ((offset + index) / pixelPerUnit) * sparsity + for ( + let tickIndex = 0; + tickIndex * gap <= h + FLOAT_EPSILON; + tickIndex += 1 + ) { + const index = tickIndex * gap + const num = normalizeScaleValue(tickIndex * unitGap) if (isCloseToInteger(num / sparsity)) { ctx.moveTo(w * 0.5, index) ctx.lineTo(w, index) @@ -110,8 +128,7 @@ const ScaleComponent: React.FC = (props) => { ctx.moveTo(w * 0.7, index) ctx.lineTo(w, index) } - index += gap - } while (index < h) + } } ctx.closePath() ctx.stroke() @@ -129,7 +146,7 @@ const ScaleComponent: React.FC = (props) => { if (mode === "horizontal") { return { ...baseStyles, - width: "100vw", + width: "100%", height: "32px", cursor: "col-resize", } diff --git a/components/label/components/ScaleToolContainer.tsx b/components/label/components/ScaleToolContainer.tsx index 780d3d0..d6873b6 100644 --- a/components/label/components/ScaleToolContainer.tsx +++ b/components/label/components/ScaleToolContainer.tsx @@ -1,25 +1,75 @@ import { ActionIcon, Flex, NumberInput, Stack, Text } from "@mantine/core" import { IconMinus, IconPlus, IconRefresh } from "@tabler/icons-react" import paper from "paper" -import { useMemo } from "react" +import { useEffect, useMemo, useRef } from "react" import { useKeyEventStore } from "../store" +import { useBottomToolsStore } from "../useBottomToolsStore" import { usePaperStore } from "../usePaperStore" import { useTopToolsStore } from "../useTopToolsStore" +import { + getDisplayedImageScale, + getViewScaleFromDisplayedScale, +} from "../utils/scale" const ScaleToolContainer = () => { - const { scale, setScale } = useTopToolsStore() + const { + scale, + setScale, + displayedScale: savedDisplayedScale, + setDisplayedScale, + } = useTopToolsStore() const { setFocusInput } = useKeyEventStore() + const activeImage = useBottomToolsStore((state) => state.activeImage) + const rasterScale = usePaperStore((state) => state.rasterScale[activeImage]) + const rasterSize = usePaperStore((state) => state.rasterSize[activeImage]) + const paperScope = usePaperStore((state) => state.paperScope) + const group = usePaperStore((state) => state.group) + const lastDisplayedScaleRef = useRef( + Number.isFinite(savedDisplayedScale) && savedDisplayedScale > 0 + ? savedDisplayedScale + : 1 + ) + + const displayedScale = useMemo(() => { + const hasValidRasterScale = + Number.isFinite(rasterScale) && Number(rasterScale) > 0 + + if (!activeImage || !hasValidRasterScale) { + if (group) return lastDisplayedScaleRef.current + return Number.isFinite(savedDisplayedScale) && savedDisplayedScale > 0 + ? savedDisplayedScale + : 1 + } + + return getDisplayedImageScale({ + rasterScale, + viewScale: scale, + }) + }, [activeImage, group, rasterScale, savedDisplayedScale, scale]) + + useEffect(() => { + if (!activeImage) return + if (!Number.isFinite(displayedScale) || displayedScale <= 0) return + lastDisplayedScaleRef.current = displayedScale + if (Math.abs(displayedScale - savedDisplayedScale) > 0.000001) { + setDisplayedScale(displayedScale) + } + }, [activeImage, displayedScale, savedDisplayedScale, setDisplayedScale]) const displayNumber = useMemo(() => { - return Number((scale * 100).toFixed(0)) - }, [scale]) + return Number((displayedScale * 100).toFixed(0)) + }, [displayedScale]) const setCurrentScale = (n: number) => { - const currentScale = n / 100 + const currentScale = getViewScaleFromDisplayedScale({ + displayedScale: n / 100, + rasterScale, + }) const group = usePaperStore.getState().group if (!group) return group.scaling = new paper.Point(currentScale, currentScale) setScale(currentScale) + setDisplayedScale(n / 100) } const handleReset = () => { @@ -31,9 +81,37 @@ const ScaleToolContainer = () => { }, }) if (!rasterList || !rasterList.length) return - const currentRaster = rasterList[0] - group.position = currentRaster.position - setCurrentScale(100) + const currentRaster = rasterList.find( + (item): item is paper.Raster => item instanceof paper.Raster + ) + if (!currentRaster) return + const rawWidth = rasterSize?.[0] ?? currentRaster.width + const rawHeight = rasterSize?.[1] ?? currentRaster.height + const canvasWidth = paperScope?.view.bounds.width + const canvasHeight = paperScope?.view.bounds.height + const fitDisplayedScale = + rawWidth && + rawHeight && + canvasWidth && + canvasHeight && + Number.isFinite(canvasWidth) && + Number.isFinite(canvasHeight) + ? Math.min(canvasWidth / rawWidth, canvasHeight / rawHeight) + : getDisplayedImageScale({ + rasterScale, + viewScale: 1, + }) + const targetViewScale = getViewScaleFromDisplayedScale({ + displayedScale: fitDisplayedScale, + rasterScale, + }) + group.scaling = new paper.Point(targetViewScale, targetViewScale) + group.position = new paper.Point( + (rawWidth * fitDisplayedScale) / 2, + (rawHeight * fitDisplayedScale) / 2 + ) + setScale(targetViewScale) + setDisplayedScale(fitDisplayedScale) } const handleEnter = (e: any) => { diff --git a/components/label/components/TopTools.tsx b/components/label/components/TopTools.tsx index a3f4ae9..52258ab 100644 --- a/components/label/components/TopTools.tsx +++ b/components/label/components/TopTools.tsx @@ -59,6 +59,7 @@ import { SquarePen, Tag, Timer, + Wrench, } from "lucide-react" import { useRouter } from "next/navigation" import { @@ -102,12 +103,13 @@ import { import { buildSubAttributeFormValues, getSubAttrStatus } from "../util" import { safeClone } from "../utils/clone" import { labelTypeMap, TaskStatusEnum } from "../utils/constants" -import { adjustAllPoints } from "../utils/paperjs" +import { adjustAllPoints, adjustPoints } from "../utils/paperjs" import BackConfirmModal from "./BackConfirmModal" import BackupModal from "./BackupModal" import ConfirmModal from "./ConfirmModal" import { splitWord } from "./EditorContainer" import RecoverModal from "./RecoverModal" +import RepairScaleModal from "./RepairScaleModal" import { renderOperationIcon } from "./RightObjectTools" import TaskCacheModal from "./TaskCacheModal" import TaskStatusTag from "./TaskStatusTag" @@ -143,6 +145,8 @@ const initialWorkLoad: WorkLoad = { question_size: {}, } +const EMPTY_SELECTED_OBJECT_IDS: number[] = [] + const TaskTimerDisplay = ({ currentTimeKey, }: { @@ -179,6 +183,7 @@ const TopTools = ( const [duplicateConfirmOpen, setDuplicateConfirmOpen] = useState(false) const [duplicateName, setDuplicateName] = useState("") const [recoverOpen, setRecoverOpen] = useState(false) + const [repairOpen, setRepairOpen] = useState(false) const [taskOpen, setTaskOpen] = useState(false) const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false) const [operationPickerOpened, setOperationPickerOpened] = useState(false) @@ -191,6 +196,12 @@ const TopTools = ( const pushStateStack = useLabelStore((state) => state.pushStateStack) const setLabelTime = useLabelTimeStore((state) => state.setLabelTime) const { activeImage } = useBottomToolsStore() + const selectedPathMap = useObjectStore((state) => state.selectedPath) + const currentRasterScale = usePaperStore( + (state) => state.rasterScale[activeImage] ?? 1 + ) + const currentSelectedObjectIds = + selectedPathMap[activeImage] ?? EMPTY_SELECTED_OBJECT_IDS const { isView, setIsView, @@ -249,6 +260,15 @@ const TopTools = ( useState(auxiliarySizeThreshold) const { pathGroupMap } = useRightToolsStore() + const activeImageLabelData = useMemo( + () => labelData.get(activeImage), + [activeImage, labelData] + ) + const repairFactorDefault = useMemo(() => { + return Number.isFinite(currentRasterScale) && currentRasterScale > 0 + ? Number(currentRasterScale.toFixed(6)) + : 1 + }, [currentRasterScale]) const isAutoSave = useIntervalStore((state) => state.isAutoSave) const setIsAutoSave = useIntervalStore((state) => state.setIsAutoSave) const autoSaveGap = useIntervalStore((state) => state.autoSaveGap) @@ -953,6 +973,36 @@ const TopTools = ( loadingData, ]) + const leaveCurrentPage = useCallback((url: string) => { + // `beforeunload` may still be cancelled by the browser/user, so do not + // mutate persisted label state after requesting a full-page navigation. + window.location.href = url + }, []) + + const rerenderActiveImageAnnotations = useCallback( + ( + nextLabelData: Map> + ) => { + if (!renderPolygons || !activeImage) return + + usePaperStore + .getState() + .group?.getItems({}) + .filter((item: any) => item.data?.id) + .forEach((item: any) => { + item.remove() + }) + + const currentImageMap = nextLabelData.get(activeImage) + if (!currentImageMap) return + + for (const key of currentImageMap.keys()) { + renderPolygons(key, currentImageMap) + } + }, + [activeImage, renderPolygons] + ) + const handleBackup = useCallback( async (name: string) => { let newTaskLabelData = new Map() @@ -1143,6 +1193,102 @@ const TopTools = ( ] ) + const handleRepairByFactor = useCallback( + (scope: "object" | "image", factor: number) => { + if (!activeImage) { + notifications.show({ + color: "yellow", + message: "当前图片未准备完成,暂时无法修复", + }) + return + } + + const normalizedFactor = Number(factor) + if (!Number.isFinite(normalizedFactor) || normalizedFactor <= 0) { + notifications.show({ + color: "yellow", + message: "请输入大于 0 的修复因子", + }) + return + } + + const currentImageData = labelData.get(activeImage) + if (!currentImageData?.size) { + notifications.show({ + color: "yellow", + message: "当前图片暂无可修复标注数据", + }) + return + } + + const scaleRatio = 1 / normalizedFactor + let nextLabelData = adjustPoints(activeImage, labelData, scaleRatio) + + if (scope === "object") { + const selectedIds = + useObjectStore.getState().selectedPath[activeImage] || [] + if (selectedIds.length !== 1) { + notifications.show({ + color: "yellow", + message: "请先选中 1 个标注对象后再修复", + }) + return + } + + const scaledImageData = nextLabelData.get(activeImage) + if (!scaledImageData) { + notifications.show({ + color: "yellow", + message: "当前对象修复失败,请重试", + }) + return + } + + const selectedSet = new Set(selectedIds) + const mergedImageData = new Map() + + currentImageData.forEach((objects, operationId) => { + const scaledObjects = scaledImageData.get(operationId) || objects + const scaledObjectById = new Map( + scaledObjects.map((item) => [item[0], item] as const) + ) + + mergedImageData.set( + operationId, + objects.map((item) => { + if (!selectedSet.has(item[0])) return item + return scaledObjectById.get(item[0]) || item + }) as [number, any[], any, any[]][] + ) + }) + + nextLabelData = safeClone(labelData) + nextLabelData.set(activeImage, mergedImageData) + } + + const finalData = safeClone(nextLabelData) + setLabel(finalData) + pushStateStack(finalData) + rerenderActiveImageAnnotations(finalData) + setRepairOpen(false) + + notifications.show({ + color: "green", + message: + scope === "object" + ? "已按修复因子更新当前对象,请检查后再保存" + : "已按修复因子更新当前图片,请检查后再保存", + }) + }, + [ + activeImage, + labelData, + pushStateStack, + rerenderActiveImageAnnotations, + setLabel, + ] + ) + useEffect(() => { if (needBackup) { handleBackup("auto_backup") @@ -1190,10 +1336,7 @@ const TopTools = ( // 无id返回时跳转回任务列表页 const url = backUrl ? basePath + backUrl : "/" // router.push(url) - window.location.href = url - // 重置图片比例及选中标注对象 - usePaperStore.getState().resetRasterScale() - useObjectStore.getState().resetPathAndOperationStatus() + leaveCurrentPage(url) } } @@ -1851,11 +1994,8 @@ const TopTools = ( if (isView) { const url = backUrl ? basePath + backUrl : "/" // router.push(url) - window.location.href = url + leaveCurrentPage(url) // handleBackup("auto_backup"); - // 重置图片比例及选中标注对象 - usePaperStore.getState().resetRasterScale() - useObjectStore.getState().resetPathAndOperationStatus() } else { setConfirmOpen(true) } @@ -2541,6 +2681,21 @@ const TopTools = ( 恢复 + { + setRepairOpen(true) + }}> + + 修复 + ) : ( <> @@ -2568,6 +2723,19 @@ const TopTools = ( 恢复 + + + 修复 + )} @@ -2658,21 +2826,15 @@ const TopTools = ( setConfirmOpen(false) const url = backUrl ? basePath + backUrl : "/" // router.push(url) - window.location.href = url - // 重置图片比例及选中标注对象 - usePaperStore.getState().resetRasterScale() - useObjectStore.getState().resetPathAndOperationStatus() + leaveCurrentPage(url) }} handleCancel={async () => { try { setConfirmOpen(false) - handleBackup("auto_backup") + await handleBackup("auto_backup") const url = backUrl ? basePath + backUrl : "/" // router.push(url) - window.location.href = url - // 重置图片比例及选中标注对象 - usePaperStore.getState().resetRasterScale() - useObjectStore.getState().resetPathAndOperationStatus() + leaveCurrentPage(url) } catch (error) { console.log(error) } @@ -2775,6 +2937,23 @@ const TopTools = ( handleOk={handleRecover} /> )} + {repairOpen && ( + { + setRepairOpen(false) + }} + handleRepairObject={(factor) => { + handleRepairByFactor("object", factor) + }} + handleRepairImage={(factor) => { + handleRepairByFactor("image", factor) + }} + /> + )} {taskOpen && ( void scale: number setScale: (val: number) => void + displayedScale: number + setDisplayedScale: (val: number) => void activeOperation: string setActiveOperation: (val: string) => void showObjectList: boolean @@ -114,6 +116,7 @@ const initialTopToolsState = { drawOption: "default" as "default" | "intersect" | "unite", saveCurrentScale: false, scale: 1, + displayedScale: 1, activeOperation: "", showObjectList: false, showGroupList: false, @@ -248,6 +251,12 @@ export const useTopToolsStore = create()( ...state, scale: val, })), + displayedScale: initialTopToolsState.displayedScale, + setDisplayedScale: (val: number) => + set((state: TopToolsState) => ({ + ...state, + displayedScale: Number.isFinite(val) && val > 0 ? val : 1, + })), activeOperation: initialTopToolsState.activeOperation, setActiveOperation: (val: string) => set((state: TopToolsState) => ({ diff --git a/components/label/utils/scale.ts b/components/label/utils/scale.ts new file mode 100644 index 0000000..28dc11d --- /dev/null +++ b/components/label/utils/scale.ts @@ -0,0 +1,23 @@ +export const normalizeScale = (value: number | null | undefined) => { + return Number.isFinite(value) && Number(value) > 0 ? Number(value) : 1 +} + +export const getDisplayedImageScale = ({ + rasterScale, + viewScale, +}: { + rasterScale?: number | null + viewScale?: number | null +}) => { + return normalizeScale(rasterScale) * normalizeScale(viewScale) +} + +export const getViewScaleFromDisplayedScale = ({ + displayedScale, + rasterScale, +}: { + displayedScale?: number | null + rasterScale?: number | null +}) => { + return normalizeScale(displayedScale) / normalizeScale(rasterScale) +}