fix: 避免自动传播重复叠加同源 mask

Bugfix:自动传播 worker 改为在本次目标帧段内按 seed 来源、方向、权重和签名查找旧传播结果;未修改且目标帧已覆盖时直接跳过,不再重复跑 SAM 造成 mask 堆叠。

Bugfix:同一 seed 被编辑、目标帧段只部分覆盖或切换 SAM 2.1 权重时,worker 会先删除本次目标帧段内同源旧自动传播标注,再重新传播。

Bugfix:未编辑的自动传播结果再次作为参考 seed 时会继承原始 propagation_seed_signature;编辑后的传播结果只保留 source_annotation_id/source_mask_id lineage,不继承旧签名,从而触发重传路径。

Bugfix:后端传播签名增加 canonical rounding,减少浮点精度细微变化导致未编辑 mask 被误判为已修改。

功能调整:清空片段遮罩改成与自动传播一致的时间轴范围选择流程,首次点击进入选区,拖拽选择起止帧后点击确认清空才执行。

接口契约:PropagationSeed 增加 propagation_seed_signature 字段,用于前端把未编辑传播结果绑定回原始 seed 传播链。

测试:补充前端 VideoWorkspace 范围清空、传播 lineage 传递测试;补充后端未编辑传播 seed 跳过重复传播、旧结果清理与换权重重传测试。

文档:同步更新 doc/03、doc/04、doc/07、doc/08、doc/09,明确 A/B 传播去重规则、清空片段范围选择和新增 seed signature 契约。
This commit is contained in:
2026-05-02 07:11:03 +08:00
parent 4899c8a08a
commit f365539ff2
10 changed files with 244 additions and 45 deletions

View File

@@ -2,7 +2,7 @@ import numpy as np
import cv2
from pathlib import Path
from models import Annotation, ProcessingTask
from services.propagation_task_runner import run_propagate_project_task
from services.propagation_task_runner import _seed_signature, run_propagate_project_task
def _create_project_and_frame(client):
@@ -614,6 +614,94 @@ 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_skips_unmodified_propagated_seed_on_overlapping_frames(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Overlap Skip"}).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)
]
original_seed_polygon = [[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]
propagated_seed_polygon = [[0.14, 0.14], [0.24, 0.14], [0.24, 0.24]]
downstream_polygon = [[0.18, 0.18], [0.28, 0.18], [0.28, 0.28]]
inherited_signature = _seed_signature({
"polygons": [original_seed_polygon],
"label": "胆囊",
"color": "#ff0000",
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
})
db_session.add(Annotation(
project_id=project["id"],
frame_id=frames[2]["id"],
mask_data={
"polygons": [downstream_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": inherited_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": [propagated_seed_polygon],
"label": "胆囊",
"color": "#ff0000",
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
"propagation_seed_signature": inherited_signature,
},
}],
},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
propagate_calls = []
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 *args, **kwargs: propagate_calls.append(args) or [])
result = run_propagate_project_task(db_session, task.id)
assert result["created_annotation_count"] == 0
assert result["deleted_annotation_count"] == 0
assert result["skipped_seed_count"] == 1
assert propagate_calls == []
annotations = db_session.query(Annotation).filter(Annotation.project_id == project["id"]).all()
assert len(annotations) == 1
assert annotations[0].mask_data["polygons"] == [downstream_polygon]
def test_predict_validation_errors(client, monkeypatch):
project, _, _ = _create_project_and_frame(client)