feat: 建立 SAM2 标注闭环基线
- 打通工作区真实标注闭环:支持手工多边形、矩形、圆形、点区域和线段生成 mask,并可保存、回显、更新和删除后端 annotation。 - 增强 polygon 编辑器:支持顶点拖动、顶点删除、边中点插入、多 polygon 子区域选择编辑,以及区域合并和区域去除。 - 接入 GT mask 导入:后端支持二值/多类别 mask 拆分、contour 转 polygon、distance transform seed point,前端支持导入、回显和 seed point 拖动编辑。 - 完善导出能力:COCO JSON 导出对齐前端,PNG mask ZIP 同时包含单标注 mask、按 zIndex 融合的 semantic_frame 和 semantic_classes.json。 - 打通异步任务管理:新增任务取消、重试、失败详情接口与 Dashboard 控件,worker 支持取消状态检查并通过 Redis/WebSocket 推送 cancelled 事件。 - 对接 Dashboard 后端数据:概览统计、解析队列和实时流转记录从 FastAPI 聚合接口与 WebSocket 更新。 - 增强 AI 推理参数:前端发送 crop_to_prompt、auto_filter_background 和 min_score,后端支持点/框 prompt 局部裁剪推理、结果回映射和负向点/低分过滤。 - 接入 SAM3 基础设施:新增独立 Python 3.12 sam3 环境安装脚本、外部 worker helper、后端桥接和真实 Python/CUDA/包/HF checkpoint access 状态检测。 - 保留 SAM3 授权边界:当前官方 facebook/sam3 gated 权重未授权时状态接口会返回不可用,不伪装成可推理。 - 增强前端状态管理:新增 mask undo/redo 历史栈、AI 模型选择状态、保存状态 dirty/draft/saved 流转和项目状态归一化。 - 更新前端 API 封装:补充 annotation CRUD、GT mask import、mask ZIP export、task cancel/retry/detail、AI runtime status 和 prediction options。 - 更新 UI 控件:ToolsPalette、AISegmentation、VideoWorkspace 和 CanvasArea 接入真实操作、导入导出、撤销重做、任务控制和模型状态。 - 新增 polygon-clipping 依赖,用于前端区域 union/difference 几何运算。 - 完善后端 schemas/status/progress:补充 AI 模型外部状态字段、任务 cancelled 状态和进度事件 payload。 - 补充测试覆盖:新增后端任务控制、SAM3 桥接、GT mask、导出融合、AI options 测试;补充前端 Canvas、Dashboard、VideoWorkspace、ToolsPalette、API 和 store 测试。 - 更新 README、AGENTS 和 doc 文档:冻结当前需求/设计/测试计划,标注真实功能、剩余 Mock、SAM3 授权边界和后续实施顺序。
This commit is contained in:
@@ -1,17 +1,180 @@
|
||||
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 } from '../store/useStore';
|
||||
import type { Frame, Mask } from '../store/useStore';
|
||||
|
||||
interface CanvasAreaProps {
|
||||
activeTool: string;
|
||||
frame: Frame | null;
|
||||
onClearMasks?: () => void;
|
||||
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps) {
|
||||
type CanvasPoint = { x: number; y: 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']);
|
||||
|
||||
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 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 multiPolygonToSegmentation(geometry: MultiPolygon): number[][] {
|
||||
return geometry
|
||||
.map((polygon) => polygon[0] || [])
|
||||
.map((ring) => {
|
||||
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.flatMap(([x, y]) => [x, y]);
|
||||
})
|
||||
.filter((polygon) => polygon.length >= 6);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -20,22 +183,46 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
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 [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 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 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;
|
||||
@@ -55,6 +242,25 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setManualStart(null);
|
||||
setManualCurrent(null);
|
||||
setPolygonPoints([]);
|
||||
setSelectedMaskId(null);
|
||||
setSelectedMaskIds([]);
|
||||
setSelectedPolygonIndex(0);
|
||||
setSelectedVertexIndex(null);
|
||||
}, [effectiveTool, frame?.id]);
|
||||
|
||||
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;
|
||||
@@ -74,6 +280,50 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
});
|
||||
};
|
||||
|
||||
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 handleMouseMove = (e: any) => {
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
@@ -90,6 +340,13 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
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?: typeof points, promptBox?: { x1: number, y1: number, x2: number, y2: number }) => {
|
||||
@@ -132,6 +389,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
label,
|
||||
color,
|
||||
segmentation: m.segmentation,
|
||||
points: promptPoints?.filter((p) => p.type === 'pos').map((p) => [p.x, p.y]),
|
||||
bbox: m.bbox,
|
||||
area: m.area,
|
||||
});
|
||||
@@ -170,6 +428,15 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -181,6 +448,27 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -199,12 +487,32 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
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) {
|
||||
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' }];
|
||||
const newPoints = [
|
||||
...points,
|
||||
{ x: pos.x, y: pos.y, type: (effectiveTool === 'point_pos' ? 'pos' : 'neg') as 'pos' | 'neg' },
|
||||
];
|
||||
setPoints(newPoints);
|
||||
// Auto-trigger inference after point selection
|
||||
runInference(newPoints);
|
||||
@@ -212,6 +520,74 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
}
|
||||
};
|
||||
|
||||
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[][]): Mask => {
|
||||
const bbox = segmentationBbox(segmentation);
|
||||
return {
|
||||
...mask,
|
||||
pathData: segmentationPath(segmentation),
|
||||
segmentation,
|
||||
bbox,
|
||||
area: segmentationArea(segmentation),
|
||||
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 (effectiveTool !== POLYGON_TOOL) return;
|
||||
if (event.key === 'Enter' && polygonPoints.length >= 3) {
|
||||
event.preventDefault();
|
||||
createManualMask('多边形', polygonPoints);
|
||||
setPolygonPoints([]);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setPolygonPoints([]);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [createManualMask, effectiveTool, polygonPoints, redoMasks, selectedMask, selectedPolygonIndex, selectedVertexIndex, undoMasks, updatePolygonMask]);
|
||||
|
||||
const boxRect = React.useMemo(() => {
|
||||
if (!boxStart || !boxCurrent) return null;
|
||||
const x = Math.min(boxStart.x, boxCurrent.x);
|
||||
@@ -221,6 +597,132 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
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) => {
|
||||
event.cancelBubble = true;
|
||||
if (BOOLEAN_TOOLS.has(effectiveTool)) {
|
||||
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);
|
||||
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 && (
|
||||
@@ -257,13 +759,18 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
|
||||
{/* AI Returned Masks */}
|
||||
{frameMasks.map((mask) => (
|
||||
<Group key={mask.id} opacity={0.5}>
|
||||
<Path
|
||||
data={mask.pathData}
|
||||
fill={mask.color}
|
||||
stroke={mask.color}
|
||||
strokeWidth={1 / scale}
|
||||
/>
|
||||
<Group key={mask.id} opacity={selectedMaskIds.includes(mask.id) ? 0.65 : 0.5}>
|
||||
{(mask.segmentation && mask.segmentation.length > 0 ? mask.segmentation : [undefined]).map((_, polygonIndex) => (
|
||||
<Path
|
||||
key={`${mask.id}-polygon-${polygonIndex}`}
|
||||
data={mask.segmentation ? segmentationPolygonPath(mask.segmentation, polygonIndex) : mask.pathData}
|
||||
fill={mask.color}
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -281,6 +788,86 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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={4 / scale}
|
||||
fill="#22d3ee"
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1 / scale}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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 */}
|
||||
{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 */}
|
||||
{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}>
|
||||
@@ -313,6 +900,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks }: CanvasAreaProps)
|
||||
|
||||
{frameMasks.length > 0 && (
|
||||
<div className="absolute bottom-4 right-4 flex gap-2">
|
||||
{BOOLEAN_TOOLS.has(effectiveTool) && booleanSelectedMasks.length >= 2 && (
|
||||
<button
|
||||
onClick={handleBooleanOperation}
|
||||
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"
|
||||
>
|
||||
{effectiveTool === 'area_merge' ? '合并选中' : '从主区域去除'}
|
||||
</button>
|
||||
)}
|
||||
{activeClass && (
|
||||
<button
|
||||
onClick={handleApplyActiveClass}
|
||||
|
||||
Reference in New Issue
Block a user