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:
@@ -15,6 +15,8 @@ from minio_client import download_file
|
||||
from models import Project, Frame, Template, Annotation
|
||||
from schemas import (
|
||||
AiRuntimeStatus,
|
||||
MaskAnalysisRequest,
|
||||
MaskAnalysisResponse,
|
||||
PredictRequest,
|
||||
PredictResponse,
|
||||
PropagateRequest,
|
||||
@@ -78,6 +80,29 @@ def _polygon_bbox(polygon: list[list[float]]) -> list[float]:
|
||||
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
||||
|
||||
|
||||
def _polygon_area(polygon: list[list[float]]) -> float:
|
||||
if len(polygon) < 3:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for index, point in enumerate(polygon):
|
||||
next_point = polygon[(index + 1) % len(polygon)]
|
||||
total += _clamp01(point[0]) * _clamp01(next_point[1])
|
||||
total -= _clamp01(next_point[0]) * _clamp01(point[1])
|
||||
return abs(total) / 2.0
|
||||
|
||||
|
||||
def _analysis_anchors(polygons: list[list[list[float]]], points: list[list[float]] | None) -> list[list[float]]:
|
||||
if points:
|
||||
return [[_clamp01(point[0]), _clamp01(point[1])] for point in points if len(point) >= 2]
|
||||
anchors: list[list[float]] = []
|
||||
for polygon in polygons:
|
||||
if not polygon:
|
||||
continue
|
||||
step = max(1, len(polygon) // 12)
|
||||
anchors.extend([[_clamp01(point[0]), _clamp01(point[1])] for point in polygon[::step]])
|
||||
return anchors[:32]
|
||||
|
||||
|
||||
def _frame_window(
|
||||
frames: list[Frame],
|
||||
source_position: int,
|
||||
@@ -389,6 +414,60 @@ def model_status(selected_model: str | None = None) -> dict:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/analyze-mask",
|
||||
response_model=MaskAnalysisResponse,
|
||||
summary="Analyze mask geometry and prompt anchors",
|
||||
)
|
||||
def analyze_mask(payload: MaskAnalysisRequest, db: Session = Depends(get_db)) -> dict:
|
||||
"""Return backend-computed mask properties for the frontend inspector."""
|
||||
if payload.frame_id is not None:
|
||||
frame = db.query(Frame).filter(Frame.id == payload.frame_id).first()
|
||||
if not frame:
|
||||
raise HTTPException(status_code=404, detail="Frame not found")
|
||||
|
||||
mask_data = payload.mask_data or {}
|
||||
polygons = mask_data.get("polygons") or []
|
||||
if not polygons:
|
||||
raise HTTPException(status_code=400, detail="Mask analysis requires polygons")
|
||||
|
||||
valid_polygons = [
|
||||
[[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2]
|
||||
for polygon in polygons
|
||||
]
|
||||
valid_polygons = [polygon for polygon in valid_polygons if len(polygon) >= 3]
|
||||
if not valid_polygons:
|
||||
raise HTTPException(status_code=400, detail="Mask analysis requires at least one valid polygon")
|
||||
|
||||
area = sum(_polygon_area(polygon) for polygon in valid_polygons)
|
||||
bbox = payload.bbox or _polygon_bbox(valid_polygons[0])
|
||||
source = mask_data.get("source")
|
||||
raw_score = mask_data.get("score")
|
||||
confidence: float | None = None
|
||||
confidence_source = "unavailable"
|
||||
if isinstance(raw_score, (int, float)):
|
||||
confidence = max(0.0, min(float(raw_score), 1.0))
|
||||
confidence_source = "model_score"
|
||||
elif source:
|
||||
confidence_source = "source_without_score"
|
||||
else:
|
||||
confidence_source = "manual_or_imported"
|
||||
|
||||
anchors = _analysis_anchors(valid_polygons, payload.points)
|
||||
message = "已从后端重新提取几何拓扑锚点" if payload.extract_skeleton else "已读取后端几何属性"
|
||||
|
||||
return {
|
||||
"confidence": confidence,
|
||||
"confidence_source": confidence_source,
|
||||
"topology_anchor_count": len(anchors),
|
||||
"topology_anchors": anchors,
|
||||
"area": area,
|
||||
"bbox": bbox,
|
||||
"source": source,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/propagate",
|
||||
response_model=PropagateResponse,
|
||||
|
||||
Reference in New Issue
Block a user