feat: 完善 mask 编辑、传播平滑与开发重启闭环
功能增加: - 新增后端 /api/ai/smooth-mask 接口,对当前 mask polygon 执行 Chaikin 边缘平滑,并返回 polygon、bbox、area 与拓扑锚点。 - 在右侧实例属性面板加入边缘平滑强度和应用边缘平滑操作,应用后将 mask 标记为 draft/dirty,并通过正常保存链路落库。 - 保存标注与传播 seed 时保留 geometry_smoothing 元数据,自动传播 forward/backward 结果保存前应用同一平滑参数。 - 自动传播 seed signature 纳入平滑参数,修改平滑强度后会触发旧同源传播结果清理并重新传播。 - 支持跨帧跟随同一传播链 mask,AI 推送回工作区时保留当前帧视角。 Bugfix: - 修复中间帧向前传播时旧 forward/backward 同物体结果未被清理导致双重 mask 的问题。 - 修复 propagation worker 写入目标帧前只按旧方向清理导致 backward 重传残留的问题。 - 修复多边形顶点拖拽和编辑后画布视口异常移动的问题,并补充拖拽状态回写。 - 修复实例属性标题跟随全局 active class 而不是当前 mask label 的问题,并移除后端模型置信度展示。 开发与部署: - 新增 restart_dev_services.sh,使用 setsid 独立后台重启 FastAPI、Celery 和前端,写入 pid/log 文件并做 3000/8000 健康检查。 - 明确后端或 Celery 相关改动完成后需要运行重启脚本,保证运行态加载最新代码。 测试与文档: - 补充后端 smooth-mask、传播平滑 metadata、seed signature、传播去重方向覆盖等测试。 - 补充前端 OntologyInspector、VideoWorkspace、CanvasArea 和 api 契约测试,覆盖边缘平滑、传播参数、跨帧选区跟随和画布编辑行为。 - 更新 README、AGENTS、安装文档、前端元素审计、需求冻结、设计冻结和测试计划,记录当前真实行为与重启要求。
This commit is contained in:
@@ -17,6 +17,8 @@ from schemas import (
|
||||
AiRuntimeStatus,
|
||||
MaskAnalysisRequest,
|
||||
MaskAnalysisResponse,
|
||||
SmoothMaskRequest,
|
||||
SmoothMaskResponse,
|
||||
PredictRequest,
|
||||
PredictResponse,
|
||||
PropagateRequest,
|
||||
@@ -96,6 +98,14 @@ def _polygon_area(polygon: list[list[float]]) -> float:
|
||||
return abs(total) / 2.0
|
||||
|
||||
|
||||
def _normalize_polygon(polygon: list[list[float]]) -> list[list[float]]:
|
||||
return [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2]
|
||||
|
||||
|
||||
def _normalize_polygons(polygons: list[list[list[float]]]) -> list[list[list[float]]]:
|
||||
return [polygon for polygon in (_normalize_polygon(polygon) for polygon in polygons) if len(polygon) >= 3]
|
||||
|
||||
|
||||
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]
|
||||
@@ -108,6 +118,63 @@ def _analysis_anchors(polygons: list[list[list[float]]], points: list[list[float
|
||||
return anchors[:32]
|
||||
|
||||
|
||||
def _normalize_smoothing_options(strength: float | int | None, method: str | None = None) -> dict[str, Any]:
|
||||
clamped_strength = max(0.0, min(float(strength or 0.0), 100.0))
|
||||
normalized_method = (method or "chaikin").lower()
|
||||
if normalized_method != "chaikin":
|
||||
normalized_method = "chaikin"
|
||||
return {
|
||||
"strength": round(clamped_strength, 2),
|
||||
"method": normalized_method,
|
||||
}
|
||||
|
||||
|
||||
def _chaikin_smooth_polygon(polygon: list[list[float]], iterations: int) -> list[list[float]]:
|
||||
points = polygon
|
||||
for _ in range(max(0, iterations)):
|
||||
if len(points) < 3:
|
||||
break
|
||||
next_points: list[list[float]] = []
|
||||
for index, current in enumerate(points):
|
||||
following = points[(index + 1) % len(points)]
|
||||
next_points.append([
|
||||
_clamp01(0.75 * current[0] + 0.25 * following[0]),
|
||||
_clamp01(0.75 * current[1] + 0.25 * following[1]),
|
||||
])
|
||||
next_points.append([
|
||||
_clamp01(0.25 * current[0] + 0.75 * following[0]),
|
||||
_clamp01(0.25 * current[1] + 0.75 * following[1]),
|
||||
])
|
||||
points = next_points
|
||||
return points
|
||||
|
||||
|
||||
def _simplify_polygon(polygon: list[list[float]], strength: float) -> list[list[float]]:
|
||||
if len(polygon) < 3 or strength <= 0:
|
||||
return polygon
|
||||
contour = np.array([[[point[0], point[1]]] for point in polygon], dtype=np.float32)
|
||||
arc_length = cv2.arcLength(contour, True)
|
||||
epsilon = arc_length * (0.001 + (strength / 100.0) * 0.006)
|
||||
approx = cv2.approxPolyDP(contour, epsilon, True).reshape(-1, 2)
|
||||
if len(approx) < 3:
|
||||
return polygon
|
||||
return [[_clamp01(float(x)), _clamp01(float(y))] for x, y in approx]
|
||||
|
||||
|
||||
def _smooth_polygon(polygon: list[list[float]], smoothing: dict[str, Any]) -> list[list[float]]:
|
||||
strength = float(smoothing.get("strength") or 0.0)
|
||||
if strength <= 0:
|
||||
return _normalize_polygon(polygon)
|
||||
iterations = max(1, min(3, int(strength // 35) + 1))
|
||||
smoothed = _chaikin_smooth_polygon(_normalize_polygon(polygon), iterations)
|
||||
simplified = _simplify_polygon(smoothed, strength)
|
||||
return simplified if len(simplified) >= 3 else _normalize_polygon(polygon)
|
||||
|
||||
|
||||
def _smooth_polygons(polygons: list[list[list[float]]], smoothing: dict[str, Any]) -> list[list[list[float]]]:
|
||||
return [polygon for polygon in (_smooth_polygon(polygon, smoothing) for polygon in polygons) if len(polygon) >= 3]
|
||||
|
||||
|
||||
def _frame_window(
|
||||
frames: list[Frame],
|
||||
source_position: int,
|
||||
@@ -436,11 +503,7 @@ def analyze_mask(payload: MaskAnalysisRequest, db: Session = Depends(get_db)) ->
|
||||
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]
|
||||
valid_polygons = _normalize_polygons(polygons)
|
||||
if not valid_polygons:
|
||||
raise HTTPException(status_code=400, detail="Mask analysis requires at least one valid polygon")
|
||||
|
||||
@@ -473,6 +536,46 @@ def analyze_mask(payload: MaskAnalysisRequest, db: Session = Depends(get_db)) ->
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/smooth-mask",
|
||||
response_model=SmoothMaskResponse,
|
||||
summary="Smooth editable mask polygons with backend geometry rules",
|
||||
)
|
||||
def smooth_mask(payload: SmoothMaskRequest, db: Session = Depends(get_db)) -> dict:
|
||||
"""Return a smoothed polygon mask without persisting it.
|
||||
|
||||
The frontend keeps this as an explicit edit operation: users preview/apply it
|
||||
to the current mask, then save through the normal annotation endpoint.
|
||||
"""
|
||||
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")
|
||||
|
||||
polygons = payload.mask_data.get("polygons") or []
|
||||
valid_polygons = _normalize_polygons(polygons)
|
||||
if not valid_polygons:
|
||||
raise HTTPException(status_code=400, detail="Mask smoothing requires at least one valid polygon")
|
||||
|
||||
smoothing = _normalize_smoothing_options(payload.strength, payload.method)
|
||||
smoothed_polygons = _smooth_polygons(valid_polygons, smoothing)
|
||||
if not smoothed_polygons:
|
||||
raise HTTPException(status_code=400, detail="Mask smoothing produced no valid polygons")
|
||||
|
||||
area = sum(_polygon_area(polygon) for polygon in smoothed_polygons)
|
||||
bbox = _polygon_bbox(smoothed_polygons[0])
|
||||
anchors = _analysis_anchors(smoothed_polygons, payload.points)
|
||||
return {
|
||||
"polygons": smoothed_polygons,
|
||||
"topology_anchor_count": len(anchors),
|
||||
"topology_anchors": anchors,
|
||||
"area": area,
|
||||
"bbox": bbox,
|
||||
"smoothing": smoothing,
|
||||
"message": f"已应用边缘平滑强度 {smoothing['strength']:.0f}",
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/propagate",
|
||||
response_model=PropagateResponse,
|
||||
@@ -544,6 +647,13 @@ def propagate(payload: PropagateRequest, db: Session = Depends(get_db)) -> dict:
|
||||
label = seed.get("label") or "Propagated Mask"
|
||||
color = seed.get("color") or "#06b6d4"
|
||||
model_id = sam_registry.normalize_model_id(payload.model)
|
||||
seed_smoothing = seed.get("smoothing")
|
||||
smoothing = _normalize_smoothing_options(
|
||||
seed_smoothing.get("strength"),
|
||||
seed_smoothing.get("method"),
|
||||
) if isinstance(seed_smoothing, dict) else None
|
||||
if smoothing and smoothing["strength"] <= 0:
|
||||
smoothing = None
|
||||
|
||||
for frame_result in propagated:
|
||||
relative_index = int(frame_result.get("frame_index", -1))
|
||||
@@ -557,22 +667,24 @@ def propagate(payload: PropagateRequest, db: Session = Depends(get_db)) -> dict:
|
||||
for polygon_index, polygon in enumerate(result_polygons):
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
polygon_to_save = _smooth_polygon(polygon, smoothing) if smoothing else polygon
|
||||
annotation = Annotation(
|
||||
project_id=payload.project_id,
|
||||
frame_id=frame.id,
|
||||
template_id=template_id,
|
||||
mask_data={
|
||||
"polygons": [polygon],
|
||||
"polygons": [polygon_to_save],
|
||||
"label": label,
|
||||
"color": color,
|
||||
"source": f"{model_id}_propagation",
|
||||
"propagated_from_frame_id": source_frame.id,
|
||||
"propagated_from_frame_index": source_frame.frame_index,
|
||||
"score": scores[polygon_index] if polygon_index < len(scores) else None,
|
||||
**({"geometry_smoothing": smoothing} if smoothing else {}),
|
||||
**({"class": class_metadata} if class_metadata else {}),
|
||||
},
|
||||
points=None,
|
||||
bbox=_polygon_bbox(polygon),
|
||||
bbox=_polygon_bbox(polygon_to_save),
|
||||
)
|
||||
db.add(annotation)
|
||||
created.append(annotation)
|
||||
|
||||
Reference in New Issue
Block a user