引入实例ID驱动传播链匹配

- 前端保存标注写入并保留 instance_id,AI 自动推理 seed 携带 source_instance_id,避免同类多 mask 只按语义混在一起。

- 后端传播任务优先用 source_instance_id/instance_id 做幂等、替换和写入前清理,并保留 source_annotation_id/source_mask_id/legacy 兼容路径。

- 前端传播链匹配、删除/分类同步和布尔合并/去重加入实例 token,保持旧 lineage 和空间最近 legacy fallback。

- 补充前后端回归测试,覆盖同类别多实例传播、重传、布尔同步、断开多区域和保存/回显 metadata。

- 更新 AGENTS 与 doc 事实文档,明确 maskid 仍只用于语义分类、GT_label 和导出,不参与实例追踪。
This commit is contained in:
2026-05-04 05:54:23 +08:00
parent 1ff757e2fa
commit 5d73eacefe
15 changed files with 325 additions and 19 deletions

View File

@@ -393,6 +393,9 @@ def test_propagate_saves_tracked_annotations(client, monkeypatch):
"color": "#ff0000",
"class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20},
"template_id": None,
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
"source_instance_id": "gallbladder-instance-7",
"smoothing": {"strength": 45, "method": "chaikin"},
},
})
@@ -409,6 +412,10 @@ def test_propagate_saves_tracked_annotations(client, monkeypatch):
assert saved["frame_id"] == frames[1]["id"]
assert saved["mask_data"]["source"] == "sam2.1_hiera_tiny_propagation"
assert saved["mask_data"]["class"]["name"] == "胆囊"
assert saved["mask_data"]["source_annotation_id"] == 7
assert saved["mask_data"]["source_mask_id"] == "annotation-7"
assert saved["mask_data"]["source_instance_id"] == "gallbladder-instance-7"
assert saved["mask_data"]["instance_id"] == "gallbladder-instance-7"
assert saved["mask_data"]["score"] == 0.8
assert saved["mask_data"]["geometry_smoothing"] == {"strength": 45.0, "method": "chaikin"}
assert saved["mask_data"]["polygons"][0] != [[0.15, 0.15], [0.25, 0.15], [0.25, 0.25]]
@@ -541,6 +548,7 @@ def test_propagation_task_runner_saves_annotations_and_progress(client, db_sessi
"label": "胆囊",
"color": "#ff0000",
"class_metadata": {"id": "c1", "name": "胆囊"},
"source_instance_id": "worker-instance-1",
"smoothing": {"strength": 40, "method": "chaikin"},
},
}],
@@ -576,6 +584,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"]["source_instance_id"] == "worker-instance-1"
assert listing.json()[0]["mask_data"]["instance_id"] == "worker-instance-1"
stored_polygon = listing.json()[0]["mask_data"]["polygons"][0]
assert listing.json()[0]["mask_data"]["geometry_smoothing"] == {"strength": 40.0, "method": "chaikin"}
assert stored_polygon != [[0.15, 0.15], [0.25, 0.15], [0.25, 0.25]]
@@ -620,6 +630,7 @@ def test_propagation_task_runner_keeps_disconnected_result_polygons_in_one_annot
"color": "#ff0000",
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
"source_instance_id": "multi-region-instance-7",
},
}],
},
@@ -651,6 +662,8 @@ def test_propagation_task_runner_keeps_disconnected_result_polygons_in_one_annot
assert annotation.mask_data["polygons"] == [first_piece, second_piece]
assert annotation.mask_data["holes"] == [[], second_hole]
assert annotation.mask_data["hasHoles"] is True
assert annotation.mask_data["source_instance_id"] == "multi-region-instance-7"
assert annotation.mask_data["instance_id"] == "multi-region-instance-7"
assert annotation.mask_data["score"] == 0.93
assert annotation.mask_data["scores"] == [0.72, 0.93]
@@ -829,8 +842,8 @@ def test_propagation_task_runner_keeps_same_class_seeds_separate(client, db_sess
]
output_by_source = {
7: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]],
8: [[0.12, 0.12], [0.32, 0.12], [0.32, 0.32], [0.12, 0.32]],
"instance-7": [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]],
"instance-8": [[0.62, 0.62], [0.82, 0.62], [0.82, 0.82], [0.62, 0.82]],
}
task = ProcessingTask(
task_type="propagate_masks",
@@ -853,6 +866,7 @@ def test_propagation_task_runner_keeps_same_class_seeds_separate(client, db_sess
"color": "#ff0000",
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
"source_instance_id": "instance-7",
"class_metadata": {"id": "gallbladder", "name": "胆囊"},
},
},
@@ -865,6 +879,7 @@ def test_propagation_task_runner_keeps_same_class_seeds_separate(client, db_sess
"color": "#ff0000",
"source_annotation_id": 8,
"source_mask_id": "annotation-8",
"source_instance_id": "instance-8",
"class_metadata": {"id": "gallbladder", "name": "胆囊"},
},
},
@@ -879,7 +894,7 @@ def test_propagation_task_runner_keeps_same_class_seeds_separate(client, db_sess
monkeypatch.setattr("services.propagation_task_runner.publish_task_progress_event", lambda event_task: None)
def fake_propagate_video(model, frame_paths, source_frame_index, seed, direction, max_frames):
output_polygon = output_by_source[seed["source_annotation_id"]]
output_polygon = output_by_source[seed["source_instance_id"]]
return [
{"frame_index": 0, "polygons": [seed["polygons"][0]], "scores": [0.9]},
{"frame_index": 1, "polygons": [output_polygon], "scores": [0.8]},
@@ -893,7 +908,92 @@ def test_propagation_task_runner_keeps_same_class_seeds_separate(client, db_sess
assert result["deleted_annotation_count"] == 0
annotations = db_session.query(Annotation).filter(Annotation.project_id == project["id"]).order_by(Annotation.id).all()
assert [annotation.mask_data["source_annotation_id"] for annotation in annotations] == [7, 8]
assert [annotation.mask_data["polygons"][0] for annotation in annotations] == [output_by_source[7], output_by_source[8]]
assert [annotation.mask_data["source_instance_id"] for annotation in annotations] == ["instance-7", "instance-8"]
assert [annotation.mask_data["instance_id"] for annotation in annotations] == ["instance-7", "instance-8"]
assert [annotation.mask_data["polygons"][0] for annotation in annotations] == [output_by_source["instance-7"], output_by_source["instance-8"]]
def test_propagation_task_runner_replaces_only_matching_instance_id(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Instance 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(2)
]
old_target_polygon = [[0.12, 0.12], [0.24, 0.12], [0.24, 0.24], [0.12, 0.24]]
sibling_polygon = [[0.16, 0.16], [0.30, 0.16], [0.30, 0.30], [0.16, 0.30]]
new_target_polygon = [[0.14, 0.14], [0.28, 0.14], [0.28, 0.28], [0.14, 0.28]]
for polygon, instance_id in [(old_target_polygon, "tracked-instance-a"), (sibling_polygon, "tracked-instance-b")]:
db_session.add(Annotation(
project_id=project["id"],
frame_id=frames[1]["id"],
mask_data={
"polygons": [polygon],
"label": "胆囊",
"color": "#ff0000",
"source": "sam2.1_hiera_tiny_propagation",
"propagated_from_frame_id": frames[0]["id"],
"propagation_seed_key": f"instance:{instance_id}",
"propagation_seed_signature": "old-signature",
"propagation_direction": "forward",
"source_instance_id": instance_id,
"instance_id": instance_id,
"class": {"id": "gallbladder", "name": "胆囊"},
},
bbox=[polygon[0][0], polygon[0][1], polygon[1][0] - polygon[0][0], polygon[2][1] - polygon[1][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[0]["id"],
"model": "sam2.1_hiera_tiny",
"include_source": False,
"save_annotations": True,
"steps": [{
"direction": "forward",
"max_frames": 2,
"seed": {
"polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]],
"label": "胆囊",
"color": "#ff0000",
"source_instance_id": "tracked-instance-a",
"class_metadata": {"id": "gallbladder", "name": "胆囊"},
},
}],
},
)
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": [new_target_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"]).order_by(Annotation.id).all()
assert len(annotations) == 2
assert [annotation.mask_data["source_instance_id"] for annotation in annotations] == ["tracked-instance-b", "tracked-instance-a"]
assert [annotation.mask_data["polygons"][0] for annotation in annotations] == [sibling_polygon, new_target_polygon]
def test_propagation_task_runner_replaces_downstream_result_from_middle_frame_manual_seed(client, db_session, monkeypatch):