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:
@@ -8,6 +8,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from minio_client import download_file
|
||||
@@ -81,6 +83,87 @@ def _polygon_bbox(polygon: list[list[float]]) -> list[float]:
|
||||
return [left, top, max(right - left, 0.0), max(bottom - top, 0.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_smoothing_options(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
try:
|
||||
strength = max(0.0, min(float(value.get("strength") or 0.0), 100.0))
|
||||
except (TypeError, ValueError):
|
||||
strength = 0.0
|
||||
if strength <= 0:
|
||||
return None
|
||||
method = str(value.get("method") or "chaikin").lower()
|
||||
if method != "chaikin":
|
||||
method = "chaikin"
|
||||
return {"strength": round(strength, 2), "method": method}
|
||||
|
||||
|
||||
def _chaikin_smooth_polygon(polygon: list[list[float]], iterations: int) -> list[list[float]]:
|
||||
points = _normalize_polygon(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:
|
||||
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] | None) -> list[list[float]]:
|
||||
if not smoothing:
|
||||
return _normalize_polygon(polygon)
|
||||
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(polygon, iterations)
|
||||
simplified = _simplify_polygon(smoothed, strength)
|
||||
return simplified if len(simplified) >= 3 else _normalize_polygon(polygon)
|
||||
|
||||
|
||||
def _bbox_area(bbox: list[float]) -> float:
|
||||
return max(float(bbox[2]), 0.0) * max(float(bbox[3]), 0.0)
|
||||
|
||||
|
||||
def _bbox_overlap_ratio(a: list[float], b: list[float]) -> float:
|
||||
ax1, ay1, aw, ah = a
|
||||
bx1, by1, bw, bh = b
|
||||
ax2 = ax1 + aw
|
||||
ay2 = ay1 + ah
|
||||
bx2 = bx1 + bw
|
||||
by2 = by1 + bh
|
||||
overlap_width = max(0.0, min(ax2, bx2) - max(ax1, bx1))
|
||||
overlap_height = max(0.0, min(ay2, by2) - max(ay1, by1))
|
||||
overlap_area = overlap_width * overlap_height
|
||||
smallest_area = min(_bbox_area(a), _bbox_area(b))
|
||||
return overlap_area / smallest_area if smallest_area > 0 else 0.0
|
||||
|
||||
|
||||
def _stable_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
@@ -109,6 +192,7 @@ def _seed_signature(seed: dict[str, Any]) -> str:
|
||||
"color": seed.get("color"),
|
||||
"class_metadata": seed.get("class_metadata") or {},
|
||||
"template_id": seed.get("template_id"),
|
||||
"smoothing": _normalize_smoothing_options(seed.get("smoothing")),
|
||||
}
|
||||
return hashlib.sha256(_stable_json(_canonicalize_signature_value(signature_payload)).encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -131,6 +215,20 @@ def _seed_key(seed: dict[str, Any]) -> str:
|
||||
})
|
||||
|
||||
|
||||
def _semantic_seed_matches(mask_data: dict[str, Any], seed: dict[str, Any]) -> bool:
|
||||
"""Best-effort match when a manually edited replacement lacks old lineage ids."""
|
||||
class_metadata = seed.get("class_metadata") or {}
|
||||
previous_class = mask_data.get("class") or {}
|
||||
previous_class_id = previous_class.get("id") or previous_class.get("name")
|
||||
class_id = class_metadata.get("id") or class_metadata.get("name")
|
||||
if previous_class_id and class_id and str(previous_class_id) != str(class_id):
|
||||
return False
|
||||
return (
|
||||
mask_data.get("label") == seed.get("label")
|
||||
and mask_data.get("color") == seed.get("color")
|
||||
)
|
||||
|
||||
|
||||
def _legacy_seed_matches(mask_data: dict[str, Any], seed: dict[str, Any]) -> bool:
|
||||
"""Best-effort match for propagation annotations created before seed keys."""
|
||||
class_metadata = seed.get("class_metadata") or {}
|
||||
@@ -174,6 +272,52 @@ def _direction_matches(mask_data: dict[str, Any], direction: str) -> bool:
|
||||
return previous_direction in {None, direction}
|
||||
|
||||
|
||||
def _annotation_spatially_matches(annotation: Annotation, polygon: list[list[float]]) -> bool:
|
||||
"""Use target-frame overlap as a final guard before replacing same-object propagation."""
|
||||
candidate_bbox = _polygon_bbox(polygon)
|
||||
for previous_polygon in (annotation.mask_data or {}).get("polygons") or []:
|
||||
if len(previous_polygon) < 3:
|
||||
continue
|
||||
if _bbox_overlap_ratio(_polygon_bbox(previous_polygon), candidate_bbox) >= 0.15:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _delete_replaced_frame_annotations(
|
||||
db: Session,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
frame_id: int,
|
||||
seed_key: str,
|
||||
seed: dict[str, Any],
|
||||
polygon: list[list[float]],
|
||||
) -> int:
|
||||
"""Delete old propagated masks for the same object immediately before writing a new result."""
|
||||
previous_annotations = (
|
||||
db.query(Annotation)
|
||||
.filter(Annotation.project_id == int(payload["project_id"]))
|
||||
.filter(Annotation.frame_id == frame_id)
|
||||
.all()
|
||||
)
|
||||
deleted_count = 0
|
||||
for annotation in previous_annotations:
|
||||
mask_data = annotation.mask_data or {}
|
||||
source = str(mask_data.get("source") or "")
|
||||
if not source.endswith("_propagation"):
|
||||
continue
|
||||
same_lineage = _seed_identity_matches(mask_data, seed_key, seed)
|
||||
same_manual_replacement = (
|
||||
_semantic_seed_matches(mask_data, seed)
|
||||
and _annotation_spatially_matches(annotation, polygon)
|
||||
)
|
||||
if same_lineage or same_manual_replacement:
|
||||
db.delete(annotation)
|
||||
deleted_count += 1
|
||||
if deleted_count:
|
||||
db.commit()
|
||||
return deleted_count
|
||||
|
||||
|
||||
def _prepare_seed_propagation(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -264,10 +408,10 @@ def _save_propagated_annotations(
|
||||
source_frame: Frame,
|
||||
propagated: list[dict[str, Any]],
|
||||
seed: dict[str, Any],
|
||||
) -> list[Annotation]:
|
||||
) -> tuple[list[Annotation], int]:
|
||||
created: list[Annotation] = []
|
||||
if payload.get("save_annotations", True) is False:
|
||||
return created
|
||||
return created, 0
|
||||
|
||||
class_metadata = seed.get("class_metadata")
|
||||
template_id = seed.get("template_id")
|
||||
@@ -279,7 +423,10 @@ def _save_propagated_annotations(
|
||||
seed_signature = _seed_signature(seed)
|
||||
source_annotation_id = seed.get("source_annotation_id")
|
||||
source_mask_id = seed.get("source_mask_id")
|
||||
smoothing = _normalize_smoothing_options(seed.get("smoothing"))
|
||||
direction = str(payload.get("current_direction") or "")
|
||||
deleted_count = 0
|
||||
cleaned_frame_ids: set[int] = set()
|
||||
|
||||
for frame_result in propagated:
|
||||
relative_index = int(frame_result.get("frame_index", -1))
|
||||
@@ -290,7 +437,23 @@ def _save_propagated_annotations(
|
||||
continue
|
||||
result_polygons = frame_result.get("polygons") or []
|
||||
scores = frame_result.get("scores") or []
|
||||
for polygon_index, polygon in enumerate(result_polygons):
|
||||
smoothed_polygons = [
|
||||
_smooth_polygon(polygon, smoothing)
|
||||
for polygon in result_polygons
|
||||
if len(polygon) >= 3
|
||||
]
|
||||
cleanup_polygon = next((polygon for polygon in smoothed_polygons if len(polygon) >= 3), None)
|
||||
if cleanup_polygon is not None and frame.id not in cleaned_frame_ids:
|
||||
deleted_count += _delete_replaced_frame_annotations(
|
||||
db,
|
||||
payload=payload,
|
||||
frame_id=int(frame.id),
|
||||
seed_key=seed_key,
|
||||
seed=seed,
|
||||
polygon=cleanup_polygon,
|
||||
)
|
||||
cleaned_frame_ids.add(int(frame.id))
|
||||
for polygon_index, polygon in enumerate(smoothed_polygons):
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
annotation = Annotation(
|
||||
@@ -310,6 +473,7 @@ def _save_propagated_annotations(
|
||||
"source_annotation_id": source_annotation_id,
|
||||
"source_mask_id": source_mask_id,
|
||||
"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,
|
||||
@@ -321,7 +485,7 @@ def _save_propagated_annotations(
|
||||
db.commit()
|
||||
for annotation in created:
|
||||
db.refresh(annotation)
|
||||
return created
|
||||
return created, deleted_count
|
||||
|
||||
|
||||
def _run_one_step(
|
||||
@@ -381,7 +545,7 @@ def _run_one_step(
|
||||
)
|
||||
|
||||
save_payload = {**payload, "current_direction": direction}
|
||||
created = _save_propagated_annotations(
|
||||
created, write_cleanup_count = _save_propagated_annotations(
|
||||
db,
|
||||
payload=save_payload,
|
||||
selected_frames=selected_frames,
|
||||
@@ -394,7 +558,7 @@ def _run_one_step(
|
||||
"direction": direction,
|
||||
"processed_frame_count": len(selected_frames),
|
||||
"created_annotation_count": len(created),
|
||||
"deleted_annotation_count": int(seed_state["deleted_annotation_count"]),
|
||||
"deleted_annotation_count": int(seed_state["deleted_annotation_count"]) + write_cleanup_count,
|
||||
"skipped_seed_count": 0,
|
||||
"seed_label": seed.get("label"),
|
||||
"seed_key": seed_state["seed_key"],
|
||||
|
||||
Reference in New Issue
Block a user