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