Files
Pre_Seg_Server/src/components/CanvasArea.tsx
admin b6a276cb8d feat: 完善工作区交互提示与后端属性分析
功能新增:
- 新增 POST /api/ai/analyze-mask 后端接口,基于 mask polygon、bbox、points 和 score 返回置信度来源、面积、拓扑锚点和后端分析提示。
- 前端新增 analyzeMask API 封装,并在本体检查面板读取选中 mask 的后端几何属性和重新提取拓扑锚点结果。
- 右侧语义分类树点击分类时,会给当前选中 mask 换标签、更新 class 元数据,并将选中 mask 移到前端渲染最上层,方便继续编辑。
- 分割工作区画布新增上下文操作提示,覆盖多边形 Enter 完成、Esc 取消、首节点闭合、拖拽图形、点区域、SAM 点/框提示、区域合并/去除选择顺序和多边形编辑。
- AI 智能分割画布新增正向点、反向点、边界框选和视口控制的上下文提示。
- 自动传播交互收敛为参考帧加起止帧范围加单个“自动传播”按钮,默认使用当前参考帧全部 mask 作为 seed。
- 时间轴改为用浅蓝色进度条区段标记自动传播生成的帧,而不是已编辑帧竖线提示。

Bugfix:
- AI 分割页无当前帧时移除外部演示背景图,改为明确空状态提示,避免误以为外部图片可参与真实推理。
- 工具栏魔法棒文案改为“打开 AI 智能分割”,避免误导为直接触发 SAM 推理。
- Canvas 底部当前图层信息改为显示真实选中 mask 标签和 annotation id,不再使用固定占位文本。
- 已保存标注回显时保留 mask metadata 中的传播来源、score 等字段,供时间轴和属性面板识别。
- 清理 server.ts 中遗留的 /api/login、/api/projects、/api/templates 内存 mock API,避免和 FastAPI 真实后端混淆。

测试:
- 补充 analyze-mask 后端测试,覆盖后端几何属性和锚点返回。
- 补充 api.analyzeMask 前端契约测试,覆盖 normalized polygon、bbox、points 和 extract_skeleton payload。
- 补充本体面板测试,覆盖后端属性读取、自定义分类写回后端模板、选中 mask 换标签和置顶显示。
- 补充 Canvas 测试,覆盖上下文提示、多边形完成提示、布尔选择顺序提示、当前图层真实显示和编辑优先级。
- 补充 AI 分割测试,覆盖无帧空状态和提示工具上下文提示。
- 更新 Konva 测试 mock,支持拖动过程、stroke/dash/fillRule 等渲染断言。

文档:
- 更新 README 和 AGENTS,说明 server.ts 不再保留业务 mock API。
- 更新 doc/02、doc/03、doc/04、doc/05、doc/07、doc/08、doc/09,记录后端属性分析、分类置顶显示、上下文提示、自动传播按钮、传播帧标记、测试覆盖和当前剩余限制。
2026-05-02 02:10:37 +08:00

1314 lines
51 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;
onClearMasks?: () => void;
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', '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<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 [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 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;
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 === 'create_line') {
return { title: '创建线段', body: '按住并拖拽画出线段,松开后生成有宽度的线状 mask。' };
}
if (effectiveTool === POINT_TOOL) {
return { title: '创建点区域', body: '点击画布创建一个小型点区域;也可以在已有 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;
}, [booleanSelectedMasks.length, effectiveTool, frame, polygonPoints.length, samPromptBox, selectedMask]);
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 handleVertexDragStart = (mask: Mask, vertexIndex: number, event?: any) => {
if (event) event.cancelBubble = true;
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setSelectedVertexIndex(vertexIndex);
};
const handleVertexDrag = (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);
setSelectedMaskIds([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<string>();
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 (
<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 && (
<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'}
onClick={handleStageClick}
>
<Layer>
{/* Background Image Layer */}
{image && (
<KonvaImage
image={image}
x={0}
y={0}
opacity={0.8}
/>
)}
{/* AI Returned Masks */}
{frameMasks.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={isMaskSelected ? 0.65 : 0.5}>
{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]}
/>
)}
{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();
}}
/>
))}
{/* Imported GT seed points / editable point regions */}
{frameMasks.flatMap((mask) => (mask.points || []).map(([x, y], index) => (
<Group key={`${mask.id}-seed-${index}`} x={x} y={y}>
<Circle
radius={5 / scale}
fill="#facc15"
stroke="#111827"
strokeWidth={2 / scale}
draggable
onDragEnd={(event: any) => handleSeedPointDragEnd(mask, index, event)}
/>
<Circle radius={1.5 / scale} fill="#111827" />
</Group>
)))}
{/* Polygon edge insertion handles */}
{isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => {
const next = selectedMaskPoints[(index + 1) % selectedMaskPoints.length];
if (!next) return null;
return (
<Circle
key={`${selectedMask.id}-edge-${selectedPolygonIndex}-${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)}
onTap={(event: any) => handleEdgeInsert(selectedMask, index, event)}
/>
);
})}
{/* Polygon vertex editor */}
{isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => (
<Circle
key={`${selectedMask.id}-vertex-${selectedPolygonIndex}-${index}`}
x={point.x}
y={point.y}
radius={(selectedVertexIndex === index ? 6 : 4.5) / scale}
fill={selectedVertexIndex === index ? '#22d3ee' : '#ffffff'}
stroke={selectedMask.color}
strokeWidth={2 / scale}
draggable
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onClick={(event: any) => {
event.cancelBubble = true;
setSelectedVertexIndex(index);
}}
onTap={(event: any) => {
event.cancelBubble = true;
setSelectedVertexIndex(index);
}}
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event)}
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event)}
/>
))}
{/* 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 && (
<div className="absolute bottom-4 right-4 flex gap-2">
{isBooleanTool && (
<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>
)}
{activeClass && (
<button
onClick={handleApplyActiveClass}
className="text-xs bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/20 px-3 py-1.5 rounded transition-colors"
>
</button>
)}
<button
onClick={handleClearMasks}
className="text-xs bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-3 py-1.5 rounded transition-colors"
>
</button>
</div>
)}
</div>
);
}