Files
Pre_Seg_Server/src/components/CanvasArea.tsx
admin a22af5f7c8 统一工作区清空遮罩入口
- 移除 Canvas 右下角旧清空遮罩和应用分类按钮,清空入口统一到左侧工具栏

- 清空遮罩优先作用于当前帧选中 mask,无选中时作用于当前帧全部 mask

- 目标 mask 无传播链结果时直接清当前帧,有传播链结果时弹窗选择只清当前帧、清空传播所有帧或取消

- 保留布尔工具右下角合并/去除操作区,避免旧分类按钮误改整帧

- 更新 Canvas、工具栏、工作区测试,覆盖直接清空、传播链范围选择和取消路径

- 同步更新前端审计、需求冻结、设计冻结、测试计划和 AGENTS 说明
2026-05-03 21:06:03 +08:00

1761 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
}
type CanvasPoint = { x: number; y: number };
type PromptPoint = CanvasPoint & { type: 'pos' | 'neg' };
type PromptBox = { x1: number; y1: number; x2: number; y2: number };
type ToolHint = { title: string; body: string };
const DRAG_MANUAL_TOOLS = new Set(['create_rectangle', 'create_circle']);
const POLYGON_TOOL = 'create_polygon';
const EDIT_POLYGON_TOOL = 'edit_polygon';
const BRUSH_TOOL = 'brush';
const ERASER_TOOL = 'eraser';
const PAINT_TOOLS = new Set([BRUSH_TOOL, ERASER_TOOL]);
const BOOLEAN_TOOLS = new Set(['area_merge', 'area_remove']);
const POLYGON_CLOSE_RADIUS = 8;
const DEFAULT_IMAGE_FIT_RATIO = 0.86;
const TOOL_HINT_TTL_MS = 3600;
const PAINT_STAMP_SEGMENTS = 16;
const MAX_PAINT_STROKE_POINTS = 128;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function metadataNumber(value: unknown): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function propagationSourceMaskTokens(value: unknown): string[] {
if (typeof value !== 'string' || value.length === 0) return [];
const tokens = [`mask:${value}`];
const annotationMatch = value.match(/^annotation-(\d+)$/);
if (annotationMatch) {
tokens.push(`annotation:${annotationMatch[1]}`);
}
return tokens;
}
function isPropagationMask(mask: Mask): boolean {
const metadata = mask.metadata || {};
const source = typeof metadata.source === 'string' ? metadata.source : '';
return source.includes('_propagation')
|| metadata.propagated_from_frame_id !== undefined
|| metadata.propagation_seed_key !== undefined
|| metadata.source_annotation_id !== undefined
|| metadata.source_mask_id !== undefined;
}
function propagationLineageTokens(mask: Mask): Set<string> {
const metadata = mask.metadata || {};
const tokens = new Set<string>([`mask:${mask.id}`]);
if (mask.annotationId) {
tokens.add(`annotation:${mask.annotationId}`);
}
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
if (sourceAnnotationId !== null) {
tokens.add(`annotation:${sourceAnnotationId}`);
}
propagationSourceMaskTokens(metadata.source_mask_id).forEach((token) => tokens.add(token));
if (typeof metadata.propagation_seed_key === 'string' && metadata.propagation_seed_key.length > 0) {
tokens.add(`seed-key:${metadata.propagation_seed_key}`);
}
return tokens;
}
function findLinkedMasksOnFrame(selectedIds: string[], allMasks: Mask[], targetFrameId?: string): string[] {
if (!targetFrameId || selectedIds.length === 0) return [];
const selectedMasks = selectedIds
.map((id) => allMasks.find((mask) => mask.id === id))
.filter((mask): mask is Mask => Boolean(mask));
if (selectedMasks.length === 0) return [];
const selectedTokens = new Set<string>();
const selectedHasPropagation = selectedMasks.some(isPropagationMask);
selectedMasks.forEach((mask) => {
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
});
return allMasks
.filter((mask) => String(mask.frameId) === String(targetFrameId))
.filter((mask) => {
const candidateHasPropagation = isPropagationMask(mask);
if (!selectedHasPropagation && !candidateHasPropagation) return false;
const candidateTokens = propagationLineageTokens(mask);
return [...candidateTokens].some((token) => selectedTokens.has(token));
})
.map((mask) => mask.id);
}
function findPropagationChainMaskIds(selectedIds: string[], allMasks: Mask[]): Set<string> {
const selectedMasks = selectedIds
.map((id) => allMasks.find((mask) => mask.id === id))
.filter((mask): mask is Mask => Boolean(mask));
const selectedTokens = new Set<string>();
selectedMasks.forEach((mask) => {
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
});
if (selectedTokens.size === 0) return new Set(selectedIds);
return new Set(
allMasks
.filter((mask) => {
const candidateTokens = propagationLineageTokens(mask);
return [...candidateTokens].some((token) => selectedTokens.has(token));
})
.map((mask) => mask.id),
);
}
function expandedPropagationDeletionMaskIds(selectedIds: string[], allMasks: Mask[]): Set<string> {
const selectedIdSet = new Set(selectedIds);
const chainIds = findPropagationChainMaskIds(selectedIds, allMasks);
return new Set(
allMasks
.filter((mask) => selectedIdSet.has(mask.id) || (chainIds.has(mask.id) && isPropagationMask(mask)))
.map((mask) => mask.id),
);
}
function maskLayerPriority(mask: Mask): number {
const parsed = Number(mask.classZIndex ?? mask.metadata?.classZIndex ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
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 extendStrokePoints(
current: CanvasPoint[],
nextPoint: CanvasPoint,
spacing: number,
maxPoints = MAX_PAINT_STROKE_POINTS,
): CanvasPoint[] {
const previous = current[current.length - 1];
if (!previous) return [nextPoint];
const distance = pointDistance(previous, nextPoint);
if (distance < spacing) return current;
const steps = Math.max(1, Math.floor(distance / spacing));
const additions: CanvasPoint[] = [];
for (let step = 1; step <= steps; step += 1) {
if (current.length + additions.length >= maxPoints) break;
const ratio = step / steps;
additions.push({
x: previous.x + (nextPoint.x - previous.x) * ratio,
y: previous.y + (nextPoint.y - previous.y) * ratio,
});
}
return [...current, ...additions];
}
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 segmentationRings(segmentation?: number[][]): Pair[][] {
return (segmentation || [])
.map((polygon) => closeRing(flatPolygonToPoints(polygon)))
.filter((ring) => ring.length >= 4);
}
function maskPolygonRingCounts(mask: Mask, ringCount: number): number[] | null {
const rawCounts = mask.metadata?.polygonRingCounts;
if (!Array.isArray(rawCounts)) return null;
const counts = rawCounts
.map((count) => Number(count))
.filter((count) => Number.isInteger(count) && count > 0);
const total = counts.reduce((sum, count) => sum + count, 0);
return total === ringCount ? counts : null;
}
function maskToMultiPolygon(mask: Mask): MultiPolygon | null {
const rings = segmentationRings(mask.segmentation);
if (rings.length === 0) return null;
const counts = maskPolygonRingCounts(mask, rings.length);
if (counts) {
let offset = 0;
return counts.map((count) => {
const polygon = rings.slice(offset, offset + count);
offset += count;
return polygon;
}).filter((polygon) => polygon.length > 0);
}
if (mask.metadata?.hasHoles && rings.length > 1) {
return [rings];
}
return rings.map((ring) => [ring]);
}
function polygonsToMultiPolygon(polygons: CanvasPoint[][]): MultiPolygon | null {
const geometry = polygons
.filter((points) => points.length >= 3)
.map((points) => [closeRing(points)]);
return geometry.length > 0 ? geometry : 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 multiPolygonRingCounts(geometry: MultiPolygon): number[] {
return geometry
.map((polygon) => polygon.length)
.filter((count) => count > 0);
}
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 maskWithSegmentation(
mask: Mask,
segmentation: number[][],
options: { area?: number; hasHoles?: boolean; polygonRingCounts?: number[] } = {},
): Mask {
const bbox = segmentationBbox(segmentation);
const metadata = { ...(mask.metadata || {}) };
if (options.hasHoles === true) metadata.hasHoles = true;
if (options.hasHoles === false) delete metadata.hasHoles;
if (options.polygonRingCounts && options.polygonRingCounts.length > 0) metadata.polygonRingCounts = options.polygonRingCounts;
if (options.hasHoles === false) delete metadata.polygonRingCounts;
return {
...mask,
pathData: segmentationPath(segmentation),
segmentation,
bbox,
area: options.area ?? segmentationArea(segmentation),
metadata,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: mask.annotationId ? false : mask.saved,
};
}
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 circleStampPoints(center: CanvasPoint, radius: number, segments = PAINT_STAMP_SEGMENTS): CanvasPoint[] {
return Array.from({ length: segments }, (_, index) => {
const angle = (Math.PI * 2 * index) / segments;
return { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius };
});
}
function paintStrokeToGeometry(strokePoints: CanvasPoint[], radius: number): MultiPolygon | null {
const geometries = strokePoints
.map((point) => polygonsToMultiPolygon([circleStampPoints(point, radius)]))
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
if (geometries.length === 0) return null;
const [firstGeometry, ...restGeometries] = geometries;
return restGeometries.length === 0
? firstGeometry
: polygonClipping.union(firstGeometry, ...restGeometries);
}
function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
return polygonClipping.intersection(first, second).length > 0;
}
export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: CanvasAreaProps) {
const containerRef = useRef<HTMLDivElement>(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<PromptPoint[]>([]);
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<PromptBox | null>(null);
const [samCandidateMaskId, setSamCandidateMaskId] = useState<string | null>(null);
const [manualStart, setManualStart] = useState<CanvasPoint | null>(null);
const [manualCurrent, setManualCurrent] = useState<CanvasPoint | null>(null);
const [paintStrokePoints, setPaintStrokePointsState] = useState<CanvasPoint[]>([]);
const [polygonPoints, setPolygonPoints] = useState<CanvasPoint[]>([]);
const [selectedMaskId, setSelectedMaskId] = useState<string | null>(() => useStore.getState().selectedMaskIds[0] || null);
const [selectedMaskIds, setSelectedMaskIds] = useState<string[]>(() => useStore.getState().selectedMaskIds);
const [selectedPolygonIndex, setSelectedPolygonIndex] = useState(0);
const [selectedVertexIndex, setSelectedVertexIndex] = useState<number | null>(null);
const previousFrameIdRef = useRef<string | undefined>(frame?.id);
const [isInferencing, setIsInferencing] = useState(false);
const [inferenceMessage, setInferenceMessage] = useState('');
const [isToolHintVisible, setIsToolHintVisible] = useState(false);
const lastAutoFitKeyRef = useRef('');
const paintStrokeRef = useRef<CanvasPoint[]>([]);
const paintToolRef = useRef<string | null>(null);
const lastPaintPointRef = useRef<CanvasPoint | null>(null);
const masks = useStore((state) => state.masks);
const addMask = useStore((state) => state.addMask);
const updateMask = useStore((state) => state.updateMask);
const setMasks = useStore((state) => state.setMasks);
const setGlobalSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
const brushSize = useStore((state) => state.brushSize);
const eraserSize = useStore((state) => state.eraserSize);
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 effectiveTool = activeTool || storeActiveTool;
// Load the actual frame image
const [image] = useImage(frame?.url || '');
const frameMasks = masks.filter((mask) => mask.frameId === frame?.id);
const displayFrameMasks = React.useMemo(() => {
if (selectedMaskIds.length > 0) return frameMasks;
return frameMasks
.map((mask, index) => ({ mask, index }))
.sort((a, b) => {
const priorityDiff = maskLayerPriority(a.mask) - maskLayerPriority(b.mask);
return priorityDiff === 0 ? a.index - b.index : priorityDiff;
})
.map((item) => item.mask);
}, [frameMasks, selectedMaskIds.length]);
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 isPaintTool = PAINT_TOOLS.has(effectiveTool);
const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL;
const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize;
const activePaintRadius = Math.max(2, activePaintSize / 2);
const setPaintStrokePoints = useCallback((nextPoints: CanvasPoint[]) => {
paintStrokeRef.current = nextPoints;
setPaintStrokePointsState(nextPoints);
}, []);
const currentLayerLabel = selectedMask
? `${selectedMask.className || selectedMask.label}${selectedMask.annotationId ? ` #${selectedMask.annotationId}` : ' (未保存)'}`
: '未选择';
const toolHint = React.useMemo<ToolHint | null>(() => {
if (!frame) return null;
if (effectiveTool === POLYGON_TOOL) {
if (polygonPoints.length === 0) {
return {
title: '创建多边形',
body: '点击画布添加顶点;至少 3 个点后,点击首节点或按 Enter 完成,按 Esc 取消。',
};
}
if (polygonPoints.length < 3) {
return {
title: `创建多边形 · 已放置 ${polygonPoints.length}`,
body: '继续点击添加顶点;满 3 个点后才能闭合,按 Esc 可取消当前多边形。',
};
}
return {
title: `创建多边形 · 已放置 ${polygonPoints.length}`,
body: '点击黄色首节点或按 Enter 闭合完成;按 Esc 放弃当前多边形。',
};
}
if (effectiveTool === 'create_rectangle') {
return { title: '创建矩形', body: '按住并拖拽框出区域,松开鼠标后生成 mask切换工具可放弃当前操作。' };
}
if (effectiveTool === 'create_circle') {
return { title: '创建圆形', body: '按住并拖拽确定外接范围,松开鼠标后生成椭圆 mask。' };
}
if (effectiveTool === BRUSH_TOOL) {
return {
title: '画笔',
body: activeClass
? '按住并拖动画出连续区域;若与当前选中 mask 连通,会自动合并到该 mask。'
: '先在右侧语义分类树选择类别,然后按住并拖动画出连续区域。',
};
}
if (effectiveTool === ERASER_TOOL) {
return {
title: '橡皮擦',
body: selectedMask
? '按住并拖动,从当前选中 mask 中扣除经过的区域。'
: '先选择一个 mask然后按住并拖动擦除区域。',
};
}
if (effectiveTool === 'box_select') {
return {
title: samPromptBox ? '边界框已建立' : '边界框选',
body: samPromptBox
? '继续添加正向/反向点可细化同一个候选区域;重新拖拽会替换当前框。'
: '按住并拖拽建立框选区域,松开后会触发 SAM 推理。',
};
}
if (effectiveTool === 'point_pos') {
return { title: '正向选点', body: '点击目标内部添加正向点并触发细化;点击已有提示点可删除并重新推理。' };
}
if (effectiveTool === 'point_neg') {
return { title: '反向选点', body: '点击不应包含的区域添加反向点;点击已有提示点可删除并重新推理。' };
}
if (effectiveTool === 'area_merge') {
return {
title: '区域合并',
body: booleanSelectedMasks.length > 0
? `已选 ${booleanSelectedMasks.length} 个区域;第一个选中的是主区域,点击“合并选中”完成。`
: '依次点击多个 mask第一个选中的区域会作为合并后的主区域。',
};
}
if (effectiveTool === 'area_remove') {
return {
title: '重叠区域去除',
body: booleanSelectedMasks.length > 0
? `已选 ${booleanSelectedMasks.length} 个区域;第一个是保留主区域,后续区域会被扣除。`
: '先点击要保留的主区域,再点击要扣除的干涉区域。',
};
}
if (effectiveTool === EDIT_POLYGON_TOOL || (effectiveTool === 'move' && selectedMask)) {
return {
title: selectedMask ? '调整多边形' : '调整多边形',
body: selectedMask
? '可直接拖动白色顶点;点击青色边中点或双击边线新增顶点;选中顶点/区域后按 Delete 删除。'
: '点击一个 mask 后,可拖动顶点、点击边中点新增顶点,或按 Delete 删除选中区域。',
};
}
return null;
}, [activeClass, booleanSelectedMasks.length, effectiveTool, frame, polygonPoints.length, samPromptBox, selectedMask]);
useEffect(() => {
if (!toolHint) {
setIsToolHintVisible(false);
return;
}
setIsToolHintVisible(true);
const timer = window.setTimeout(() => {
setIsToolHintVisible(false);
}, TOOL_HINT_TTL_MS);
return () => window.clearTimeout(timer);
}, [toolHint?.body, toolHint?.title]);
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(() => {
if (!frame?.id || stageSize.width <= 0 || stageSize.height <= 0) return;
const imageWidth = frame.width || image?.naturalWidth || image?.width || 0;
const imageHeight = frame.height || image?.naturalHeight || image?.height || 0;
if (imageWidth <= 0 || imageHeight <= 0) return;
const fitKey = `${frame.id}:${stageSize.width}x${stageSize.height}:${imageWidth}x${imageHeight}`;
if (lastAutoFitKeyRef.current === fitKey) return;
lastAutoFitKeyRef.current = fitKey;
const nextScale = Math.max(
0.05,
Math.min(stageSize.width / imageWidth, stageSize.height / imageHeight) * DEFAULT_IMAGE_FIT_RATIO,
);
setScale(nextScale);
setPosition({
x: (stageSize.width - imageWidth * nextScale) / 2,
y: (stageSize.height - imageHeight * nextScale) / 2,
});
}, [frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]);
useEffect(() => {
setManualStart(null);
setManualCurrent(null);
setPaintStrokePoints([]);
paintToolRef.current = null;
lastPaintPointRef.current = null;
setPolygonPoints([]);
setSelectedVertexIndex(null);
if (!isPolygonEditTool && !isBooleanTool && !isPaintTool) {
setSelectedMaskId(null);
setSelectedMaskIds([]);
setSelectedPolygonIndex(0);
}
}, [effectiveTool, isBooleanTool, isPaintTool, isPolygonEditTool, setPaintStrokePoints]);
useEffect(() => {
if (previousFrameIdRef.current === frame?.id) return;
previousFrameIdRef.current = frame?.id;
const linkedMaskIds = findLinkedMasksOnFrame(useStore.getState().selectedMaskIds, masks, frame?.id);
if (linkedMaskIds.length > 0) {
setSelectedMaskId(linkedMaskIds[0]);
setSelectedMaskIds(linkedMaskIds);
setGlobalSelectedMaskIds(linkedMaskIds);
} else {
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
}
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}, [frame?.id, masks, setGlobalSelectedMaskIds]);
useEffect(() => {
setPoints([]);
setSamPromptBox(null);
setSamCandidateMaskId(null);
}, [frame?.id]);
useEffect(() => {
const currentGlobalSelectedIds = useStore.getState().selectedMaskIds;
const validLocalSelectedIds = selectedMaskIds.filter((id) => (
frameMasks.some((mask) => mask.id === id)
));
if (selectedMaskIds.length > 0 && validLocalSelectedIds.length === 0) {
return;
}
if (selectedMaskIds.length === 0) {
const validGlobalSelectedIds = currentGlobalSelectedIds.filter((id) => (
frameMasks.some((mask) => mask.id === id)
));
if (validGlobalSelectedIds.length > 0) return;
}
const nextSelectedMaskIds = selectedMaskIds.length > 0 ? validLocalSelectedIds : selectedMaskIds;
const isSameSelection = currentGlobalSelectedIds.length === nextSelectedMaskIds.length
&& currentGlobalSelectedIds.every((id, index) => id === nextSelectedMaskIds[index]);
if (!isSameSelection) {
setGlobalSelectedMaskIds(nextSelectedMaskIds);
}
}, [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)) {
const linkedMaskIds = findLinkedMasksOnFrame([selectedMaskId, ...selectedMaskIds], masks, frame?.id);
if (linkedMaskIds.length > 0) {
setSelectedMaskId(linkedMaskIds[0]);
setSelectedMaskIds(linkedMaskIds);
setGlobalSelectedMaskIds(linkedMaskIds);
} else {
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
}
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}
}, [frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]);
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 handleStageDragEnd = (e: any) => {
const stage = e.target?.getStage?.();
if (!stage || e.target !== stage) return;
setPosition({
x: stage.x(),
y: stage.y(),
});
};
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,
classMaskId: activeClass?.maskId,
saveStatus: 'draft',
saved: false,
pathData: polygonPath(polygon),
label,
color,
segmentation: polygonSegmentation(polygon),
bbox: polygonBbox(polygon),
area,
metadata: { source: 'manual', shape },
};
addMask(mask);
}, [activeClass, activeTemplateId, addMask, frame?.id]);
const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
if (!frame?.id || !activeClass) return null;
const segmentation = multiPolygonToSegmentation(geometry);
const polygonRingCounts = multiPolygonRingCounts(geometry);
if (segmentation.length === 0) return null;
const area = multiPolygonArea(geometry);
if (area <= 1) return null;
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,
classMaskId: activeClass.maskId,
saveStatus: 'draft',
saved: false,
pathData: segmentationPath(segmentation),
label: activeClass.name,
color: activeClass.color,
segmentation,
bbox: segmentationBbox(segmentation),
area,
metadata: {
source: 'manual',
shape,
...(multiPolygonHasHoles(geometry) ? { hasHoles: true } : {}),
...(multiPolygonHasHoles(geometry) ? { polygonRingCounts } : {}),
},
};
addMask(mask);
return 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 });
}
}
if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) {
const pos = stagePoint(e);
const previous = lastPaintPointRef.current;
if (!pos || !previous) return;
const radius = Math.max(2, (paintToolRef.current === ERASER_TOOL ? eraserSize : brushSize) / 2);
const minDistance = Math.max(3, radius * 0.55);
if (pointDistance(previous, pos) < minDistance) return;
const currentStroke = paintStrokeRef.current;
if (currentStroke.length >= MAX_PAINT_STROKE_POINTS) return;
const nextStroke = extendStrokePoints(currentStroke, pos, minDistance);
lastPaintPointRef.current = nextStroke[nextStroke.length - 1] || pos;
setPaintStrokePoints(nextStroke);
}
};
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,
classMaskId: activeClass?.maskId ?? existingCandidate?.classMaskId,
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 deleteMasksById = useCallback((maskIds: string[]) => {
if (maskIds.length === 0) return;
const idSet = expandedPropagationDeletionMaskIds(maskIds, masks);
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 applyPaintStroke = useCallback((tool: string | null, strokePoints: CanvasPoint[]) => {
if (!frame?.id || strokePoints.length === 0) return;
const radius = Math.max(2, (tool === ERASER_TOOL ? eraserSize : brushSize) / 2);
const strokeGeometry = paintStrokeToGeometry(strokePoints, radius);
if (!strokeGeometry) return;
if (tool === BRUSH_TOOL) {
if (!activeClass) {
setInferenceMessage('请先在右侧语义分类树选择类别,再使用画笔。');
return;
}
const targetGeometry = selectedMask ? maskToMultiPolygon(selectedMask) : null;
const shouldMerge = Boolean(targetGeometry && geometriesOverlap(targetGeometry, strokeGeometry));
if (selectedMask && targetGeometry && shouldMerge) {
const resultGeometry = polygonClipping.union(targetGeometry, strokeGeometry);
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) return;
const nextMask = {
...maskWithSegmentation(selectedMask, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
}),
templateId: activeTemplateId || selectedMask.templateId,
classId: activeClass.id,
className: activeClass.name,
classZIndex: activeClass.zIndex,
classMaskId: activeClass.maskId,
label: activeClass.name,
color: activeClass.color,
};
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
setSelectedMaskId(selectedMask.id);
setSelectedMaskIds([selectedMask.id]);
setSelectedVertexIndex(null);
return;
}
const nextMask = createManualMaskFromGeometry('画笔', strokeGeometry);
if (nextMask) {
setSelectedMaskId(nextMask.id);
setSelectedMaskIds([nextMask.id]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}
return;
}
if (tool === ERASER_TOOL) {
if (!selectedMask) {
setInferenceMessage('请先选择一个 mask再使用橡皮擦。');
return;
}
const targetGeometry = maskToMultiPolygon(selectedMask);
if (!targetGeometry) return;
const resultGeometry = polygonClipping.difference(targetGeometry, strokeGeometry);
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) {
deleteMasksById([selectedMask.id]);
return;
}
const nextMask = maskWithSegmentation(selectedMask, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
});
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
setSelectedMaskId(selectedMask.id);
setSelectedMaskIds([selectedMask.id]);
setSelectedVertexIndex(null);
}
}, [
activeClass,
activeTemplateId,
brushSize,
createManualMaskFromGeometry,
deleteMasksById,
eraserSize,
frame?.id,
masks,
selectedMask,
setMasks,
]);
const handleStageMouseDown = (e: any) => {
if (PAINT_TOOLS.has(effectiveTool)) {
const canStart = effectiveTool === BRUSH_TOOL ? Boolean(activeClass) : Boolean(selectedMask);
if (!canStart) return;
const pos = stagePoint(e);
if (pos) {
paintToolRef.current = effectiveTool;
lastPaintPointRef.current = pos;
setPaintStrokePoints([pos]);
}
return;
}
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 (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) {
const finalPoint = stagePoint(e);
const currentStroke = paintStrokeRef.current;
const spacing = Math.max(3, activePaintRadius * 0.55);
const nextStroke = finalPoint
&& currentStroke.length > 0
&& pointDistance(currentStroke[currentStroke.length - 1], finalPoint) >= spacing
&& currentStroke.length < MAX_PAINT_STROKE_POINTS
? extendStrokePoints(currentStroke, finalPoint, spacing)
: currentStroke;
const tool = paintToolRef.current;
setPaintStrokePoints([]);
paintToolRef.current = null;
lastPaintPointRef.current = null;
applyPaintStroke(tool, nextStroke);
return;
}
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);
if (effectiveTool === 'create_rectangle' && width > 4 && height > 4) {
createManualMask('矩形', rectanglePoints(manualStart, end));
}
if (effectiveTool === 'create_circle' && width > 4 && height > 4) {
createManualMask('圆形', circlePoints(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 (PAINT_TOOLS.has(effectiveTool)) 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);
const nextGeometry = maskToMultiPolygon({ ...mask, segmentation: nextSegmentation });
const hasHoles = nextGeometry ? multiPolygonHasHoles(nextGeometry) : Boolean(mask.metadata?.hasHoles);
const metadata = { ...(mask.metadata || {}) };
if (hasHoles && nextGeometry) {
metadata.hasHoles = true;
metadata.polygonRingCounts = multiPolygonRingCounts(nextGeometry);
} else {
delete metadata.hasHoles;
delete metadata.polygonRingCounts;
}
updateMask(mask.id, {
pathData: segmentationPath(nextSegmentation),
segmentation: nextSegmentation,
bbox,
area: nextGeometry ? multiPolygonArea(nextGeometry) : segmentationArea(nextSegmentation),
metadata,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: mask.annotationId ? false : mask.saved,
});
}, [updateMask]);
const updateMaskFromSegmentation = useCallback((
mask: Mask,
segmentation: number[][],
options: { area?: number; hasHoles?: boolean; polygonRingCounts?: number[] } = {},
): Mask => {
return maskWithSegmentation(mask, segmentation, options);
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
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, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, 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 === POLYGON_TOOL && polygonPoints.length > 0) {
const previewPoints = [...polygonPoints, cursorPos];
return polygonPath(previewPoints);
}
return null;
}, [cursorPos, effectiveTool, manualCurrent, manualStart, polygonPoints]);
const selectedMaskEditableRings = React.useMemo(() => {
if (!selectedMask?.segmentation) return [];
const hasHoles = Boolean(selectedMask.metadata?.hasHoles);
if (!hasHoles) {
return [{ polygonIndex: selectedPolygonIndex, points: selectedMaskPoints }];
}
return selectedMask.segmentation
.map((_, polygonIndex) => ({ polygonIndex, points: segmentationToPoints(selectedMask.segmentation, polygonIndex) }))
.filter((ring) => ring.points.length >= 3);
}, [selectedMask, selectedMaskPoints, selectedPolygonIndex]);
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 handleVertexDragStart = (mask: Mask, vertexIndex: number, polygonIndex = selectedPolygonIndex, event?: any) => {
if (event) event.cancelBubble = true;
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(vertexIndex);
};
const handleVertexDrag = (mask: Mask, vertexIndex: number, event: any, polygonIndex = selectedPolygonIndex) => {
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, polygonIndex);
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);
setSelectedMaskIds([mask.id]);
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(vertexIndex);
updatePolygonMask(mask, nextPoints, polygonIndex);
};
const handleEdgeInsert = (mask: Mask, edgeIndex: number, event: any, polygonIndex = selectedPolygonIndex) => {
event.cancelBubble = true;
const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex);
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);
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(edgeIndex + 1);
updatePolygonMask(mask, nextPoints, polygonIndex);
};
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 secondaryMasks = booleanSelectedMasks.slice(1);
const currentFrameId = String(frame.id);
const targetFrameIds = new Set<string>([currentFrameId]);
masks.forEach((mask) => {
const targetFrameId = String(mask.frameId);
if (targetFrameId === currentFrameId) return;
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId).length > 0;
if (!hasPrimary) return;
const hasSecondary = secondaryMasks.some((secondary) => (
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId).length > 0
));
if (hasSecondary) targetFrameIds.add(targetFrameId);
});
const updatedMasks = new Map<string, Mask>();
const deletedMaskIds = new Set<string>();
const applyOperationForFrame = (targetFrameId: string) => {
const primaryTargetId = targetFrameId === currentFrameId
? primary.id
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId)[0];
const primaryTarget = masks.find((mask) => mask.id === primaryTargetId);
if (!primaryTarget || deletedMaskIds.has(primaryTarget.id)) return;
const primaryGeometry = maskToMultiPolygon(primaryTarget);
if (!primaryGeometry) return;
const secondaryTargetIds = Array.from(new Set(
secondaryMasks.flatMap((secondary) => (
targetFrameId === currentFrameId
? [secondary.id]
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId)
)),
)).filter((maskId) => maskId !== primaryTarget.id && !deletedMaskIds.has(maskId));
const secondaryTargets = secondaryTargetIds
.map((maskId) => masks.find((mask) => mask.id === maskId))
.filter((mask): mask is Mask => Boolean(mask));
const clipGeometries = secondaryTargets
.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) {
deletedMaskIds.add(primaryTarget.id);
} else {
updatedMasks.set(primaryTarget.id, updateMaskFromSegmentation(primaryTarget, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
}));
}
if (effectiveTool === 'area_merge') {
secondaryTargets.forEach((mask) => deletedMaskIds.add(mask.id));
}
};
targetFrameIds.forEach(applyOperationForFrame);
const deletedAnnotationIds = Array.from(new Set(
masks
.filter((mask) => deletedMaskIds.has(mask.id))
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setMasks(masks
.filter((mask) => !deletedMaskIds.has(mask.id))
.map((mask) => updatedMasks.get(mask.id) || mask));
if (deletedAnnotationIds.length > 0) await onDeleteMaskAnnotations?.(deletedAnnotationIds);
if (deletedMaskIds.has(primary.id)) {
setSelectedMaskId(null);
setSelectedMaskIds([]);
} else {
setSelectedMaskId(primary.id);
setSelectedMaskIds([primary.id]);
}
setSelectedVertexIndex(null);
};
return (
<div ref={containerRef} className="w-full h-full relative cursor-crosshair overflow-hidden rounded-sm">
{isInferencing && (
<div className="absolute top-4 right-4 z-20 flex items-center gap-2 bg-[#111] border border-white/10 px-3 py-2 rounded-lg shadow-xl">
<div className="w-3 h-3 border-2 border-cyan-500 border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-cyan-400 font-mono">AI ...</span>
</div>
)}
{!isInferencing && inferenceMessage && (
<div className="absolute top-4 right-4 z-20 max-w-xs bg-[#111] border border-white/10 px-3 py-2 rounded-lg shadow-xl text-xs leading-relaxed text-gray-300">
{inferenceMessage}
</div>
)}
{toolHint && isToolHintVisible && (
<div className="absolute top-4 left-4 z-20 max-w-sm rounded-lg border border-cyan-400/20 bg-[#0d0d0d]/95 px-3 py-2 shadow-xl pointer-events-none">
<div className="text-[10px] font-semibold uppercase tracking-widest text-cyan-300">{toolHint.title}</div>
<div className="mt-1 text-xs leading-relaxed text-gray-300">{toolHint.body}</div>
</div>
)}
<Stage
width={stageSize.width}
height={stageSize.height}
onWheel={handleWheel}
onMouseMove={handleMouseMove}
onMouseDown={handleStageMouseDown}
onMouseUp={handleStageMouseUp}
scaleX={scale}
scaleY={scale}
x={position.x}
y={position.y}
draggable={effectiveTool === 'move'}
onDragEnd={handleStageDragEnd}
onClick={handleStageClick}
>
<Layer>
{/* Background Image Layer */}
{image && (
<KonvaImage
image={image}
x={0}
y={0}
opacity={0.8}
/>
)}
{/* AI Returned Masks */}
{displayFrameMasks.map((mask) => {
const selectedIndex = selectedMaskIds.indexOf(mask.id);
const isMaskSelected = selectedIndex >= 0;
const isBooleanPrimary = isBooleanTool && selectedIndex === 0;
const isBooleanSecondary = isBooleanTool && selectedIndex > 0;
const strokeColor = isBooleanPrimary
? '#facc15'
: isBooleanSecondary
? '#fb7185'
: mask.color;
const strokeDash = isBooleanSecondary ? [6 / scale, 4 / scale] : undefined;
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 (
<Group key={mask.id} opacity={Math.min(1, Math.max(0.1, maskPreviewOpacity / 100 + (isMaskSelected ? 0.15 : 0)))}>
{paths.map(({ data, polygonIndex, fillRule }) => (
<Path
key={`${mask.id}-polygon-${polygonIndex}`}
data={data}
fill={mask.color}
fillRule={fillRule}
stroke={strokeColor}
strokeWidth={(isMaskSelected ? 2 : 1) / scale}
dash={strokeDash}
onClick={(event: any) => 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)}
/>
))}
</Group>
);
})}
{/* Box selection preview */}
{boxRect && effectiveTool === 'box_select' && (
<Rect
x={boxRect.x}
y={boxRect.y}
width={boxRect.width}
height={boxRect.height}
fill="rgba(6, 182, 212, 0.1)"
stroke="#06b6d4"
strokeWidth={2 / scale}
dash={[4 / scale, 4 / scale]}
/>
)}
{/* Manual shape preview */}
{manualPreviewPath && (
<Path
data={manualPreviewPath}
fill="rgba(34, 211, 238, 0.12)"
stroke="#22d3ee"
strokeWidth={2 / scale}
dash={[5 / scale, 5 / scale]}
/>
)}
{paintStrokePoints.length > 0 && (
<Group opacity={effectiveTool === ERASER_TOOL ? 0.28 : 0.22}>
{paintStrokePoints.map((point, index) => (
<Circle
key={`paint-stroke-${index}`}
x={point.x}
y={point.y}
radius={activePaintRadius}
fill={effectiveTool === ERASER_TOOL ? '#ef4444' : activeClass?.color || '#22d3ee'}
stroke={effectiveTool === ERASER_TOOL ? '#fecaca' : '#ffffff'}
strokeWidth={1 / scale}
/>
))}
</Group>
)}
{isPaintTool && (effectiveTool === BRUSH_TOOL ? activeClass : selectedMask) && paintStrokePoints.length === 0 && (
<Circle
x={cursorPos.x}
y={cursorPos.y}
radius={activePaintRadius}
fill="rgba(255,255,255,0.02)"
stroke={effectiveTool === ERASER_TOOL ? '#f87171' : activeClass?.color || '#22d3ee'}
strokeWidth={1.5 / scale}
dash={[4 / scale, 4 / scale]}
/>
)}
{polygonPoints.map((point, index) => (
<Circle
key={`poly-point-${index}`}
x={point.x}
y={point.y}
radius={(index === 0 && polygonPoints.length >= 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();
}}
/>
))}
{/* Polygon edge insertion handles */}
{isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
ringPoints.map((point, index) => {
const next = ringPoints[(index + 1) % ringPoints.length];
if (!next) return null;
return (
<Circle
key={`${selectedMask.id}-edge-${polygonIndex}-${index}`}
x={(point.x + next.x) / 2}
y={(point.y + next.y) / 2}
radius={3.5 / scale}
fill="#22d3ee"
stroke="#111827"
strokeWidth={1.5 / scale}
onClick={(event: any) => handleEdgeInsert(selectedMask, index, event, polygonIndex)}
onTap={(event: any) => handleEdgeInsert(selectedMask, index, event, polygonIndex)}
/>
);
})
))}
{/* Polygon vertex editor */}
{isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
ringPoints.map((point, index) => {
const isActiveVertex = selectedPolygonIndex === polygonIndex && selectedVertexIndex === index;
return (
<Circle
key={`${selectedMask.id}-vertex-${polygonIndex}-${index}`}
x={point.x}
y={point.y}
radius={(isActiveVertex ? 6 : 4.5) / scale}
fill={isActiveVertex ? '#22d3ee' : '#ffffff'}
stroke={selectedMask.color}
strokeWidth={2 / scale}
draggable
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onClick={(event: any) => {
event.cancelBubble = true;
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(index);
}}
onTap={(event: any) => {
event.cancelBubble = true;
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(index);
}}
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
/>
);
})
))}
{/* AI Prompts Point Regions */}
{points.map((p, i) => (
<Group key={i} x={p.x} y={p.y}>
<Circle
radius={6 / scale}
fill={p.type === 'pos' ? '#22c55e' : '#ef4444'}
stroke="#ffffff"
strokeWidth={2 / scale}
shadowColor="black"
shadowBlur={4}
onClick={(event: any) => removePromptPoint(i, event)}
onTap={(event: any) => removePromptPoint(i, event)}
/>
<Circle
radius={1.5 / scale}
fill="#ffffff"
onClick={(event: any) => removePromptPoint(i, event)}
onTap={(event: any) => removePromptPoint(i, event)}
/>
</Group>
))}
</Layer>
</Stage>
<div className="absolute bottom-4 left-4 flex gap-4 text-[10px] font-mono text-gray-500 pointer-events-none">
<span>: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
<span>: {currentLayerLabel}</span>
<span>: {(scale * 100).toFixed(0)}%</span>
<span>: {frameMasks.length}</span>
<span>: {savedMaskCount}</span>
<span>: {draftMaskCount}</span>
<span>: {dirtyMaskCount}</span>
</div>
{frameMasks.length > 0 && isBooleanTool && (
<div className="absolute bottom-4 right-4 flex gap-2">
<div className="flex items-center gap-2">
<span className="text-xs bg-white/5 text-gray-300 border border-white/10 px-2.5 py-1.5 rounded">
{booleanSelectedMasks.length}
</span>
<button
onClick={handleBooleanOperation}
disabled={booleanSelectedMasks.length < 2}
className="text-xs bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/20 px-3 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-500/10"
>
{effectiveTool === 'area_merge' ? '合并选中' : '从主区域去除'}
</button>
</div>
</div>
)}
</div>
);
}