diff --git a/backend/schemas.py b/backend/schemas.py index 01b8212..b0f2160 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -220,6 +220,7 @@ class PropagationSeed(BaseModel): template_id: Optional[int] = None source_mask_id: Optional[str] = None source_annotation_id: Optional[int] = None + propagation_seed_signature: Optional[str] = None class PropagateRequest(BaseModel): diff --git a/backend/services/propagation_task_runner.py b/backend/services/propagation_task_runner.py index 2759428..f8e83e0 100644 --- a/backend/services/propagation_task_runner.py +++ b/backend/services/propagation_task_runner.py @@ -85,8 +85,21 @@ def _stable_json(value: Any) -> str: 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: """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 = { "polygons": seed.get("polygons") 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 {}, "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: @@ -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" -def _is_propagation_annotation( - annotation: Annotation, - source_frame: Frame, - seed_key: str, - seed: dict[str, Any], -) -> bool: +def _seed_identity_matches(mask_data: dict[str, Any], seed_key: str, seed: dict[str, Any]) -> bool: + previous_seed_key = mask_data.get("propagation_seed_key") + if previous_seed_key == seed_key: + 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 + 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 {} source = str(mask_data.get("source") or "") if not source.endswith("_propagation"): return False - if int(mask_data.get("propagated_from_frame_id") or 0) != int(source_frame.id): - 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) + return _seed_identity_matches(mask_data, seed_key, seed) def _direction_matches(mask_data: dict[str, Any], direction: str) -> bool: @@ -163,27 +179,36 @@ def _prepare_seed_propagation( *, payload: dict[str, Any], model_id: str, - source_frame: Frame, seed: dict[str, Any], direction: str, + target_frame_ids: set[int], ) -> dict[str, Any]: seed_key = _seed_key(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 = ( db.query(Annotation) .filter(Annotation.project_id == int(payload["project_id"])) + .filter(Annotation.frame_id.in_(target_frame_ids)) .all() ) matching = [ 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) ] + covered_frame_ids = {int(annotation.frame_id) for annotation in matching} if matching and all( (annotation.mask_data or {}).get("propagation_seed_signature") == seed_signature and _source_model_matches(annotation.mask_data or {}, model_id) for annotation in matching - ): + ) and target_frame_ids.issubset(covered_frame_ids): return { "skip": True, "seed_key": seed_key, @@ -317,13 +342,20 @@ def _run_one_step( raise ValueError("Propagation requires seed polygons, bbox, or points") 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( db, payload=payload, model_id=model_id, - source_frame=source_frame, seed=seed, direction=direction, + target_frame_ids=target_frame_ids, ) if seed_state["skip"]: return { @@ -337,7 +369,6 @@ def _run_one_step( "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: frame_paths = _write_frame_sequence(selected_frames, Path(tmpdir)) propagated = sam_registry.propagate_video( diff --git a/backend/tests/test_ai.py b/backend/tests/test_ai.py index 38b9753..f0b080d 100644 --- a/backend/tests/test_ai.py +++ b/backend/tests/test_ai.py @@ -2,7 +2,7 @@ import numpy as np import cv2 from pathlib import Path 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): @@ -614,6 +614,94 @@ def test_propagation_task_runner_replaces_legacy_or_different_weight_results(cli 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): project, _, _ = _create_project_and_frame(client) diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index 6d1b97c..42dc067 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -68,8 +68,8 @@ | “导出 JSON 标注集”按钮 | 真实可用 | 导出前会保存未归档 mask,然后调用 `exportCoco()` 下载 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,再回显到工作区 | -| 参考帧/起止帧/传播权重/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 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 predictor;worker 会按 seed 来源和几何/语义签名做幂等判断,未改变的 seed 直接跳过,已改变的 seed 会先删除同源旧自动传播标注再重新传播,避免重复传播产生重叠 mask;传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数,前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask | -| 清空片段遮罩 | 真实可用 | 复用顶栏传播起始帧/结束帧作为视频片段范围,点击“清空片段遮罩”后会删除该帧段内所有本地 draft mask,并对已保存 mask 调用 `DELETE /api/ai/annotations/{annotation_id}` 删除后端标注;不在范围内的 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 predictor;worker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播,避免重复传播产生重叠 mask;传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数,前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 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,避免仍显示未保存 | ## CanvasArea 画布 @@ -115,7 +115,7 @@ | 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 | | 顶部 range 拖动 | 真实可用 | 改变当前帧 | | 具体时间显示 | 真实可用 | 根据项目 `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 | | 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 | diff --git a/doc/04-api-contracts.md b/doc/04-api-contracts.md index 3eea1ce..f90cb06 100644 --- a/doc/04-api-contracts.md +++ b/doc/04-api-contracts.md @@ -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` 为 `_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 进度流显示该任务。 ## 已完成的接口对齐 diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md index 477ae2f..c787550 100644 --- a/doc/07-current-requirements-freeze.md +++ b/doc/07-current-requirements-freeze.md @@ -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()`。 - 自动传播任务写入 `processing_tasks`,前端轮询 `GET /api/tasks/{task_id}` 显示进度并刷新标注;Dashboard 也能看到该任务,任务可取消和重试。 - 传播结果会写入后续帧 `annotations`,`mask_data.source` 标记为 `_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 参数支持 `crop_to_prompt`、`auto_filter_background` 和 `min_score`;点/框 prompt 可以裁剪局部区域推理并回映射结果,背景过滤会移除低分结果和包含负向点的 polygon。 - 后端返回 `polygons` 和 `scores`。 diff --git a/doc/08-current-design-freeze.md b/doc/08-current-design-freeze.md index 99ab560..9083182 100644 --- a/doc/08-current-design-freeze.md +++ b/doc/08-current-design-freeze.md @@ -108,14 +108,14 @@ 4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask,但保留没有 `annotationId` 的未保存 draft mask;这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。 5. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态。 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`。 9. 工作区顶栏短状态文本会在空闲状态下自动消失;保存、导出、导入 GT 和传播任务运行中仍保留进度状态,无帧项目提示也会保留。 10. 左侧工具栏和右侧本体/语义分类面板使用 `seg-scrollbar` 定制纵向滚动条;默认滚动条 thumb 低透明度融入深色背景,hover/focus 时增强为青色提示,避免系统默认滚动条在工具区中过于突兀。左侧工具栏额外保留右侧滚动条槽位,按钮列仍按原 48px 布局,避免滚动条和图标抢空间。 11. 右侧面板不再显示“本体论与属性分类管理树”固定说明栏,直接展示实际可操作内容。 12. 右侧“遮罩透明度”滑杆写入 Zustand `maskPreviewOpacity`,`CanvasArea` 用该值计算 mask group opacity;选中 mask 在基础透明度上加亮,方便保留选中反馈。 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 点/框推理 @@ -150,10 +150,10 @@ 2. 用户可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 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`。 +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 请求阻塞前端等待。 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` 对象名无关。 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 代码保留但没有接入产品路径。 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index dba8382..2f278f5 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -17,9 +17,9 @@ | 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、删除级联、帧列表 | | 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 提示点点击删除且不冒泡新增点、工作区顶栏撤销/重做按钮、撤销/重做快捷键和输入框快捷键跳过、撤销/重做历史栈 | -| 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 回显/归一化、项目不存在、帧不存在 | | 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 后端属性分析、重新提取拓扑锚点 | @@ -35,10 +35,10 @@ | R1 | 登录页、默认开发凭证、token 写入、失败提示、后端 401 | `Login.test.tsx`, `test_auth.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` | 已覆盖 | -| 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 | 顶点直接拖动编辑、顶点拖拽结束不改变 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` | 已覆盖 | | 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` | 已覆盖 | @@ -61,7 +61,7 @@ - R4/R6:补充时间轴传播范围选择测试,验证点击“自动传播”后可在播放进度条或视频处理进度条上拖拽回填起止帧,再提交后台传播任务。 - R4/R6:补充视频处理进度条传播历史测试,验证多次自动传播后会按不同色系渐变片段显示最近处理范围。 - 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` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。 - R3:补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps`、`max_frames`、`target_width` 会进入任务。 - R3:补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms`、`source_frame_number` 和 `result.frame_sequence` 元数据。 diff --git a/src/components/VideoWorkspace.test.tsx b/src/components/VideoWorkspace.test.tsx index ea7bb0a..6fa2ed2 100644 --- a/src/components/VideoWorkspace.test.tsx +++ b/src/components/VideoWorkspace.test.tsx @@ -316,6 +316,11 @@ describe('VideoWorkspace', () => { saveStatus: 'dirty', segmentation: [[0, 0, 10, 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', { template_id: 2, - mask_data: { polygons: [], label: '胆囊' }, + mask_data: { + polygons: [], + label: '胆囊', + source_annotation_id: 7, + source_mask_id: 'annotation-7', + }, points: 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: '清空片段遮罩' })); + 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')); expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100'); @@ -539,6 +568,12 @@ describe('VideoWorkspace', () => { color: '#ff0000', segmentation: [[64, 36, 192, 36, 192, 108]], 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', segmentation: [[64, 36, 192, 36, 192, 108]], 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({ 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()); }); diff --git a/src/components/VideoWorkspace.tsx b/src/components/VideoWorkspace.tsx index 7f27cc8..8d09bae 100644 --- a/src/components/VideoWorkspace.tsx +++ b/src/components/VideoWorkspace.tsx @@ -40,6 +40,7 @@ type PropagationHistorySegment = { colorIndex: number; label: string; }; +type RangeSelectionMode = 'propagation' | 'clear' | null; const PROPAGATION_POLL_INTERVAL_MS = 250; const STATUS_MESSAGE_TTL_MS = 3600; @@ -75,6 +76,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void const [propagationStartFrame, setPropagationStartFrame] = useState(1); const [propagationEndFrame, setPropagationEndFrame] = useState(1); const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false); + const [rangeSelectionMode, setRangeSelectionMode] = useState(null); const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false); const [propagationProgress, setPropagationProgress] = useState(null); const [propagationTaskId, setPropagationTaskId] = useState(null); @@ -226,6 +228,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void setPropagationStartFrame(currentFrameNumber); setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29)); setIsPropagationRangeSelecting(false); + setRangeSelectionMode(null); setHasExplicitPropagationRange(false); }, [currentFrameNumber, totalFrames]); @@ -255,9 +258,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void const frame = frameById.get(mask.frameId); const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : 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 = { template_id: payload.template_id, - mask_data: payload.mask_data, + mask_data: { ...payload.mask_data, ...propagationLineage }, points: payload.points, bbox: payload.bbox, }; @@ -316,6 +323,12 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }, [currentFrame, masks, setMasks]); const handleClearFrameRangeMasks = useCallback(async () => { + if (rangeSelectionMode !== 'clear') { + setIsPropagationRangeSelecting(true); + setRangeSelectionMode('clear'); + setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”'); + return; + } if (frames.length === 0) return; const clampRangeFrameNumber = (value: number) => { if (totalFrames <= 0) return 1; @@ -354,13 +367,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void setMasks(latestMasks.filter((mask) => !frameIdsToClear.has(String(mask.frameId)))); setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id))); setStatusMessage(`已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length} 个`); + setIsPropagationRangeSelecting(false); + setRangeSelectionMode(null); + setHasExplicitPropagationRange(false); } catch (err) { console.error('Delete range annotations failed:', err); setStatusMessage('批量清空失败,请检查后端服务'); } finally { setIsSaving(false); } - }, [frames, masks, propagationEndFrame, propagationStartFrame, setMasks, setSelectedMaskIds, totalFrames]); + }, [frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, setMasks, setSelectedMaskIds, totalFrames]); const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => { if (annotationIds.length === 0) return; @@ -463,9 +479,18 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) { 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) : 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 { polygons: seedPayload.mask_data?.polygons, bbox: seedPayload.bbox, @@ -474,8 +499,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void color: seedPayload.mask_data?.color, class_metadata: seedPayload.mask_data?.class, template_id: seedPayload.template_id, - source_mask_id: seedMask.id, + source_mask_id: metadataSourceMaskId || seedMask.id, source_annotation_id: sourceAnnotationId, + propagation_seed_signature: inheritedSeedSignature, }; }, [activeTemplateId, currentFrame, currentProject?.id]); @@ -485,8 +511,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void setPropagationStartFrame(nextStart); setPropagationEndFrame(nextEnd); setHasExplicitPropagationRange(true); - setStatusMessage(`已选择自动传播范围:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)} 帧`); - }, [clampFrameNumber]); + const actionLabel = rangeSelectionMode === 'clear' ? '清空范围' : '自动传播范围'; + setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)} 帧`); + }, [clampFrameNumber, rangeSelectionMode]); const handlePropagationStartInput = (value: number) => { setPropagationStartFrame(clampFrameNumber(value || 1)); @@ -649,18 +676,22 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void const handleAutoPropagate = async () => { if (!hasExplicitPropagationRange && !isPropagationRangeSelecting) { setIsPropagationRangeSelecting(true); + setRangeSelectionMode('propagation'); setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”'); return; } + setRangeSelectionMode('propagation'); await runAutoPropagate(); }; const handleCancelPropagationRangeSelection = () => { + const previousMode = rangeSelectionMode; setIsPropagationRangeSelecting(false); + setRangeSelectionMode(null); setHasExplicitPropagationRange(false); setPropagationStartFrame(currentFrameNumber || 1); setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29)); - setStatusMessage('已取消自动传播范围选择'); + setStatusMessage(previousMode === 'clear' ? '已取消清空片段范围选择' : '已取消自动传播范围选择'); }; const handleCancelPropagation = async () => { @@ -800,14 +831,14 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void 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" > - 清空片段遮罩 + {rangeSelectionMode === 'clear' ? '确认清空' : '清空片段遮罩'} {isPropagationRangeSelecting && (