引入实例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

@@ -797,6 +797,13 @@ def propagate(
label = seed.get("label") or "Propagated Mask"
color = seed.get("color") or "#06b6d4"
model_id = sam_registry.normalize_model_id(payload.model)
source_annotation_id = seed.get("source_annotation_id")
source_mask_id = seed.get("source_mask_id")
source_instance_id = (
seed.get("source_instance_id")
or (f"annotation:{source_annotation_id}" if source_annotation_id is not None else None)
or (f"mask:{source_mask_id}" if source_mask_id else None)
)
seed_smoothing = seed.get("smoothing")
smoothing = _normalize_smoothing_options(
seed_smoothing.get("strength"),
@@ -843,6 +850,9 @@ def propagate(
"source": f"{model_id}_propagation",
"propagated_from_frame_id": source_frame.id,
"propagated_from_frame_index": source_frame.frame_index,
**({"instance_id": source_instance_id, "source_instance_id": source_instance_id} if source_instance_id else {}),
**({"source_annotation_id": source_annotation_id} if source_annotation_id is not None else {}),
**({"source_mask_id": source_mask_id} if source_mask_id else {}),
"score": max(score_values) if score_values else None,
**({"scores": score_values} if len(score_values) > 1 else {}),
**({"geometry_smoothing": smoothing} if smoothing else {}),

View File

@@ -304,6 +304,7 @@ class PropagationSeed(BaseModel):
template_id: Optional[int] = None
source_mask_id: Optional[str] = None
source_annotation_id: Optional[int] = None
source_instance_id: Optional[str] = None
propagation_seed_signature: Optional[str] = None
smoothing: Optional[dict[str, Any]] = None

View File

@@ -233,6 +233,9 @@ def _seed_signature(seed: dict[str, Any]) -> str:
def _seed_key(seed: dict[str, Any]) -> str:
"""Prefer stable persisted ids; fall back to semantic attrs for legacy callers."""
source_instance_id = seed.get("source_instance_id")
if source_instance_id:
return f"instance:{source_instance_id}"
source_annotation_id = seed.get("source_annotation_id")
if source_annotation_id is not None:
return f"annotation:{source_annotation_id}"
@@ -284,14 +287,26 @@ def _seed_identity_matches(mask_data: dict[str, Any], seed_key: str, seed: dict[
previous_seed_key = mask_data.get("propagation_seed_key")
if previous_seed_key == seed_key:
return True
source_instance_id = seed.get("source_instance_id")
if source_instance_id and (
mask_data.get("source_instance_id") == source_instance_id
or mask_data.get("instance_id") == source_instance_id
):
return True
source_annotation_id = seed.get("source_annotation_id")
if source_annotation_id is not None and str(mask_data.get("source_annotation_id") or "") == str(source_annotation_id):
return True
source_mask_id = seed.get("source_mask_id")
if source_mask_id and mask_data.get("source_mask_id") == source_mask_id:
return True
has_persisted_seed_identity = source_annotation_id is not None or bool(source_mask_id)
has_previous_identity = bool(previous_seed_key) or mask_data.get("source_annotation_id") is not None or bool(mask_data.get("source_mask_id"))
has_persisted_seed_identity = bool(source_instance_id) or source_annotation_id is not None or bool(source_mask_id)
has_previous_identity = (
bool(previous_seed_key)
or mask_data.get("source_instance_id") is not None
or mask_data.get("instance_id") is not None
or mask_data.get("source_annotation_id") is not None
or bool(mask_data.get("source_mask_id"))
)
if has_persisted_seed_identity or has_previous_identity:
return False
return _legacy_seed_matches(mask_data, seed)
@@ -299,6 +314,9 @@ def _seed_identity_matches(mask_data: dict[str, Any], seed_key: str, seed: dict[
def _seed_identity_markers(seed: dict[str, Any]) -> set[str]:
markers = {f"seed:{_seed_key(seed)}"}
source_instance_id = seed.get("source_instance_id")
if source_instance_id:
markers.add(f"instance:{source_instance_id}")
source_annotation_id = seed.get("source_annotation_id")
if source_annotation_id is not None:
markers.add(f"annotation:{source_annotation_id}")
@@ -313,6 +331,12 @@ def _mask_identity_markers(mask_data: dict[str, Any]) -> set[str]:
previous_seed_key = mask_data.get("propagation_seed_key")
if previous_seed_key:
markers.add(f"seed:{previous_seed_key}")
source_instance_id = mask_data.get("source_instance_id")
if source_instance_id:
markers.add(f"instance:{source_instance_id}")
instance_id = mask_data.get("instance_id")
if instance_id:
markers.add(f"instance:{instance_id}")
source_annotation_id = mask_data.get("source_annotation_id")
if source_annotation_id is not None:
markers.add(f"annotation:{source_annotation_id}")
@@ -378,6 +402,14 @@ def _delete_replaced_frame_annotations(
source = str(mask_data.get("source") or "")
if not source.endswith("_propagation"):
continue
source_instance_id = seed.get("source_instance_id")
mask_instance_ids = {
str(value)
for value in (mask_data.get("source_instance_id"), mask_data.get("instance_id"))
if value
}
if source_instance_id and mask_instance_ids and str(source_instance_id) not in mask_instance_ids:
continue
mask_markers = _mask_identity_markers(mask_data)
# Keep sibling seeds in the same propagation task from deleting each other.
if mask_markers and mask_markers.isdisjoint(current_seed_markers) and not mask_markers.isdisjoint(task_seed_markers):
@@ -500,6 +532,7 @@ 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")
source_instance_id = seed.get("source_instance_id") or seed_key
smoothing = _normalize_smoothing_options(seed.get("smoothing"))
direction = str(payload.get("current_direction") or "")
deleted_count = 0
@@ -562,6 +595,8 @@ def _save_propagated_annotations(
"propagation_seed_key": seed_key,
"propagation_seed_signature": seed_signature,
"propagation_direction": direction,
"instance_id": source_instance_id,
"source_instance_id": source_instance_id,
"source_annotation_id": source_annotation_id,
"source_mask_id": source_mask_id,
"score": max(score_values) if score_values else None,

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):