引入实例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:
@@ -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 {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user