引入实例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):
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
| 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask;回显时保留当前项目帧里尚未保存的 AI/手工 draft mask,避免从 AI 页推送的候选被覆盖 |
|
||||
| “分割结果导出”按钮 | 真实可用 | 原“导出 JSON 标注集”和“导出 PNG Mask ZIP”已合并为一个入口;按钮使用 `FileDown` 图标和绿色强调背景,区别于普通灰色操作按钮;点击后可选择整体视频、特定范围帧或当前图片,默认导出范围为当前图片,并勾选导出分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;选择“特定范围帧”后会进入时间轴范围选择模式,可在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧,也可直接修改起止帧输入框;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览;提交前会保存未归档 mask,然后调用 `GET /api/export/{project_id}/results` 下载 ZIP;浏览器下载名和后端 `Content-Disposition` 均使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;时间戳格式为 `0h00m00s000ms`,帧序号来自项目抽帧后的 1-based 顺序,不使用原视频帧号;包内固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`;选择分开 mask 时包含按帧子目录组织且同类合并的 `分开Mask分割结果/`,选择 GT_label/Pro_label/Mix_label 时分别包含 `GT_label图/`、`Pro_label彩色分割结果/`、`Mix_label重叠覆盖彩色分割结果/`。GT_label 图固定为 8-bit uint8 PNG,背景为 0,语义类别值使用类别真实 maskid,`maskid: 0` 的“待分类”与背景同为 0,Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 |
|
||||
| “导入 GT Mask”按钮 | 真实可用 | 入口已从工作区顶栏移动到左侧工具栏“重叠区域去除”之后,使用紫色图标底色;选择图片后先弹出导入结果预览和未知 maskid 策略选择,可舍弃未知类别或导入为未定义类别;随后调用 `POST /api/ai/import-gt-mask`,后端仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,不符合 8-bit 灰度/maskid 图要求时返回错误,16-bit/uint16 GT_label 会被拒绝;尺寸不同会自动最近邻拉伸到当前帧,再按类别/连通域生成高精度 polygon 标注,最后回显到工作区;导入 mask 与普通 mask 一样不显示黄色 seed point,并共用拓扑锚点统计、边缘平滑、编辑、分类和保存链路 |
|
||||
| 参考帧/起止帧/传播权重/AI自动推理 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed;左侧工具栏橡皮擦下方有彩色 AI 大脑图标“AI自动推理”入口,点击后进入时间轴范围选择模式,顶栏才显示独立“传播权重”下拉,可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 来源 id、未编辑传播结果的原始 seed 签名和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id,再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictor;同一参考帧多个同类别 seed 会按来源 id 分开传播;worker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon,因此传播以新几何参与签名;中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏蓝色进度面板显示任务进度、已处理帧次、删除旧区域数和已保存区域数,同一任务 message 不再同时显示在左侧灰色状态文字里;前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask |
|
||||
| 参考帧/起止帧/传播权重/AI自动推理 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed;左侧工具栏橡皮擦下方有彩色 AI 大脑图标“AI自动推理”入口,点击后进入时间轴范围选择模式,顶栏才显示独立“传播权重”下拉,可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 实例 id、未编辑传播结果的原始 seed 签名和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id,再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictor;同一参考帧多个同类别 seed 会优先按 `source_instance_id/instance_id` 分开传播,语义 `maskid` 只用于类别/导出;worker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon,因此传播以新几何参与签名;中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏蓝色进度面板显示任务进度、已处理帧次、删除旧区域数和已保存区域数,同一任务 message 不再同时显示在左侧灰色状态文字里;前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask |
|
||||
| 清空片段遮罩 | 已移除 | 顶栏不再提供重复的“清空片段遮罩”;当前帧清空和 DEL 删除只从左侧工具栏或键盘触发,存在传播链时在同一弹窗提供取消/只清当前帧/按帧范围选择/清空所有传播帧 |
|
||||
| 保存状态按钮 | 真实可用 | 顶栏按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;未保存 mask 写入 `POST /api/ai/annotate`,dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask,避免仍显示未保存 |
|
||||
|
||||
|
||||
@@ -259,7 +259,8 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同
|
||||
"label": "胆囊",
|
||||
"color": "#ff0000",
|
||||
"class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20, "maskId": 1},
|
||||
"template_id": 2
|
||||
"template_id": 2,
|
||||
"source_instance_id": "instance-123"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -280,7 +281,8 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同
|
||||
"seed": {
|
||||
"polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||||
"label": "胆囊",
|
||||
"color": "#ff0000"
|
||||
"color": "#ff0000",
|
||||
"source_instance_id": "instance-123"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -288,7 +290,7 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同
|
||||
```
|
||||
|
||||
SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny,`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source` 为 `<model_id>_propagation`,前端回显时会把该字段保留到 `Mask.metadata`,用于在视频处理进度条上把自动传播帧显示为蓝色区段。
|
||||
后台任务入队接口会先规范化/校验 `model` 字段中的 SAM 2.1 权重 id,再把规范化后的权重 id 写入 `processing_tasks.payload.model`;前端提交传播前会先保存当前项目中的 draft/dirty mask,使 seed 尽量携带稳定的 `source_annotation_id`,同时仍会携带 `source_mask_id`。如果参考 mask 本身来自自动传播且未被编辑,前端会继承其 `propagation_seed_signature`,让后端识别它仍是原始 seed 的同一条传播链;如果该 mask 被编辑,保存时只保留 `source_annotation_id/source_mask_id` lineage,不继承旧签名,从而触发旧结果清理和重传。worker 保存传播结果时会写入 `propagation_seed_key`、`propagation_seed_signature` 和 `propagation_direction`。同一目标帧段内,同一 seed、同一权重、同一方向再次传播时,如果所有目标帧已有同签名结果,worker 会跳过该 seed;如果签名变化、目标帧段只部分覆盖或本次改用其他 SAM 2.1 权重,worker 会先删除本次目标帧段内的旧自动传播标注再保存新结果。同一参考帧多个同类别 seed 会按 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 区分实例,避免 label/color/class 相同的不同 mask 互相清理;旧版本缺少稳定来源 id 的传播结果才走 label/color/class 兼容清理,避免保存后的 `source_annotation_id` 无法替换旧结果。任务运行中/完成后会写入 `processing_tasks.result.model`、`completed_steps`、`processed_frame_count`、`created_annotation_count`、`deleted_annotation_count`、`skipped_seed_count` 和每个 step 的权重/方向/数量结果;前端通过 `GET /api/tasks/{task_id}` 轮询,Dashboard 同时可通过 Redis/WebSocket 进度流显示该任务。
|
||||
后台任务入队接口会先规范化/校验 `model` 字段中的 SAM 2.1 权重 id,再把规范化后的权重 id 写入 `processing_tasks.payload.model`;前端提交传播前会先保存当前项目中的 draft/dirty mask,使 seed 尽量携带稳定的 `source_instance_id`、`source_annotation_id` 和 `source_mask_id`。如果参考 mask 本身来自自动传播且未被编辑,前端会继承其 `source_instance_id` 和 `propagation_seed_signature`,让后端识别它仍是原始 seed 的同一条传播链;如果该 mask 被编辑,保存时只保留 lineage,不继承旧签名,从而触发旧结果清理和重传。worker 保存传播结果时会写入 `instance_id`、`source_instance_id`、`propagation_seed_key`、`propagation_seed_signature` 和 `propagation_direction`。同一目标帧段内,同一 seed、同一权重、同一方向再次传播时,如果所有目标帧已有同签名结果,worker 会跳过该 seed;如果签名变化、目标帧段只部分覆盖或本次改用其他 SAM 2.1 权重,worker 会先删除本次目标帧段内的旧自动传播标注再保存新结果。同一参考帧多个同类别 seed 会优先按 `source_instance_id/instance_id` 区分实例,再兼容 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key`,避免 label/color/class/maskid 相同的不同 mask 互相清理;旧版本缺少稳定来源 id 的传播结果才走 label/color/class 兼容清理,避免保存后的稳定 id 无法替换旧结果。任务运行中/完成后会写入 `processing_tasks.result.model`、`completed_steps`、`processed_frame_count`、`created_annotation_count`、`deleted_annotation_count`、`skipped_seed_count` 和每个 step 的权重/方向/数量结果;前端通过 `GET /api/tasks/{task_id}` 轮询,Dashboard 同时可通过 Redis/WebSocket 进度流显示该任务。
|
||||
|
||||
## 已完成的接口对齐
|
||||
|
||||
@@ -304,7 +306,7 @@ SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2`
|
||||
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype,仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图,并按模板 `maskId` 匹配类别;16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误,全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为 `gt_unknown_class` 未定义类别。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,中空导入结果通过 `mask_data.holes` 和 `metadata.polygonRingCounts` 回显为可编辑内洞,前端不显示黄色 seed point。
|
||||
- `exportMasks()` 已接入 `GET /api/export/{projectId}/masks`。
|
||||
- `parseMedia()` 已改为创建 Celery 后台任务,并返回 `ProcessingTask`。
|
||||
- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求;传播 seed 可携带与 `polygons` 对齐的 `holes`,后端 seed 签名、SAM 2 seed mask 栅格化和传播结果保存都会保留内洞。
|
||||
- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求;传播 seed 可携带与 `polygons` 对齐的 `holes` 和 `source_instance_id`,后端 seed 签名、SAM 2 seed mask 栅格化和传播结果保存都会保留内洞,并用实例 id 区分同语义多 mask。
|
||||
- `getTask()` 已接入 `GET /api/tasks/{taskId}`。
|
||||
- `cancelTask()` 已接入 `POST /api/tasks/{taskId}/cancel`。
|
||||
- `retryTask()` 已接入 `POST /api/tasks/{taskId}/retry`。
|
||||
|
||||
@@ -166,14 +166,14 @@
|
||||
2. 用户点击左侧工具栏橡皮擦下方的彩色 AI 大脑图标“AI自动推理”后,可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重;该入口不提供 SAM2/SAM3 家族切换,默认跟随全局 AI 权重,用户手动选择后不再被 AI 页权重切换覆盖;未进入自动传播时顶栏不显示传播权重。
|
||||
3. `VideoWorkspace` 以当前参考帧为 seed,将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。
|
||||
4. `VideoWorkspace` 在提交传播前会先调用现有归档保存链路保存当前项目中的 draft/dirty mask,并重新读取 store 中的回显结果;参考帧 seed 因此优先携带稳定的后端 `source_annotation_id`,避免用前端临时 mask id 生成传播结果后,二次传播无法找到旧结果。
|
||||
5. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`source_mask_id` 和可用时的 `source_annotation_id`;中空 mask 会按 `metadata.polygonRingCounts` 将外圈写入 `mask_data.polygons`,把与外圈对齐的内洞写入 `mask_data.holes`,传播 seed 同步携带 `holes`;如果 seed mask 是未编辑的自动传播结果,会沿用其原始 `source_annotation_id/source_mask_id/propagation_seed_signature`,让后端把它识别为原传播链的同一个 seed;如果该传播结果被编辑并保存,更新 payload 只保留 lineage,不保留旧签名,使后端按“已修改”路径清理旧结果并重传。对历史或外部写入的 `geometry_smoothing` metadata,payload 仍可透传给后端兼容处理;当前前端平滑应用会直接改写 polygon 几何并移除该参数。
|
||||
5. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`instance_id`、`source_mask_id` 和可用时的 `source_annotation_id/source_instance_id`;中空 mask 会按 `metadata.polygonRingCounts` 将外圈写入 `mask_data.polygons`,把与外圈对齐的内洞写入 `mask_data.holes`,传播 seed 同步携带 `holes`;如果 seed mask 是未编辑的自动传播结果,会沿用其原始 `source_instance_id/source_annotation_id/source_mask_id/propagation_seed_signature`,让后端把它识别为原传播链的同一个 seed;如果该传播结果被编辑并保存,更新 payload 只保留 lineage,不保留旧签名,使后端按“已修改”路径清理旧结果并重传。`maskid` 仍是语义类别和导出像素值,不用于区分同类别实例。对历史或外部写入的 `geometry_smoothing` metadata,payload 仍可透传给后端兼容处理;当前前端平滑应用会直接改写 polygon 几何并移除该参数。
|
||||
6. 前端把传播权重 id、每个 seed、每个方向组装成 `steps`,一次调用 `POST /api/ai/propagate/task`,`include_source=false`、`save_annotations=true`;接口先规范化/校验 `model` 字段中的权重 id,再创建 `processing_tasks.task_type=propagate_masks` 并投递 Celery,避免长 HTTP 请求阻塞前端等待。
|
||||
7. `VideoWorkspace` 记录返回的 `task_id`,轮询 `GET /api/tasks/{task_id}` 显示任务 message、步骤进度、已处理帧次和已保存区域数;传播进度存在时,顶栏只在蓝色进度面板内显示任务 message,隐藏左侧灰色状态文字,避免同一提示重复出现;任务运行期间提供取消传播按钮,调用通用 `POST /api/tasks/{task_id}/cancel`。
|
||||
8. Celery worker 逐 step 顺序执行传播,避免多个视频 tracker 并发抢占 GPU;每个 step 开始/完成都会写入 `processing_tasks.progress/result/message` 并发布 Redis `seg:progress`,Dashboard 可同步显示。每个 step 开始前,worker 会在本次目标帧段内用 seed 来源 id、传播方向和 seed 签名查找旧传播标注:同权重、签名相同且目标帧都已有结果时跳过该 seed;签名不同、目标帧只部分覆盖或本次使用了其他 SAM 2.1 权重则先删除本次目标帧段内对应方向的旧自动传播标注,再执行新的 video predictor 传播;若历史 seed 签名中包含 `geometry_smoothing`,仍按完整签名参与兼容去重。对同一参考帧多个同类别 seed,worker 以稳定来源 id/seed key 区分实例,避免 label/color/class 相同的不同实例互相清理;旧版本缺少稳定来源 id 的传播标注才使用 label/color/class 兼容匹配,写入新结果前仍用目标帧 bbox 重叠做二次确认和清理。写入前这层清理不限制旧结果方向,确保 backward 传播可覆盖早先 forward 传播留下的同物体旧 mask。
|
||||
8. Celery worker 逐 step 顺序执行传播,避免多个视频 tracker 并发抢占 GPU;每个 step 开始/完成都会写入 `processing_tasks.progress/result/message` 并发布 Redis `seg:progress`,Dashboard 可同步显示。每个 step 开始前,worker 会在本次目标帧段内用 seed 来源 id、传播方向和 seed 签名查找旧传播标注:同权重、签名相同且目标帧都已有结果时跳过该 seed;签名不同、目标帧只部分覆盖或本次使用了其他 SAM 2.1 权重则先删除本次目标帧段内对应方向的旧自动传播标注,再执行新的 video predictor 传播;若历史 seed 签名中包含 `geometry_smoothing`,仍按完整签名参与兼容去重。对同一参考帧多个同类别 seed,worker 优先以 `source_instance_id/instance_id` 区分实例,再兼容 `source_annotation_id/source_mask_id/propagation_seed_key`,避免 label/color/class/maskid 相同的不同实例互相清理;旧版本缺少稳定来源 id 的传播标注才使用 label/color/class 兼容匹配,写入新结果前仍用目标帧 bbox 重叠做二次确认和清理,但已有稳定实例 id 且与当前 seed 不同的结果不会被这层空间兜底清理误删。写入前这层清理不限制旧结果方向,确保 backward 传播可覆盖早先 forward 传播留下的同物体旧 mask。
|
||||
9. 后端按项目帧序列截取片段,下载对应帧到临时目录,并写成 `000000.jpg` 这类纯数字文件名;这是 `SAM2VideoPredictor` 对视频帧排序的要求,和项目库中持久化的 `frame_%06d.jpg` 对象名无关。
|
||||
10. `model` 为任一 SAM 2.1 权重变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播;注入 seed 前会把外圈 polygon 栅格化为前景,再按 `holes` 扣除内洞,避免中空参考 mask 以实心形式传播;`model=sam2` 会在入队时规范化为 tiny,任务 payload/result 会保留规范化后的权重 id;单个 SAM2 video predictor 调用内部暂不提供逐帧流式进度。
|
||||
11. `model=sam3` 当前不支持;SAM 3 video tracker 代码保留但没有接入产品路径。
|
||||
12. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧;同一个 seed 在同一目标帧得到的多个不连通外轮廓会保存在同一个 annotation 的 `mask_data.polygons` 中,前端回显为一个含多个分离区域的 mask;传播 mask 轮廓提取使用层级信息保留内洞,外圈写入 `mask_data.polygons`,内洞按外圈对齐写入 `mask_data.holes`,并设置 `metadata.hasHoles` 供前端按中空 mask 回显和编辑;如果历史或外部 seed 带 `geometry_smoothing`,保存前仍会用同一平滑参数处理 forward/backward 两个方向的结果:强度先经过缓入曲线映射,低强度使用较小 Chaikin 切角比例和简化阈值,高强度再逐步增加迭代、切角和简化力度;随后按强度对 SAM 密集轮廓做 `approxPolyDP` 去噪简化,再做 Chaikin 平滑,最后二次简化并以平滑后的多 polygon 组合 bbox 后落库。当前工作区“应用边缘平滑”会在前端把同传播链对应 mask 直接改写为新的 polygon 并移除 `geometry_smoothing` 参数,因此后续传播通常按新几何本身参与 seed 签名。`mask_data.source` 记录权重传播来源,同时写入 `propagation_seed_key`、`propagation_seed_signature`、`propagation_direction`、`source_annotation_id` 和 `source_mask_id` 供后续幂等传播判断;历史 `geometry_smoothing` 仅在存在时保留用于兼容判断。
|
||||
12. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧;同一个 seed 在同一目标帧得到的多个不连通外轮廓会保存在同一个 annotation 的 `mask_data.polygons` 中,前端回显为一个含多个分离区域的 mask;传播 mask 轮廓提取使用层级信息保留内洞,外圈写入 `mask_data.polygons`,内洞按外圈对齐写入 `mask_data.holes`,并设置 `metadata.hasHoles` 供前端按中空 mask 回显和编辑;如果历史或外部 seed 带 `geometry_smoothing`,保存前仍会用同一平滑参数处理 forward/backward 两个方向的结果:强度先经过缓入曲线映射,低强度使用较小 Chaikin 切角比例和简化阈值,高强度再逐步增加迭代、切角和简化力度;随后按强度对 SAM 密集轮廓做 `approxPolyDP` 去噪简化,再做 Chaikin 平滑,最后二次简化并以平滑后的多 polygon 组合 bbox 后落库。当前工作区“应用边缘平滑”会在前端把同传播链对应 mask 直接改写为新的 polygon 并移除 `geometry_smoothing` 参数,因此后续传播通常按新几何本身参与 seed 签名。`mask_data.source` 记录权重传播来源,同时写入 `instance_id`、`source_instance_id`、`propagation_seed_key`、`propagation_seed_signature`、`propagation_direction`、`source_annotation_id` 和 `source_mask_id` 供后续幂等传播判断;历史 `geometry_smoothing` 仅在存在时保留用于兼容判断。
|
||||
13. 前端轮询到已创建区域后刷新 `GET /api/ai/annotations` 并回显新标注;任务结束后如果后端返回 0 个新区域,工作区会明确提示没有生成新的 mask,若是未改变 seed 被跳过则提示未改变 mask 已跳过。处理过帧次大于 0 的成功任务会追加一条本地传播历史片段,用于视频处理进度条显示最近传播范围;`annotationToMask()` 会保留传播来源 metadata,供时间轴视频处理进度条显示蓝色传播区段。
|
||||
|
||||
### 手工绘制与历史栈
|
||||
@@ -207,7 +207,7 @@
|
||||
3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除。
|
||||
4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常。
|
||||
5. `CanvasArea` 把 `Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。
|
||||
6. `area_merge` 使用 union,更新第一个选中的主 mask,并从前端 store 移除后续被合并 mask;如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。执行前会按 `source_annotation_id`、`source_mask_id` 和可靠的 `propagation_seed_key` 计算可同步的传播帧;若存在其它传播帧,先弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择。布尔同步使用严格实例匹配:优先可靠 lineage,旧传播结果缺少可靠 id 时只为每个已选 mask 选取空间最近的一个同语义传播结果,不使用宽泛同类别 legacy 分组批量合并,避免同类其它实例被一起卷入。按帧范围选择会把本次布尔操作交给 `VideoWorkspace`,复用底部时间轴范围选择和最终确认弹窗;确认后只在范围内且具备对应关系的帧上执行同一次 union,只删除该帧参与合并的次级 mask,避免把同链但未参与同步或范围外的区域整链误删。用户在顶栏范围确认前重新点击“合并选中”开始新的布尔选择时,旧的范围请求必须立即取消。
|
||||
6. `area_merge` 使用 union,更新第一个选中的主 mask,并从前端 store 移除后续被合并 mask;如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。执行前会按 `source_instance_id/instance_id`、`source_annotation_id`、`source_mask_id` 和可靠的 `propagation_seed_key` 计算可同步的传播帧;若存在其它传播帧,先弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择。布尔同步使用严格实例匹配:优先可靠 lineage,旧传播结果缺少可靠 id 时只为每个已选 mask 选取空间最近的一个同语义传播结果,不使用宽泛同类别 legacy 分组批量合并,避免同类其它实例被一起卷入。按帧范围选择会把本次布尔操作交给 `VideoWorkspace`,复用底部时间轴范围选择和最终确认弹窗;确认后只在范围内且具备对应关系的帧上执行同一次 union,只删除该帧参与合并的次级 mask,避免把同链但未参与同步或范围外的区域整链误删。用户在顶栏范围确认前重新点击“合并选中”开始新的布尔选择时,旧的范围请求必须立即取消。
|
||||
7. `area_remove` 使用 difference,从第一个选中的主 mask 中扣除后续选中 mask,扣除对象本身保留;同样会在执行前计算可同步的传播帧并弹出当前帧/所有传播帧/按帧范围选择。按帧范围选择确认后,会在范围内其它传播帧中找到对应主区域和扣除区域并执行 difference,扣除区域本身继续保留;如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring,`metadata.polygonRingCounts` 记录每个 polygon 的 ring 数,渲染时使用 even-odd fill。
|
||||
8. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;同步到传播帧时保留传播来源和 lineage metadata,避免自动传播帧在时间轴上变成人工/AI 标注帧;带洞结果的面积按外圈减内洞计算;进入调整多边形时,外圈和内洞 ring 都会显示顶点和边中点插入手柄,内洞拖动、插点、保存与回显继续保持中空结构。
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
- R4/R6:补充视频处理进度条传播历史测试,验证多次自动传播后会按同一蓝色系显示最近处理范围,最新最亮、旧记录逐次变暗且第 5 次后统一阈值色,单个片段不使用渐变。
|
||||
- R6/R10:补充 `queuePropagationTask()`、`POST /api/ai/propagate/task`、传播 Celery runner 和传播任务重试测试,验证工作区自动传播不再依赖长 HTTP 请求,并验证传给 `SAM2VideoPredictor` 的临时帧文件名是纯数字序列。
|
||||
- R6:补充传播去重回归测试,验证前端传播前会先保存 draft seed mask 并用稳定 `source_annotation_id` 入队;后端在 seed 来源由前端临时 id 迁移到后端 annotation id、用户换用其他 SAM 2.1 权重、未编辑传播结果再次作为 seed、已编辑传播结果重新作为 seed、中间帧人工新增替代 seed 时,会分别跳过或清理旧传播标注再保存新结果。
|
||||
- R6/R7:补充传播实例 id 回归测试,验证保存标注会写入/保留 `instance_id`,自动传播 seed 携带 `source_instance_id`,同类别多个 mask 在传播、重传、布尔合并/去除和选择高亮时按实例链路同步,不因相同 label/color/maskid 互相合并或删除。
|
||||
- R5/R6/R7:补充中空 mask 回归测试,验证保存时拆分 `polygons`/`holes` 并回显为 ring 分组,调整多边形时内洞显示可编辑顶点,以及 SAM 2 seed mask 会扣除 holes、传播结果轮廓提取会保留 holes。
|
||||
- R7:补充 dirty 本地旧 annotationId 回归测试,验证后端标注 id 预检已缺失时会跳过失败 PATCH、直接 `POST /api/ai/annotate` 重新创建;同时验证预检后 `PATCH /api/ai/annotations/{id}` 返回 404 时,保存链路也会改用 `POST` 重新创建并用回显标注替换本地旧 mask。
|
||||
- R4/R5/R8/R9:补充模板切换、工具栏清空入口和传播链布尔操作回归测试,验证已有 mask 切换模板需确认清空,模板详情按钮改为“编辑模板”,当前帧清空会在传播链存在时同一行提供取消/只清当前帧/按帧范围选择/清空所有传播帧,且按范围/全部清空遇到人工/AI 标注帧时可选择保留人工帧,区域合并/去除会在存在传播帧时同一行选择取消/按帧范围选择/当前帧/所有传播帧并保留传播 metadata。
|
||||
|
||||
@@ -1149,6 +1149,110 @@ describe('CanvasArea', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses propagation instance ids to merge only the intended same-class propagated masks', async () => {
|
||||
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-1',
|
||||
annotationId: '1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { instance_id: 'same-class-primary' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-2',
|
||||
annotationId: '2',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { instance_id: 'same-class-secondary' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: {
|
||||
source: 'sam2.1_hiera_tiny_propagation',
|
||||
source_annotation_id: 101,
|
||||
source_instance_id: 'same-class-primary',
|
||||
instance_id: 'same-class-primary',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'annotation-20',
|
||||
annotationId: '20',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: {
|
||||
source: 'sam2.1_hiera_tiny_propagation',
|
||||
source_annotation_id: 202,
|
||||
source_instance_id: 'same-class-secondary',
|
||||
instance_id: 'same-class-secondary',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'annotation-30',
|
||||
annotationId: '30',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 180 180 L 230 180 L 230 230 L 180 230 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[180, 180, 230, 180, 230, 230, 180, 230]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: {
|
||||
source: 'sam2.1_hiera_tiny_propagation',
|
||||
source_annotation_id: 303,
|
||||
source_instance_id: 'same-class-unselected',
|
||||
instance_id: 'same-class-unselected',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
||||
const paths = screen.getAllByTestId('konva-path');
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
|
||||
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
|
||||
|
||||
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20'])));
|
||||
expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['30']));
|
||||
const masks = useStore.getState().masks;
|
||||
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-30']);
|
||||
expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
metadata: expect.objectContaining({ source_instance_id: 'same-class-primary' }),
|
||||
}));
|
||||
expect(masks.find((mask) => mask.id === 'annotation-30')).toEqual(expect.objectContaining({
|
||||
saveStatus: 'saved',
|
||||
}));
|
||||
});
|
||||
|
||||
it('merges legacy propagated masks by nearest same-label result when stable lineage is missing', async () => {
|
||||
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
|
||||
useStore.setState({
|
||||
|
||||
@@ -66,12 +66,18 @@ function propagationSourceMaskTokens(value: unknown): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function propagationInstanceTokens(value: unknown): string[] {
|
||||
return typeof value === 'string' && value.length > 0 ? [`instance:${value}`] : [];
|
||||
}
|
||||
|
||||
function reliablePropagationLineageTokens(mask: Mask): Set<string> {
|
||||
const metadata = mask.metadata || {};
|
||||
const tokens = new Set<string>([`mask:${mask.id}`]);
|
||||
if (mask.annotationId) {
|
||||
tokens.add(`annotation:${mask.annotationId}`);
|
||||
}
|
||||
propagationInstanceTokens(metadata.source_instance_id).forEach((token) => tokens.add(token));
|
||||
propagationInstanceTokens(metadata.instance_id).forEach((token) => tokens.add(token));
|
||||
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
|
||||
if (sourceAnnotationId !== null) {
|
||||
tokens.add(`annotation:${sourceAnnotationId}`);
|
||||
@@ -105,6 +111,14 @@ function propagationLineageTokens(mask: Mask): Set<string> {
|
||||
tokens.add(`annotation:${mask.annotationId}`);
|
||||
}
|
||||
let hasStablePropagationToken = false;
|
||||
const instanceTokens = [
|
||||
...propagationInstanceTokens(metadata.source_instance_id),
|
||||
...propagationInstanceTokens(metadata.instance_id),
|
||||
];
|
||||
if (instanceTokens.length > 0) {
|
||||
instanceTokens.forEach((token) => tokens.add(token));
|
||||
hasStablePropagationToken = true;
|
||||
}
|
||||
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
|
||||
if (sourceAnnotationId !== null) {
|
||||
tokens.add(`annotation:${sourceAnnotationId}`);
|
||||
|
||||
@@ -1935,6 +1935,7 @@ describe('VideoWorkspace', () => {
|
||||
max_frames: 2,
|
||||
seed: {
|
||||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||||
holes: undefined,
|
||||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||||
points: undefined,
|
||||
label: '胆囊',
|
||||
@@ -1943,6 +1944,8 @@ describe('VideoWorkspace', () => {
|
||||
template_id: 2,
|
||||
source_mask_id: 'annotation-5',
|
||||
source_annotation_id: 5,
|
||||
source_instance_id: 'annotation:5',
|
||||
propagation_seed_signature: undefined,
|
||||
smoothing: { strength: 35, method: 'chaikin' },
|
||||
},
|
||||
}],
|
||||
@@ -1985,6 +1988,8 @@ describe('VideoWorkspace', () => {
|
||||
source: 'sam2.1_hiera_tiny_propagation',
|
||||
source_annotation_id: 5,
|
||||
source_mask_id: 'annotation-5',
|
||||
source_instance_id: 'seed-instance-5',
|
||||
instance_id: 'seed-instance-5',
|
||||
propagation_seed_signature: 'seed-signature-5',
|
||||
},
|
||||
}],
|
||||
@@ -2006,6 +2011,7 @@ describe('VideoWorkspace', () => {
|
||||
seed: expect.objectContaining({
|
||||
source_annotation_id: 5,
|
||||
source_mask_id: 'annotation-5',
|
||||
source_instance_id: 'seed-instance-5',
|
||||
propagation_seed_signature: 'seed-signature-5',
|
||||
}),
|
||||
})],
|
||||
|
||||
@@ -286,11 +286,23 @@ const propagationSourceMaskTokens = (value: unknown): string[] => {
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const propagationInstanceTokens = (value: unknown): string[] => (
|
||||
typeof value === 'string' && value.length > 0 ? [`instance:${value}`] : []
|
||||
);
|
||||
|
||||
const propagationLineageTokens = (mask: Mask): Set<string> => {
|
||||
const metadata = mask.metadata || {};
|
||||
const tokens = new Set<string>([`mask:${mask.id}`]);
|
||||
if (mask.annotationId) tokens.add(`annotation:${mask.annotationId}`);
|
||||
let hasStablePropagationToken = false;
|
||||
const instanceTokens = [
|
||||
...propagationInstanceTokens(metadata.source_instance_id),
|
||||
...propagationInstanceTokens(metadata.instance_id),
|
||||
];
|
||||
if (instanceTokens.length > 0) {
|
||||
instanceTokens.forEach((token) => tokens.add(token));
|
||||
hasStablePropagationToken = true;
|
||||
}
|
||||
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
|
||||
if (sourceAnnotationId !== null) {
|
||||
tokens.add(`annotation:${sourceAnnotationId}`);
|
||||
@@ -1512,6 +1524,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const metadataSourceMaskId = typeof seedMask.metadata?.source_mask_id === 'string'
|
||||
? seedMask.metadata.source_mask_id
|
||||
: undefined;
|
||||
const metadataSourceInstanceId = typeof seedMask.metadata?.source_instance_id === 'string'
|
||||
? seedMask.metadata.source_instance_id
|
||||
: typeof seedMask.metadata?.instance_id === 'string'
|
||||
? seedMask.metadata.instance_id
|
||||
: undefined;
|
||||
const inheritedSeedSignature = typeof seedMask.metadata?.propagation_seed_signature === 'string'
|
||||
? seedMask.metadata.propagation_seed_signature
|
||||
: undefined;
|
||||
@@ -1539,6 +1556,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
template_id: seedPayload.template_id,
|
||||
source_mask_id: metadataSourceMaskId || seedMask.id,
|
||||
source_annotation_id: sourceAnnotationId,
|
||||
source_instance_id: metadataSourceInstanceId || (sourceAnnotationId ? `annotation:${sourceAnnotationId}` : seedMask.id),
|
||||
propagation_seed_signature: inheritedSeedSignature,
|
||||
smoothing: geometrySmoothing,
|
||||
};
|
||||
|
||||
@@ -528,6 +528,7 @@ describe('api client contracts', () => {
|
||||
frame_id: 5,
|
||||
template_id: 2,
|
||||
mask_data: {
|
||||
instance_id: 'm1',
|
||||
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
@@ -550,6 +551,8 @@ describe('api client contracts', () => {
|
||||
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 7 },
|
||||
source: 'sam2.1_hiera_tiny_propagation',
|
||||
propagated_from_frame_id: 4,
|
||||
instance_id: 'instance:gallbladder-1',
|
||||
source_instance_id: 'gallbladder-1',
|
||||
geometry_smoothing: { strength: 35, method: 'chaikin' },
|
||||
},
|
||||
points: [[0.5, 0.5]],
|
||||
@@ -575,6 +578,8 @@ describe('api client contracts', () => {
|
||||
metadata: {
|
||||
source: 'sam2.1_hiera_tiny_propagation',
|
||||
propagated_from_frame_id: 4,
|
||||
instance_id: 'instance:gallbladder-1',
|
||||
source_instance_id: 'gallbladder-1',
|
||||
geometry_smoothing: { strength: 35, method: 'chaikin' },
|
||||
},
|
||||
}));
|
||||
@@ -745,6 +750,8 @@ describe('api client contracts', () => {
|
||||
propagated_from_frame_id: 1,
|
||||
source_annotation_id: 7,
|
||||
source_mask_id: 'annotation-7',
|
||||
source_instance_id: 'tracked-instance-7',
|
||||
instance_id: 'tracked-instance-7',
|
||||
propagation_seed_key: 'annotation:7',
|
||||
geometry_smoothing_preview: { strength: 35, method: 'chaikin' },
|
||||
},
|
||||
@@ -754,6 +761,8 @@ describe('api client contracts', () => {
|
||||
propagated_from_frame_id: 1,
|
||||
source_annotation_id: 7,
|
||||
source_mask_id: 'annotation-7',
|
||||
source_instance_id: 'tracked-instance-7',
|
||||
instance_id: 'tracked-instance-7',
|
||||
propagation_seed_key: 'annotation:7',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -466,6 +466,7 @@ export interface PropagateMasksPayload {
|
||||
template_id?: number;
|
||||
source_mask_id?: string;
|
||||
source_annotation_id?: number;
|
||||
source_instance_id?: string;
|
||||
propagation_seed_signature?: string;
|
||||
smoothing?: GeometrySmoothingOptions;
|
||||
};
|
||||
@@ -768,6 +769,10 @@ export function buildAnnotationPayload(
|
||||
: undefined;
|
||||
const geometrySmoothing = normalizeGeometrySmoothing(mask.metadata?.geometry_smoothing);
|
||||
const metadata = persistableMaskMetadata(mask.metadata);
|
||||
const existingInstanceId = typeof metadata.instance_id === 'string' && metadata.instance_id.length > 0
|
||||
? metadata.instance_id
|
||||
: undefined;
|
||||
const instanceId = existingInstanceId || (mask.annotationId ? `annotation:${mask.annotationId}` : mask.id);
|
||||
|
||||
const payload: SaveAnnotationPayload = {
|
||||
project_id: Number(projectId),
|
||||
@@ -775,6 +780,7 @@ export function buildAnnotationPayload(
|
||||
template_id: effectiveTemplateId ? Number(effectiveTemplateId) : undefined,
|
||||
mask_data: {
|
||||
...metadata,
|
||||
instance_id: instanceId,
|
||||
polygons,
|
||||
...(splitGeometry.holes ? { holes: splitGeometry.holes } : {}),
|
||||
label: mask.label,
|
||||
|
||||
Reference in New Issue
Block a user