From 1d5e75ab7cf51aaa244d55771dc687bf417493cf Mon Sep 17 00:00:00 2001 From: zhangheng Date: Wed, 8 Apr 2026 15:14:17 +0800 Subject: [PATCH] fix(righttool): fix --- components/label/LabelNossr.tsx | 158 +- components/label/components/BottomTools.tsx | 51 +- .../label/components/PaperContainer.tsx | 1575 +++++++++++++++-- components/label/components/TopTools.tsx | 36 +- components/label/config/performance.ts | 56 + components/label/useBottomToolsStore.ts | 53 +- components/label/usePaperStore.ts | 16 + 7 files changed, 1755 insertions(+), 190 deletions(-) create mode 100644 components/label/config/performance.ts diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx index d94fd83..356e14a 100644 --- a/components/label/LabelNossr.tsx +++ b/components/label/LabelNossr.tsx @@ -80,6 +80,9 @@ const initialState: LabelState = new Map() const operationTypeList = [101, 102, 103, 104] let latestProjectDetailRequestSeq = 0 let latestTaskDetailRequestSeq = 0 +const RIGHT_PANEL_TRANSITION = + "width 160ms ease, min-width 160ms ease, opacity 160ms ease" +const RIGHT_PANEL_WILL_CHANGE = "width, min-width, opacity" // utils const getRectPoints = ([sx, sy, w, h]: number[]) => { @@ -95,6 +98,19 @@ const getSegmentPoints = (arr: Array) => { return arr.map((item) => [item[0], item[1]]) } +const getRightPanelViewportStyle = (width: number) => { + const stableWidth = Math.max(0, Math.round(width)) + return { + position: "absolute" as const, + top: 0, + left: 0, + width: stableWidth, + minWidth: stableWidth, + height: "100%", + contain: "layout paint", + } +} + const videoExtPattern = /\.(mp4|mov|avi|mkv|webm|h264|264|ts|m4v)$/i const GLOBAL_LOADING_Z_INDEX = 900 const videoMimeByExt: Record = { @@ -2551,17 +2567,16 @@ const LabelPage = ({ } }, []) - const mainBoxHeight = useMemo(() => { + const bottomToolsHeight = useMemo(() => { if (projectDetail?.label_type === 5 && metaOperation) { - let h = `calc(100% - ${headerHeight}px - 198px)` - return h - } else if (showMultiFrame) { - let h = `calc(100% - ${headerHeight}px - 150px)` - return h + return 128 } - let h = `calc(100% - ${headerHeight}px - 70px)` - return h - }, [projectDetail?.label_type, metaOperation, showMultiFrame, headerHeight]) + return 80 + }, [metaOperation, projectDetail?.label_type]) + + const mainBoxHeight = useMemo(() => { + return `calc(100% - ${headerHeight}px - ${70 + bottomToolsHeight}px)` + }, [bottomToolsHeight, headerHeight]) const selectedTagPreviewList = useMemo(() => { if (!showTags) return [] @@ -2741,48 +2756,139 @@ const LabelPage = ({ - {showTaskList && ( - + + - )} - {showGroupList && ( - + + + - )} - {showDescList && projectDetail && projectDetail.label_type === 5 && ( - + + + - )} - {showDescList && projectDetail && projectDetail.label_type === 6 && ( - + + + - )} - {showObjectList && ( - + + + - )} + {/* */} + h={bottomToolsHeight} + style={{ + overflow: "hidden", + opacity: showMultiFrame ? 1 : 0, + pointerEvents: showMultiFrame ? "auto" : "none", + transition: "opacity 160ms ease", + }}> = (props) => { inputNumber, setInputNumber, activeImage, + pendingImage, + frameSwitchStatus, setActiveImage, setActiveImageId, + requestFrameSwitch, onlyKeyFrame, setOnlyKeyFrame, keyFrameData, @@ -168,24 +171,32 @@ const BottomTools: React.FC = (props) => { if (index < 0 || index >= currentKeys.length) return false const nextImage = currentKeys[index] - const absoluteIndex = allKeys.findIndex((k) => k === nextImage) + if (!nextImage) return false scrollToIndex(index) - setInputNumber(String(index + 1)) - setActiveImageId(absoluteIndex >= 0 ? absoluteIndex + 1 : 0) - setActiveImage(nextImage) + if ( + frameSwitchStatus === "preparing" && + pendingImage && + pendingImage === nextImage + ) { + closeRightContext() + return true + } + if (nextImage !== activeImage) { + requestFrameSwitch(nextImage) + } closeRightContext() return true }, [ - allKeys, + activeImage, closeRightContext, currentKeys, + frameSwitchStatus, + pendingImage, + requestFrameSwitch, scrollToIndex, - setActiveImage, - setActiveImageId, - setInputNumber, ] ) @@ -569,9 +580,14 @@ const BottomTools: React.FC = (props) => { radius={"sm"} onClick={() => { let flag = false - keyFrameData.entries().forEach(([key, value]) => { - if (labelState.has(key) && value?.key_frame) flag = true - }) + keyFrameData + .entries() + .forEach( + ([key, value]: [string, { key_frame?: boolean }]) => { + if (labelState.has(key) && value?.key_frame) + flag = true + } + ) if (!flag) { showNotification({ title: "警告", @@ -711,6 +727,10 @@ const BottomTools: React.FC = (props) => { {currentKeys.map((key, index) => { const isActive = activeImage === key + const isPending = + frameSwitchStatus === "preparing" && + pendingImage === key && + !isActive const isSelected = selectedItems.length && selectedItems.includes(key) const isHighlight = isActive || isSelected @@ -735,12 +755,17 @@ const BottomTools: React.FC = (props) => { style={{ border: isHighlight ? "1px solid var(--mantine-color-blue-6)" - : "1px solid var(--mantine-color-gray-3)", + : isPending + ? "1px dashed var(--mantine-color-blue-4)" + : "1px solid var(--mantine-color-gray-3)", borderRadius: "6px", backgroundColor: isHighlight ? "var(--mantine-color-blue-6)" - : undefined, + : isPending + ? "rgba(34, 139, 230, 0.08)" + : undefined, color: isHighlight ? "white" : undefined, + opacity: isPending ? 0.85 : 1, cursor: "pointer", position: "relative", display: "flex", diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx index 1dcb00f..4143de7 100644 --- a/components/label/components/PaperContainer.tsx +++ b/components/label/components/PaperContainer.tsx @@ -56,6 +56,7 @@ import useRenderTag from "../useRenderTag" import { useRightToolsStore } from "../useRightToolsStore" import { useTopToolsStore } from "../useTopToolsStore" import { checkCommentsIsSame } from "../util" +import { labelimagePerformanceConfig } from "../config/performance" import { safeClone } from "../utils/clone" import { labelTypeMap } from "../utils/constants" import { adjustPoints } from "../utils/paperjs" @@ -96,6 +97,8 @@ const renderRaster = (paperGroup: paper.Group, option: any) => { } type NormalizedPoint = [number, number] +type LabelPathItem = [number, any[], any, any[]] +type ImageLabelData = Map type SupportBox = [number, number, number, number] type ContextMenuLayout = { top: number @@ -104,6 +107,24 @@ type ContextMenuLayout = { maxWidth: number } +type ResizeGhostState = { + id: number + src: string + fromWidth: number + fromHeight: number + scale: number + fadeOnly: boolean + animating: boolean +} + +type FrameSwitchGhostState = { + id: number + src: string + width: number + height: number + animating: boolean +} + const normalizePoint = (value: any): NormalizedPoint | null => { if (value instanceof paper.Point) return [value.x, value.y] if (value instanceof paper.Segment) { @@ -136,6 +157,23 @@ const normalizeContour = (contour: any[] = []): NormalizedPoint[] => { .filter((point): point is NormalizedPoint => point !== null) } +const FRAME_PREFETCH_RADIUS = 2 +const FRAME_CACHE_KEEP_RADIUS = 6 +const POLYGON_RENDER_BATCH_SIZE = + labelimagePerformanceConfig.polygonRenderBatchSize +const POLYGON_RENDER_BATCH_BUDGET_MS = + labelimagePerformanceConfig.polygonRenderBatchBudgetMs +const FRAME_SWITCH_LOADING_DELAY_MS = + labelimagePerformanceConfig.frameSwitchLoadingDelayMs +const RASTER_CROSSFADE_DURATION_MS = + labelimagePerformanceConfig.rasterCrossfadeDurationMs +const RESIZE_COMMIT_DEBOUNCE_MS = + labelimagePerformanceConfig.resizeCommitDebounceMs +const RESIZE_INTERPOLATION_DURATION_MS = + labelimagePerformanceConfig.resizeInterpolationDurationMs +const RESIZE_GHOST_MIN_SCALE_DELTA = 0.001 +const RESIZE_GHOST_FADE_ONLY_SCALE_DELTA = 0.03 + const PaperContainer = ( props: PaperComponentProps, ref: React.Ref | undefined @@ -147,6 +185,9 @@ const PaperContainer = ( clientWidth: number clientHeight: number }>() + const [resizeGhost, setResizeGhost] = useState(null) + const [frameSwitchGhost, setFrameSwitchGhost] = + useState(null) const [contextMenuLayout, setContextMenuLayout] = useState( { top: 0, @@ -165,14 +206,153 @@ const PaperContainer = ( // 监听画布容器宽高变化 useEffect(() => { const container = containerRef.current + const scheduleCommitImageSize = ( + size: { clientWidth: number; clientHeight: number }, + immediate = false + ) => { + const commit = () => { + const lastSize = lastContainerSizeRef.current + if ( + lastSize?.clientWidth === size.clientWidth && + lastSize?.clientHeight === size.clientHeight + ) { + return + } + + if ( + RESIZE_INTERPOLATION_DURATION_MS > 0 && + firstRender && + lastSize?.clientWidth && + lastSize?.clientHeight + ) { + const forcedGhost = forcedResizeGhostSnapshotRef.current + let snapshotSrc = "" + if (forcedGhost?.src) { + snapshotSrc = forcedGhost.src + } else { + try { + snapshotSrc = canvasElem.current?.toDataURL("image/png") || "" + } catch (error) { + void error + } + } + + const resizeImageId = useBottomToolsStore.getState().activeImage + const rawRasterSize = + usePaperStore.getState().rasterSize[resizeImageId] || null + const currentScale = + labelRenderScaleRef.current[resizeImageId] ?? + usePaperStore.getState().rasterScale[resizeImageId] + const nextScale = + rawRasterSize?.[0] && rawRasterSize?.[1] + ? Math.min( + size.clientWidth / rawRasterSize[0], + size.clientHeight / rawRasterSize[1] + ) + : null + const scale = + currentScale && + Number.isFinite(currentScale) && + nextScale && + Number.isFinite(nextScale) && + currentScale > 0 + ? nextScale / currentScale + : null + const scaleDelta = + typeof scale === "number" && Number.isFinite(scale) + ? Math.abs(scale - 1) + : null + const fallbackScale = + forcedGhost?.fromWidth && size.clientWidth + ? size.clientWidth / forcedGhost.fromWidth + : 1 + + if (snapshotSrc && forcedGhost) { + pendingResizeGhostRef.current = { + id: resizeGhostIdRef.current + 1, + src: snapshotSrc, + fromWidth: forcedGhost.fromWidth, + fromHeight: forcedGhost.fromHeight, + scale: + typeof scale === "number" && Number.isFinite(scale) + ? scale + : fallbackScale, + fadeOnly: false, + } + resizeGhostIdRef.current += 1 + forcedResizeGhostSnapshotRef.current = null + } else if ( + snapshotSrc && + typeof scale === "number" && + Number.isFinite(scale) && + typeof scaleDelta === "number" && + scaleDelta > RESIZE_GHOST_MIN_SCALE_DELTA + ) { + pendingResizeGhostRef.current = { + id: resizeGhostIdRef.current + 1, + src: snapshotSrc, + fromWidth: lastSize.clientWidth, + fromHeight: lastSize.clientHeight, + scale, + fadeOnly: scaleDelta < RESIZE_GHOST_FADE_ONLY_SCALE_DELTA, + } + resizeGhostIdRef.current += 1 + } else { + pendingResizeGhostRef.current = null + } + } else { + pendingResizeGhostRef.current = null + } + + lastContainerSizeRef.current = size + setImageSize(size) + } + + if (immediate || RESIZE_COMMIT_DEBOUNCE_MS <= 0) { + if (containerResizeCommitTimerRef.current !== null) { + clearTimeout(containerResizeCommitTimerRef.current) + containerResizeCommitTimerRef.current = null + } + commit() + return + } + + if (containerResizeCommitTimerRef.current !== null) { + clearTimeout(containerResizeCommitTimerRef.current) + } + containerResizeCommitTimerRef.current = window.setTimeout(() => { + containerResizeCommitTimerRef.current = null + commit() + }, RESIZE_COMMIT_DEBOUNCE_MS) + } + const resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { if (entry.target === container) { - setImageSize({ - clientWidth: entry.contentRect.width, - clientHeight: entry.contentRect.height, - }) - if (!firstRender) setFirstRender(true) + const nextSize = { + clientWidth: Math.max(0, Math.round(entry.contentRect.width)), + clientHeight: Math.max(0, Math.round(entry.contentRect.height)), + } + if (!nextSize.clientWidth || !nextSize.clientHeight) continue + if (!firstRender) { + scheduleCommitImageSize(nextSize, true) + setFirstRender(true) + continue + } + pendingContainerSizeRef.current = nextSize + if (typeof globalThis.requestAnimationFrame !== "function") { + scheduleCommitImageSize(nextSize) + } else if (containerResizeRafIdRef.current === null) { + containerResizeRafIdRef.current = globalThis.requestAnimationFrame( + () => { + containerResizeRafIdRef.current = null + const nextPendingSize = pendingContainerSizeRef.current + if (!nextPendingSize) return + pendingContainerSizeRef.current = null + scheduleCommitImageSize(nextPendingSize) + } + ) + } } } }) @@ -181,6 +361,30 @@ const PaperContainer = ( } return () => { + if ( + containerResizeRafIdRef.current !== null && + typeof globalThis.cancelAnimationFrame === "function" + ) { + globalThis.cancelAnimationFrame(containerResizeRafIdRef.current) + } + if (containerResizeCommitTimerRef.current !== null) { + clearTimeout(containerResizeCommitTimerRef.current) + } + if ( + resizeGhostRafRef.current !== null && + typeof globalThis.cancelAnimationFrame === "function" + ) { + globalThis.cancelAnimationFrame(resizeGhostRafRef.current) + } + if (resizeGhostTimerRef.current !== null) { + clearTimeout(resizeGhostTimerRef.current) + } + containerResizeRafIdRef.current = null + containerResizeCommitTimerRef.current = null + resizeGhostRafRef.current = null + resizeGhostTimerRef.current = null + pendingContainerSizeRef.current = null + pendingResizeGhostRef.current = null if (container) { resizeObserver.unobserve(container) } @@ -204,15 +408,29 @@ const PaperContainer = ( addPoint, addCompoundPath, addRectangle, + setGroup, setRasterPath, setRasterSize, setRasterScale, loadingData, + resizeGhostRequestToken, } = usePaperStore() - const { activeImage } = useBottomToolsStore() + const { + activeImage, + pendingImage, + frameSwitchStatus, + frameSwitchToken, + allItems, + commitFrameSwitch, + clearPendingFrameSwitch, + } = useBottomToolsStore() const storeLabel = useLabelStore((state) => state.label) + const activeImageLabel = useMemo( + () => storeLabel.get(activeImage), + [activeImage, storeLabel] + ) const deleteOperation = useLabelStore((state) => state.deleteOperation) const setOperation = useLabelStore((state) => state.setOperation) const getLabelDetailDataByKeys = useLabelStore( @@ -248,6 +466,51 @@ const PaperContainer = ( const [setup, setSetup] = useState(false) const labelRenderScaleRef = useRef>({}) const renderedImageRef = useRef("") + const frameImageCacheRef = useRef>(new Map()) + const frameDecodeTaskRef = useRef< + Map> + >(new Map()) + const decodeFrameImageRef = useRef< + (imageName: string) => Promise + >(async () => null) + const polygonRenderJobIdRef = useRef(0) + const polygonRenderRunningRef = useRef(false) + const containerResizeRafIdRef = useRef(null) + const containerResizeCommitTimerRef = useRef(null) + const pendingContainerSizeRef = useRef<{ + clientWidth: number + clientHeight: number + } | null>(null) + const lastContainerSizeRef = useRef<{ + clientWidth: number + clientHeight: number + } | null>(null) + const pendingResizeGhostRef = useRef | null>(null) + const resizeGhostRafRef = useRef(null) + const resizeGhostTimerRef = useRef(null) + const resizeGhostIdRef = useRef(0) + const forcedResizeGhostSnapshotRef = useRef<{ + token: number + src: string + fromWidth: number + fromHeight: number + } | null>(null) + const rasterLoadingImageRef = useRef(null) + const prevActiveImageRef = useRef("") + const rasterFadeTransitionIdRef = useRef(0) + const rasterFadeRafIdRef = useRef(null) + const frameSwitchNoticeTimerRef = useRef(null) + const frameSwitchGhostIdRef = useRef(0) + const frameSwitchGhostRafRef = useRef(null) + const frameSwitchGhostTimerRef = useRef(null) + const frameSwitchGhostCapturedTokenRef = useRef(null) + const frameSwitchGhostTargetRef = useRef<{ + token: number + imageName: string + } | null>(null) const paperScope = useRef(null) useEffect(() => { @@ -277,17 +540,333 @@ const PaperContainer = ( } }, [imgSize]) + useLayoutEffect(() => { + const pendingGhost = pendingResizeGhostRef.current + if (!pendingGhost || RESIZE_INTERPOLATION_DURATION_MS <= 0) return + pendingResizeGhostRef.current = null + + if ( + resizeGhostRafRef.current !== null && + typeof globalThis.cancelAnimationFrame === "function" + ) { + globalThis.cancelAnimationFrame(resizeGhostRafRef.current) + } + if (resizeGhostTimerRef.current !== null) { + clearTimeout(resizeGhostTimerRef.current) + } + resizeGhostRafRef.current = null + resizeGhostTimerRef.current = null + + setResizeGhost({ + ...pendingGhost, + animating: false, + }) + + const startGhostAnimation = () => { + setResizeGhost((current) => { + if (!current || current.id !== pendingGhost.id) return current + return { + ...current, + animating: true, + } + }) + + resizeGhostTimerRef.current = window.setTimeout(() => { + setResizeGhost((current) => { + if (!current || current.id !== pendingGhost.id) return current + return null + }) + resizeGhostTimerRef.current = null + }, RESIZE_INTERPOLATION_DURATION_MS + 40) + } + + if (typeof globalThis.requestAnimationFrame === "function") { + resizeGhostRafRef.current = globalThis.requestAnimationFrame(() => { + resizeGhostRafRef.current = null + startGhostAnimation() + }) + } else { + startGhostAnimation() + } + }, [imgSize?.clientHeight, imgSize?.clientWidth]) + + useEffect(() => { + if (!resizeGhostRequestToken) return + if ( + !canvasElem.current || + !imgSize?.clientWidth || + !imgSize?.clientHeight + ) { + return + } + + let snapshotSrc = "" + try { + snapshotSrc = canvasElem.current.toDataURL("image/png") + } catch (error) { + void error + } + + if (!snapshotSrc) return + + forcedResizeGhostSnapshotRef.current = { + token: resizeGhostRequestToken, + src: snapshotSrc, + fromWidth: imgSize.clientWidth, + fromHeight: imgSize.clientHeight, + } + }, [imgSize?.clientHeight, imgSize?.clientWidth, resizeGhostRequestToken]) + + useEffect(() => { + return () => { + if ( + resizeGhostRafRef.current !== null && + typeof globalThis.cancelAnimationFrame === "function" + ) { + globalThis.cancelAnimationFrame(resizeGhostRafRef.current) + } + if (resizeGhostTimerRef.current !== null) { + clearTimeout(resizeGhostTimerRef.current) + } + resizeGhostRafRef.current = null + resizeGhostTimerRef.current = null + pendingResizeGhostRef.current = null + forcedResizeGhostSnapshotRef.current = null + } + }, []) + + const cancelFrameSwitchGhostTransition = useCallback(() => { + if ( + frameSwitchGhostRafRef.current !== null && + typeof globalThis.cancelAnimationFrame === "function" + ) { + globalThis.cancelAnimationFrame(frameSwitchGhostRafRef.current) + } + if (frameSwitchGhostTimerRef.current !== null) { + clearTimeout(frameSwitchGhostTimerRef.current) + } + frameSwitchGhostRafRef.current = null + frameSwitchGhostTimerRef.current = null + }, []) + + const captureFrameSwitchGhost = useCallback( + (token: number, imageName: string) => { + if ( + !canvasElem.current || + !imgSize?.clientWidth || + !imgSize?.clientHeight + ) { + frameSwitchGhostTargetRef.current = { + token, + imageName, + } + frameSwitchGhostCapturedTokenRef.current = token + return + } + + let snapshotSrc = "" + try { + snapshotSrc = canvasElem.current.toDataURL("image/png") + } catch (error) { + void error + } + + frameSwitchGhostTargetRef.current = { + token, + imageName, + } + frameSwitchGhostCapturedTokenRef.current = token + cancelFrameSwitchGhostTransition() + + if (!snapshotSrc) { + setFrameSwitchGhost(null) + return + } + + const ghostId = frameSwitchGhostIdRef.current + 1 + frameSwitchGhostIdRef.current = ghostId + setFrameSwitchGhost({ + id: ghostId, + src: snapshotSrc, + width: imgSize.clientWidth, + height: imgSize.clientHeight, + animating: false, + }) + }, + [ + cancelFrameSwitchGhostTransition, + imgSize?.clientHeight, + imgSize?.clientWidth, + ] + ) + + const startFrameSwitchGhostFade = useCallback( + (token: number, imageName: string) => { + const target = frameSwitchGhostTargetRef.current + if (!target || target.token !== token || target.imageName !== imageName) { + return + } + + cancelFrameSwitchGhostTransition() + + const currentGhostId = frameSwitchGhostIdRef.current + const beginFade = () => { + setFrameSwitchGhost((current) => { + if (!current || current.id !== currentGhostId) return current + if (current.animating) return current + return { + ...current, + animating: true, + } + }) + + frameSwitchGhostTimerRef.current = window.setTimeout(() => { + setFrameSwitchGhost((current) => { + if (!current || current.id !== currentGhostId) return current + return null + }) + if ( + frameSwitchGhostTargetRef.current?.token === token && + frameSwitchGhostTargetRef.current?.imageName === imageName + ) { + frameSwitchGhostTargetRef.current = null + } + frameSwitchGhostTimerRef.current = null + }, RASTER_CROSSFADE_DURATION_MS + 40) + } + + const runAfterPaint = () => { + if (typeof globalThis.requestAnimationFrame !== "function") { + beginFade() + return + } + frameSwitchGhostRafRef.current = globalThis.requestAnimationFrame( + () => { + frameSwitchGhostRafRef.current = globalThis.requestAnimationFrame( + () => { + frameSwitchGhostRafRef.current = null + beginFade() + } + ) + } + ) + } + + runAfterPaint() + }, + [cancelFrameSwitchGhostTransition] + ) + + useEffect(() => { + return () => { + cancelFrameSwitchGhostTransition() + frameSwitchGhostCapturedTokenRef.current = null + frameSwitchGhostTargetRef.current = null + } + }, [cancelFrameSwitchGhostTransition]) + + useEffect(() => { + if ( + frameSwitchStatus === "preparing" && + pendingImage && + pendingImage !== activeImage + ) { + if (frameSwitchGhostCapturedTokenRef.current !== frameSwitchToken) { + captureFrameSwitchGhost(frameSwitchToken, pendingImage) + } + return + } + + const target = frameSwitchGhostTargetRef.current + if (!target) { + frameSwitchGhostCapturedTokenRef.current = null + setFrameSwitchGhost(null) + return + } + + if (activeImage === target.imageName) { + startFrameSwitchGhostFade(target.token, target.imageName) + frameSwitchGhostCapturedTokenRef.current = null + return + } + + cancelFrameSwitchGhostTransition() + frameSwitchGhostTargetRef.current = null + frameSwitchGhostCapturedTokenRef.current = null + setFrameSwitchGhost(null) + }, [ + activeImage, + cancelFrameSwitchGhostTransition, + captureFrameSwitchGhost, + frameSwitchStatus, + frameSwitchToken, + pendingImage, + startFrameSwitchGhostFade, + ]) + + useEffect(() => { + if (frameSwitchNoticeTimerRef.current !== null) { + clearTimeout(frameSwitchNoticeTimerRef.current) + frameSwitchNoticeTimerRef.current = null + } + + if ( + frameSwitchStatus !== "preparing" || + !pendingImage || + pendingImage === activeImage + ) { + notifications.hide("frameSwitching") + return + } + + if (FRAME_SWITCH_LOADING_DELAY_MS <= 0) { + notifications.show({ + id: "frameSwitching", + message: "切换中...", + autoClose: false, + withCloseButton: false, + }) + return + } + + frameSwitchNoticeTimerRef.current = window.setTimeout(() => { + notifications.show({ + id: "frameSwitching", + message: "切换中...", + autoClose: false, + withCloseButton: false, + }) + frameSwitchNoticeTimerRef.current = null + }, FRAME_SWITCH_LOADING_DELAY_MS) + + return () => { + if (frameSwitchNoticeTimerRef.current !== null) { + clearTimeout(frameSwitchNoticeTimerRef.current) + frameSwitchNoticeTimerRef.current = null + } + notifications.hide("frameSwitching") + } + }, [activeImage, frameSwitchStatus, pendingImage]) + useEffect(() => { if (loadingData) { + polygonRenderJobIdRef.current += 1 + polygonRenderRunningRef.current = false labelRenderScaleRef.current = {} renderedImageRef.current = "" + rasterLoadingImageRef.current = null + prevActiveImageRef.current = "" + frameImageCacheRef.current.clear() + frameDecodeTaskRef.current.clear() + clearPendingFrameSwitch() + notifications.hide("frameSwitching") const group = usePaperStore.getState().group if (group) { group.removeChildren() } setRasterInited(false) } - }, [loadingData]) + }, [clearPendingFrameSwitch, loadingData]) const renderSupportAnnotation = useCallback( async ( @@ -886,9 +1465,12 @@ const PaperContainer = ( const renderPolygons = useCallback( ( operationId: string, - imageData: Map | undefined + imageData: ImageLabelData | undefined, + pathsOverride?: LabelPathItem[], + imageIdOverride?: string ) => { let operationSchema = getOperationSchema(operationId) + const renderImageId = imageIdOverride ?? activeImage if (operationSchema && imageData) { const toPaperPoint = (segment: any): paper.Point | null => { @@ -941,7 +1523,7 @@ const PaperContainer = ( let fillColor = `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)` let blankColor = `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)` - let paths = imageData.get(operationId) + let paths = pathsOverride ?? imageData.get(operationId) switch ( operationSchema && @@ -963,16 +1545,16 @@ const PaperContainer = ( // fillColor: fillColor, fillColor: blankColor, visible: - !paperPathStatus[activeImage + item[0]]?.["HIDE"], + !paperPathStatus[renderImageId + item[0]]?.["HIDE"], }, { type: "polygon", id: item[0], - imageId: activeImage, + imageId: renderImageId, operationId: operationId, fillColor: fillColor, blankColor: blankColor, - selected: paperSelectedPath[activeImage]?.includes( + selected: paperSelectedPath[renderImageId]?.includes( item[0] ), } @@ -992,16 +1574,16 @@ const PaperContainer = ( strokeWidth: 2, fillColor: fillColor, visible: - !paperPathStatus[activeImage + item[0]]?.["HIDE"], + !paperPathStatus[renderImageId + item[0]]?.["HIDE"], }, { type: "polygon", id: item[0], - imageId: activeImage, + imageId: renderImageId, operationId: operationId, fillColor: fillColor, blankColor: blankColor, - selected: paperSelectedPath[activeImage]?.includes( + selected: paperSelectedPath[renderImageId]?.includes( item[0] ), isHollowPolygon: true, @@ -1018,16 +1600,16 @@ const PaperContainer = ( strokeWidth: 2, // fillColor: fillColor, fillColor: blankColor, - visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"], + visible: !paperPathStatus[renderImageId + item[0]]?.["HIDE"], }, { type: "polygon", id: item[0], - imageId: activeImage, + imageId: renderImageId, operationId: operationId, fillColor: fillColor, blankColor: blankColor, - selected: paperSelectedPath[activeImage]?.includes(item[0]), + selected: paperSelectedPath[renderImageId]?.includes(item[0]), } ) @@ -1078,8 +1660,8 @@ const PaperContainer = ( radius: 3, }, { - imageId: useBottomToolsStore.getState().activeImage, - operationId: useTopToolsStore.getState().activeOperation, + imageId: renderImageId, + operationId: operationId, text: segmentIndex + 1, id: item[0], attributes: item[2]?.circles?.[segmentIndex]?.attributes, @@ -1096,15 +1678,18 @@ const PaperContainer = ( strokeWidth: 2, // fillColor: fillColor, fillColor: blankColor, - visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"], + visible: + !paperPathStatus[renderImageId + item[0]]?.["HIDE"], }, { id: item[0], - imageId: activeImage, + imageId: renderImageId, operationId: operationId, fillColor: fillColor, blankColor: blankColor, - selected: paperSelectedPath[activeImage]?.includes(item[0]), + selected: paperSelectedPath[renderImageId]?.includes( + item[0] + ), } ) }) @@ -1126,16 +1711,16 @@ const PaperContainer = ( // fillColor: fillColor, fillColor: blankColor, visible: - !paperPathStatus[activeImage + item[0]]?.["HIDE"], + !paperPathStatus[renderImageId + item[0]]?.["HIDE"], }, { type: "brush", id: item[0], - imageId: activeImage, + imageId: renderImageId, operationId: operationId, fillColor: fillColor, blankColor: blankColor, - selected: paperSelectedPath[activeImage]?.includes( + selected: paperSelectedPath[renderImageId]?.includes( item[0] ), } @@ -1151,16 +1736,16 @@ const PaperContainer = ( strokeWidth: 2, // fillColor: fillColor, fillColor: blankColor, - visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"], + visible: !paperPathStatus[renderImageId + item[0]]?.["HIDE"], }, { type: "brush", id: item[0], - imageId: activeImage, + imageId: renderImageId, operationId: operationId, fillColor: fillColor, blankColor: blankColor, - selected: paperSelectedPath[activeImage]?.includes(item[0]), + selected: paperSelectedPath[renderImageId]?.includes(item[0]), } ) }) @@ -1190,16 +1775,19 @@ const PaperContainer = ( strokeWidth: 2, // fillColor: fillColor, fillColor: blankColor, - visible: !paperPathStatus[activeImage + item[0]]?.["HIDE"], + visible: + !paperPathStatus[renderImageId + item[0]]?.["HIDE"], }, { type: "rectangle", id: item[0], - imageId: activeImage, + imageId: renderImageId, operationId: operationId, fillColor: fillColor, blankColor: blankColor, - selected: paperSelectedPath[activeImage]?.includes(item[0]), + selected: paperSelectedPath[renderImageId]?.includes( + item[0] + ), } ) }) @@ -1531,15 +2119,552 @@ const PaperContainer = ( const [rasterInited, setRasterInited] = useState(false) const rasterLoadRequestIdRef = useRef(0) + const clearRenderedAnnotationItems = useCallback(() => { + const group = usePaperStore.getState().group + if (!group) return + group + .getItems({}) + .filter((item) => item.data?.name !== "pic") + .forEach((item) => { + item.remove() + }) + }, []) + + const scaleRenderedAnnotationItemsInPlace = useCallback( + (scaleRatio: number, currentRaster?: paper.Raster) => { + const group = usePaperStore.getState().group + if (!group) return + if (!Number.isFinite(scaleRatio) || scaleRatio <= 0) return + + group.children.forEach((item) => { + if (item === currentRaster) return + if (item.data?.name === "pic") return + if (item.data?.type === "tag") return + if (item.data?.type === "groupPath") return + item.scale(scaleRatio, new paper.Point(0, 0)) + }) + }, + [] + ) + + const renderActiveImagePolygonsBatched = useCallback( + (imageId: string, imageData?: ImageLabelData) => { + const expectedImage = + imageId || useBottomToolsStore.getState().activeImage + const taskImageData = imageData + const jobId = ++polygonRenderJobIdRef.current + polygonRenderRunningRef.current = true + + clearRenderedAnnotationItems() + + if (!expectedImage || !taskImageData?.size) { + polygonRenderRunningRef.current = false + if (jobId !== polygonRenderJobIdRef.current) return + if (useBottomToolsStore.getState().activeImage !== expectedImage) return + renderGroupPath() + renderTag() + return + } + + const tasks: Array<{ + operationId: string + paths: LabelPathItem[] + }> = [] + + objectOperations?.forEach((operation) => { + const operationId = operation.category_id.toString() + const paths = taskImageData.get(operationId) + if (!paths?.length) return + + for ( + let start = 0; + start < paths.length; + start += POLYGON_RENDER_BATCH_SIZE + ) { + tasks.push({ + operationId, + paths: paths.slice(start, start + POLYGON_RENDER_BATCH_SIZE), + }) + } + }) + + if (!tasks.length) { + polygonRenderRunningRef.current = false + if (jobId !== polygonRenderJobIdRef.current) return + if (useBottomToolsStore.getState().activeImage !== expectedImage) return + renderGroupPath() + renderTag() + return + } + + let cursor = 0 + const runBatch = () => { + if (jobId !== polygonRenderJobIdRef.current) { + polygonRenderRunningRef.current = false + return + } + if (useBottomToolsStore.getState().activeImage !== expectedImage) { + polygonRenderRunningRef.current = false + return + } + + const now = + typeof globalThis.performance?.now === "function" + ? () => globalThis.performance.now() + : () => Date.now() + const startedAt = now() + + while (cursor < tasks.length) { + const task = tasks[cursor] + renderPolygons(task.operationId, taskImageData, task.paths) + cursor += 1 + + if (now() - startedAt >= POLYGON_RENDER_BATCH_BUDGET_MS) { + break + } + } + + if (cursor < tasks.length) { + if (typeof globalThis.requestAnimationFrame === "function") { + globalThis.requestAnimationFrame(() => runBatch()) + } else { + setTimeout(runBatch, 0) + } + return + } + + polygonRenderRunningRef.current = false + if (jobId !== polygonRenderJobIdRef.current) return + if (useBottomToolsStore.getState().activeImage !== expectedImage) return + renderGroupPath() + renderTag() + } + + if (typeof globalThis.requestAnimationFrame === "function") { + globalThis.requestAnimationFrame(() => runBatch()) + } else { + setTimeout(runBatch, 0) + } + }, + [clearRenderedAnnotationItems, objectOperations, renderPolygons, renderTag] + ) + + const withPaperGroup = useCallback( + (targetGroup: paper.Group, runner: () => T) => { + const previousGroup = usePaperStore.getState().group + setGroup(targetGroup) + try { + return runner() + } finally { + setGroup(previousGroup) + } + }, + [setGroup] + ) + + const isPendingFrameSwitchStale = useCallback( + (imageName: string, token: number) => { + const currentState = useBottomToolsStore.getState() + return ( + currentState.frameSwitchToken !== token || + currentState.frameSwitchStatus !== "preparing" || + currentState.pendingImage !== imageName + ) + }, + [] + ) + + const reconcileRasterScaleForImage = useCallback( + (imageName: string, scale: number) => { + const currentImgScale = usePaperStore.getState().rasterScale[imageName] + const currentLabelRenderScale = labelRenderScaleRef.current[imageName] + const scaleEpsilon = 0.000001 + + if (!currentLabelRenderScale) { + setRasterScale(imageName, scale) + labelRenderScaleRef.current[imageName] = scale + if (useLabelStore.getState().label.get(imageName)) { + const newData = adjustPoints( + imageName, + useLabelStore.getState().label, + scale + ) + useLabelStore.getState().setLabel(newData) + } + const stack = useLabelStore.getState().stateStack + if (stack.length) { + useLabelStore + .getState() + .setStateStack( + stack.map((item) => adjustPoints(imageName, item, scale)) + ) + } + return + } + + if (Math.abs(currentLabelRenderScale - scale) > scaleEpsilon) { + const scaleRatio = scale / currentLabelRenderScale + setRasterScale(imageName, scale) + labelRenderScaleRef.current[imageName] = scale + const newData = adjustPoints( + imageName, + useLabelStore.getState().label, + scaleRatio + ) + useLabelStore.getState().setLabel(newData) + const stack = useLabelStore.getState().stateStack + if (stack.length) { + useLabelStore + .getState() + .setStateStack( + stack.map((item) => adjustPoints(imageName, item, scaleRatio)) + ) + } + return + } + + if (currentImgScale !== scale) { + setRasterScale(imageName, scale) + } + }, + [setRasterScale] + ) + + const renderImagePolygonsBatchedToGroup = useCallback( + ( + imageId: string, + imageData: ImageLabelData | undefined, + targetGroup: paper.Group, + token: number + ) => + new Promise((resolve) => { + if (!imageData?.size) { + resolve(true) + return + } + + const tasks: Array<{ + operationId: string + paths: LabelPathItem[] + }> = [] + + objectOperations?.forEach((operation) => { + const operationId = operation.category_id.toString() + const paths = imageData.get(operationId) + if (!paths?.length) return + for ( + let start = 0; + start < paths.length; + start += POLYGON_RENDER_BATCH_SIZE + ) { + tasks.push({ + operationId, + paths: paths.slice(start, start + POLYGON_RENDER_BATCH_SIZE), + }) + } + }) + + if (!tasks.length) { + resolve(true) + return + } + + let cursor = 0 + const runBatch = () => { + if (isPendingFrameSwitchStale(imageId, token)) { + resolve(false) + return + } + + const now = + typeof globalThis.performance?.now === "function" + ? () => globalThis.performance.now() + : () => Date.now() + const startedAt = now() + + while (cursor < tasks.length) { + const task = tasks[cursor] + withPaperGroup(targetGroup, () => { + renderPolygons(task.operationId, imageData, task.paths, imageId) + }) + cursor += 1 + + if (now() - startedAt >= POLYGON_RENDER_BATCH_BUDGET_MS) { + break + } + } + + if (cursor < tasks.length) { + if (typeof globalThis.requestAnimationFrame === "function") { + globalThis.requestAnimationFrame(() => runBatch()) + } else { + setTimeout(runBatch, 0) + } + return + } + + resolve(true) + } + + if (typeof globalThis.requestAnimationFrame === "function") { + globalThis.requestAnimationFrame(() => runBatch()) + } else { + setTimeout(runBatch, 0) + } + }), + [ + isPendingFrameSwitchStale, + objectOperations, + renderPolygons, + withPaperGroup, + ] + ) + + const preparePendingFrameSwitch = useCallback( + async (imageName: string, token: number) => { + if ( + !imageName || + !projectDetail?.id || + !imgSize?.clientWidth || + !imgSize?.clientHeight + ) { + clearPendingFrameSwitch(token) + return + } + + const currentVisibleGroup = usePaperStore.getState().group + if (!currentVisibleGroup) { + clearPendingFrameSwitch(token) + return + } + + const stagingGroup = new paper.Group({ + applyMatrix: false, + selected: true, + strokeScaling: false, + visible: false, + }) + stagingGroup.scaling = new paper.Point( + currentVisibleGroup.scaling.x, + currentVisibleGroup.scaling.y + ) + stagingGroup.position = new paper.Point( + currentVisibleGroup.position.x, + currentVisibleGroup.position.y + ) + + const cleanupStagingGroup = () => { + try { + stagingGroup.removeChildren() + } catch (error) { + void error + } + try { + stagingGroup.remove() + } catch (error) { + void error + } + } + + try { + const decodedImage = await decodeFrameImageRef.current(imageName) + if (!decodedImage?.src || isPendingFrameSwitchStale(imageName, token)) { + cleanupStagingGroup() + return + } + + const raster = renderRaster(stagingGroup, { + source: decodedImage.src, + visible: false, + data: { + name: "pic", + imageId: imageName, + requestId: token, + }, + }) + + await new Promise((resolve, reject) => { + raster.onLoad = () => resolve() + raster.onError = () => reject(new Error("pending raster load failed")) + }) + + if (isPendingFrameSwitchStale(imageName, token)) { + cleanupStagingGroup() + return + } + + const canvasWidth = + imgSize?.clientWidth ?? paperScope.current?.view.bounds.width + const canvasHeight = + imgSize?.clientHeight ?? paperScope.current?.view.bounds.height + + if (!canvasWidth || !canvasHeight) { + cleanupStagingGroup() + clearPendingFrameSwitch(token) + return + } + + const scaleX = canvasWidth / raster.bounds.width + const scaleY = canvasHeight / raster.bounds.height + const scale = Math.min(scaleX, scaleY) + + if (!Number.isFinite(scale) || scale <= 0) { + cleanupStagingGroup() + clearPendingFrameSwitch(token) + return + } + + setRasterSize(imageName, [raster.bounds.width, raster.bounds.height]) + raster.scale(scale, new paper.Point(0, 0)) + reconcileRasterScaleForImage(imageName, scale) + raster.position = new paper.Point( + raster.bounds.width / 2, + raster.bounds.height / 2 + ) + stagingGroup.position = new paper.Point( + raster.bounds.width / 2, + raster.bounds.height / 2 + ) + raster.sendToBack() + + const imageData = useLabelStore.getState().label.get(imageName) + const renderOk = await renderImagePolygonsBatchedToGroup( + imageName, + imageData, + stagingGroup, + token + ) + + if (!renderOk || isPendingFrameSwitchStale(imageName, token)) { + cleanupStagingGroup() + return + } + + const previousGroup = usePaperStore.getState().group + setGroup(stagingGroup) + setRasterPath(new paper.Path.Rectangle(raster.bounds)) + renderedImageRef.current = imageName + commitFrameSwitch(imageName, token) + renderGroupPath() + renderTag() + stagingGroup.visible = true + stagingGroup.bringToFront() + + if (previousGroup && previousGroup !== stagingGroup) { + previousGroup.remove() + } + } catch (error) { + console.log(error) + cleanupStagingGroup() + if (!isPendingFrameSwitchStale(imageName, token)) { + clearPendingFrameSwitch(token) + notifications.show({ + color: "red", + message: "切帧失败,请重试", + autoClose: 2500, + }) + } + } + }, + [ + clearPendingFrameSwitch, + commitFrameSwitch, + imgSize?.clientHeight, + imgSize?.clientWidth, + isPendingFrameSwitchStale, + projectDetail?.id, + reconcileRasterScaleForImage, + renderImagePolygonsBatchedToGroup, + renderTag, + setGroup, + setRasterPath, + setRasterSize, + ] + ) + + const decodeFrameImage = useCallback( + async (imageName: string) => { + if (!imageName || !projectDetail?.id) return null + + const cached = frameImageCacheRef.current.get(imageName) + if (cached) return cached + + const inFlight = frameDecodeTaskRef.current.get(imageName) + if (inFlight) return inFlight + + const task = (async () => { + let text = useImagesStore.getState().images.get(imageName) + if (!text) { + const response = await getServerImage({ + data_names: [imageName], + data_type: 0, + project_id: projectDetail.id, + }) + text = `data:image/jpeg;base64,${response}` + const images = useImagesStore.getState().images + images.set(imageName, text) + useImagesStore.getState().setImages(images) + } + + if (!text) return null + + const image = await new Promise((resolve) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => resolve(null) + img.src = text! + }) + + if (image) { + frameImageCacheRef.current.set(imageName, image) + } + frameDecodeTaskRef.current.delete(imageName) + return image + })().catch((error) => { + frameDecodeTaskRef.current.delete(imageName) + throw error + }) + + frameDecodeTaskRef.current.set(imageName, task) + return task + }, + [projectDetail?.id] + ) + 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 ( + rasterFadeRafIdRef.current !== null && + typeof globalThis.cancelAnimationFrame === "function" + ) { + globalThis.cancelAnimationFrame(rasterFadeRafIdRef.current) + } + rasterFadeRafIdRef.current = null + }, []) + + useEffect(() => { + return () => { + cancelRasterFadeTransition() + } + }, [cancelRasterFadeTransition]) + const renderAndScaleRaster = useCallback( async (paperGroup: paper.Group) => { - // const raster = renderRaster(paperGroup, { - // source: `${process.env.NEXT_PUBLIC_URL}/api/v1/label_server/data/image?data_name=${activeImage}&project_path=${projectDetail?.rpath}`, - // }); - if (activeImage && projectDetail?.id) { const requestId = ++rasterLoadRequestIdRef.current const requestImage = activeImage + rasterLoadingImageRef.current = requestImage + cancelRasterFadeTransition() + getPicRasters(paperGroup).forEach((item) => { + item.opacity = 1 + }) const isStaleRasterRequest = () => { const currentImage = useBottomToolsStore.getState().activeImage const stale = @@ -1556,8 +2681,6 @@ const PaperContainer = ( return stale } - console.log(usePaperStore.getState().paperScope) - // console.log(activeImage, projectDetail?.rpath); notifications.show({ message: "图片加载中...", loading: true, @@ -1566,28 +2689,16 @@ const PaperContainer = ( }) try { let raster - let text - const images = useImagesStore.getState().images - const currentImageBase64Text = images.get(activeImage) - if (currentImageBase64Text) { - text = currentImageBase64Text - } else { - const response = await getServerImage({ - data_names: [activeImage], - data_type: 0, - project_id: projectDetail?.id, - }) - if (isStaleRasterRequest()) return - text = `data:image/jpeg;base64,${response}` - images.set(activeImage, text) - useImagesStore.getState().setImages(images) - } + const decodedImage = await decodeFrameImage(requestImage) if (isStaleRasterRequest()) return - if (!text) return + if (!decodedImage?.src) return raster = renderRaster(paperGroup, { - source: text, + source: decodedImage.src, + visible: false, data: { name: "pic", + imageId: requestImage, + requestId, }, }) if (!raster) return @@ -1619,7 +2730,7 @@ const PaperContainer = ( let scaleX = canvasWidth! / raster.bounds.width let scaleY = canvasHeight! / raster.bounds.height - setRasterSize(activeImage, [ + setRasterSize(requestImage, [ raster.bounds.width, raster.bounds.height, ]) @@ -1643,12 +2754,12 @@ const PaperContainer = ( // 将raster以(0,0)为中心点进行缩放 raster.scale(scale, new paper.Point(0, 0)) const currentImgScale = - usePaperStore.getState().rasterScale[activeImage] + usePaperStore.getState().rasterScale[requestImage] const currentLabelRenderScale = - labelRenderScaleRef.current[activeImage] + labelRenderScaleRef.current[requestImage] const scaleEpsilon = 0.000001 console.debug("[PaperContainer] raster scale reconcile", { - image: activeImage, + image: requestImage, currentImgScale, currentLabelRenderScale, nextScale: scale, @@ -1660,15 +2771,14 @@ const PaperContainer = ( }) // 当前图片无已应用的渲染比例时,按原始坐标转换到当前显示比例 if (!currentLabelRenderScale) { - setRasterScale(activeImage, scale) - labelRenderScaleRef.current[activeImage] = scale - if (useLabelStore.getState().label.get(activeImage)) { + setRasterScale(requestImage, scale) + labelRenderScaleRef.current[requestImage] = scale + if (useLabelStore.getState().label.get(requestImage)) { const newData = adjustPoints( - activeImage, + requestImage, useLabelStore.getState().label, scale ) - // 处理hollowPoints useLabelStore.getState().setLabel(newData) } let stack = useLabelStore.getState().stateStack @@ -1676,17 +2786,17 @@ const PaperContainer = ( useLabelStore .getState() .setStateStack( - stack.map((item) => adjustPoints(activeImage, item, scale)) + stack.map((item) => adjustPoints(requestImage, item, scale)) ) } } else if ( Math.abs(currentLabelRenderScale - scale) > scaleEpsilon ) { const scaleRatio = scale / currentLabelRenderScale - setRasterScale(activeImage, scale) - labelRenderScaleRef.current[activeImage] = scale + setRasterScale(requestImage, scale) + labelRenderScaleRef.current[requestImage] = scale const newData = adjustPoints( - activeImage, + requestImage, useLabelStore.getState().label, scaleRatio ) @@ -1697,40 +2807,97 @@ const PaperContainer = ( .getState() .setStateStack( stack.map((item) => - adjustPoints(activeImage, item, scaleRatio) + adjustPoints(requestImage, item, scaleRatio) ) ) } } else if (currentImgScale !== scale) { - setRasterScale(activeImage, scale) + setRasterScale(requestImage, scale) } - // 调整raster的左上角到(0,0) raster.position = new paper.Point( raster.bounds.width / 2, raster.bounds.height / 2 ) - // if (!useTopToolsStore.getState().saveCurrentScale) - usePaperStore.getState().group!.position = raster.position + raster.visible = true + const previousRasters = getPicRasters(paperGroup).filter( + (item) => item !== raster + ) + + if ( + RASTER_CROSSFADE_DURATION_MS > 0 && + previousRasters.length > 0 && + typeof globalThis.requestAnimationFrame === "function" + ) { + const transitionId = ++rasterFadeTransitionIdRef.current + const startTime = + typeof performance !== "undefined" + ? performance.now() + : Date.now() + raster.opacity = 0 + const step = (timestamp: number) => { + if (transitionId !== rasterFadeTransitionIdRef.current) return + if (isStaleRasterRequest()) { + raster.remove() + rasterFadeRafIdRef.current = null + return + } + const now = + typeof timestamp === "number" && Number.isFinite(timestamp) + ? timestamp + : typeof performance !== "undefined" + ? performance.now() + : Date.now() + const progress = Math.min( + 1, + (now - startTime) / RASTER_CROSSFADE_DURATION_MS + ) + raster.opacity = progress + if (progress >= 1) { + previousRasters.forEach((item) => { + item.remove() + }) + rasterFadeRafIdRef.current = null + return + } + rasterFadeRafIdRef.current = + globalThis.requestAnimationFrame(step) + } + rasterFadeRafIdRef.current = + globalThis.requestAnimationFrame(step) + } else { + raster.opacity = 1 + previousRasters.forEach((item) => { + item.remove() + }) + } + + usePaperStore.getState().group!.position = new paper.Point( + raster.bounds.width / 2, + raster.bounds.height / 2 + ) setRasterPath(new paper.Path.Rectangle(raster.bounds)) - // 将raster置底 raster.sendToBack() - renderedImageRef.current = activeImage + renderedImageRef.current = requestImage setRasterInited(true) - console.log(usePaperStore.getState().group) usePaperStore.getState().group?.bringToFront() } } catch (error) { console.log(error) } finally { + if (requestId === rasterLoadRequestIdRef.current) { + rasterLoadingImageRef.current = null + } notifications.hide("loadingRaster") } } }, [ activeImage, + cancelRasterFadeTransition, + decodeFrameImage, + getPicRasters, imgSize?.clientHeight, imgSize?.clientWidth, - projectDetail?.id, setRasterPath, setRasterScale, @@ -1739,37 +2906,118 @@ const PaperContainer = ( ) const loadImageAndPolygons = useCallback( - async ( - // imageData: Map | undefined, - group: paper.Group - ) => { - // 清除当前的 raster 和多边形 - group.removeChildren() - // 添加新的 raster + async (group: paper.Group) => { + clearRenderedAnnotationItems() setRasterInited(false) renderAndScaleRaster(group) - // if (imageData) { - // objectOperations?.forEach((item) => { - // renderPolygons(item.category_id.toString(), imageData); - // }); - // } }, - [renderAndScaleRaster] + [clearRenderedAnnotationItems, renderAndScaleRaster] ) + useEffect(() => { + const focusImage = pendingImage || activeImage + if (!focusImage) return + + void decodeFrameImage(focusImage).catch((error) => { + void error + }) + + const currentIndex = allItems.findIndex((item) => item === focusImage) + if (currentIndex < 0) { + const keepSet = new Set([focusImage, activeImage].filter(Boolean)) + for (const key of Array.from(frameImageCacheRef.current.keys())) { + if (!keepSet.has(key)) frameImageCacheRef.current.delete(key) + } + for (const key of Array.from(frameDecodeTaskRef.current.keys())) { + if (!keepSet.has(key)) frameDecodeTaskRef.current.delete(key) + } + return + } + + const prefetchStart = Math.max(0, currentIndex - FRAME_PREFETCH_RADIUS) + const prefetchEnd = Math.min( + allItems.length - 1, + currentIndex + FRAME_PREFETCH_RADIUS + ) + const prefetchSet = new Set() + for (let index = prefetchStart; index <= prefetchEnd; index += 1) { + const imageName = allItems[index] + if (!imageName) continue + prefetchSet.add(imageName) + void decodeFrameImage(imageName).catch((error) => { + void error + }) + } + + const keepStart = Math.max(0, currentIndex - FRAME_CACHE_KEEP_RADIUS) + const keepEnd = Math.min( + allItems.length - 1, + currentIndex + FRAME_CACHE_KEEP_RADIUS + ) + const keepSet = new Set() + for (let index = keepStart; index <= keepEnd; index += 1) { + const imageName = allItems[index] + if (imageName) keepSet.add(imageName) + } + keepSet.add(focusImage) + if (activeImage) keepSet.add(activeImage) + + for (const key of Array.from(frameImageCacheRef.current.keys())) { + if (!keepSet.has(key)) frameImageCacheRef.current.delete(key) + } + for (const key of Array.from(frameDecodeTaskRef.current.keys())) { + if (!keepSet.has(key) && !prefetchSet.has(key)) { + frameDecodeTaskRef.current.delete(key) + } + } + }, [activeImage, allItems, decodeFrameImage, pendingImage]) + + useEffect(() => { + if ( + frameSwitchStatus !== "preparing" || + !pendingImage || + pendingImage === activeImage + ) { + return + } + + polygonRenderJobIdRef.current += 1 + polygonRenderRunningRef.current = false + void preparePendingFrameSwitch(pendingImage, frameSwitchToken) + }, [ + activeImage, + frameSwitchStatus, + frameSwitchToken, + pendingImage, + preparePendingFrameSwitch, + ]) + const resizeCurrentImage = useCallback(() => { if (!imgSize?.clientWidth || !imgSize?.clientHeight) return false const group = usePaperStore.getState().group if (!group) return false - const currentRaster = group.getItems({ data: { name: "pic" } })?.[0] as - | paper.Raster - | undefined + const rasterItems = getPicRasters(group) + const activeRasters = rasterItems.filter( + (item) => item.data?.imageId === activeImage + ) + const currentRaster = + (activeRasters.length + ? activeRasters[activeRasters.length - 1] + : undefined) || + (rasterItems.length ? rasterItems[rasterItems.length - 1] : undefined) if (!currentRaster) return false - const rawRasterSize = usePaperStore.getState().rasterSize[activeImage] + const rasterSizeStore = usePaperStore.getState().rasterSize + const rawRasterSize = rasterSizeStore[activeImage] ?? [ + currentRaster.width, + currentRaster.height, + ] if (!rawRasterSize?.[0] || !rawRasterSize?.[1]) return false + if (!rasterSizeStore[activeImage]) { + setRasterSize(activeImage, [rawRasterSize[0], rawRasterSize[1]]) + } const nextScale = Math.min( imgSize.clientWidth / rawRasterSize[0], @@ -1790,6 +3038,7 @@ const PaperContainer = ( if (Math.abs(scaleRatio - 1) > scaleEpsilon) { currentRaster.scale(scaleRatio, new paper.Point(0, 0)) + scaleRenderedAnnotationItemsInPlace(scaleRatio, currentRaster) const currentLabel = useLabelStore.getState().label if (currentLabel.get(activeImage)) { @@ -1812,39 +3061,30 @@ const PaperContainer = ( currentRaster.bounds.width / 2, currentRaster.bounds.height / 2 ) - group.position = currentRaster.position - - group - .getItems({}) - .filter((item) => item !== currentRaster && item.data?.id) - .forEach((item) => { - item.remove() - }) + group.position = new paper.Point( + currentRaster.bounds.width / 2, + currentRaster.bounds.height / 2 + ) setRasterPath(new paper.Path.Rectangle(currentRaster.bounds)) setRasterScale(activeImage, nextScale) labelRenderScaleRef.current[activeImage] = nextScale - const imageData = useLabelStore.getState().label.get(activeImage) - if (imageData) { - objectOperations?.forEach((item) => { - renderPolygons(item.category_id.toString(), imageData) - }) - renderGroupPath() - renderTag() - } + renderGroupPath() + renderTag() group.bringToFront() return true }, [ activeImage, + getPicRasters, imgSize?.clientHeight, imgSize?.clientWidth, - objectOperations, - renderPolygons, renderTag, + scaleRenderedAnnotationItemsInPlace, setRasterPath, setRasterScale, + setRasterSize, ]) useEffect(() => { @@ -1852,6 +3092,10 @@ const PaperContainer = ( if (!imgSize?.clientWidth || !imgSize?.clientHeight) return const currentGroup = usePaperStore.getState().group if (currentGroup && projectDetail) { + const activeImageChanged = prevActiveImageRef.current !== activeImage + prevActiveImageRef.current = activeImage + const hasPicRaster = getPicRasters(currentGroup).length > 0 + if (!useTopToolsStore.getState().saveCurrentScale) { currentGroup.scaling = new paper.Point(1, 1) useTopToolsStore.getState().setScale(1) @@ -1861,21 +3105,29 @@ const PaperContainer = ( .setGroupScale(useTopToolsStore.getState().scale) } - if ( - renderedImageRef.current === activeImage && - currentGroup.getItems({ data: { name: "pic" } })?.length - ) { + if (!activeImageChanged) { + if (resizeCurrentImage()) { + return + } + if (hasPicRaster || rasterLoadingImageRef.current === activeImage) { + return + } + } + + if (renderedImageRef.current === activeImage && hasPicRaster) { resizeCurrentImage() return } - loadImageAndPolygons( - // storeLabel.get(activeImage), - currentGroup - ) + if (rasterLoadingImageRef.current === activeImage) { + return + } + + loadImageAndPolygons(currentGroup) } }, [ activeImage, + getPicRasters, imgSize?.clientHeight, imgSize?.clientWidth, loadImageAndPolygons, @@ -1886,34 +3138,25 @@ const PaperContainer = ( useEffect(() => { if (rasterInited) { - if (storeLabel.get(activeImage)) { - objectOperations?.forEach((item) => { - renderPolygons( - item.category_id.toString(), - storeLabel.get(activeImage) - ) - }) - renderGroupPath() - renderTag() - } + renderActiveImagePolygonsBatched(activeImage, activeImageLabel) setRasterInited(false) } }, [ activeImage, - objectOperations, + activeImageLabel, rasterInited, - renderPolygons, - renderTag, - storeLabel, + renderActiveImagePolygonsBatched, ]) useEffect(() => { + if (polygonRenderRunningRef.current) return renderGroupPath() - }, [storeLabel]) + }, [activeImage, activeImageLabel]) useEffect(() => { + if (polygonRenderRunningRef.current) return renderTag() - }, [showTags, showTagsConfigs, storeLabel, renderTag]) + }, [activeImage, showTags, showTagsConfigs, activeImageLabel, renderTag]) const [ids, setIds] = useState([]) const [existIds, setExistIds] = useState([]) @@ -2722,8 +3965,62 @@ const PaperContainer = ( top: 0, left: 0, zIndex: 40, - minWidth: "fit-content", - maxWidth: "fit-content", + width: imgSize?.clientWidth, + height: imgSize?.clientHeight, + display: "block", + }} + /> + )} + {resizeGhost && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + {frameSwitchGhost && ( + // eslint-disable-next-line @next/next/no-img-element + )} diff --git a/components/label/components/TopTools.tsx b/components/label/components/TopTools.tsx index 73bd22a..81dd0bf 100644 --- a/components/label/components/TopTools.tsx +++ b/components/label/components/TopTools.tsx @@ -158,7 +158,7 @@ const TopTools = ( renderPolygons, } = props const { backUrl, basePath } = useBackUrlStore() - const { mode, loadingData } = usePaperStore() + const { mode, loadingData, requestResizeGhost } = usePaperStore() const router = useRouter() const user_id = usePermissionStore.getState().user_id @@ -232,6 +232,32 @@ const TopTools = ( const autoSaveGap = useIntervalStore((state) => state.autoSaveGap) const setAutoSaveGap = useIntervalStore((state) => state.setAutoSaveGap) + const visibleRightPanelCount = useMemo(() => { + return [ + showTaskList, + showGroupList, + showDescList && + !!projectDetail && + [5, 6].includes(projectDetail.label_type), + showObjectList, + ].filter(Boolean).length + }, [projectDetail, showDescList, showGroupList, showObjectList, showTaskList]) + + const toggleRightPanel = useCallback( + (visible: boolean, setter: (val: boolean) => void) => { + const nextVisible = !visible + const nextCount = visibleRightPanelCount + (nextVisible ? 1 : -1) + if ( + (visibleRightPanelCount === 0 && nextCount === 1) || + (visibleRightPanelCount === 1 && nextCount === 0) + ) { + requestResizeGhost() + } + setter(nextVisible) + }, + [requestResizeGhost, visibleRightPanelCount] + ) + // 当前是否可以操作功能按键 const currentEditAuth = useMemo(() => { if (!taskDetail) return false @@ -2432,7 +2458,7 @@ const TopTools = ( c={!showTaskList ? "gray" : "var(--mantine-color-text)"} style={{ width: "auto", display: "flex", gap: 4 }} onClick={() => { - setShowTaskList(!showTaskList) + toggleRightPanel(showTaskList, setShowTaskList) }}> 任务 @@ -2452,7 +2478,7 @@ const TopTools = ( c={!showGroupList ? "gray" : "var(--mantine-color-text)"} style={{ width: "auto", display: "flex", gap: 4 }} onClick={() => { - setShowGroupList(!showGroupList) + toggleRightPanel(showGroupList, setShowGroupList) }}> 群组 @@ -2463,7 +2489,7 @@ const TopTools = ( c={!showDescList ? "gray" : "var(--mantine-color-text)"} style={{ width: "auto", display: "flex", gap: 4 }} onClick={() => { - setShowDescList(!showDescList) + toggleRightPanel(showDescList, setShowDescList) }}> 描述 @@ -2474,7 +2500,7 @@ const TopTools = ( c={!showObjectList ? "gray" : "var(--mantine-color-text)"} style={{ width: "auto", display: "flex", gap: 4 }} onClick={() => { - setShowObjectList(!showObjectList) + toggleRightPanel(showObjectList, setShowObjectList) }}> 对象 diff --git a/components/label/config/performance.ts b/components/label/config/performance.ts new file mode 100644 index 0000000..1b65982 --- /dev/null +++ b/components/label/config/performance.ts @@ -0,0 +1,56 @@ +const parseEnvInt = ( + value: string | undefined, + fallback: number, + min: number, + max: number +) => { + if (!value) return fallback + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} + +export const labelimagePerformanceConfig = { + // 单次渲染的路径切片大小。越小越丝滑,越大越快完成。 + polygonRenderBatchSize: parseEnvInt( + process.env.NEXT_PUBLIC_LABELIMAGE_POLYGON_BATCH_SIZE, + 12, + 1, + 200 + ), + // 每帧渲染预算(毫秒)。越小越不卡操作,越大越快完成绘制。 + polygonRenderBatchBudgetMs: parseEnvInt( + process.env.NEXT_PUBLIC_LABELIMAGE_POLYGON_BATCH_BUDGET_MS, + 6, + 1, + 33 + ), + // 切帧准备超过该阈值后才显示轻量提示,避免短切换频繁闪动提示。 + frameSwitchLoadingDelayMs: parseEnvInt( + process.env.NEXT_PUBLIC_LABELIMAGE_FRAME_SWITCH_LOADING_DELAY_MS, + 120, + 0, + 1000 + ), + // 切帧时新旧图层的交叉淡入时长(毫秒)。设为 0 可关闭淡入。 + rasterCrossfadeDurationMs: parseEnvInt( + process.env.NEXT_PUBLIC_LABELIMAGE_RASTER_CROSSFADE_MS, + 90, + 0, + 400 + ), + // 容器尺寸连续变化时,延迟提交 canvas 实际宽高,减少频繁重置导致的白闪。 + resizeCommitDebounceMs: parseEnvInt( + process.env.NEXT_PUBLIC_LABELIMAGE_RESIZE_COMMIT_DEBOUNCE_MS, + 90, + 0, + 500 + ), + // 容器尺寸变更后的视觉缓动时长(毫秒),用于降低“跳变感”。 + resizeInterpolationDurationMs: parseEnvInt( + process.env.NEXT_PUBLIC_LABELIMAGE_RESIZE_INTERPOLATION_MS, + 100, + 0, + 400 + ), +} as const diff --git a/components/label/useBottomToolsStore.ts b/components/label/useBottomToolsStore.ts index 3aaaa29..47461b2 100644 --- a/components/label/useBottomToolsStore.ts +++ b/components/label/useBottomToolsStore.ts @@ -13,6 +13,12 @@ interface KeyFrameData { interface BottomToolsState { activeImage: string setActiveImage: (val: string) => void + pendingImage: string + frameSwitchStatus: "idle" | "preparing" + frameSwitchToken: number + requestFrameSwitch: (val: string) => number + commitFrameSwitch: (val: string, token: number) => void + clearPendingFrameSwitch: (token?: number) => void inputNumber: string setInputNumber: (val: string) => void activeImageId: number @@ -36,13 +42,48 @@ const initialBottomToolsState = { inputNumber: "", } -export const useBottomToolsStore = create((set) => ({ +export const useBottomToolsStore = create()((set, get) => ({ activeImage: initialBottomToolsState.activeImage, setActiveImage: (val: string) => set((state: BottomToolsState) => ({ ...state, activeImage: val, + pendingImage: "", + frameSwitchStatus: "idle", })), + pendingImage: "", + frameSwitchStatus: "idle", + frameSwitchToken: 0, + requestFrameSwitch: (val: string): number => { + const nextToken = get().frameSwitchToken + 1 + set((state: BottomToolsState) => ({ + ...state, + pendingImage: val, + frameSwitchStatus: + val && val !== state.activeImage ? "preparing" : "idle", + frameSwitchToken: nextToken, + })) + return nextToken + }, + commitFrameSwitch: (val: string, token: number) => + set((state: BottomToolsState) => { + if (state.frameSwitchToken !== token) return state + return { + ...state, + activeImage: val, + pendingImage: "", + frameSwitchStatus: "idle", + } + }), + clearPendingFrameSwitch: (token?: number) => + set((state: BottomToolsState) => { + if (token != null && state.frameSwitchToken !== token) return state + return { + ...state, + pendingImage: "", + frameSwitchStatus: "idle", + } + }), inputNumber: initialBottomToolsState.inputNumber, setInputNumber: (val: string) => set((state: BottomToolsState) => ({ @@ -63,7 +104,7 @@ export const useBottomToolsStore = create((set) => ({ })), keyFrameData: new Map(), setKeyFrameData: (imageId, value) => { - const data = useBottomToolsStore.getState().keyFrameData + const data = get().keyFrameData data.set(imageId, value) return set((state) => ({ ...state, @@ -78,15 +119,13 @@ export const useBottomToolsStore = create((set) => ({ })), selectedItems: [], setSelectedItems: (id, ctrlKey, shiftKey) => { - let arr = [...useBottomToolsStore.getState().selectedItems] + let arr = [...get().selectedItems] if ((ctrlKey && shiftKey) || (!ctrlKey && !shiftKey)) arr = [] else if (ctrlKey && !shiftKey) { arr.push(id) } else if (!ctrlKey && shiftKey) { - const allItems = useBottomToolsStore.getState().allItems - const prevId = arr.length - ? arr[arr.length - 1] - : useBottomToolsStore.getState().activeImage + const allItems = get().allItems + const prevId = arr.length ? arr[arr.length - 1] : get().activeImage const prevIndex = allItems.findIndex((v) => v === prevId) const currentIndex = allItems.findIndex((v) => v === id) arr = diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index 9ddc6d1..5bd17c6 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -50,6 +50,7 @@ interface PaperState { initScope: paper.PaperScope, initGroup: paper.Group ) => void + setGroup: (group: paper.Group | null) => void setRasterPath: (paperRaster: paper.Path) => void setRasterScale: (id: string, scale: number) => void setRasterSize: (id: string, size: [number, number]) => void @@ -180,6 +181,8 @@ interface PaperState { getItemsById: (id: number) => Array setContinuePolygonTarget: (target: ContinuePolygonTarget | null) => void setLoadingData: (flag: boolean) => void + resizeGhostRequestToken: number + requestResizeGhost: () => void } interface PositionStore { @@ -208,6 +211,7 @@ const initialPaperState = { rasterScale: {}, rasterSize: {}, reciprocalRasterScale: {}, + resizeGhostRequestToken: 0, } const handleDelete = () => { const group = usePaperStore.getState().group! @@ -529,6 +533,7 @@ export const usePaperStore = create((set) => ({ rasterScale: initialPaperState.rasterScale, rasterSize: initialPaperState.rasterSize, reciprocalRasterScale: initialPaperState.reciprocalRasterScale, + resizeGhostRequestToken: initialPaperState.resizeGhostRequestToken, continuePolygonTarget: null, currentMousePosition: [0, 0], loadingData: false, @@ -544,6 +549,12 @@ export const usePaperStore = create((set) => ({ el: initEl, })) }, + setGroup: (group) => { + set((state: PaperState) => ({ + ...state, + group, + })) + }, setRasterPath: (rasterPath) => { set((state: PaperState) => ({ ...state, @@ -577,6 +588,11 @@ export const usePaperStore = create((set) => ({ ...state, loadingData: flag, })), + requestResizeGhost: () => + set((state: PaperState) => ({ + ...state, + resizeGhostRequestToken: state.resizeGhostRequestToken + 1, + })), setContinuePolygonTarget: (target) => set((state: PaperState) => ({ ...state,