feat: 完善工作区交互提示与后端属性分析

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

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

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

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

View File

@@ -16,6 +16,7 @@ interface CanvasAreaProps {
type CanvasPoint = { x: number; y: number };
type PromptPoint = CanvasPoint & { type: 'pos' | 'neg' };
type PromptBox = { x1: number; y1: number; x2: number; y2: number };
type ToolHint = { title: string; body: string };
const DRAG_MANUAL_TOOLS = new Set(['create_rectangle', 'create_circle', 'create_line']);
const POLYGON_TOOL = 'create_polygon';
@@ -282,6 +283,81 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const dirtyMaskCount = frameMasks.filter((mask) => mask.saveStatus === 'dirty').length;
const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool);
const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL;
const currentLayerLabel = selectedMask
? `${selectedMask.className || selectedMask.label}${selectedMask.annotationId ? ` #${selectedMask.annotationId}` : ' (未保存)'}`
: '未选择';
const toolHint = React.useMemo<ToolHint | null>(() => {
if (!frame) return null;
if (effectiveTool === POLYGON_TOOL) {
if (polygonPoints.length === 0) {
return {
title: '创建多边形',
body: '点击画布添加顶点;至少 3 个点后,点击首节点或按 Enter 完成,按 Esc 取消。',
};
}
if (polygonPoints.length < 3) {
return {
title: `创建多边形 · 已放置 ${polygonPoints.length}`,
body: '继续点击添加顶点;满 3 个点后才能闭合,按 Esc 可取消当前多边形。',
};
}
return {
title: `创建多边形 · 已放置 ${polygonPoints.length}`,
body: '点击黄色首节点或按 Enter 闭合完成;按 Esc 放弃当前多边形。',
};
}
if (effectiveTool === 'create_rectangle') {
return { title: '创建矩形', body: '按住并拖拽框出区域,松开鼠标后生成 mask切换工具可放弃当前操作。' };
}
if (effectiveTool === 'create_circle') {
return { title: '创建圆形', body: '按住并拖拽确定外接范围,松开鼠标后生成椭圆 mask。' };
}
if (effectiveTool === 'create_line') {
return { title: '创建线段', body: '按住并拖拽画出线段,松开后生成有宽度的线状 mask。' };
}
if (effectiveTool === POINT_TOOL) {
return { title: '创建点区域', body: '点击画布创建一个小型点区域;也可以在已有 mask 上继续落点。' };
}
if (effectiveTool === 'box_select') {
return {
title: samPromptBox ? '边界框已建立' : '边界框选',
body: samPromptBox
? '继续添加正向/反向点可细化同一个候选区域;重新拖拽会替换当前框。'
: '按住并拖拽建立框选区域,松开后会触发 SAM 推理。',
};
}
if (effectiveTool === 'point_pos') {
return { title: '正向选点', body: '点击目标内部添加正向点并触发细化;点击已有提示点可删除并重新推理。' };
}
if (effectiveTool === 'point_neg') {
return { title: '反向选点', body: '点击不应包含的区域添加反向点;点击已有提示点可删除并重新推理。' };
}
if (effectiveTool === 'area_merge') {
return {
title: '区域合并',
body: booleanSelectedMasks.length > 0
? `已选 ${booleanSelectedMasks.length} 个区域;第一个选中的是主区域,点击“合并选中”完成。`
: '依次点击多个 mask第一个选中的区域会作为合并后的主区域。',
};
}
if (effectiveTool === 'area_remove') {
return {
title: '重叠区域去除',
body: booleanSelectedMasks.length > 0
? `已选 ${booleanSelectedMasks.length} 个区域;第一个是保留主区域,后续区域会被扣除。`
: '先点击要保留的主区域,再点击要扣除的干涉区域。',
};
}
if (effectiveTool === EDIT_POLYGON_TOOL || (effectiveTool === 'move' && selectedMask)) {
return {
title: selectedMask ? '调整多边形' : '调整多边形',
body: selectedMask
? '可直接拖动白色顶点;点击青色边中点或双击边线新增顶点;选中顶点/区域后按 Delete 删除。'
: '点击一个 mask 后,可拖动顶点、点击边中点新增顶点,或按 Delete 删除选中区域。',
};
}
return null;
}, [booleanSelectedMasks.length, effectiveTool, frame, polygonPoints.length, samPromptBox, selectedMask]);
useEffect(() => {
const handleResize = () => {
@@ -860,7 +936,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
setSelectedVertexIndex(null);
};
const handleVertexDragEnd = (mask: Mask, vertexIndex: number, event: any) => {
const handleVertexDragStart = (mask: Mask, vertexIndex: number, event?: any) => {
if (event) event.cancelBubble = true;
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setSelectedVertexIndex(vertexIndex);
};
const handleVertexDrag = (mask: Mask, vertexIndex: number, event: any) => {
const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width;
const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height;
const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex);
@@ -874,6 +957,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
: point
));
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setSelectedVertexIndex(vertexIndex);
updatePolygonMask(mask, nextPoints, selectedPolygonIndex);
};
@@ -977,6 +1061,12 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
{inferenceMessage}
</div>
)}
{toolHint && (
<div className="absolute top-4 left-4 z-20 max-w-sm rounded-lg border border-cyan-400/20 bg-[#0d0d0d]/95 px-3 py-2 shadow-xl pointer-events-none">
<div className="text-[10px] font-semibold uppercase tracking-widest text-cyan-300">{toolHint.title}</div>
<div className="mt-1 text-xs leading-relaxed text-gray-300">{toolHint.body}</div>
</div>
)}
<Stage
width={stageSize.width}
@@ -1005,6 +1095,16 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
{/* AI Returned Masks */}
{frameMasks.map((mask) => {
const selectedIndex = selectedMaskIds.indexOf(mask.id);
const isMaskSelected = selectedIndex >= 0;
const isBooleanPrimary = isBooleanTool && selectedIndex === 0;
const isBooleanSecondary = isBooleanTool && selectedIndex > 0;
const strokeColor = isBooleanPrimary
? '#facc15'
: isBooleanSecondary
? '#fb7185'
: mask.color;
const strokeDash = isBooleanSecondary ? [6 / scale, 4 / scale] : undefined;
const hasHoles = Boolean(mask.metadata?.hasHoles);
const paths = hasHoles
? [{ data: segmentationPath(mask.segmentation), polygonIndex: 0, fillRule: 'evenodd' }]
@@ -1014,15 +1114,16 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
fillRule: undefined,
}));
return (
<Group key={mask.id} opacity={selectedMaskIds.includes(mask.id) ? 0.65 : 0.5}>
<Group key={mask.id} opacity={isMaskSelected ? 0.65 : 0.5}>
{paths.map(({ data, polygonIndex, fillRule }) => (
<Path
key={`${mask.id}-polygon-${polygonIndex}`}
data={data}
fill={mask.color}
fillRule={fillRule}
stroke={mask.color}
strokeWidth={(selectedMaskIds.includes(mask.id) ? 2 : 1) / scale}
stroke={strokeColor}
strokeWidth={(isMaskSelected ? 2 : 1) / scale}
dash={strokeDash}
onClick={(event: any) => handleMaskSelect(mask, event, polygonIndex)}
onTap={(event: any) => handleMaskSelect(mask, event, polygonIndex)}
onDblClick={(event: any) => handlePathDoubleClick(mask, event, polygonIndex)}
@@ -1125,6 +1226,9 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
stroke={selectedMask.color}
strokeWidth={2 / scale}
draggable
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onClick={(event: any) => {
event.cancelBubble = true;
setSelectedVertexIndex(index);
@@ -1133,7 +1237,8 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
event.cancelBubble = true;
setSelectedVertexIndex(index);
}}
onDragEnd={(event: any) => handleVertexDragEnd(selectedMask, index, event)}
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event)}
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event)}
/>
))}
@@ -1163,7 +1268,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
<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>: {currentLayerLabel}</span>
<span>: {(scale * 100).toFixed(0)}%</span>
<span>: {frameMasks.length}</span>
<span>: {savedMaskCount}</span>