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:
2026-05-02 17:04:02 +08:00
parent f365539ff2
commit 4c1d3dba73
20 changed files with 1358 additions and 71 deletions

View File

@@ -223,6 +223,41 @@ def test_analyze_mask_returns_backend_geometry_properties(client):
assert body["message"] == "已从后端重新提取几何拓扑锚点"
def test_smooth_mask_returns_backend_smoothed_geometry(client):
_, frame, _ = _create_project_and_frame(client)
response = client.post("/api/ai/smooth-mask", json={
"frame_id": frame["id"],
"mask_data": {
"polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]]],
"label": "胆囊",
"color": "#ff0000",
},
"strength": 45,
})
assert response.status_code == 200
body = response.json()
assert body["smoothing"] == {"strength": 45.0, "method": "chaikin"}
assert len(body["polygons"]) == 1
assert len(body["polygons"][0]) > 4
assert body["topology_anchor_count"] > 0
assert body["message"] == "已应用边缘平滑强度 45"
def test_seed_signature_includes_smoothing_parameters():
seed = {
"polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
"label": "胆囊",
"color": "#ff0000",
}
assert _seed_signature({**seed, "smoothing": {"strength": 20, "method": "chaikin"}}) != _seed_signature({
**seed,
"smoothing": {"strength": 40, "method": "chaikin"},
})
def test_propagate_saves_tracked_annotations(client, monkeypatch):
project = client.post("/api/projects", json={"name": "Video Project"}).json()
frames = [
@@ -324,6 +359,7 @@ def test_queue_propagation_task_creates_processing_task(client, monkeypatch):
"seed": {
"polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]],
"label": "胆囊",
"smoothing": {"strength": 35, "method": "chaikin"},
},
}],
})
@@ -335,6 +371,7 @@ def test_queue_propagation_task_creates_processing_task(client, monkeypatch):
assert body["celery_task_id"] == "celery-propagate-1"
assert body["payload"]["model"] == "sam2.1_hiera_tiny"
assert body["payload"]["steps"][0]["seed"]["label"] == "胆囊"
assert body["payload"]["steps"][0]["seed"]["smoothing"] == {"strength": 35, "method": "chaikin"}
assert queued == [body["id"]]
@@ -418,6 +455,7 @@ def test_propagation_task_runner_saves_annotations_and_progress(client, db_sessi
"label": "胆囊",
"color": "#ff0000",
"class_metadata": {"id": "c1", "name": "胆囊"},
"smoothing": {"strength": 40, "method": "chaikin"},
},
}],
},
@@ -452,6 +490,8 @@ def test_propagation_task_runner_saves_annotations_and_progress(client, db_sessi
listing = client.get(f"/api/ai/annotations?project_id={project['id']}")
assert listing.json()[0]["frame_id"] == frames[1]["id"]
assert listing.json()[0]["mask_data"]["source"] == "sam2.1_hiera_tiny_propagation"
assert listing.json()[0]["mask_data"]["geometry_smoothing"] == {"strength": 40.0, "method": "chaikin"}
assert len(listing.json()[0]["mask_data"]["polygons"][0]) > 3
def test_propagation_task_runner_skips_unchanged_seed_and_replaces_changed_seed(client, db_session, monkeypatch):
@@ -614,6 +654,172 @@ def test_propagation_task_runner_replaces_legacy_or_different_weight_results(cli
assert annotations[0].mask_data["polygons"] == [output_polygon]
def test_propagation_task_runner_replaces_downstream_result_from_middle_frame_manual_seed(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Middle Frame Replacement"}).json()
frames = [
client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": idx,
"image_url": f"frames/{idx}.jpg",
"width": 640,
"height": 360,
}).json()
for idx in range(3)
]
old_downstream_polygon = [[0.18, 0.18], [0.28, 0.18], [0.28, 0.28]]
replacement_seed_polygon = [[0.16, 0.16], [0.26, 0.16], [0.26, 0.26]]
replacement_downstream_polygon = [[0.19, 0.19], [0.29, 0.19], [0.29, 0.29]]
db_session.add(Annotation(
project_id=project["id"],
frame_id=frames[2]["id"],
template_id=3,
mask_data={
"polygons": [old_downstream_polygon],
"label": "胆囊",
"color": "#ff0000",
"class": {"id": "c1", "name": "胆囊", "color": "#ff0000"},
"source": "sam2.1_hiera_tiny_propagation",
"propagated_from_frame_id": frames[0]["id"],
"propagation_seed_key": "annotation:7",
"propagation_seed_signature": "old-signature",
"propagation_direction": "forward",
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
},
bbox=[0.18, 0.18, 0.1, 0.1],
))
db_session.commit()
task = ProcessingTask(
task_type="propagate_masks",
status="queued",
progress=0,
project_id=project["id"],
payload={
"project_id": project["id"],
"frame_id": frames[1]["id"],
"model": "sam2.1_hiera_tiny",
"include_source": False,
"save_annotations": True,
"steps": [{
"direction": "forward",
"max_frames": 2,
"seed": {
"polygons": [replacement_seed_polygon],
"label": "胆囊",
"color": "#ff0000",
"source_annotation_id": 20,
"source_mask_id": "annotation-20",
},
}],
},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
monkeypatch.setattr("services.propagation_task_runner.download_file", lambda object_name: b"jpeg")
monkeypatch.setattr("services.propagation_task_runner.publish_task_progress_event", lambda event_task: None)
monkeypatch.setattr("services.propagation_task_runner.sam_registry.propagate_video", lambda model, frame_paths, source_frame_index, seed, direction, max_frames: [
{"frame_index": 0, "polygons": [seed["polygons"][0]], "scores": [0.9]},
{"frame_index": 1, "polygons": [replacement_downstream_polygon], "scores": [0.8]},
])
result = run_propagate_project_task(db_session, task.id)
assert result["created_annotation_count"] == 1
assert result["deleted_annotation_count"] == 1
annotations = db_session.query(Annotation).filter(Annotation.project_id == project["id"]).all()
assert len(annotations) == 1
assert annotations[0].frame_id == frames[2]["id"]
assert annotations[0].mask_data["polygons"] == [replacement_downstream_polygon]
assert annotations[0].mask_data["source_annotation_id"] == 20
assert annotations[0].mask_data["source_mask_id"] == "annotation-20"
def test_propagation_task_runner_replaces_forward_result_when_middle_frame_propagates_backward(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Backward Middle Replacement"}).json()
frames = [
client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": idx,
"image_url": f"frames/{idx}.jpg",
"width": 640,
"height": 360,
}).json()
for idx in range(3)
]
old_upstream_polygon = [[0.12, 0.12], [0.22, 0.12], [0.22, 0.22]]
replacement_seed_polygon = [[0.16, 0.16], [0.26, 0.16], [0.26, 0.26]]
replacement_upstream_polygon = [[0.13, 0.13], [0.23, 0.13], [0.23, 0.23]]
db_session.add(Annotation(
project_id=project["id"],
frame_id=frames[0]["id"],
mask_data={
"polygons": [old_upstream_polygon],
"label": "胆囊",
"color": "#ff0000",
"source": "sam2.1_hiera_tiny_propagation",
"propagated_from_frame_id": frames[0]["id"],
"propagation_seed_key": "annotation:7",
"propagation_seed_signature": "old-signature",
"propagation_direction": "forward",
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
},
bbox=[0.12, 0.12, 0.1, 0.1],
))
db_session.commit()
task = ProcessingTask(
task_type="propagate_masks",
status="queued",
progress=0,
project_id=project["id"],
payload={
"project_id": project["id"],
"frame_id": frames[1]["id"],
"model": "sam2.1_hiera_tiny",
"include_source": False,
"save_annotations": True,
"steps": [{
"direction": "backward",
"max_frames": 2,
"seed": {
"polygons": [replacement_seed_polygon],
"label": "胆囊",
"color": "#ff0000",
"source_annotation_id": 20,
"source_mask_id": "annotation-20",
},
}],
},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
monkeypatch.setattr("services.propagation_task_runner.download_file", lambda object_name: b"jpeg")
monkeypatch.setattr("services.propagation_task_runner.publish_task_progress_event", lambda event_task: None)
monkeypatch.setattr("services.propagation_task_runner.sam_registry.propagate_video", lambda model, frame_paths, source_frame_index, seed, direction, max_frames: [
{"frame_index": 0, "polygons": [replacement_upstream_polygon], "scores": [0.8]},
{"frame_index": 1, "polygons": [seed["polygons"][0]], "scores": [0.9]},
])
result = run_propagate_project_task(db_session, task.id)
assert result["created_annotation_count"] == 1
assert result["deleted_annotation_count"] == 1
annotations = db_session.query(Annotation).filter(Annotation.project_id == project["id"]).all()
assert len(annotations) == 1
assert annotations[0].frame_id == frames[0]["id"]
assert annotations[0].mask_data["polygons"] == [replacement_upstream_polygon]
assert annotations[0].mask_data["propagation_direction"] == "backward"
assert annotations[0].mask_data["source_annotation_id"] == 20
def test_propagation_task_runner_skips_unmodified_propagated_seed_on_overlapping_frames(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Overlap Skip"}).json()
frames = [