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

@@ -220,6 +220,7 @@ class PropagationSeed(BaseModel):
template_id: Optional[int] = None template_id: Optional[int] = None
source_mask_id: Optional[str] = None source_mask_id: Optional[str] = None
source_annotation_id: Optional[int] = None source_annotation_id: Optional[int] = None
propagation_seed_signature: Optional[str] = None
class PropagateRequest(BaseModel): class PropagateRequest(BaseModel):

View File

@@ -85,8 +85,21 @@ def _stable_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _canonicalize_signature_value(value: Any) -> Any:
if isinstance(value, float):
return round(value, 6)
if isinstance(value, list):
return [_canonicalize_signature_value(item) for item in value]
if isinstance(value, dict):
return {key: _canonicalize_signature_value(value[key]) for key in sorted(value)}
return value
def _seed_signature(seed: dict[str, Any]) -> str: def _seed_signature(seed: dict[str, Any]) -> str:
"""Return a stable signature for seed geometry and semantic attrs.""" """Return a stable signature for seed geometry and semantic attrs."""
inherited_signature = seed.get("propagation_seed_signature")
if inherited_signature:
return str(inherited_signature)
signature_payload = { signature_payload = {
"polygons": seed.get("polygons") or [], "polygons": seed.get("polygons") or [],
"bbox": seed.get("bbox") or [], "bbox": seed.get("bbox") or [],
@@ -97,7 +110,7 @@ def _seed_signature(seed: dict[str, Any]) -> str:
"class_metadata": seed.get("class_metadata") or {}, "class_metadata": seed.get("class_metadata") or {},
"template_id": seed.get("template_id"), "template_id": seed.get("template_id"),
} }
return hashlib.sha256(_stable_json(signature_payload).encode("utf-8")).hexdigest() return hashlib.sha256(_stable_json(_canonicalize_signature_value(signature_payload)).encode("utf-8")).hexdigest()
def _seed_key(seed: dict[str, Any]) -> str: def _seed_key(seed: dict[str, Any]) -> str:
@@ -135,22 +148,25 @@ def _source_model_matches(mask_data: dict[str, Any], model_id: str) -> bool:
return str(mask_data.get("source") or "") == f"{model_id}_propagation" return str(mask_data.get("source") or "") == f"{model_id}_propagation"
def _is_propagation_annotation( def _seed_identity_matches(mask_data: dict[str, Any], seed_key: str, seed: dict[str, Any]) -> bool:
annotation: Annotation, previous_seed_key = mask_data.get("propagation_seed_key")
source_frame: Frame, if previous_seed_key == seed_key:
seed_key: str, return True
seed: dict[str, Any], source_annotation_id = seed.get("source_annotation_id")
) -> bool: 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
return _legacy_seed_matches(mask_data, seed)
def _is_propagation_annotation(annotation: Annotation, seed_key: str, seed: dict[str, Any]) -> bool:
mask_data = annotation.mask_data or {} mask_data = annotation.mask_data or {}
source = str(mask_data.get("source") or "") source = str(mask_data.get("source") or "")
if not source.endswith("_propagation"): if not source.endswith("_propagation"):
return False return False
if int(mask_data.get("propagated_from_frame_id") or 0) != int(source_frame.id): return _seed_identity_matches(mask_data, seed_key, seed)
return False
previous_seed_key = mask_data.get("propagation_seed_key")
if previous_seed_key is not None:
return previous_seed_key == seed_key or _legacy_seed_matches(mask_data, seed)
return _legacy_seed_matches(mask_data, seed)
def _direction_matches(mask_data: dict[str, Any], direction: str) -> bool: def _direction_matches(mask_data: dict[str, Any], direction: str) -> bool:
@@ -163,27 +179,36 @@ def _prepare_seed_propagation(
*, *,
payload: dict[str, Any], payload: dict[str, Any],
model_id: str, model_id: str,
source_frame: Frame,
seed: dict[str, Any], seed: dict[str, Any],
direction: str, direction: str,
target_frame_ids: set[int],
) -> dict[str, Any]: ) -> dict[str, Any]:
seed_key = _seed_key(seed) seed_key = _seed_key(seed)
seed_signature = _seed_signature(seed) seed_signature = _seed_signature(seed)
if not target_frame_ids:
return {
"skip": True,
"seed_key": seed_key,
"seed_signature": seed_signature,
"deleted_annotation_count": 0,
}
previous_annotations = ( previous_annotations = (
db.query(Annotation) db.query(Annotation)
.filter(Annotation.project_id == int(payload["project_id"])) .filter(Annotation.project_id == int(payload["project_id"]))
.filter(Annotation.frame_id.in_(target_frame_ids))
.all() .all()
) )
matching = [ matching = [
annotation for annotation in previous_annotations annotation for annotation in previous_annotations
if _is_propagation_annotation(annotation, source_frame, seed_key, seed) if _is_propagation_annotation(annotation, seed_key, seed)
and _direction_matches(annotation.mask_data or {}, direction) and _direction_matches(annotation.mask_data or {}, direction)
] ]
covered_frame_ids = {int(annotation.frame_id) for annotation in matching}
if matching and all( if matching and all(
(annotation.mask_data or {}).get("propagation_seed_signature") == seed_signature (annotation.mask_data or {}).get("propagation_seed_signature") == seed_signature
and _source_model_matches(annotation.mask_data or {}, model_id) and _source_model_matches(annotation.mask_data or {}, model_id)
for annotation in matching for annotation in matching
): ) and target_frame_ids.issubset(covered_frame_ids):
return { return {
"skip": True, "skip": True,
"seed_key": seed_key, "seed_key": seed_key,
@@ -317,13 +342,20 @@ def _run_one_step(
raise ValueError("Propagation requires seed polygons, bbox, or points") raise ValueError("Propagation requires seed polygons, bbox, or points")
model_id = sam_registry.normalize_model_id(payload.get("model")) model_id = sam_registry.normalize_model_id(payload.get("model"))
selected_frames, source_relative_index = _frame_window(frames, source_position, direction, max_frames)
include_source = bool(payload.get("include_source", False))
target_frame_ids = {
int(frame.id)
for frame in selected_frames
if include_source or frame.id != source_frame.id
}
seed_state = _prepare_seed_propagation( seed_state = _prepare_seed_propagation(
db, db,
payload=payload, payload=payload,
model_id=model_id, model_id=model_id,
source_frame=source_frame,
seed=seed, seed=seed,
direction=direction, direction=direction,
target_frame_ids=target_frame_ids,
) )
if seed_state["skip"]: if seed_state["skip"]:
return { return {
@@ -337,7 +369,6 @@ def _run_one_step(
"seed_key": seed_state["seed_key"], "seed_key": seed_state["seed_key"],
} }
selected_frames, source_relative_index = _frame_window(frames, source_position, direction, max_frames)
with tempfile.TemporaryDirectory(prefix=f"seg_propagate_{payload['project_id']}_") as tmpdir: with tempfile.TemporaryDirectory(prefix=f"seg_propagate_{payload['project_id']}_") as tmpdir:
frame_paths = _write_frame_sequence(selected_frames, Path(tmpdir)) frame_paths = _write_frame_sequence(selected_frames, Path(tmpdir))
propagated = sam_registry.propagate_video( propagated = sam_registry.propagate_video(

View File

@@ -2,7 +2,7 @@ import numpy as np
import cv2 import cv2
from pathlib import Path from pathlib import Path
from models import Annotation, ProcessingTask 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): 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] 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): def test_predict_validation_errors(client, monkeypatch):
project, _, _ = _create_project_and_frame(client) project, _, _ = _create_project_and_frame(client)

View File

@@ -68,8 +68,8 @@
| “导出 JSON 标注集”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `exportCoco()` 下载 JSON | | “导出 JSON 标注集”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `exportCoco()` 下载 JSON |
| “导出 PNG Mask ZIP”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `GET /api/export/{project_id}/masks` 下载 ZIP后端同时包含单标注 mask、每帧语义融合 mask 和 `semantic_classes.json` | | “导出 PNG Mask ZIP”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `GET /api/export/{project_id}/masks` 下载 ZIP后端同时包含单标注 mask、每帧语义融合 mask 和 `semantic_classes.json` |
| “导入 GT Mask”按钮 | 真实可用 | 选择图片后调用 `POST /api/ai/import-gt-mask`,后端按非零像素值和连通域生成 polygon 标注与距离变换 seed point再回显到工作区 | | “导入 GT Mask”按钮 | 真实可用 | 选择图片后调用 `POST /api/ai/import-gt-mask`,后端按非零像素值和连通域生成 polygon 标注与距离变换 seed point再回显到工作区 |
| 参考帧/起止帧/传播权重/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed工作区顶栏有独立“传播权重”下拉可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;如果用户尚未显式设置范围,点击“自动传播”会先进入时间轴范围选择模式,播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 来源 id 和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictorworker 会按 seed 来源和几何/语义签名做幂等判断,未改变的 seed 直接跳过,已改变的 seed 会先删除同源旧自动传播标注再重新传播,避免重复传播产生重叠 mask传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask | | 参考帧/起止帧/传播权重/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed工作区顶栏有独立“传播权重”下拉可在传播前二次选择 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 predictorworker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播,避免重复传播产生重叠 mask传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask |
| 清空片段遮罩 | 真实可用 | 复用顶栏传播起始帧/结束帧作为视频片段范围,点击“清空片段遮罩”后会删除该帧段内所有本地 draft mask并对已保存 mask 调用 `DELETE /api/ai/annotations/{annotation_id}` 删除后端标注;不在范围内的 mask 和选区会保留 | | 清空片段遮罩 | 真实可用 | 点击“清空片段遮罩”后会进入和自动传播一致的时间轴范围选择模式,用户可在播放进度条或视频处理进度条上点击/拖拽选择起止帧,再点“确认清空”;执行后删除该帧段内所有本地 draft mask并对已保存 mask 调用 `DELETE /api/ai/annotations/{annotation_id}` 删除后端标注;不在范围内的 mask 和选区会保留 |
| “结构化归档保存”按钮 | 真实可用 | 未保存 mask 写入 `POST /api/ai/annotate`dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask避免仍显示未保存 | | “结构化归档保存”按钮 | 真实可用 | 未保存 mask 写入 `POST /api/ai/annotate`dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask避免仍显示未保存 |
## CanvasArea 画布 ## CanvasArea 画布
@@ -115,7 +115,7 @@
| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 | | 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 |
| 顶部 range 拖动 | 真实可用 | 改变当前帧 | | 顶部 range 拖动 | 真实可用 | 改变当前帧 |
| 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` | | 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` |
| 播放进度条 / 视频处理进度条 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `mask_data.source` / `propagated_from_frame_id` 识别自动传播生成的帧并显示蓝色区段,人工绘制或 AI 智能分割生成的帧显示红色竖线,红/蓝标识也可点击跳转到对应帧;每次自动传播成功处理帧后,工作区会在当前会话记录最近传播范围,并在视频处理进度条上叠加不同色系的深到浅渐变片段,辅助识别最近处理过的视频区间;未处理背景使用中性灰以和红/蓝/渐变标记区分;只有工作区进入自动传播范围选择模式时,两条进度条显示 amber 选区,并可点击/拖拽选择起止帧 | | 播放进度条 / 视频处理进度条 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `mask_data.source` / `propagated_from_frame_id` 识别自动传播生成的帧并显示蓝色区段,人工绘制或 AI 智能分割生成的帧显示红色竖线,红/蓝标识也可点击跳转到对应帧;每次自动传播成功处理帧后,工作区会在当前会话记录最近传播范围,并在视频处理进度条上叠加不同色系的深到浅渐变片段,辅助识别最近处理过的视频区间;未处理背景使用中性灰以和红/蓝/渐变标记区分;工作区进入自动传播或清空片段遮罩的范围选择模式时,两条进度条显示 amber 选区,并可点击/拖拽选择起止帧 |
| 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps | | 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps |
| 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 | | 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 |

View File

@@ -280,7 +280,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`,用于在视频处理进度条上把自动传播帧显示为蓝色区段。 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`。worker 保存传播结果时会写入 `propagation_seed_key``propagation_seed_signature``propagation_direction`。同一 seed、同一权重、同一方向再次传播时如果签名未变化worker 会跳过该 seed如果签名变化或本次改用其他 SAM 2.1 权重worker 会先删除旧自动传播标注再保存新结果。对于旧版本只记录前端临时 `source_mask_id` 的传播结果worker 会按同一参考帧、方向和 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_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 会先删除本次目标帧段内的旧自动传播标注再保存新结果。对于旧版本只记录前端临时 `source_mask_id` 的传播结果worker 会按方向和 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 进度流显示该任务。
## 已完成的接口对齐 ## 已完成的接口对齐

View File

@@ -108,7 +108,7 @@
- `POST /api/ai/propagate` 作为单 seed 同步兼容接口保留;`POST /api/ai/propagate/task` 是工作区自动传播使用的任务接口。两者当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()` - `POST /api/ai/propagate` 作为单 seed 同步兼容接口保留;`POST /api/ai/propagate/task` 是工作区自动传播使用的任务接口。两者当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()`
- 自动传播任务写入 `processing_tasks`,前端轮询 `GET /api/tasks/{task_id}` 显示进度并刷新标注Dashboard 也能看到该任务,任务可取消和重试。 - 自动传播任务写入 `processing_tasks`,前端轮询 `GET /api/tasks/{task_id}` 显示进度并刷新标注Dashboard 也能看到该任务,任务可取消和重试。
- 传播结果会写入后续帧 `annotations``mask_data.source` 标记为 `<model_id>_propagation`,并保留 label、color、class 元数据、seed 来源 id、seed 签名和传播方向。 - 传播结果会写入后续帧 `annotations``mask_data.source` 标记为 `<model_id>_propagation`,并保留 label、color、class 元数据、seed 来源 id、seed 签名和传播方向。
- 自动传播任务必须避免重复叠加:同一参考 seed、同一权重、同一方向且 seed 签名未变化时worker 直接跳过;同一参考 seed 已变化或用户改用其他 SAM 2.1 权重时worker 先删除对应旧自动传播标注,再保存新传播结果;对早期只记录前端临时 `source_mask_id` 的旧传播结果worker 会按同一参考帧、传播方向和语义信息做兼容清理。 - 自动传播任务必须避免重复叠加:同一目标帧段内,同一参考 seed、同一权重、同一方向且所有目标帧已有未变化结果worker 直接跳过;同一参考 seed 已变化、目标帧段只部分覆盖或用户改用其他 SAM 2.1 权重时worker 先删除本次目标帧段内对应旧自动传播标注,再保存新传播结果;对早期只记录前端临时 `source_mask_id` 的旧传播结果worker 会按传播方向和语义信息做兼容清理。未编辑的自动传播结果再次作为参考 seed 时,会继承原始 `propagation_seed_signature` 以避免重复传播;被编辑后的传播结果只保留 lineage不继承旧签名以便触发删除旧结果并重新传播。
- AI 页面会对未放置点提示、后端错误和返回 0 个 mask 的情况显示明确反馈。 - AI 页面会对未放置点提示、后端错误和返回 0 个 mask 的情况显示明确反馈。
- AI 参数支持 `crop_to_prompt``auto_filter_background``min_score`;点/框 prompt 可以裁剪局部区域推理并回映射结果,背景过滤会移除低分结果和包含负向点的 polygon。 - AI 参数支持 `crop_to_prompt``auto_filter_background``min_score`;点/框 prompt 可以裁剪局部区域推理并回映射结果,背景过滤会移除低分结果和包含负向点的 polygon。
- 后端返回 `polygons``scores` - 后端返回 `polygons``scores`

View File

@@ -108,14 +108,14 @@
4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask但保留没有 `annotationId` 的未保存 draft mask这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。 4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask但保留没有 `annotationId` 的未保存 draft mask这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。
5. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态。 5. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态。
6. `CanvasArea` 根据容器和帧尺寸按 86% 适配比例计算初始 scale/position使底图默认居中且尽量大但保留画布边距滚轮缩放和拖拽平移仍由用户后续控制。 6. `CanvasArea` 根据容器和帧尺寸按 86% 适配比例计算初始 scale/position使底图默认居中且尽量大但保留画布边距滚轮缩放和拖拽平移仍由用户后续控制。
7. `FrameTimeline` 顶部播放进度条显示当前播放位置;其下方视频处理进度条根据 `Mask.metadata.source` / `propagated_from_frame_id` 计算自动传播帧并显示蓝色区段,对人工绘制或 AI 智能分割等非传播 mask 帧显示红色竖线。普通状态下,视频处理进度条可点击跳转到对应帧,红色人工/AI 标注帧和蓝色自动传播帧标识本身也可点击跳转。处理条未处理背景使用中性灰,和红色/蓝色标记保持明显区分。`VideoWorkspace` 会记录当前会话最近 8 次成功处理过的自动传播范围,并通过 `propagationHistory` 传给 `FrameTimeline`;时间轴会把这些片段叠加为不同色系的横向渐变条,片段内按视频时间从深到浅,较早片段降低透明度。底部缩略图导航轴对非当前帧使用红色边框标识人工/AI 标注帧,使用蓝色边框标识自动传播/推理帧;如果同一帧同时存在人工/AI 标注和自动传播结果,红色人工/AI 标注边框优先保留,自动传播状态只作为蓝色内描边。当前帧使用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则以青色外框加红色内描边同时表达两个状态,外层当前帧框和内层人工/AI 框的顺序固定。工作区只有进入自动传播范围选择模式时,播放进度条和视频处理进度条显示 amber 覆盖层,并可点击/拖拽设置传播起止帧。 7. `FrameTimeline` 顶部播放进度条显示当前播放位置;其下方视频处理进度条根据 `Mask.metadata.source` / `propagated_from_frame_id` 计算自动传播帧并显示蓝色区段,对人工绘制或 AI 智能分割等非传播 mask 帧显示红色竖线。普通状态下,视频处理进度条可点击跳转到对应帧,红色人工/AI 标注帧和蓝色自动传播帧标识本身也可点击跳转。处理条未处理背景使用中性灰,和红色/蓝色标记保持明显区分。`VideoWorkspace` 会记录当前会话最近 8 次成功处理过的自动传播范围,并通过 `propagationHistory` 传给 `FrameTimeline`;时间轴会把这些片段叠加为不同色系的横向渐变条,片段内按视频时间从深到浅,较早片段降低透明度。底部缩略图导航轴对非当前帧使用红色边框标识人工/AI 标注帧,使用蓝色边框标识自动传播/推理帧;如果同一帧同时存在人工/AI 标注和自动传播结果,红色人工/AI 标注边框优先保留,自动传播状态只作为蓝色内描边。当前帧使用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则以青色外框加红色内描边同时表达两个状态,外层当前帧框和内层人工/AI 框的顺序固定。工作区进入自动传播或清空片段遮罩范围选择模式时,播放进度条和视频处理进度条显示 amber 覆盖层,并可点击/拖拽设置处理起止帧。
8. 当前帧传入 `CanvasArea` 8. 当前帧传入 `CanvasArea`
9. 工作区顶栏短状态文本会在空闲状态下自动消失;保存、导出、导入 GT 和传播任务运行中仍保留进度状态,无帧项目提示也会保留。 9. 工作区顶栏短状态文本会在空闲状态下自动消失;保存、导出、导入 GT 和传播任务运行中仍保留进度状态,无帧项目提示也会保留。
10. 左侧工具栏和右侧本体/语义分类面板使用 `seg-scrollbar` 定制纵向滚动条;默认滚动条 thumb 低透明度融入深色背景hover/focus 时增强为青色提示,避免系统默认滚动条在工具区中过于突兀。左侧工具栏额外保留右侧滚动条槽位,按钮列仍按原 48px 布局,避免滚动条和图标抢空间。 10. 左侧工具栏和右侧本体/语义分类面板使用 `seg-scrollbar` 定制纵向滚动条;默认滚动条 thumb 低透明度融入深色背景hover/focus 时增强为青色提示,避免系统默认滚动条在工具区中过于突兀。左侧工具栏额外保留右侧滚动条槽位,按钮列仍按原 48px 布局,避免滚动条和图标抢空间。
11. 右侧面板不再显示“本体论与属性分类管理树”固定说明栏,直接展示实际可操作内容。 11. 右侧面板不再显示“本体论与属性分类管理树”固定说明栏,直接展示实际可操作内容。
12. 右侧“遮罩透明度”滑杆写入 Zustand `maskPreviewOpacity``CanvasArea` 用该值计算 mask group opacity选中 mask 在基础透明度上加亮,方便保留选中反馈。 12. 右侧“遮罩透明度”滑杆写入 Zustand `maskPreviewOpacity``CanvasArea` 用该值计算 mask group opacity选中 mask 在基础透明度上加亮,方便保留选中反馈。
13. Canvas 点击 mask 后,全局 `selectedMaskIds` 会同步到 `OntologyInspector`;本体面板按选中 mask 的 `classId``className/label` 和颜色匹配模板分类,自动设置 active class并把分类按钮滚动/聚焦到可见区域。 13. Canvas 点击 mask 后,全局 `selectedMaskIds` 会同步到 `OntologyInspector`;本体面板按选中 mask 的 `classId``className/label` 和颜色匹配模板分类,自动设置 active class并把分类按钮滚动/聚焦到可见区域。
14. 工作区顶栏“清空片段遮罩”复用传播起始帧/结束帧输入作为范围;执行时对范围内已保存 mask 调用 `DELETE /api/ai/annotations/{id}`,同时移除范围内本地 draft mask 和被清空的选区,范围外 mask 保持不变。 14. 工作区顶栏“清空片段遮罩”和“自动传播”共用时间轴范围选择交互;第一次点击“清空片段遮罩”会进入范围选择模式,按钮变为“确认清空”,用户可在播放进度条或视频处理进度条上点击/拖拽选择起止帧;确认执行时对范围内已保存 mask 调用 `DELETE /api/ai/annotations/{id}`,同时移除范围内本地 draft mask 和被清空的选区,范围外 mask 保持不变。
### AI 点/框推理 ### AI 点/框推理
@@ -150,10 +150,10 @@
2. 用户可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重;该入口不提供 SAM2/SAM3 家族切换,默认跟随全局 AI 权重,用户手动选择后不再被 AI 页权重切换覆盖。 2. 用户可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重;该入口不提供 SAM2/SAM3 家族切换,默认跟随全局 AI 权重,用户手动选择后不再被 AI 页权重切换覆盖。
3. `VideoWorkspace` 以当前参考帧为 seed将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。 3. `VideoWorkspace` 以当前参考帧为 seed将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。
4. `VideoWorkspace` 在提交传播前会先调用现有归档保存链路保存当前项目中的 draft/dirty mask并重新读取 store 中的回显结果;参考帧 seed 因此优先携带稳定的后端 `source_annotation_id`,避免用前端临时 mask id 生成传播结果后,二次传播无法找到旧结果。 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` 5. `VideoWorkspace``buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`source_mask_id` 和可用时的 `source_annotation_id`;如果 seed mask 是未编辑的自动传播结果,会沿用其原始 `source_annotation_id/source_mask_id/propagation_seed_signature`,让后端把它识别为原传播链的同一个 seed如果该传播结果被编辑并保存更新 payload 只保留 lineage不保留旧签名使后端按“已修改”路径清理旧结果并重传
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 请求阻塞前端等待。 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、步骤进度、已处理帧次和已保存区域数任务运行期间提供取消传播按钮调用通用 `POST /api/tasks/{task_id}/cancel` 7. `VideoWorkspace` 记录返回的 `task_id`,轮询 `GET /api/tasks/{task_id}` 显示任务 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 传播。对旧版本只记录前端临时 `source_mask_id` 的传播标注worker 会在同一参考帧和传播方向内按 label/color/class 做兼容匹配,确保可被后续稳定 `source_annotation_id` 的传播替换。 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 传播。对旧版本只记录前端临时 `source_mask_id` 的传播标注worker 会按 label/color/class 做兼容匹配,确保可被后续稳定 `source_annotation_id` 的传播替换。
9. 后端按项目帧序列截取片段,下载对应帧到临时目录,并写成 `000000.jpg` 这类纯数字文件名;这是 `SAM2VideoPredictor` 对视频帧排序的要求,和项目库中持久化的 `frame_%06d.jpg` 对象名无关。 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()` 传播;`model=sam2` 会在入队时规范化为 tiny任务 payload/result 会保留规范化后的权重 id单个 SAM2 video predictor 调用内部暂不提供逐帧流式进度。 10. `model` 为任一 SAM 2.1 权重变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask再用 `propagate_in_video()` 传播;`model=sam2` 会在入队时规范化为 tiny任务 payload/result 会保留规范化后的权重 id单个 SAM2 video predictor 调用内部暂不提供逐帧流式进度。
11. `model=sam3` 当前不支持SAM 3 video tracker 代码保留但没有接入产品路径。 11. `model=sam3` 当前不支持SAM 3 video tracker 代码保留但没有接入产品路径。

View File

@@ -17,9 +17,9 @@
| R1 登录与会话 | `src/components/Login.test.tsx`, `backend/tests/test_auth.py` | 成功登录、失败提示、后端 401 | | R1 登录与会话 | `src/components/Login.test.tsx`, `backend/tests/test_auth.py` | 成功登录、失败提示、后端 401 |
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片删除、DELETE 契约、后端 CRUD、删除级联、帧列表 | | R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片删除、DELETE 契约、后端 CRUD、删除级联、帧列表 |
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、显式生成帧 FPS 选择、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | | R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、显式生成帧 FPS 选择、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、按起止帧批量清空片段遮罩、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段不同色系渐变显示、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播范围、当前帧由播放进度条末端和缩略图青色高亮表达/左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | | R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、清空片段遮罩进入时间轴范围选择并按选区批量清空、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段不同色系渐变显示、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/清空范围、当前帧由播放进度条末端和缩略图青色高亮表达/左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、整块 mask 删除、区域合并/去除、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染、合并模式隐藏编辑手柄、工作区 SAM 提示点点击删除且不冒泡新增点、工作区顶栏撤销/重做按钮、撤销/重做快捷键和输入框快捷键跳过、撤销/重做历史栈 | | R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、整块 mask 删除、区域合并/去除、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染、合并模式隐藏编辑手柄、工作区 SAM 提示点点击删除且不冒泡新增点、工作区顶栏撤销/重做按钮、撤销/重做快捷键和输入框快捷键跳过、撤销/重做历史栈 |
| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、传播前自动保存 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名 metadata、未改变 seed 跳过、已改变 seed 先删旧自动传播标注再重传、换权重传播先清理旧权重结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry | | R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、传播前自动保存 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名 metadata、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、换权重传播先清理旧权重结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
| R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、seed point 回显/归一化、项目不存在、帧不存在 | | R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、seed point 回显/归一化、项目不存在、帧不存在 |
| R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、JSON 分类导入、JSON/保存错误非阻塞提示、mapping_rules 解包/打包、后端模板 CRUD | | R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、JSON 分类导入、JSON/保存错误非阻塞提示、mapping_rules 解包/打包、后端模板 CRUD |
| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、自定义分类 PATCH 后端模板、选中 mask 后端属性分析、重新提取拓扑锚点 | | R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、自定义分类 PATCH 后端模板、选中 mask 后端属性分析、重新提取拓扑锚点 |
@@ -35,10 +35,10 @@
| R1 | 登录页、默认开发凭证、token 写入、失败提示、后端 401 | `Login.test.tsx`, `test_auth.py` | 已覆盖 | | R1 | 登录页、默认开发凭证、token 写入、失败提示、后端 401 | `Login.test.tsx`, `test_auth.py` | 已覆盖 |
| R2 | 项目列表/创建/选择、视频导入、DICOM 导入、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 | | R2 | 项目列表/创建/选择、视频导入、DICOM 导入、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、显式 FPS 生成帧、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 | | R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、显式 FPS 生成帧、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、按起止帧批量清空片段遮罩、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段不同色系渐变显示、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、当前帧由播放进度条末端和缩略图青色高亮表达/左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 | | R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、清空片段遮罩进入时间轴范围选择并按选区批量清空、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段不同色系渐变显示、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播/清空范围、Canvas/AI 画布拖拽平移回写 position state、当前帧由播放进度条末端和缩略图青色高亮表达/左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
| R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | | R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、顶点删除、整块删除、工作区 SAM 提示点删除优先级、工作区顶栏撤销/重做按钮、撤销/重做快捷键、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `useStore.test.ts` | 已覆盖 | | R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、顶点删除、整块删除、工作区 SAM 提示点删除优先级、工作区顶栏撤销/重做按钮、撤销/重做快捷键、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `useStore.test.ts` | 已覆盖 |
| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、传播前自动保存 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名 metadata、未改变 seed 跳过、已改变 seed 先删旧自动传播标注再重传、换权重传播先清理旧权重结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 | | R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、传播前自动保存 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名 metadata、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、换权重传播先清理旧权重结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 |
| R7 | 保存、保存后替换已提交 draft、查询、更新、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 回写 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 | | R7 | 保存、保存后替换已提交 draft、查询、更新、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 回写 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 |
| R8 | 模板加载、新建、编辑、删除、JSON 分类导入、JSON/保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py` | 已覆盖 | | R8 | 模板加载、新建、编辑、删除、JSON 分类导入、JSON/保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py` | 已覆盖 |
| R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、自定义分类写入后端模板、后端属性分析、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 | | R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、自定义分类写入后端模板、后端属性分析、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 |
@@ -61,7 +61,7 @@
- R4/R6补充时间轴传播范围选择测试验证点击“自动传播”后可在播放进度条或视频处理进度条上拖拽回填起止帧再提交后台传播任务。 - R4/R6补充时间轴传播范围选择测试验证点击“自动传播”后可在播放进度条或视频处理进度条上拖拽回填起止帧再提交后台传播任务。
- R4/R6补充视频处理进度条传播历史测试验证多次自动传播后会按不同色系渐变片段显示最近处理范围。 - R4/R6补充视频处理进度条传播历史测试验证多次自动传播后会按不同色系渐变片段显示最近处理范围。
- R6/R10补充 `queuePropagationTask()``POST /api/ai/propagate/task`、传播 Celery runner 和传播任务重试测试,验证工作区自动传播不再依赖长 HTTP 请求,并验证传给 `SAM2VideoPredictor` 的临时帧文件名是纯数字序列。 - 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 权重时,会先删除旧传播标注再保存新结果。 - R6补充传播去重回归测试验证前端传播前会先保存 draft seed mask 并用稳定 `source_annotation_id` 入队;后端在 seed 来源由前端临时 id 迁移到后端 annotation id、用户换用其他 SAM 2.1 权重、未编辑传播结果再次作为 seed、已编辑传播结果重新作为 seed 时,会分别跳过或清理旧传播标注再保存新结果。
- R6`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。 - R6`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。
- R3补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps``max_frames``target_width` 会进入任务。 - R3补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps``max_frames``target_width` 会进入任务。
- R3补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms``source_frame_number``result.frame_sequence` 元数据。 - R3补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms``source_frame_number``result.frame_sequence` 元数据。

View File

@@ -316,6 +316,11 @@ describe('VideoWorkspace', () => {
saveStatus: 'dirty', saveStatus: 'dirty',
segmentation: [[0, 0, 10, 0, 10, 10]], segmentation: [[0, 0, 10, 0, 10, 10]],
bbox: [0, 0, 10, 10], bbox: [0, 0, 10, 10],
metadata: {
source_annotation_id: 7,
source_mask_id: 'annotation-7',
propagation_seed_signature: 'old-signature',
},
}], }],
}); });
}); });
@@ -324,7 +329,12 @@ describe('VideoWorkspace', () => {
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('99', { await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('99', {
template_id: 2, template_id: 2,
mask_data: { polygons: [], label: '胆囊' }, mask_data: {
polygons: [],
label: '胆囊',
source_annotation_id: 7,
source_mask_id: 'annotation-7',
},
points: undefined, points: undefined,
bbox: undefined, bbox: undefined,
})); }));
@@ -390,9 +400,28 @@ describe('VideoWorkspace', () => {
}); });
}); });
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' })); fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
expect(screen.getByText('请在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”')).toBeInTheDocument();
const processingBar = screen.getByLabelText('视频处理进度条');
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 100,
top: 0,
bottom: 10,
width: 100,
height: 10,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(processingBar, { clientX: 0, pointerId: 1 });
fireEvent.pointerMove(processingBar, { clientX: 50, pointerId: 1 });
fireEvent.pointerUp(processingBar, { clientX: 50, pointerId: 1 });
expect(screen.getByLabelText('传播起始帧')).toHaveValue(1);
expect(screen.getByLabelText('传播结束帧')).toHaveValue(2);
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99')); await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100'); expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100');
@@ -539,6 +568,12 @@ describe('VideoWorkspace', () => {
color: '#ff0000', color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]], segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72], bbox: [64, 36, 128, 72],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
},
}], }],
}); });
}); });
@@ -604,6 +639,12 @@ describe('VideoWorkspace', () => {
color: '#ff0000', color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]], segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72], bbox: [64, 36, 128, 72],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
},
}], }],
}); });
}); });
@@ -618,6 +659,13 @@ describe('VideoWorkspace', () => {
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({ await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
model: 'sam2.1_hiera_small', model: 'sam2.1_hiera_small',
steps: [expect.objectContaining({
seed: expect.objectContaining({
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
}),
})],
}))); })));
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
}); });

View File

@@ -40,6 +40,7 @@ type PropagationHistorySegment = {
colorIndex: number; colorIndex: number;
label: string; label: string;
}; };
type RangeSelectionMode = 'propagation' | 'clear' | null;
const PROPAGATION_POLL_INTERVAL_MS = 250; const PROPAGATION_POLL_INTERVAL_MS = 250;
const STATUS_MESSAGE_TTL_MS = 3600; const STATUS_MESSAGE_TTL_MS = 3600;
@@ -75,6 +76,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const [propagationStartFrame, setPropagationStartFrame] = useState(1); const [propagationStartFrame, setPropagationStartFrame] = useState(1);
const [propagationEndFrame, setPropagationEndFrame] = useState(1); const [propagationEndFrame, setPropagationEndFrame] = useState(1);
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false); const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false); const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null); const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null); const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
@@ -226,6 +228,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setPropagationStartFrame(currentFrameNumber); setPropagationStartFrame(currentFrameNumber);
setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29)); setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29));
setIsPropagationRangeSelecting(false); setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false); setHasExplicitPropagationRange(false);
}, [currentFrameNumber, totalFrames]); }, [currentFrameNumber, totalFrames]);
@@ -255,9 +258,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const frame = frameById.get(mask.frameId); const frame = frameById.get(mask.frameId);
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null; const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
if (!payload || !mask.annotationId) return null; if (!payload || !mask.annotationId) return null;
const propagationLineage = {
...(mask.metadata?.source_annotation_id !== undefined ? { source_annotation_id: mask.metadata.source_annotation_id } : {}),
...(mask.metadata?.source_mask_id !== undefined ? { source_mask_id: mask.metadata.source_mask_id } : {}),
};
const updatePayload = { const updatePayload = {
template_id: payload.template_id, template_id: payload.template_id,
mask_data: payload.mask_data, mask_data: { ...payload.mask_data, ...propagationLineage },
points: payload.points, points: payload.points,
bbox: payload.bbox, bbox: payload.bbox,
}; };
@@ -316,6 +323,12 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}, [currentFrame, masks, setMasks]); }, [currentFrame, masks, setMasks]);
const handleClearFrameRangeMasks = useCallback(async () => { const handleClearFrameRangeMasks = useCallback(async () => {
if (rangeSelectionMode !== 'clear') {
setIsPropagationRangeSelecting(true);
setRangeSelectionMode('clear');
setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”');
return;
}
if (frames.length === 0) return; if (frames.length === 0) return;
const clampRangeFrameNumber = (value: number) => { const clampRangeFrameNumber = (value: number) => {
if (totalFrames <= 0) return 1; if (totalFrames <= 0) return 1;
@@ -354,13 +367,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setMasks(latestMasks.filter((mask) => !frameIdsToClear.has(String(mask.frameId)))); setMasks(latestMasks.filter((mask) => !frameIdsToClear.has(String(mask.frameId))));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id))); setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
setStatusMessage(`已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length}`); setStatusMessage(`已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length}`);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
} catch (err) { } catch (err) {
console.error('Delete range annotations failed:', err); console.error('Delete range annotations failed:', err);
setStatusMessage('批量清空失败,请检查后端服务'); setStatusMessage('批量清空失败,请检查后端服务');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [frames, masks, propagationEndFrame, propagationStartFrame, setMasks, setSelectedMaskIds, totalFrames]); }, [frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, setMasks, setSelectedMaskIds, totalFrames]);
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => { const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
if (annotationIds.length === 0) return; if (annotationIds.length === 0) return;
@@ -463,9 +479,18 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) { if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) {
return null; return null;
} }
const sourceAnnotationId = seedMask.annotationId && /^\d+$/.test(seedMask.annotationId) const metadataSourceAnnotationId = Number(seedMask.metadata?.source_annotation_id);
const sourceAnnotationId = Number.isFinite(metadataSourceAnnotationId) && metadataSourceAnnotationId > 0
? metadataSourceAnnotationId
: seedMask.annotationId && /^\d+$/.test(seedMask.annotationId)
? Number(seedMask.annotationId) ? Number(seedMask.annotationId)
: undefined; : undefined;
const metadataSourceMaskId = typeof seedMask.metadata?.source_mask_id === 'string'
? seedMask.metadata.source_mask_id
: undefined;
const inheritedSeedSignature = typeof seedMask.metadata?.propagation_seed_signature === 'string'
? seedMask.metadata.propagation_seed_signature
: undefined;
return { return {
polygons: seedPayload.mask_data?.polygons, polygons: seedPayload.mask_data?.polygons,
bbox: seedPayload.bbox, bbox: seedPayload.bbox,
@@ -474,8 +499,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
color: seedPayload.mask_data?.color, color: seedPayload.mask_data?.color,
class_metadata: seedPayload.mask_data?.class, class_metadata: seedPayload.mask_data?.class,
template_id: seedPayload.template_id, template_id: seedPayload.template_id,
source_mask_id: seedMask.id, source_mask_id: metadataSourceMaskId || seedMask.id,
source_annotation_id: sourceAnnotationId, source_annotation_id: sourceAnnotationId,
propagation_seed_signature: inheritedSeedSignature,
}; };
}, [activeTemplateId, currentFrame, currentProject?.id]); }, [activeTemplateId, currentFrame, currentProject?.id]);
@@ -485,8 +511,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setPropagationStartFrame(nextStart); setPropagationStartFrame(nextStart);
setPropagationEndFrame(nextEnd); setPropagationEndFrame(nextEnd);
setHasExplicitPropagationRange(true); setHasExplicitPropagationRange(true);
setStatusMessage(`已选择自动传播范围:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)}`); const actionLabel = rangeSelectionMode === 'clear' ? '清空范围' : '自动传播范围';
}, [clampFrameNumber]); setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)}`);
}, [clampFrameNumber, rangeSelectionMode]);
const handlePropagationStartInput = (value: number) => { const handlePropagationStartInput = (value: number) => {
setPropagationStartFrame(clampFrameNumber(value || 1)); setPropagationStartFrame(clampFrameNumber(value || 1));
@@ -649,18 +676,22 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const handleAutoPropagate = async () => { const handleAutoPropagate = async () => {
if (!hasExplicitPropagationRange && !isPropagationRangeSelecting) { if (!hasExplicitPropagationRange && !isPropagationRangeSelecting) {
setIsPropagationRangeSelecting(true); setIsPropagationRangeSelecting(true);
setRangeSelectionMode('propagation');
setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”'); setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”');
return; return;
} }
setRangeSelectionMode('propagation');
await runAutoPropagate(); await runAutoPropagate();
}; };
const handleCancelPropagationRangeSelection = () => { const handleCancelPropagationRangeSelection = () => {
const previousMode = rangeSelectionMode;
setIsPropagationRangeSelecting(false); setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false); setHasExplicitPropagationRange(false);
setPropagationStartFrame(currentFrameNumber || 1); setPropagationStartFrame(currentFrameNumber || 1);
setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29)); setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29));
setStatusMessage('已取消自动传播范围选择'); setStatusMessage(previousMode === 'clear' ? '已取消清空片段范围选择' : '已取消自动传播范围选择');
}; };
const handleCancelPropagation = async () => { const handleCancelPropagation = async () => {
@@ -800,14 +831,14 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
title="按当前起止帧清空这一段视频内的全部遮罩" title="按当前起止帧清空这一段视频内的全部遮罩"
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/25 rounded-md text-xs transition-colors text-red-200 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/25 rounded-md text-xs transition-colors text-red-200 disabled:opacity-40 disabled:cursor-not-allowed"
> >
{rangeSelectionMode === 'clear' ? '确认清空' : '清空片段遮罩'}
</button> </button>
<button <button
onClick={handleAutoPropagate} onClick={handleAutoPropagate}
disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating} disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating}
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed" className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
> >
{isPropagating ? '传播中...' : isPropagationRangeSelecting ? '开始传播' : '自动传播'} {isPropagating ? '传播中...' : rangeSelectionMode === 'propagation' ? '开始传播' : '自动传播'}
</button> </button>
{isPropagationRangeSelecting && ( {isPropagationRangeSelecting && (
<button <button