feat: 完善分割工作区导入导出与管理流程
- 新增基于 JWT 当前用户的登录恢复、角色权限、用户管理、审计日志和演示出厂重置后台接口与前端管理页。 - 重串 GT_label 导出和 GT Mask 导入逻辑:导出保留类别真实 maskid,导入仅接受灰度或 RGB 等通道 maskid 图,支持未知 maskid 策略、尺寸最近邻拉伸和导入预览。 - 统一分割结果导出体验:默认当前帧,按项目抽帧顺序和 XhXXmXXsXXXms 时间戳命名 ZIP 与图片,补齐 GT/Pro/Mix/分开 Mask 输出和映射 JSON。 - 调整工作区左侧工具栏:移除创建点/线段入口,新增画笔、橡皮擦及尺寸控制,并按绘制、布尔、导入/AI 工具分组分隔。 - 扩展 Canvas 编辑能力:画笔按语义分类绘制并可自动并入连通选中 mask,橡皮擦对选中区域扣除,优化布尔操作、选区、撤销重做和保存状态联动。 - 优化自动传播时间轴显示:同一蓝色系按传播新旧递进变暗,老传播记录达到阈值后统一旧记录色,并维护范围选择与清空后的历史显示。 - 将 AI 智能分割入口替换为更明确的 AI 元素图标,并同步侧栏、工作区和 AI 页面入口表现。 - 完善模板分类、maskid 工具函数、分类树联动、遮罩透明度、边缘平滑和传播链同步相关前端状态。 - 扩展后端项目、媒体、任务、Dashboard、模板和传播 runner 的用户隔离、任务控制、进度事件与兼容处理。 - 补充前后端测试,覆盖用户管理、GT_label 往返导入导出、GT Mask 校验和预览、画笔/橡皮擦、时间轴传播历史、导出范围、WebSocket 与 API 封装。 - 更新 AGENTS、README 和 doc 文档,记录当前接口契约、实现状态、测试计划、安装说明和 maskid/GT_label 规则。
This commit is contained in:
@@ -18,14 +18,18 @@ 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 DRAG_MANUAL_TOOLS = new Set(['create_rectangle', 'create_circle']);
|
||||
const POLYGON_TOOL = 'create_polygon';
|
||||
const EDIT_POLYGON_TOOL = 'edit_polygon';
|
||||
const POINT_TOOL = 'create_point';
|
||||
const BRUSH_TOOL = 'brush';
|
||||
const ERASER_TOOL = 'eraser';
|
||||
const PAINT_TOOLS = new Set([BRUSH_TOOL, ERASER_TOOL]);
|
||||
const BOOLEAN_TOOLS = new Set(['area_merge', 'area_remove']);
|
||||
const POLYGON_CLOSE_RADIUS = 8;
|
||||
const DEFAULT_IMAGE_FIT_RATIO = 0.86;
|
||||
const TOOL_HINT_TTL_MS = 3600;
|
||||
const PAINT_STAMP_SEGMENTS = 16;
|
||||
const MAX_PAINT_STROKE_POINTS = 128;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -97,6 +101,31 @@ function findLinkedMasksOnFrame(selectedIds: string[], allMasks: Mask[], targetF
|
||||
.map((mask) => mask.id);
|
||||
}
|
||||
|
||||
function findPropagationChainMaskIds(selectedIds: string[], allMasks: Mask[]): Set<string> {
|
||||
const selectedMasks = selectedIds
|
||||
.map((id) => allMasks.find((mask) => mask.id === id))
|
||||
.filter((mask): mask is Mask => Boolean(mask));
|
||||
const selectedTokens = new Set<string>();
|
||||
selectedMasks.forEach((mask) => {
|
||||
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
|
||||
});
|
||||
if (selectedTokens.size === 0) return new Set(selectedIds);
|
||||
|
||||
return new Set(
|
||||
allMasks
|
||||
.filter((mask) => {
|
||||
const candidateTokens = propagationLineageTokens(mask);
|
||||
return [...candidateTokens].some((token) => selectedTokens.has(token));
|
||||
})
|
||||
.map((mask) => mask.id),
|
||||
);
|
||||
}
|
||||
|
||||
function maskLayerPriority(mask: Mask): number {
|
||||
const parsed = Number(mask.classZIndex ?? mask.metadata?.classZIndex ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function polygonPath(points: CanvasPoint[]): string {
|
||||
if (points.length === 0) return '';
|
||||
return points
|
||||
@@ -165,6 +194,29 @@ function pointDistance(a: CanvasPoint, b: CanvasPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
function extendStrokePoints(
|
||||
current: CanvasPoint[],
|
||||
nextPoint: CanvasPoint,
|
||||
spacing: number,
|
||||
maxPoints = MAX_PAINT_STROKE_POINTS,
|
||||
): CanvasPoint[] {
|
||||
const previous = current[current.length - 1];
|
||||
if (!previous) return [nextPoint];
|
||||
const distance = pointDistance(previous, nextPoint);
|
||||
if (distance < spacing) return current;
|
||||
const steps = Math.max(1, Math.floor(distance / spacing));
|
||||
const additions: CanvasPoint[] = [];
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
if (current.length + additions.length >= maxPoints) break;
|
||||
const ratio = step / steps;
|
||||
additions.push({
|
||||
x: previous.x + (nextPoint.x - previous.x) * ratio,
|
||||
y: previous.y + (nextPoint.y - previous.y) * ratio,
|
||||
});
|
||||
}
|
||||
return [...current, ...additions];
|
||||
}
|
||||
|
||||
function distanceToSegmentSquared(point: CanvasPoint, start: CanvasPoint, end: CanvasPoint): number {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
@@ -218,6 +270,13 @@ function maskToMultiPolygon(mask: Mask): MultiPolygon | null {
|
||||
return polygons.length > 0 ? polygons : null;
|
||||
}
|
||||
|
||||
function polygonsToMultiPolygon(polygons: CanvasPoint[][]): MultiPolygon | null {
|
||||
const geometry = polygons
|
||||
.filter((points) => points.length >= 3)
|
||||
.map((points) => [closeRing(points)]);
|
||||
return geometry.length > 0 ? geometry : null;
|
||||
}
|
||||
|
||||
function openRingPoints(ring: Pair[]): CanvasPoint[] {
|
||||
const openRing = ring.length > 1
|
||||
&& ring[0][0] === ring[ring.length - 1][0]
|
||||
@@ -247,6 +306,27 @@ function multiPolygonHasHoles(geometry: MultiPolygon): boolean {
|
||||
return geometry.some((polygon) => polygon.length > 1);
|
||||
}
|
||||
|
||||
function maskWithSegmentation(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function rectanglePoints(start: CanvasPoint, end: CanvasPoint): CanvasPoint[] {
|
||||
const x1 = Math.min(start.x, end.x);
|
||||
const y1 = Math.min(start.y, end.y);
|
||||
@@ -271,25 +351,26 @@ function circlePoints(start: CanvasPoint, end: CanvasPoint): CanvasPoint[] {
|
||||
});
|
||||
}
|
||||
|
||||
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 circleStampPoints(center: CanvasPoint, radius: number, segments = PAINT_STAMP_SEGMENTS): CanvasPoint[] {
|
||||
return Array.from({ length: segments }, (_, index) => {
|
||||
const angle = (Math.PI * 2 * index) / segments;
|
||||
return { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius };
|
||||
});
|
||||
}
|
||||
|
||||
function 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 },
|
||||
];
|
||||
function paintStrokeToGeometry(strokePoints: CanvasPoint[], radius: number): MultiPolygon | null {
|
||||
const geometries = strokePoints
|
||||
.map((point) => polygonsToMultiPolygon([circleStampPoints(point, radius)]))
|
||||
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
|
||||
if (geometries.length === 0) return null;
|
||||
const [firstGeometry, ...restGeometries] = geometries;
|
||||
return restGeometries.length === 0
|
||||
? firstGeometry
|
||||
: polygonClipping.union(firstGeometry, ...restGeometries);
|
||||
}
|
||||
|
||||
function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
|
||||
return polygonClipping.intersection(first, second).length > 0;
|
||||
}
|
||||
|
||||
export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnotations }: CanvasAreaProps) {
|
||||
@@ -305,6 +386,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const [samCandidateMaskId, setSamCandidateMaskId] = useState<string | null>(null);
|
||||
const [manualStart, setManualStart] = useState<CanvasPoint | null>(null);
|
||||
const [manualCurrent, setManualCurrent] = useState<CanvasPoint | null>(null);
|
||||
const [paintStrokePoints, setPaintStrokePointsState] = useState<CanvasPoint[]>([]);
|
||||
const [polygonPoints, setPolygonPoints] = useState<CanvasPoint[]>([]);
|
||||
const [selectedMaskId, setSelectedMaskId] = useState<string | null>(() => useStore.getState().selectedMaskIds[0] || null);
|
||||
const [selectedMaskIds, setSelectedMaskIds] = useState<string[]>(() => useStore.getState().selectedMaskIds);
|
||||
@@ -315,6 +397,9 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const [inferenceMessage, setInferenceMessage] = useState('');
|
||||
const [isToolHintVisible, setIsToolHintVisible] = useState(false);
|
||||
const lastAutoFitKeyRef = useRef('');
|
||||
const paintStrokeRef = useRef<CanvasPoint[]>([]);
|
||||
const paintToolRef = useRef<string | null>(null);
|
||||
const lastPaintPointRef = useRef<CanvasPoint | null>(null);
|
||||
|
||||
const masks = useStore((state) => state.masks);
|
||||
const addMask = useStore((state) => state.addMask);
|
||||
@@ -323,6 +408,8 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const setMasks = useStore((state) => state.setMasks);
|
||||
const setGlobalSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
|
||||
const brushSize = useStore((state) => state.brushSize);
|
||||
const eraserSize = useStore((state) => state.eraserSize);
|
||||
const storeActiveTool = useStore((state) => state.activeTool);
|
||||
const aiModel = useStore((state) => state.aiModel);
|
||||
const activeTemplateId = useStore((state) => state.activeTemplateId);
|
||||
@@ -333,6 +420,16 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
// Load the actual frame image
|
||||
const [image] = useImage(frame?.url || '');
|
||||
const frameMasks = masks.filter((mask) => mask.frameId === frame?.id);
|
||||
const displayFrameMasks = React.useMemo(() => {
|
||||
if (selectedMaskIds.length > 0) return frameMasks;
|
||||
return frameMasks
|
||||
.map((mask, index) => ({ mask, index }))
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = maskLayerPriority(a.mask) - maskLayerPriority(b.mask);
|
||||
return priorityDiff === 0 ? a.index - b.index : priorityDiff;
|
||||
})
|
||||
.map((item) => item.mask);
|
||||
}, [frameMasks, selectedMaskIds.length]);
|
||||
const selectedMask = React.useMemo(
|
||||
() => frameMasks.find((mask) => mask.id === selectedMaskId) || null,
|
||||
[frameMasks, selectedMaskId],
|
||||
@@ -351,7 +448,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const draftMaskCount = frameMasks.filter((mask) => !mask.annotationId).length;
|
||||
const dirtyMaskCount = frameMasks.filter((mask) => mask.saveStatus === 'dirty').length;
|
||||
const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool);
|
||||
const isPaintTool = PAINT_TOOLS.has(effectiveTool);
|
||||
const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL;
|
||||
const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize;
|
||||
const activePaintRadius = Math.max(2, activePaintSize / 2);
|
||||
const setPaintStrokePoints = useCallback((nextPoints: CanvasPoint[]) => {
|
||||
paintStrokeRef.current = nextPoints;
|
||||
setPaintStrokePointsState(nextPoints);
|
||||
}, []);
|
||||
const currentLayerLabel = selectedMask
|
||||
? `${selectedMask.className || selectedMask.label}${selectedMask.annotationId ? ` #${selectedMask.annotationId}` : ' (未保存)'}`
|
||||
: '未选择';
|
||||
@@ -381,11 +485,21 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
if (effectiveTool === 'create_circle') {
|
||||
return { title: '创建圆形', body: '按住并拖拽确定外接范围,松开鼠标后生成椭圆 mask。' };
|
||||
}
|
||||
if (effectiveTool === 'create_line') {
|
||||
return { title: '创建线段', body: '按住并拖拽画出线段,松开后生成有宽度的线状 mask。' };
|
||||
if (effectiveTool === BRUSH_TOOL) {
|
||||
return {
|
||||
title: '画笔',
|
||||
body: activeClass
|
||||
? '按住并拖动画出连续区域;若与当前选中 mask 连通,会自动合并到该 mask。'
|
||||
: '先在右侧语义分类树选择类别,然后按住并拖动画出连续区域。',
|
||||
};
|
||||
}
|
||||
if (effectiveTool === POINT_TOOL) {
|
||||
return { title: '创建点区域', body: '点击画布创建一个小型点区域;也可以在已有 mask 上继续落点。' };
|
||||
if (effectiveTool === ERASER_TOOL) {
|
||||
return {
|
||||
title: '橡皮擦',
|
||||
body: selectedMask
|
||||
? '按住并拖动,从当前选中 mask 中扣除经过的区域。'
|
||||
: '先选择一个 mask,然后按住并拖动擦除区域。',
|
||||
};
|
||||
}
|
||||
if (effectiveTool === 'box_select') {
|
||||
return {
|
||||
@@ -426,7 +540,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [booleanSelectedMasks.length, effectiveTool, frame, polygonPoints.length, samPromptBox, selectedMask]);
|
||||
}, [activeClass, booleanSelectedMasks.length, effectiveTool, frame, polygonPoints.length, samPromptBox, selectedMask]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolHint) {
|
||||
@@ -479,14 +593,17 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
useEffect(() => {
|
||||
setManualStart(null);
|
||||
setManualCurrent(null);
|
||||
setPaintStrokePoints([]);
|
||||
paintToolRef.current = null;
|
||||
lastPaintPointRef.current = null;
|
||||
setPolygonPoints([]);
|
||||
setSelectedVertexIndex(null);
|
||||
if (!isPolygonEditTool && !isBooleanTool) {
|
||||
if (!isPolygonEditTool && !isBooleanTool && !isPaintTool) {
|
||||
setSelectedMaskId(null);
|
||||
setSelectedMaskIds([]);
|
||||
setSelectedPolygonIndex(0);
|
||||
}
|
||||
}, [effectiveTool, isBooleanTool, isPolygonEditTool]);
|
||||
}, [effectiveTool, isBooleanTool, isPaintTool, isPolygonEditTool, setPaintStrokePoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousFrameIdRef.current === frame?.id) return;
|
||||
@@ -617,18 +734,13 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
classId: activeClass?.id,
|
||||
className: activeClass?.name,
|
||||
classZIndex: activeClass?.zIndex,
|
||||
classMaskId: activeClass?.maskId,
|
||||
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 },
|
||||
@@ -636,6 +748,38 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
addMask(mask);
|
||||
}, [activeClass, activeTemplateId, addMask, frame?.id]);
|
||||
|
||||
const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
|
||||
if (!frame?.id || !activeClass) return null;
|
||||
const segmentation = multiPolygonToSegmentation(geometry);
|
||||
if (segmentation.length === 0) return null;
|
||||
const area = multiPolygonArea(geometry);
|
||||
if (area <= 1) return null;
|
||||
const mask: Mask = {
|
||||
id: `manual-${frame.id}-${shape}-${Date.now()}`,
|
||||
frameId: frame.id,
|
||||
templateId: activeTemplateId || undefined,
|
||||
classId: activeClass.id,
|
||||
className: activeClass.name,
|
||||
classZIndex: activeClass.zIndex,
|
||||
classMaskId: activeClass.maskId,
|
||||
saveStatus: 'draft',
|
||||
saved: false,
|
||||
pathData: segmentationPath(segmentation),
|
||||
label: activeClass.name,
|
||||
color: activeClass.color,
|
||||
segmentation,
|
||||
bbox: segmentationBbox(segmentation),
|
||||
area,
|
||||
metadata: {
|
||||
source: 'manual',
|
||||
shape,
|
||||
...(multiPolygonHasHoles(geometry) ? { hasHoles: true } : {}),
|
||||
},
|
||||
};
|
||||
addMask(mask);
|
||||
return mask;
|
||||
}, [activeClass, activeTemplateId, addMask, frame?.id]);
|
||||
|
||||
const finishPolygon = useCallback(() => {
|
||||
if (polygonPoints.length < 3) return;
|
||||
createManualMask('多边形', polygonPoints);
|
||||
@@ -665,6 +809,20 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
setManualCurrent({ x: pos.x, y: pos.y });
|
||||
}
|
||||
}
|
||||
|
||||
if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) {
|
||||
const pos = stagePoint(e);
|
||||
const previous = lastPaintPointRef.current;
|
||||
if (!pos || !previous) return;
|
||||
const radius = Math.max(2, (paintToolRef.current === ERASER_TOOL ? eraserSize : brushSize) / 2);
|
||||
const minDistance = Math.max(3, radius * 0.55);
|
||||
if (pointDistance(previous, pos) < minDistance) return;
|
||||
const currentStroke = paintStrokeRef.current;
|
||||
if (currentStroke.length >= MAX_PAINT_STROKE_POINTS) return;
|
||||
const nextStroke = extendStrokePoints(currentStroke, pos, minDistance);
|
||||
lastPaintPointRef.current = nextStroke[nextStroke.length - 1] || pos;
|
||||
setPaintStrokePoints(nextStroke);
|
||||
}
|
||||
};
|
||||
|
||||
const runInference = useCallback(async (
|
||||
@@ -721,6 +879,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
classId: activeClass?.id || existingCandidate?.classId,
|
||||
className: activeClass?.name || existingCandidate?.className,
|
||||
classZIndex: activeClass?.zIndex ?? existingCandidate?.classZIndex,
|
||||
classMaskId: activeClass?.maskId ?? existingCandidate?.classMaskId,
|
||||
saveStatus: existingCandidate?.annotationId ? 'dirty' as const : 'draft' as const,
|
||||
saved: false,
|
||||
pathData: m.pathData,
|
||||
@@ -768,14 +927,19 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
|
||||
const handleApplyActiveClass = () => {
|
||||
if (!frame?.id || !activeClass) return;
|
||||
const seedIds = selectedMaskIds.length > 0
|
||||
? selectedMaskIds
|
||||
: frameMasks.map((mask) => mask.id);
|
||||
const targetIds = findPropagationChainMaskIds(seedIds, masks);
|
||||
setMasks(masks.map((mask) => {
|
||||
if (mask.frameId !== frame.id) return mask;
|
||||
if (!targetIds.has(mask.id)) return mask;
|
||||
return {
|
||||
...mask,
|
||||
templateId: activeTemplateId || mask.templateId,
|
||||
classId: activeClass.id,
|
||||
className: activeClass.name,
|
||||
classZIndex: activeClass.zIndex,
|
||||
classMaskId: activeClass.maskId,
|
||||
label: activeClass.name,
|
||||
color: activeClass.color,
|
||||
saveStatus: mask.annotationId ? 'dirty' : 'draft',
|
||||
@@ -815,7 +979,102 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
setSelectedVertexIndex(null);
|
||||
}, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setMasks]);
|
||||
|
||||
const applyPaintStroke = useCallback((tool: string | null, strokePoints: CanvasPoint[]) => {
|
||||
if (!frame?.id || strokePoints.length === 0) return;
|
||||
const radius = Math.max(2, (tool === ERASER_TOOL ? eraserSize : brushSize) / 2);
|
||||
const strokeGeometry = paintStrokeToGeometry(strokePoints, radius);
|
||||
if (!strokeGeometry) return;
|
||||
|
||||
if (tool === BRUSH_TOOL) {
|
||||
if (!activeClass) {
|
||||
setInferenceMessage('请先在右侧语义分类树选择类别,再使用画笔。');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetGeometry = selectedMask ? maskToMultiPolygon(selectedMask) : null;
|
||||
const shouldMerge = Boolean(targetGeometry && geometriesOverlap(targetGeometry, strokeGeometry));
|
||||
if (selectedMask && targetGeometry && shouldMerge) {
|
||||
const resultGeometry = polygonClipping.union(targetGeometry, strokeGeometry);
|
||||
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
|
||||
if (resultSegmentation.length === 0) return;
|
||||
const nextMask = {
|
||||
...maskWithSegmentation(selectedMask, resultSegmentation, {
|
||||
area: multiPolygonArea(resultGeometry),
|
||||
hasHoles: multiPolygonHasHoles(resultGeometry),
|
||||
}),
|
||||
templateId: activeTemplateId || selectedMask.templateId,
|
||||
classId: activeClass.id,
|
||||
className: activeClass.name,
|
||||
classZIndex: activeClass.zIndex,
|
||||
classMaskId: activeClass.maskId,
|
||||
label: activeClass.name,
|
||||
color: activeClass.color,
|
||||
};
|
||||
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
|
||||
setSelectedMaskId(selectedMask.id);
|
||||
setSelectedMaskIds([selectedMask.id]);
|
||||
setSelectedVertexIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMask = createManualMaskFromGeometry('画笔', strokeGeometry);
|
||||
if (nextMask) {
|
||||
setSelectedMaskId(nextMask.id);
|
||||
setSelectedMaskIds([nextMask.id]);
|
||||
setSelectedPolygonIndex(0);
|
||||
setSelectedVertexIndex(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === ERASER_TOOL) {
|
||||
if (!selectedMask) {
|
||||
setInferenceMessage('请先选择一个 mask,再使用橡皮擦。');
|
||||
return;
|
||||
}
|
||||
const targetGeometry = maskToMultiPolygon(selectedMask);
|
||||
if (!targetGeometry) return;
|
||||
const resultGeometry = polygonClipping.difference(targetGeometry, strokeGeometry);
|
||||
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
|
||||
if (resultSegmentation.length === 0) {
|
||||
deleteMasksById([selectedMask.id]);
|
||||
return;
|
||||
}
|
||||
const nextMask = maskWithSegmentation(selectedMask, resultSegmentation, {
|
||||
area: multiPolygonArea(resultGeometry),
|
||||
hasHoles: multiPolygonHasHoles(resultGeometry),
|
||||
});
|
||||
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
|
||||
setSelectedMaskId(selectedMask.id);
|
||||
setSelectedMaskIds([selectedMask.id]);
|
||||
setSelectedVertexIndex(null);
|
||||
}
|
||||
}, [
|
||||
activeClass,
|
||||
activeTemplateId,
|
||||
brushSize,
|
||||
createManualMaskFromGeometry,
|
||||
deleteMasksById,
|
||||
eraserSize,
|
||||
frame?.id,
|
||||
masks,
|
||||
selectedMask,
|
||||
setMasks,
|
||||
]);
|
||||
|
||||
const handleStageMouseDown = (e: any) => {
|
||||
if (PAINT_TOOLS.has(effectiveTool)) {
|
||||
const canStart = effectiveTool === BRUSH_TOOL ? Boolean(activeClass) : Boolean(selectedMask);
|
||||
if (!canStart) return;
|
||||
const pos = stagePoint(e);
|
||||
if (pos) {
|
||||
paintToolRef.current = effectiveTool;
|
||||
lastPaintPointRef.current = pos;
|
||||
setPaintStrokePoints([pos]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (DRAG_MANUAL_TOOLS.has(effectiveTool)) {
|
||||
const pos = stagePoint(e);
|
||||
if (pos) {
|
||||
@@ -836,11 +1095,28 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
};
|
||||
|
||||
const handleStageMouseUp = (e: any) => {
|
||||
if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) {
|
||||
const finalPoint = stagePoint(e);
|
||||
const currentStroke = paintStrokeRef.current;
|
||||
const spacing = Math.max(3, activePaintRadius * 0.55);
|
||||
const nextStroke = finalPoint
|
||||
&& currentStroke.length > 0
|
||||
&& pointDistance(currentStroke[currentStroke.length - 1], finalPoint) >= spacing
|
||||
&& currentStroke.length < MAX_PAINT_STROKE_POINTS
|
||||
? extendStrokePoints(currentStroke, finalPoint, spacing)
|
||||
: currentStroke;
|
||||
const tool = paintToolRef.current;
|
||||
setPaintStrokePoints([]);
|
||||
paintToolRef.current = null;
|
||||
lastPaintPointRef.current = null;
|
||||
applyPaintStroke(tool, nextStroke);
|
||||
return;
|
||||
}
|
||||
|
||||
if (DRAG_MANUAL_TOOLS.has(effectiveTool) && manualStart) {
|
||||
const end = stagePoint(e) || manualCurrent || manualStart;
|
||||
const width = Math.abs(end.x - manualStart.x);
|
||||
const height = Math.abs(end.y - manualStart.y);
|
||||
const distance = Math.hypot(width, height);
|
||||
|
||||
if (effectiveTool === 'create_rectangle' && width > 4 && height > 4) {
|
||||
createManualMask('矩形', rectanglePoints(manualStart, end));
|
||||
@@ -848,9 +1124,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
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);
|
||||
@@ -880,14 +1153,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
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 (PAINT_TOOLS.has(effectiveTool)) return;
|
||||
|
||||
if (effectiveTool === POLYGON_TOOL) {
|
||||
const pos = stagePoint(e);
|
||||
@@ -955,20 +1221,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
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,
|
||||
};
|
||||
return maskWithSegmentation(mask, segmentation, options);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1017,7 +1270,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
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];
|
||||
@@ -1217,7 +1469,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
)}
|
||||
|
||||
{/* AI Returned Masks */}
|
||||
{frameMasks.map((mask) => {
|
||||
{displayFrameMasks.map((mask) => {
|
||||
const selectedIndex = selectedMaskIds.indexOf(mask.id);
|
||||
const isMaskSelected = selectedIndex >= 0;
|
||||
const isBooleanPrimary = isBooleanTool && selectedIndex === 0;
|
||||
@@ -1282,6 +1534,34 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
/>
|
||||
)}
|
||||
|
||||
{paintStrokePoints.length > 0 && (
|
||||
<Group opacity={effectiveTool === ERASER_TOOL ? 0.28 : 0.22}>
|
||||
{paintStrokePoints.map((point, index) => (
|
||||
<Circle
|
||||
key={`paint-stroke-${index}`}
|
||||
x={point.x}
|
||||
y={point.y}
|
||||
radius={activePaintRadius}
|
||||
fill={effectiveTool === ERASER_TOOL ? '#ef4444' : activeClass?.color || '#22d3ee'}
|
||||
stroke={effectiveTool === ERASER_TOOL ? '#fecaca' : '#ffffff'}
|
||||
strokeWidth={1 / scale}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{isPaintTool && (effectiveTool === BRUSH_TOOL ? activeClass : selectedMask) && paintStrokePoints.length === 0 && (
|
||||
<Circle
|
||||
x={cursorPos.x}
|
||||
y={cursorPos.y}
|
||||
radius={activePaintRadius}
|
||||
fill="rgba(255,255,255,0.02)"
|
||||
stroke={effectiveTool === ERASER_TOOL ? '#f87171' : activeClass?.color || '#22d3ee'}
|
||||
strokeWidth={1.5 / scale}
|
||||
dash={[4 / scale, 4 / scale]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{polygonPoints.map((point, index) => (
|
||||
<Circle
|
||||
key={`poly-point-${index}`}
|
||||
|
||||
Reference in New Issue
Block a user