From 7c700bf95209916d163ecb3181a3442fa52ac965 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Fri, 3 Apr 2026 18:51:59 +0800 Subject: [PATCH] feat(shortcut): b/m --- components/label/LabelNossr.tsx | 64 +++++++- components/label/usePaperStore.ts | 244 +++++++++++++++++++++++++++++- 2 files changed, 305 insertions(+), 3 deletions(-) diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx index 600ca45..8e3a961 100644 --- a/components/label/LabelNossr.tsx +++ b/components/label/LabelNossr.tsx @@ -1467,6 +1467,7 @@ const LabelPage = ({ } setActiveOperation(selectedContext.operationId) + usePaperStore.getState().setContinuePolygonTarget(null) setEditMode(true) setDrawOption("default") useKeyboardStore.getState().setDrawAction(mode) @@ -1566,6 +1567,47 @@ const LabelPage = ({ [] ) + const handleContinueSelectedPolygon = useCallback(() => { + const selectedContext = resolveSelectedObjectContext() + if (!selectedContext) { + notifications.show({ + color: "yellow", + message: "请先选中单个对象", + }) + return + } + + if (selectedContext.objectType !== "polygon") { + notifications.show({ + color: "yellow", + message: "继续标注快捷键仅支持 polygon 类型对象", + }) + return + } + + setEditMode(true) + setDrawOption("default") + setPressA(false) + setActiveOperation(selectedContext.operationId) + useKeyboardStore.getState().setDrawAction("none") + usePaperStore.getState().setContinuePolygonTarget({ + imageId: selectedContext.imageId, + operationId: selectedContext.operationId, + objectId: selectedContext.objectId, + }) + usePaperStore.getState().setPaperMode("polygon") + notifications.show({ + color: "green", + message: "已进入继续标注模式", + }) + }, [ + resolveSelectedObjectContext, + setActiveOperation, + setDrawOption, + setEditMode, + setPressA, + ]) + const hasShapeSegments = useCallback((shape: any) => { if (!shape) return false if (shape instanceof paper.CompoundPath) { @@ -2039,13 +2081,23 @@ const LabelPage = ({ useEffect(() => { const down = (e: KeyboardEvent) => { // console.log(e, e.key); - if (focusInput || isView) return + if (focusInput) return console.log(e.key) const mode = usePaperStore.getState().mode const lowerKey = e.key.toLowerCase() - if (e.repeat && ["a", "d", "h", ",", "."].includes(lowerKey)) return + if (e.repeat && ["a", "b", "d", "h", "m", ",", "."].includes(lowerKey)) + return + + if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "m") { + e.preventDefault() + const topState = useTopToolsStore.getState() + topState.setMagnetFlag(!topState.magnetFlag) + return + } + + if (isView) return if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "h") { e.preventDefault() @@ -2065,6 +2117,12 @@ const LabelPage = ({ return } + if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "b") { + e.preventDefault() + handleContinueSelectedPolygon() + return + } + if ( !e.ctrlKey && !e.metaKey && @@ -2335,6 +2393,7 @@ const LabelPage = ({ getOperationSchema, handleToggleHideSelectedObject, handleMergeSelectedObjects, + handleContinueSelectedPolygon, handleShortcut, isView, projectDetail?.label_type, @@ -2360,6 +2419,7 @@ const LabelPage = ({ useEffect(() => { useKeyboardStore.getState().setCopyDataIds([]) useKeyboardStore.getState().setDrawAction("none") + usePaperStore.getState().setContinuePolygonTarget(null) drawShortcutSnapshotRef.current = null }, [activeImage]) diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index a3bd420..b2ee6b5 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -1,5 +1,6 @@ import { Comment } from "./api/label/typing" import { Project } from "./api/project/typing" +import { notifications } from "@mantine/notifications" import dayjs from "dayjs" import paper from "paper" import { DispatchWithoutAction } from "react" @@ -19,6 +20,12 @@ import { useRightToolsStore } from "./useRightToolsStore" import { useTopToolsStore } from "./useTopToolsStore" import { safeClone } from "./utils/clone" +interface ContinuePolygonTarget { + imageId: string + operationId: string + objectId: number +} + interface PaperState { paperScope: paper.PaperScope | null group: paper.Group | null @@ -36,6 +43,7 @@ interface PaperState { rasterScale: { [x: string]: number } rasterSize: { [x: string]: [number, number] } reciprocalRasterScale: { [x: string]: number } + continuePolygonTarget: ContinuePolygonTarget | null loadingData: boolean init: ( initEl: HTMLCanvasElement, @@ -170,6 +178,7 @@ interface PaperState { getAll: () => paper.Path[] getItemById: (id: number) => paper.Path | null getItemsById: (id: number) => Array + setContinuePolygonTarget: (target: ContinuePolygonTarget | null) => void setLoadingData: (flag: boolean) => void } @@ -520,6 +529,7 @@ export const usePaperStore = create((set) => ({ rasterScale: initialPaperState.rasterScale, rasterSize: initialPaperState.rasterSize, reciprocalRasterScale: initialPaperState.reciprocalRasterScale, + continuePolygonTarget: null, currentMousePosition: [0, 0], loadingData: false, init: ( @@ -567,6 +577,11 @@ export const usePaperStore = create((set) => ({ ...state, loadingData: flag, })), + setContinuePolygonTarget: (target) => + set((state: PaperState) => ({ + ...state, + continuePolygonTarget: target, + })), setCurrentMousePosition: (point: [number, number]) => set((state: PaperState) => ({ ...state, @@ -2679,6 +2694,9 @@ export const usePaperStore = create((set) => ({ let polygonTool = paperPolygonTool let currentMagnetPoint: paper.Point | null = null let draftId: number | null = null + let continueSourceItems: Array = [] + let continueTargetContourIndex: number | null = null + let continueTargetContext: ContinuePolygonTarget | null = null const handleCircles = (path: paper.Path | paper.CompoundPath | null) => { // 更新选中的点 @@ -2724,6 +2742,193 @@ export const usePaperStore = create((set) => ({ currentMagnetPoint = null } + const getPointFromSegment = (segment: any): paper.Point | null => { + if (segment instanceof paper.Point) return segment + if (segment instanceof paper.Segment) + return new paper.Point(segment.point) + if ( + Array.isArray(segment) && + segment.length >= 2 && + typeof segment[0] === "number" && + typeof segment[1] === "number" + ) { + return new paper.Point(segment[0], segment[1]) + } + if ( + Array.isArray(segment) && + Array.isArray(segment[0]) && + segment[0].length >= 2 && + typeof segment[0][0] === "number" && + typeof segment[0][1] === "number" + ) { + return new paper.Point(segment[0][0], segment[0][1]) + } + if ( + segment?.point && + typeof segment.point.x === "number" && + typeof segment.point.y === "number" + ) { + return new paper.Point(segment.point.x, segment.point.y) + } + if ( + segment?.point && + Array.isArray(segment.point) && + segment.point.length >= 2 && + typeof segment.point[0] === "number" && + typeof segment.point[1] === "number" + ) { + return new paper.Point(segment.point[0], segment.point[1]) + } + return null + } + + const clearContinueSourceItems = (restoreVisible: boolean) => { + continueSourceItems.forEach((item) => { + if (restoreVisible) { + item.visible = true + } else { + item.remove() + } + }) + continueSourceItems = [] + } + + const clearContinuePolygonContext = (restoreVisible: boolean) => { + clearContinueSourceItems(restoreVisible) + continueTargetContourIndex = null + continueTargetContext = null + usePaperStore.getState().setContinuePolygonTarget(null) + } + + const startContinuePolygonDraft = () => { + const target = usePaperStore.getState().continuePolygonTarget + if (!target) return false + + const imageLabel = useLabelStore.getState().label.get(target.imageId) + const operationPaths = imageLabel?.get(target.operationId) || [] + const targetTuple = operationPaths.find( + (path) => path[0] === target.objectId + ) + if (!targetTuple) { + notifications.show({ + color: "yellow", + message: "未找到可继续标注的对象", + }) + clearContinuePolygonContext(true) + return false + } + + const nextPoints = safeClone(targetTuple[1] || []) + let contourIndex = -1 + for (let i = nextPoints.length - 1; i >= 0; i -= 1) { + if (nextPoints[i]?.length > 3) { + contourIndex = i + break + } + } + if (contourIndex === -1) { + notifications.show({ + color: "yellow", + message: "当前对象没有可继续标注的区域", + }) + clearContinuePolygonContext(true) + return false + } + + const contourSegments = safeClone(nextPoints[contourIndex] || []) + const continueSegments = contourSegments.slice().reverse() + continueSegments.pop() + const contourPoints = continueSegments + .map((segment: any) => getPointFromSegment(segment)) + .filter((point: paper.Point | null): point is paper.Point => !!point) + + if (contourPoints.length < 3) { + notifications.show({ + color: "yellow", + message: "当前区域至少保留3个节点,无法继续撤回", + }) + clearContinuePolygonContext(true) + return false + } + + clearContinueSourceItems(true) + continueSourceItems = usePaperStore + .getState() + .getItemsById(target.objectId) + .filter((item) => item.data?.type === "polygon") + continueSourceItems.forEach((item) => { + item.visible = false + }) + + continueTargetContourIndex = contourIndex + continueTargetContext = target + points = contourPoints + draftId = target.objectId + polygon?.remove() + polygon = usePaperStore.getState().addPolygon( + renderPath, + points, + { + ...usePaperStore.getState().toolOption, + }, + { + imageId: target.imageId, + operationId: target.operationId, + id: target.objectId, + type: "polygon", + } + ) + handleCircles(polygon) + lastPoint = usePaperStore + .getState() + .group!.localToGlobal(points[points.length - 1]) + lastTime = Date.now() + clearPolygonMagnet() + return true + } + + const saveContinuePolygonDraft = (draftPath: paper.Path) => { + const target = + continueTargetContext || usePaperStore.getState().continuePolygonTarget + if (!target) return false + + const imageLabel = useLabelStore.getState().label.get(target.imageId) + const operationPaths = imageLabel?.get(target.operationId) || [] + const targetTuple = operationPaths.find( + (path) => path[0] === target.objectId + ) + if (!targetTuple) return false + + const pathData = JSON.parse(draftPath.exportJSON({ precision: 20 })) + const nextContour = pathData?.[1]?.segments + if (!nextContour?.length) return false + + const nextPoints = safeClone(targetTuple[1] || []) + const contourIndex = + continueTargetContourIndex !== null && + continueTargetContourIndex >= 0 && + continueTargetContourIndex < nextPoints.length + ? continueTargetContourIndex + : nextPoints.length - 1 + if (contourIndex < 0) return false + + nextPoints[contourIndex] = nextContour + useLabelStore + .getState() + .setOperation(target.imageId, target.operationId, [ + target.objectId, + nextPoints, + safeClone(targetTuple[2] || initialDetail), + safeClone(targetTuple[3] || []), + ]) + useObjectStore + .getState() + .updateSelectedPath(target.imageId, "ADD", target.objectId) + clearContinuePolygonContext(false) + forceUpdate() + return true + } + const rebuildPolygonDraft = () => { polygon?.remove() polygon = null @@ -3099,6 +3304,14 @@ export const usePaperStore = create((set) => ({ usePaperSupportStore.getState().clearAll() } + if ( + !polygon && + !points.length && + usePaperStore.getState().continuePolygonTarget + ) { + if (!startContinuePolygonDraft()) return + } + // 先判断是否存在吸附点 let checkPoint = currentMagnetPoint ? currentMagnetPoint : e.point // doubleclick @@ -3122,6 +3335,30 @@ export const usePaperStore = create((set) => ({ // 初始化 compoundPolygon compoundPolygon = new paper.Path() + if (usePaperStore.getState().continuePolygonTarget) { + ;(polygon as paper.Path).closePath() + const continueSaved = saveContinuePolygonDraft( + polygon as paper.Path + ) + if (!continueSaved) { + polygon.remove() + clearContinuePolygonContext(true) + } + + selectedCircles.forEach((item) => { + item.remove() + }) + selectedCircles = [] + clearPolygonMagnet() + useTopToolsStore.getState().setPressA(false) + draftId = null + lastPoint = null + lastTime = 0 + points = [] + polygon = null + return + } + if (useKeyboardStore.getState().drawAction === "subtract") { ;(polygon as paper.Path).closePath() handleSelectedPolygonSubtract(polygon as paper.Path) @@ -3544,8 +3781,13 @@ export const usePaperStore = create((set) => ({ } polygonTool.onKeyDown = (e: paper.KeyEvent) => { if (e.key === "escape" || e.key === "alt") { - if (polygon || points.length) { + if ( + polygon || + points.length || + usePaperStore.getState().continuePolygonTarget + ) { resetPolygonDraft() + clearContinuePolygonContext(true) usePaperSupportStore.getState().handleBufferPaths(null) } } else if (e.key === "delete") {