- 接入 SAM2 视频传播能力:新增 /api/ai/propagate,支持用当前帧 mask/polygon/bbox 作为 seed,通过 SAM2 video predictor 向前、向后或双向传播,并可保存为真实 annotation。 - 接入 SAM3 video tracker:通过独立 Python 3.12 external worker 调用 SAM3 video predictor/tracker,使用本地 checkpoint 与 bbox seed 执行视频级跟踪,并在模型状态中标记 video_track 能力。 - 完善 SAM 模型分发:sam_registry 按 model_id 明确区分 sam2 propagation 与 sam3 video_track,避免两个模型链路混用。 - 打通前端“传播片段”:VideoWorkspace 使用当前选中 mask 和当前 AI 模型调用后端传播接口,传播结果回写并刷新工作区已保存标注。 - 增强 SAM3 本地 checkpoint 配置:新增 sam3_checkpoint_path 配置和 .env.example 示例,状态检查改为基于本地 checkpoint/独立环境/模型包可用性。 - 完善视频拆帧参数:/api/media/parse 支持 parse_fps、max_frames、target_width,后端任务保存帧时间戳、源帧号和 frame_sequence 元数据。 - 增加运行时 schema 兼容处理:启动时为旧 frames 表补充 timestamp_ms 和 source_frame_number 列,避免旧库升级后缺字段。 - 强化 Canvas 标注编辑:补齐多边形闭合、点工具、顶点拖拽、边中点插入、Delete/Backspace 删除、区域合并和重叠去除等交互。 - 增强语义分类联动:选中 mask 后可通过右侧语义分类树更新标签、颜色和 class metadata,并同步到保存/导出链路。 - 增加关键帧时间轴体验:FrameTimeline 显示具体时间信息,并支持键盘左右方向键切换关键帧。 - 完善 AI 交互分割参数:前端保留正向点、反向点、框选和 interactive prompt 的调用状态,支持 SAM2 细化候选区域与 SAM3 bbox 入口。 - 扩展后端/前端 API 类型:新增 propagateMasks、传播请求/响应 schema,并补齐 annotation、导出、模型状态和任务接口的测试覆盖。 - 更新项目文档:同步 README、AGENTS、接口契约、需求冻结、设计冻结、前端元素审计、实施计划和测试计划,标明真实功能边界与剩余风险。 - 增加测试覆盖:补充 SAM2/SAM3 传播、SAM3 状态、媒体拆帧参数、Canvas 编辑、语义标签切换、时间轴、工作区传播和 API 合约测试。 - 加强仓库安全边界:将 sam3权重/ 加入 .gitignore,避免本地模型权重被误提交。 验证:npm run test:run;pytest backend/tests;npm run lint;npm run build;python -m py_compile;git diff --check。
1096 lines
41 KiB
TypeScript
1096 lines
41 KiB
TypeScript
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 };
|
|
|
|
const DRAG_MANUAL_TOOLS = new Set(['create_rectangle', 'create_circle', 'create_line']);
|
|
const POLYGON_TOOL = 'create_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 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>(null);
|
|
const [selectedMaskIds, setSelectedMaskIds] = useState<string[]>([]);
|
|
const [selectedPolygonIndex, setSelectedPolygonIndex] = useState(0);
|
|
const [selectedVertexIndex, setSelectedVertexIndex] = useState<number | null>(null);
|
|
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);
|
|
|
|
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([]);
|
|
setSelectedMaskId(null);
|
|
setSelectedMaskIds([]);
|
|
setSelectedPolygonIndex(0);
|
|
setSelectedVertexIndex(null);
|
|
}, [effectiveTool, frame?.id]);
|
|
|
|
useEffect(() => {
|
|
setPoints([]);
|
|
setSamPromptBox(null);
|
|
setSamCandidateMaskId(null);
|
|
}, [frame?.id]);
|
|
|
|
useEffect(() => {
|
|
setGlobalSelectedMaskIds(selectedMaskIds);
|
|
}, [selectedMaskIds, setGlobalSelectedMaskIds]);
|
|
|
|
useEffect(() => () => setGlobalSelectedMaskIds([]), [setGlobalSelectedMaskIds]);
|
|
|
|
useEffect(() => {
|
|
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;
|
|
}
|
|
if (aiModel === 'sam3' && (!promptBox || (promptPoints?.length ?? 0) > 0)) {
|
|
setInferenceMessage('SAM3 当前工作区只支持框选提示;正/反点修正请切回 SAM2。');
|
|
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 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,
|
|
});
|
|
|
|
const [m] = result.masks;
|
|
if (m) {
|
|
const existingCandidate = !options.resetCandidate && samCandidateMaskId
|
|
? masks.find((mask) => mask.id === samCandidateMaskId)
|
|
: null;
|
|
const label = activeClass?.name || existingCandidate?.label || m.label;
|
|
const color = activeClass?.color || existingCandidate?.color || m.color;
|
|
const metadata = {
|
|
...(existingCandidate?.metadata || {}),
|
|
source: aiModel === 'sam3' ? 'sam3_box' : 'sam2_interactive',
|
|
promptBox: promptBox || null,
|
|
promptPointCount: promptPoints?.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 {
|
|
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, 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 (effectiveTool === 'move') 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 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, 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 (effectiveTool !== 'move' && !isBooleanTool) return;
|
|
event.cancelBubble = true;
|
|
if (isBooleanTool) {
|
|
setSelectedMaskIds((current) => (
|
|
current.includes(mask.id)
|
|
? current.filter((id) => id !== mask.id)
|
|
: [...current, mask.id]
|
|
));
|
|
setSelectedMaskId(mask.id);
|
|
setSelectedPolygonIndex(polygonIndex);
|
|
setSelectedVertexIndex(null);
|
|
return;
|
|
}
|
|
setSelectedMaskId(mask.id);
|
|
setSelectedMaskIds([mask.id]);
|
|
setSelectedPolygonIndex(polygonIndex);
|
|
setSelectedVertexIndex(null);
|
|
};
|
|
|
|
const handleVertexDragEnd = (mask: Mask, vertexIndex: number, event: any) => {
|
|
const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width;
|
|
const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height;
|
|
const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex);
|
|
if (!currentPoints[vertexIndex]) return;
|
|
const nextPoints = currentPoints.map((point, index) => (
|
|
index === vertexIndex
|
|
? {
|
|
x: clamp(event.target.x(), 0, imageWidth),
|
|
y: clamp(event.target.y(), 0, imageHeight),
|
|
}
|
|
: point
|
|
));
|
|
setSelectedMaskId(mask.id);
|
|
setSelectedVertexIndex(vertexIndex);
|
|
updatePolygonMask(mask, nextPoints, selectedPolygonIndex);
|
|
};
|
|
|
|
const handleEdgeInsert = (mask: Mask, edgeIndex: number, event: any) => {
|
|
event.cancelBubble = true;
|
|
const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex);
|
|
const start = currentPoints[edgeIndex];
|
|
const end = currentPoints[(edgeIndex + 1) % currentPoints.length];
|
|
if (!start || !end) return;
|
|
const inserted = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
const nextPoints = [
|
|
...currentPoints.slice(0, edgeIndex + 1),
|
|
inserted,
|
|
...currentPoints.slice(edgeIndex + 1),
|
|
];
|
|
setSelectedMaskId(mask.id);
|
|
setSelectedVertexIndex(edgeIndex + 1);
|
|
updatePolygonMask(mask, nextPoints, selectedPolygonIndex);
|
|
};
|
|
|
|
const 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>
|
|
)}
|
|
|
|
<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 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={selectedMaskIds.includes(mask.id) ? 0.65 : 0.5}>
|
|
{paths.map(({ data, polygonIndex, fillRule }) => (
|
|
<Path
|
|
key={`${mask.id}-polygon-${polygonIndex}`}
|
|
data={data}
|
|
fill={mask.color}
|
|
fillRule={fillRule}
|
|
stroke={mask.color}
|
|
strokeWidth={(selectedMaskIds.includes(mask.id) ? 2 : 1) / scale}
|
|
onClick={(event: any) => handleMaskSelect(mask, event, polygonIndex)}
|
|
onTap={(event: any) => handleMaskSelect(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 */}
|
|
{!isBooleanTool && 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 */}
|
|
{!isBooleanTool && 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
|
|
onClick={(event: any) => {
|
|
event.cancelBubble = true;
|
|
setSelectedVertexIndex(index);
|
|
}}
|
|
onTap={(event: any) => {
|
|
event.cancelBubble = true;
|
|
setSelectedVertexIndex(index);
|
|
}}
|
|
onDragEnd={(event: any) => handleVertexDragEnd(selectedMask, index, event)}
|
|
/>
|
|
))}
|
|
|
|
{/* AI Prompts Point Regions */}
|
|
{points.map((p, i) => (
|
|
<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}
|
|
/>
|
|
<Circle
|
|
radius={1.5 / scale}
|
|
fill="#ffffff"
|
|
/>
|
|
</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>当前图层树: OBJECT_VEHICLE_01</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>
|
|
);
|
|
}
|