import React, { useEffect, useRef, useState, useCallback } from 'react'; import { Stage, Layer, Image as KonvaImage, Circle, Rect, Path, Group } from 'react-konva'; import polygonClipping, { type MultiPolygon, type Pair } from 'polygon-clipping'; import useImage from 'use-image'; import { useStore } from '../store/useStore'; import { predictMask } from '../lib/api'; import type { Frame, Mask } from '../store/useStore'; interface CanvasAreaProps { activeTool: string; frame: Frame | null; onClearMasks?: () => void; onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise | void; } type CanvasPoint = { x: number; y: number }; type PromptPoint = CanvasPoint & { type: 'pos' | 'neg' }; type PromptBox = { x1: number; y1: number; x2: number; y2: number }; const DRAG_MANUAL_TOOLS = new Set(['create_rectangle', 'create_circle', 'create_line']); const POLYGON_TOOL = 'create_polygon'; const EDIT_POLYGON_TOOL = 'edit_polygon'; const POINT_TOOL = 'create_point'; const BOOLEAN_TOOLS = new Set(['area_merge', 'area_remove']); const POLYGON_CLOSE_RADIUS = 8; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function polygonPath(points: CanvasPoint[]): string { if (points.length === 0) return ''; return points .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) .join(' ') .concat(' Z'); } function segmentationPath(segmentation?: number[][]): string { return (segmentation || []) .map((polygon) => polygonPath(flatPolygonToPoints(polygon))) .filter(Boolean) .join(' '); } function segmentationPolygonPath(segmentation: number[][] | undefined, polygonIndex: number): string { const polygon = segmentation?.[polygonIndex]; return polygon ? polygonPath(flatPolygonToPoints(polygon)) : ''; } function polygonSegmentation(points: CanvasPoint[]): number[][] { return [points.flatMap((point) => [point.x, point.y])]; } function segmentationToPoints(segmentation?: number[][], polygonIndex = 0): CanvasPoint[] { const polygon = segmentation?.[polygonIndex] || []; const points: CanvasPoint[] = []; for (let index = 0; index < polygon.length - 1; index += 2) { points.push({ x: polygon[index], y: polygon[index + 1] }); } return points; } function flatPolygonToPoints(polygon: number[]): CanvasPoint[] { const points: CanvasPoint[] = []; for (let index = 0; index < polygon.length - 1; index += 2) { points.push({ x: polygon[index], y: polygon[index + 1] }); } return points; } function segmentationAllPoints(segmentation?: number[][]): CanvasPoint[] { return (segmentation || []).flatMap((polygon) => flatPolygonToPoints(polygon)); } function polygonBbox(points: CanvasPoint[]): [number, number, number, number] { const xs = points.map((point) => point.x); const ys = points.map((point) => point.y); const minX = Math.min(...xs); const minY = Math.min(...ys); const maxX = Math.max(...xs); const maxY = Math.max(...ys); return [minX, minY, maxX - minX, maxY - minY]; } function polygonArea(points: CanvasPoint[]): number { if (points.length < 3) return 0; const sum = points.reduce((acc, point, index) => { const next = points[(index + 1) % points.length]; return acc + point.x * next.y - next.x * point.y; }, 0); return Math.abs(sum) / 2; } function pointDistance(a: CanvasPoint, b: CanvasPoint): number { return Math.hypot(a.x - b.x, a.y - b.y); } function distanceToSegmentSquared(point: CanvasPoint, start: CanvasPoint, end: CanvasPoint): number { const dx = end.x - start.x; const dy = end.y - start.y; const lengthSquared = dx * dx + dy * dy; if (lengthSquared === 0) { return (point.x - start.x) ** 2 + (point.y - start.y) ** 2; } const t = clamp(((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared, 0, 1); const projected = { x: start.x + t * dx, y: start.y + t * dy }; return (point.x - projected.x) ** 2 + (point.y - projected.y) ** 2; } function nearestPolygonEdgeIndex(points: CanvasPoint[], point: CanvasPoint): number { return points.reduce((bestIndex, start, index) => { const end = points[(index + 1) % points.length]; if (!end) return bestIndex; const bestStart = points[bestIndex]; const bestEnd = points[(bestIndex + 1) % points.length]; const currentDistance = distanceToSegmentSquared(point, start, end); const bestDistance = bestStart && bestEnd ? distanceToSegmentSquared(point, bestStart, bestEnd) : Number.POSITIVE_INFINITY; return currentDistance < bestDistance ? index : bestIndex; }, 0); } function segmentationArea(segmentation?: number[][]): number { return (segmentation || []).reduce((sum, polygon) => sum + polygonArea(flatPolygonToPoints(polygon)), 0); } function segmentationBbox(segmentation?: number[][]): [number, number, number, number] | undefined { const points = segmentationAllPoints(segmentation); return points.length > 0 ? polygonBbox(points) : undefined; } function closeRing(points: CanvasPoint[]): Pair[] { const ring = points.map((point) => [point.x, point.y] as Pair); const first = ring[0]; const last = ring[ring.length - 1]; if (first && last && (first[0] !== last[0] || first[1] !== last[1])) { ring.push([first[0], first[1]]); } return ring; } function maskToMultiPolygon(mask: Mask): MultiPolygon | null { const polygons = (mask.segmentation || []) .map((polygon) => flatPolygonToPoints(polygon)) .filter((points) => points.length >= 3) .map((points) => [closeRing(points)]); return polygons.length > 0 ? polygons : null; } function openRingPoints(ring: Pair[]): CanvasPoint[] { const openRing = ring.length > 1 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1] ? ring.slice(0, -1) : ring; return openRing.map(([x, y]) => ({ x, y })); } function multiPolygonToSegmentation(geometry: MultiPolygon): number[][] { return geometry .flatMap((polygon) => polygon) .map((ring) => openRingPoints(ring).flatMap(({ x, y }) => [x, y])) .filter((polygon) => polygon.length >= 6); } function multiPolygonArea(geometry: MultiPolygon): number { return geometry.reduce((sum, polygon) => { const [outerRing, ...holeRings] = polygon; const outerArea = outerRing ? polygonArea(openRingPoints(outerRing)) : 0; const holesArea = holeRings.reduce((holeSum, ring) => holeSum + polygonArea(openRingPoints(ring)), 0); return sum + Math.max(outerArea - holesArea, 0); }, 0); } function multiPolygonHasHoles(geometry: MultiPolygon): boolean { return geometry.some((polygon) => polygon.length > 1); } function rectanglePoints(start: CanvasPoint, end: CanvasPoint): CanvasPoint[] { const x1 = Math.min(start.x, end.x); const y1 = Math.min(start.y, end.y); const x2 = Math.max(start.x, end.x); const y2 = Math.max(start.y, end.y); return [ { x: x1, y: y1 }, { x: x2, y: y1 }, { x: x2, y: y2 }, { x: x1, y: y2 }, ]; } function circlePoints(start: CanvasPoint, end: CanvasPoint): CanvasPoint[] { const cx = (start.x + end.x) / 2; const cy = (start.y + end.y) / 2; const rx = Math.abs(end.x - start.x) / 2; const ry = Math.abs(end.y - start.y) / 2; return Array.from({ length: 32 }, (_, index) => { const angle = (Math.PI * 2 * index) / 32; return { x: cx + Math.cos(angle) * rx, y: cy + Math.sin(angle) * ry }; }); } function pointRegion(point: CanvasPoint, radius = 5): CanvasPoint[] { return Array.from({ length: 12 }, (_, index) => { const angle = (Math.PI * 2 * index) / 12; return { x: point.x + Math.cos(angle) * radius, y: point.y + Math.sin(angle) * radius }; }); } function lineRegion(start: CanvasPoint, end: CanvasPoint, halfWidth = 4): CanvasPoint[] { const dx = end.x - start.x; const dy = end.y - start.y; const length = Math.hypot(dx, dy) || 1; const nx = (-dy / length) * halfWidth; const ny = (dx / length) * halfWidth; return [ { x: start.x + nx, y: start.y + ny }, { x: end.x + nx, y: end.y + ny }, { x: end.x - nx, y: end.y - ny }, { x: start.x - nx, y: start.y - ny }, ]; } export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnotations }: CanvasAreaProps) { const containerRef = useRef(null); const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [points, setPoints] = useState([]); const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); const [boxStart, setBoxStart] = useState<{ x: number, y: number } | null>(null); const [boxCurrent, setBoxCurrent] = useState<{ x: number, y: number } | null>(null); const [samPromptBox, setSamPromptBox] = useState(null); const [samCandidateMaskId, setSamCandidateMaskId] = useState(null); const [manualStart, setManualStart] = useState(null); const [manualCurrent, setManualCurrent] = useState(null); const [polygonPoints, setPolygonPoints] = useState([]); const [selectedMaskId, setSelectedMaskId] = useState(() => useStore.getState().selectedMaskIds[0] || null); const [selectedMaskIds, setSelectedMaskIds] = useState(() => useStore.getState().selectedMaskIds); const [selectedPolygonIndex, setSelectedPolygonIndex] = useState(0); const [selectedVertexIndex, setSelectedVertexIndex] = useState(null); const previousFrameIdRef = useRef(frame?.id); const [isInferencing, setIsInferencing] = useState(false); const [inferenceMessage, setInferenceMessage] = useState(''); const masks = useStore((state) => state.masks); const addMask = useStore((state) => state.addMask); const updateMask = useStore((state) => state.updateMask); const clearMasks = useStore((state) => state.clearMasks); const setMasks = useStore((state) => state.setMasks); const setGlobalSelectedMaskIds = useStore((state) => state.setSelectedMaskIds); const storeActiveTool = useStore((state) => state.activeTool); const aiModel = useStore((state) => state.aiModel); const activeTemplateId = useStore((state) => state.activeTemplateId); const activeClass = useStore((state) => state.activeClass); const undoMasks = useStore((state) => state.undoMasks); const redoMasks = useStore((state) => state.redoMasks); const effectiveTool = activeTool || storeActiveTool; // Load the actual frame image const [image] = useImage(frame?.url || ''); const frameMasks = masks.filter((mask) => mask.frameId === frame?.id); const selectedMask = React.useMemo( () => frameMasks.find((mask) => mask.id === selectedMaskId) || null, [frameMasks, selectedMaskId], ); const booleanSelectedMasks = React.useMemo( () => selectedMaskIds .map((id) => frameMasks.find((mask) => mask.id === id)) .filter((mask): mask is Mask => Boolean(mask)), [frameMasks, selectedMaskIds], ); const selectedMaskPoints = React.useMemo( () => segmentationToPoints(selectedMask?.segmentation, selectedPolygonIndex), [selectedMask?.segmentation, selectedPolygonIndex], ); const savedMaskCount = frameMasks.filter((mask) => mask.saveStatus === 'saved' || mask.saved).length; const draftMaskCount = frameMasks.filter((mask) => !mask.annotationId).length; const dirtyMaskCount = frameMasks.filter((mask) => mask.saveStatus === 'dirty').length; const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool); const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL; useEffect(() => { const handleResize = () => { if (containerRef.current) { setStageSize({ width: containerRef.current.clientWidth, height: containerRef.current.clientHeight, }); } }; handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); useEffect(() => { setManualStart(null); setManualCurrent(null); setPolygonPoints([]); setSelectedVertexIndex(null); if (!isPolygonEditTool && !isBooleanTool) { setSelectedMaskId(null); setSelectedMaskIds([]); setSelectedPolygonIndex(0); } }, [effectiveTool, isBooleanTool, isPolygonEditTool]); useEffect(() => { if (previousFrameIdRef.current === frame?.id) return; previousFrameIdRef.current = frame?.id; setSelectedMaskId(null); setSelectedMaskIds([]); setSelectedPolygonIndex(0); setSelectedVertexIndex(null); }, [frame?.id]); useEffect(() => { setPoints([]); setSamPromptBox(null); setSamCandidateMaskId(null); }, [frame?.id]); useEffect(() => { const currentGlobalSelectedIds = useStore.getState().selectedMaskIds; if (selectedMaskIds.length === 0) { const validGlobalSelectedIds = currentGlobalSelectedIds.filter((id) => ( frameMasks.some((mask) => mask.id === id) )); if (validGlobalSelectedIds.length > 0) return; } const isSameSelection = currentGlobalSelectedIds.length === selectedMaskIds.length && currentGlobalSelectedIds.every((id, index) => id === selectedMaskIds[index]); if (!isSameSelection) { setGlobalSelectedMaskIds(selectedMaskIds); } }, [frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]); useEffect(() => () => setGlobalSelectedMaskIds([]), [setGlobalSelectedMaskIds]); useEffect(() => { if (!selectedMaskId) { const validGlobalSelectedIds = useStore.getState().selectedMaskIds.filter((id) => ( frameMasks.some((mask) => mask.id === id) )); if (validGlobalSelectedIds.length > 0) { setSelectedMaskId(validGlobalSelectedIds[0]); setSelectedMaskIds(validGlobalSelectedIds); setSelectedPolygonIndex(0); setSelectedVertexIndex(null); return; } } if (selectedMaskId && !frameMasks.some((mask) => mask.id === selectedMaskId)) { setSelectedMaskId(null); setSelectedMaskIds([]); setSelectedPolygonIndex(0); setSelectedVertexIndex(null); } }, [frameMasks, selectedMaskId]); const handleWheel = (e: any) => { e.evt.preventDefault(); const scaleBy = 1.1; const stage = e.target.getStage(); const oldScale = stage.scaleX(); const mousePointTo = { x: stage.getPointerPosition().x / oldScale - stage.x() / oldScale, y: stage.getPointerPosition().y / oldScale - stage.y() / oldScale, }; const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy; setScale(newScale); setPosition({ x: -(mousePointTo.x - stage.getPointerPosition().x / newScale) * newScale, y: -(mousePointTo.y - stage.getPointerPosition().y / newScale) * newScale, }); }; const stagePoint = (e: any): CanvasPoint | null => { const stage = e.target.getStage(); const relPos = stage?.getRelativePointerPosition(); if (!relPos) return null; const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width; const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height; return { x: clamp(relPos.x, 0, imageWidth), y: clamp(relPos.y, 0, imageHeight), }; }; const createManualMask = useCallback((shape: string, polygon: CanvasPoint[]) => { if (!frame?.id || polygon.length < 3) return; const area = polygonArea(polygon); if (area <= 1) return; const color = activeClass?.color || '#06b6d4'; const label = activeClass?.name || `手工${shape}`; const mask: Mask = { id: `manual-${frame.id}-${shape}-${Date.now()}`, frameId: frame.id, templateId: activeTemplateId || undefined, classId: activeClass?.id, className: activeClass?.name, classZIndex: activeClass?.zIndex, saveStatus: 'draft', saved: false, pathData: polygonPath(polygon), label, color, segmentation: polygonSegmentation(polygon), points: shape === '点区域' ? [[ polygon.reduce((sum, point) => sum + point.x, 0) / polygon.length, polygon.reduce((sum, point) => sum + point.y, 0) / polygon.length, ]] : undefined, bbox: polygonBbox(polygon), area, metadata: { source: 'manual', shape }, }; addMask(mask); }, [activeClass, activeTemplateId, addMask, frame?.id]); const finishPolygon = useCallback(() => { if (polygonPoints.length < 3) return; createManualMask('多边形', polygonPoints); setPolygonPoints([]); }, [createManualMask, polygonPoints]); const handleMouseMove = (e: any) => { const stage = e.target.getStage(); if (!stage) return; const pos = stage.getPointerPosition(); if (pos) { const imageX = (pos.x - position.x) / scale; const imageY = (pos.y - position.y) / scale; setCursorPos({ x: imageX, y: imageY }); } if (boxStart && effectiveTool === 'box_select') { const relPos = stage.getRelativePointerPosition(); if (relPos) { setBoxCurrent({ x: relPos.x, y: relPos.y }); } } if (manualStart && DRAG_MANUAL_TOOLS.has(effectiveTool)) { const pos = stage.getRelativePointerPosition(); if (pos) { setManualCurrent({ x: pos.x, y: pos.y }); } } }; const runInference = useCallback(async ( promptPoints?: PromptPoint[], promptBox?: PromptBox, options: { resetCandidate?: boolean } = {}, ) => { if (!frame?.id) { console.warn('Inference skipped: no active frame'); setInferenceMessage('请先选择一帧图像。'); return; } const imageWidth = frame.width || image?.naturalWidth || image?.width || 0; const imageHeight = frame.height || image?.naturalHeight || image?.height || 0; if (imageWidth <= 0 || imageHeight <= 0) { console.warn('Inference skipped: active frame dimensions are unavailable'); setInferenceMessage('当前帧缺少宽高信息,无法推理。'); return; } setIsInferencing(true); setInferenceMessage(''); try { const hasNegativePrompt = Boolean(promptPoints?.some((point) => point.type === 'neg')); const existingCandidate = !options.resetCandidate && samCandidateMaskId ? masks.find((mask) => mask.id === samCandidateMaskId) : null; const result = await predictMask({ imageId: frame.id, imageWidth, imageHeight, model: aiModel, points: promptPoints && promptPoints.length > 0 ? promptPoints.map((p) => ({ x: p.x, y: p.y, type: p.type })) : undefined, box: promptBox, ...(hasNegativePrompt ? { options: { auto_filter_background: true, min_score: 0.05 } } : {}), }); const [m] = result.masks; if (m) { const label = activeClass?.name || existingCandidate?.label || m.label; const color = activeClass?.color || existingCandidate?.color || m.color; const metadata = { ...(existingCandidate?.metadata || {}), source: 'sam2_interactive', promptBox: promptBox || null, promptPointCount: promptPoints?.length || 0, promptNegativePointCount: promptPoints?.filter((point) => point.type === 'neg').length || 0, }; const nextMask = { frameId: frame.id, templateId: activeTemplateId || existingCandidate?.templateId || undefined, classId: activeClass?.id || existingCandidate?.classId, className: activeClass?.name || existingCandidate?.className, classZIndex: activeClass?.zIndex ?? existingCandidate?.classZIndex, saveStatus: existingCandidate?.annotationId ? 'dirty' as const : 'draft' as const, saved: false, pathData: m.pathData, label, color, segmentation: m.segmentation, points: promptPoints?.filter((p) => p.type === 'pos').map((p) => [p.x, p.y]), bbox: m.bbox, area: m.area, metadata, }; if (existingCandidate) { updateMask(existingCandidate.id, nextMask); setSelectedMaskId(existingCandidate.id); setSelectedMaskIds([existingCandidate.id]); } else { const id = m.id; setSamCandidateMaskId(id); setSelectedMaskId(id); setSelectedMaskIds([id]); addMask({ id, ...nextMask, }); } } else { if (existingCandidate && hasNegativePrompt) { setMasks(masks.filter((mask) => mask.id !== existingCandidate.id)); setSamCandidateMaskId(null); setSelectedMaskId(null); setSelectedMaskIds([]); setInferenceMessage('反向点已排除当前候选区域,请重新框选或添加新的正向点。'); } else { setInferenceMessage('模型没有返回可用区域,请调整点/框提示后重试。'); } } } catch (err) { console.error('Inference failed:', err); const detail = (err as any)?.response?.data?.detail; setInferenceMessage(detail || 'AI 推理失败,请查看模型状态或后端日志。'); } finally { setIsInferencing(false); } }, [activeClass, activeTemplateId, addMask, aiModel, frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, samCandidateMaskId, setMasks, updateMask]); const handleApplyActiveClass = () => { if (!frame?.id || !activeClass) return; setMasks(masks.map((mask) => { if (mask.frameId !== frame.id) return mask; return { ...mask, templateId: activeTemplateId || mask.templateId, classId: activeClass.id, className: activeClass.name, classZIndex: activeClass.zIndex, label: activeClass.name, color: activeClass.color, saveStatus: mask.annotationId ? 'dirty' : 'draft', saved: Boolean(mask.annotationId) ? false : mask.saved, }; })); }; const handleClearMasks = () => { if (onClearMasks) { onClearMasks(); return; } clearMasks(); }; const deleteMasksById = useCallback((maskIds: string[]) => { if (maskIds.length === 0) return; const idSet = new Set(maskIds); const deletingMasks = masks.filter((mask) => idSet.has(mask.id)); if (deletingMasks.length === 0) return; setMasks(masks.filter((mask) => !idSet.has(mask.id))); const annotationIds = deletingMasks .map((mask) => mask.annotationId) .filter((annotationId): annotationId is string => Boolean(annotationId)); if (annotationIds.length > 0) { void onDeleteMaskAnnotations?.(annotationIds); } if (samCandidateMaskId && idSet.has(samCandidateMaskId)) { setSamCandidateMaskId(null); setSamPromptBox(null); setPoints([]); } setSelectedMaskId(null); setSelectedMaskIds([]); setSelectedPolygonIndex(0); setSelectedVertexIndex(null); }, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setMasks]); const handleStageMouseDown = (e: any) => { if (DRAG_MANUAL_TOOLS.has(effectiveTool)) { const pos = stagePoint(e); if (pos) { setManualStart(pos); setManualCurrent(pos); } return; } if (effectiveTool === 'box_select') { const stage = e.target.getStage(); const pos = stage.getRelativePointerPosition(); if (pos) { setBoxStart({ x: pos.x, y: pos.y }); setBoxCurrent({ x: pos.x, y: pos.y }); } } }; const handleStageMouseUp = (e: any) => { if (DRAG_MANUAL_TOOLS.has(effectiveTool) && manualStart) { const end = stagePoint(e) || manualCurrent || manualStart; const width = Math.abs(end.x - manualStart.x); const height = Math.abs(end.y - manualStart.y); const distance = Math.hypot(width, height); if (effectiveTool === 'create_rectangle' && width > 4 && height > 4) { createManualMask('矩形', rectanglePoints(manualStart, end)); } if (effectiveTool === 'create_circle' && width > 4 && height > 4) { createManualMask('圆形', circlePoints(manualStart, end)); } if (effectiveTool === 'create_line' && distance > 4) { createManualMask('线段', lineRegion(manualStart, end)); } setManualStart(null); setManualCurrent(null); return; } if (effectiveTool === 'box_select' && boxStart && boxCurrent) { const x1 = Math.min(boxStart.x, boxCurrent.x); const y1 = Math.min(boxStart.y, boxCurrent.y); const x2 = Math.max(boxStart.x, boxCurrent.x); const y2 = Math.max(boxStart.y, boxCurrent.y); if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) { const nextBox = { x1, y1, x2, y2 }; setPoints([]); setSamPromptBox(nextBox); setSamCandidateMaskId(null); runInference([], nextBox, { resetCandidate: true }); } setBoxStart(null); setBoxCurrent(null); } }; const handleStageClick = (e: any) => { if (isPolygonEditTool) return; if (effectiveTool === 'box_select') return; // handled by mouseup if (DRAG_MANUAL_TOOLS.has(effectiveTool)) return; if (effectiveTool === POINT_TOOL) { const pos = stagePoint(e); if (pos) { createManualMask('点区域', pointRegion(pos)); } return; } if (effectiveTool === POLYGON_TOOL) { const pos = stagePoint(e); if (pos) { const closeRadius = POLYGON_CLOSE_RADIUS / Math.max(scale, 0.1); if (polygonPoints.length >= 3 && pointDistance(pos, polygonPoints[0]) <= closeRadius) { finishPolygon(); return; } setPolygonPoints((current) => [...current, pos]); } return; } if (effectiveTool === 'point_pos' || effectiveTool === 'point_neg') { const stage = e.target.getStage(); const pos = stage.getRelativePointerPosition(); if (pos) { const newPoints = [ ...points, { x: pos.x, y: pos.y, type: (effectiveTool === 'point_pos' ? 'pos' : 'neg') as 'pos' | 'neg' }, ]; setPoints(newPoints); runInference(newPoints, samPromptBox || undefined); } } }; const removePromptPoint = useCallback((pointIndex: number, event?: any) => { if (event) event.cancelBubble = true; const nextPoints = points.filter((_, index) => index !== pointIndex); setPoints(nextPoints); if (nextPoints.length > 0 || samPromptBox) { runInference(nextPoints, samPromptBox || undefined); return; } if (samCandidateMaskId) { setMasks(masks.filter((mask) => mask.id !== samCandidateMaskId)); setSamCandidateMaskId(null); setSelectedMaskId(null); setSelectedMaskIds([]); setInferenceMessage('已移除最后一个提示点和对应候选区域。'); } }, [masks, points, runInference, samCandidateMaskId, samPromptBox, setMasks]); const updatePolygonMask = useCallback((mask: Mask, nextPoints: CanvasPoint[], polygonIndex = 0) => { if (nextPoints.length < 3) return; const nextSegmentation = [...(mask.segmentation || [])]; nextSegmentation[polygonIndex] = nextPoints.flatMap((point) => [point.x, point.y]); const bbox = segmentationBbox(nextSegmentation) || polygonBbox(nextPoints); updateMask(mask.id, { pathData: segmentationPath(nextSegmentation), segmentation: nextSegmentation, bbox, area: segmentationArea(nextSegmentation), saveStatus: mask.annotationId ? 'dirty' : 'draft', saved: mask.annotationId ? false : mask.saved, }); }, [updateMask]); const updateMaskFromSegmentation = useCallback(( mask: Mask, segmentation: number[][], options: { area?: number; hasHoles?: boolean } = {}, ): Mask => { const bbox = segmentationBbox(segmentation); const metadata = { ...(mask.metadata || {}) }; if (options.hasHoles === true) metadata.hasHoles = true; if (options.hasHoles === false) delete metadata.hasHoles; return { ...mask, pathData: segmentationPath(segmentation), segmentation, bbox, area: options.area ?? segmentationArea(segmentation), metadata, saveStatus: mask.annotationId ? 'dirty' : 'draft', saved: mask.annotationId ? false : mask.saved, }; }, []); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const key = event.key.toLowerCase(); if ((event.metaKey || event.ctrlKey) && key === 'z') { event.preventDefault(); if (event.shiftKey) redoMasks(); else undoMasks(); return; } if ((event.metaKey || event.ctrlKey) && key === 'y') { event.preventDefault(); redoMasks(); return; } if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask && selectedVertexIndex !== null) { const currentPoints = segmentationToPoints(selectedMask.segmentation, selectedPolygonIndex); if (currentPoints.length > 3) { event.preventDefault(); const nextPoints = currentPoints.filter((_, index) => index !== selectedVertexIndex); updatePolygonMask(selectedMask, nextPoints, selectedPolygonIndex); setSelectedVertexIndex(null); } return; } if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask) { event.preventDefault(); const ids = selectedMaskIds.length > 0 ? selectedMaskIds : [selectedMask.id]; deleteMasksById(ids); return; } if (effectiveTool !== POLYGON_TOOL) return; if (event.key === 'Enter' && polygonPoints.length >= 3) { event.preventDefault(); finishPolygon(); } if (event.key === 'Escape') { event.preventDefault(); setPolygonPoints([]); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, polygonPoints, redoMasks, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, undoMasks, updatePolygonMask]); const boxRect = React.useMemo(() => { if (!boxStart || !boxCurrent) return null; const x = Math.min(boxStart.x, boxCurrent.x); const y = Math.min(boxStart.y, boxCurrent.y); const width = Math.abs(boxCurrent.x - boxStart.x); const height = Math.abs(boxCurrent.y - boxStart.y); return { x, y, width, height }; }, [boxStart, boxCurrent]); const manualPreviewPath = React.useMemo(() => { if (manualStart && manualCurrent) { if (effectiveTool === 'create_rectangle') return polygonPath(rectanglePoints(manualStart, manualCurrent)); if (effectiveTool === 'create_circle') return polygonPath(circlePoints(manualStart, manualCurrent)); if (effectiveTool === 'create_line') return polygonPath(lineRegion(manualStart, manualCurrent)); } if (effectiveTool === POLYGON_TOOL && polygonPoints.length > 0) { const previewPoints = [...polygonPoints, cursorPos]; return polygonPath(previewPoints); } return null; }, [cursorPos, effectiveTool, manualCurrent, manualStart, polygonPoints]); const handleSeedPointDragEnd = (mask: Mask, pointIndex: number, event: any) => { const x = event.target.x(); const y = event.target.y(); const nextPoints = [...(mask.points || [])]; nextPoints[pointIndex] = [x, y]; updateMask(mask.id, { points: nextPoints, saveStatus: mask.annotationId ? 'dirty' : 'draft', saved: mask.annotationId ? false : mask.saved, }); }; const handleMaskSelect = (mask: Mask, event: any, polygonIndex = 0) => { if (!isPolygonEditTool && !isBooleanTool) return; event.cancelBubble = true; if (isBooleanTool) { setSelectedMaskIds((current) => ( current.includes(mask.id) ? current.filter((id) => id !== mask.id) : [...current, mask.id] )); setSelectedMaskId(mask.id); setSelectedPolygonIndex(polygonIndex); setSelectedVertexIndex(null); return; } setSelectedMaskId(mask.id); setSelectedMaskIds([mask.id]); setSelectedPolygonIndex(polygonIndex); setSelectedVertexIndex(null); }; const handleVertexDragEnd = (mask: Mask, vertexIndex: number, event: any) => { const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width; const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height; const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex); if (!currentPoints[vertexIndex]) return; const nextPoints = currentPoints.map((point, index) => ( index === vertexIndex ? { x: clamp(event.target.x(), 0, imageWidth), y: clamp(event.target.y(), 0, imageHeight), } : point )); setSelectedMaskId(mask.id); setSelectedVertexIndex(vertexIndex); updatePolygonMask(mask, nextPoints, selectedPolygonIndex); }; const handleEdgeInsert = (mask: Mask, edgeIndex: number, event: any) => { event.cancelBubble = true; const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex); const start = currentPoints[edgeIndex]; const end = currentPoints[(edgeIndex + 1) % currentPoints.length]; if (!start || !end) return; const inserted = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; const nextPoints = [ ...currentPoints.slice(0, edgeIndex + 1), inserted, ...currentPoints.slice(edgeIndex + 1), ]; setSelectedMaskId(mask.id); setSelectedVertexIndex(edgeIndex + 1); updatePolygonMask(mask, nextPoints, selectedPolygonIndex); }; const handlePathDoubleClick = (mask: Mask, event: any, polygonIndex = 0) => { if (effectiveTool !== EDIT_POLYGON_TOOL) return; event.cancelBubble = true; const point = stagePoint(event); const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex); if (!point || currentPoints.length < 3) return; const edgeIndex = nearestPolygonEdgeIndex(currentPoints, point); const nextPoints = [ ...currentPoints.slice(0, edgeIndex + 1), point, ...currentPoints.slice(edgeIndex + 1), ]; setSelectedMaskId(mask.id); setSelectedMaskIds([mask.id]); setSelectedPolygonIndex(polygonIndex); setSelectedVertexIndex(edgeIndex + 1); updatePolygonMask(mask, nextPoints, polygonIndex); }; const handleBooleanOperation = async () => { if (!frame || booleanSelectedMasks.length < 2) return; const primary = booleanSelectedMasks[0]; const primaryGeometry = maskToMultiPolygon(primary); if (!primaryGeometry) return; const clipGeometries = booleanSelectedMasks .slice(1) .map(maskToMultiPolygon) .filter((geometry): geometry is MultiPolygon => Boolean(geometry)); if (clipGeometries.length === 0) return; const resultGeometry = effectiveTool === 'area_merge' ? polygonClipping.union(primaryGeometry, ...clipGeometries) : polygonClipping.difference(primaryGeometry, ...clipGeometries); const resultSegmentation = multiPolygonToSegmentation(resultGeometry); if (resultSegmentation.length === 0) { const deleteIds = primary.annotationId ? [primary.annotationId] : []; setMasks(masks.filter((mask) => mask.id !== primary.id)); if (deleteIds.length > 0) await onDeleteMaskAnnotations?.(deleteIds); setSelectedMaskId(null); setSelectedMaskIds([]); setSelectedVertexIndex(null); return; } const nextPrimary = updateMaskFromSegmentation(primary, resultSegmentation, { area: multiPolygonArea(resultGeometry), hasHoles: multiPolygonHasHoles(resultGeometry), }); const secondaryIds = effectiveTool === 'area_merge' ? new Set(booleanSelectedMasks.slice(1).map((mask) => mask.id)) : new Set(); const secondaryAnnotationIds = effectiveTool === 'area_merge' ? booleanSelectedMasks .slice(1) .map((mask) => mask.annotationId) .filter((annotationId): annotationId is string => Boolean(annotationId)) : []; setMasks(masks .filter((mask) => !secondaryIds.has(mask.id)) .map((mask) => (mask.id === primary.id ? nextPrimary : mask))); if (secondaryAnnotationIds.length > 0) await onDeleteMaskAnnotations?.(secondaryAnnotationIds); setSelectedMaskId(primary.id); setSelectedMaskIds([primary.id]); setSelectedVertexIndex(null); }; return (
{isInferencing && (
AI 推理中...
)} {!isInferencing && inferenceMessage && (
{inferenceMessage}
)} {/* Background Image Layer */} {image && ( )} {/* AI Returned Masks */} {frameMasks.map((mask) => { const hasHoles = Boolean(mask.metadata?.hasHoles); const paths = hasHoles ? [{ data: segmentationPath(mask.segmentation), polygonIndex: 0, fillRule: 'evenodd' }] : (mask.segmentation && mask.segmentation.length > 0 ? mask.segmentation : [undefined]).map((_, polygonIndex) => ({ data: mask.segmentation ? segmentationPolygonPath(mask.segmentation, polygonIndex) : mask.pathData, polygonIndex, fillRule: undefined, })); return ( {paths.map(({ data, polygonIndex, fillRule }) => ( handleMaskSelect(mask, event, polygonIndex)} onTap={(event: any) => handleMaskSelect(mask, event, polygonIndex)} onDblClick={(event: any) => handlePathDoubleClick(mask, event, polygonIndex)} onDblTap={(event: any) => handlePathDoubleClick(mask, event, polygonIndex)} /> ))} ); })} {/* Box selection preview */} {boxRect && effectiveTool === 'box_select' && ( )} {/* Manual shape preview */} {manualPreviewPath && ( )} {polygonPoints.map((point, index) => ( = 3 ? 6 : 4) / scale} fill={index === 0 && polygonPoints.length >= 3 ? '#facc15' : '#22d3ee'} stroke={index === 0 && polygonPoints.length >= 3 ? '#fef3c7' : '#ffffff'} strokeWidth={1 / scale} onClick={(event: any) => { if (index !== 0 || polygonPoints.length < 3) return; event.cancelBubble = true; finishPolygon(); }} onTap={(event: any) => { if (index !== 0 || polygonPoints.length < 3) return; event.cancelBubble = true; finishPolygon(); }} /> ))} {/* Imported GT seed points / editable point regions */} {frameMasks.flatMap((mask) => (mask.points || []).map(([x, y], index) => ( handleSeedPointDragEnd(mask, index, event)} /> )))} {/* Polygon edge insertion handles */} {isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => { const next = selectedMaskPoints[(index + 1) % selectedMaskPoints.length]; if (!next) return null; return ( handleEdgeInsert(selectedMask, index, event)} onTap={(event: any) => handleEdgeInsert(selectedMask, index, event)} /> ); })} {/* Polygon vertex editor */} {isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => ( { event.cancelBubble = true; setSelectedVertexIndex(index); }} onTap={(event: any) => { event.cancelBubble = true; setSelectedVertexIndex(index); }} onDragEnd={(event: any) => handleVertexDragEnd(selectedMask, index, event)} /> ))} {/* AI Prompts Point Regions */} {points.map((p, i) => ( removePromptPoint(i, event)} onTap={(event: any) => removePromptPoint(i, event)} /> removePromptPoint(i, event)} onTap={(event: any) => removePromptPoint(i, event)} /> ))}
光标: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)} 当前图层树: OBJECT_VEHICLE_01 缩放比: {(scale * 100).toFixed(0)}% 遮罩数: {frameMasks.length} 已保存: {savedMaskCount} 未保存: {draftMaskCount} 待更新: {dirtyMaskCount}
{frameMasks.length > 0 && (
{isBooleanTool && (
已选 {booleanSelectedMasks.length}
)} {activeClass && ( )}
)}
); }