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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user