diff --git a/AGENTS.md b/AGENTS.md index f3aa070..14caddc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,14 +218,14 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload ## 主要业务流程 1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,默认开发凭证为 `admin / 123456`。 -2. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表。 +2. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、删除项目;删除当前项目后会清空工作区当前项目、帧、mask 和选区。 3. 上传资源:视频走 `/api/media/upload`,只上传源文件并关联项目,不自动拆帧;DICOM 批量走 `/api/media/upload/dicom`。 4. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery,接口支持 `parse_fps`、`max_frames` 和 `target_width` 标准帧序列参数。 5. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;视频帧按 `frame_%06d.jpg` 连续命名并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据。 -6. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`FrameTimeline` 会根据当前项目帧内的 `masks` 在进度条和缩略图导航轴之间标出已有编辑/标注的帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。 +6. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`FrameTimeline` 会根据当前项目帧内的 `masks` 在顶部进度条上用琥珀色竖线标出已有编辑/标注的帧,当前帧位置由播放进度条末端、时间提示和缩略图高亮表达;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。 7. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、点区域和线段生成 polygon mask;多边形可按 Enter 或点击首节点闭合;绘制工具可在已有 mask 上继续落点;工具栏有“调整多边形”入口,点击 mask 可拖动/删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;区域合并/去除会隐藏编辑手柄并显示已选数量,使用 `polygon-clipping` 做 union/difference,内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。 -8. AI 分割:前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。 -9. 视频片段传播:工作区“传播片段”把当前选中 mask 或当前帧第一个 mask 作为 seed,调用 `POST /api/ai/propagate`;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`。 +8. AI 分割:前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。 +9. 视频片段传播:工作区可选择“选中区域”或“当前帧全部”作为 seed,并用起止帧指定追踪范围;“传播全部可达”会把范围设为第 1 帧到最后 1 帧。前端会按 seed mask 和前/后方向顺序调用单 seed `POST /api/ai/propagate`,避免多个视频 tracker 并发抢占 GPU;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`。 10. GT 导入:工作区“导入 GT Mask”调用 `/api/ai/import-gt-mask`;后端按非零像素值和连通域生成 polygon 标注,并用 distance transform 生成 seed point;前端回显 seed point,拖动后可归档更新。 11. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色和 z-index;`OntologyInspector.tsx` 在工作区显示当前模板分类树。 12. 导出:后端支持 COCO JSON 和 PNG mask ZIP 导出;PNG ZIP 包含单标注 mask、按 zIndex 融合的语义 mask 和 `semantic_classes.json`。 diff --git a/README.md b/README.md index 97d8d0a..4f25656 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ ## 核心功能 -- **多媒体资产管理** — 支持视频(MP4/AVI/MOV)和 DICOM 医学影像上传;视频导入与生成帧分离,生成帧时选择目标 FPS -- **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体(tiny/small/base+/large)选择;支持点分割(point)、框分割(box)、交互式正/反点细化、自动分割(auto)和 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示 +- **多媒体资产管理** — 支持视频(MP4/AVI/MOV)和 DICOM 医学影像上传;视频导入与生成帧分离,生成帧时选择目标 FPS,项目卡片可删除项目及其关联帧、标注和任务记录 +- **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体(tiny/small/base+/large)选择;支持点分割(point)、框分割(box)、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割(auto)和 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示 - **交互式画布标注** — 基于 Konva 的高性能 Canvas,支持缩放/平移/手工多边形/矩形/圆/点/线、polygon 顶点拖动/删除、边中点插点、双击边界插点、区域合并/去除、选点/框选、撤销/重做,实时渲染 Mask 遮罩 - **GT Mask 导入** — 工作区可导入 GT mask 图片,后端按非零像素值和连通域生成 polygon 标注并用 distance transform 写入 seed point;前端可回显和拖动 seed point - **本体字典管理** — 可配置的分类体系、颜色映射、图层优先级(z-index) @@ -466,8 +466,8 @@ pip install -e . --no-build-isolation - 前端 `predictMask()` 已发送后端需要的 `image_id`、`prompt_type`、`prompt_data`,并把后端 `polygons` 转成 Konva `pathData`。 - 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict`。 - 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。 -- AI 页面只显示本页新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签,“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择。 -- 工作区“传播片段”会使用当前选中区域或当前帧第一个区域作为 seed,调用 `/api/ai/propagate`,并在完成后刷新已保存标注。 +- AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签,“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择。 +- 工作区传播功能会使用当前选中区域或当前帧全部区域作为 seed,按用户设置的起止帧向前/向后追踪;“传播全部可达”会覆盖项目第 1 帧到最后 1 帧。前端会按 seed 和方向顺序调用 `/api/ai/propagate`,并在完成后刷新已保存标注。 - 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco`。 - 工作区“导出 JSON 标注集”和“导出 PNG Mask ZIP”按钮已绑定下载流程;导出前会先保存当前待归档的前端 mask。 - 工作区“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,导入后会刷新并回显已保存标注和 seed point。 diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index da70e85..e6f2e91 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -1,3 +1,6 @@ +from models import Annotation, Frame, Mask, ProcessingTask, Project + + def test_project_crud_and_frames(client, monkeypatch): monkeypatch.setattr("routers.projects.get_presigned_url", lambda key, expires=3600: f"http://storage/{key}") @@ -43,6 +46,42 @@ def test_project_crud_and_frames(client, monkeypatch): assert client.get(f"/api/projects/{project_id}").status_code == 404 +def test_delete_project_cascades_related_records(client, db_session): + created = client.post("/api/projects", json={"name": "Cascade Delete"}) + assert created.status_code == 201 + project_id = created.json()["id"] + + frame = client.post(f"/api/projects/{project_id}/frames", json={ + "project_id": project_id, + "frame_index": 0, + "image_url": "frames/0.jpg", + "width": 640, + "height": 360, + }) + assert frame.status_code == 201 + frame_id = frame.json()["id"] + + annotation = client.post("/api/ai/annotate", json={ + "project_id": project_id, + "frame_id": frame_id, + "mask_data": {"polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]]}, + }) + assert annotation.status_code == 201 + annotation_id = annotation.json()["id"] + + db_session.add(Mask(annotation_id=annotation_id, mask_url="masks/1.png")) + db_session.add(ProcessingTask(task_type="parse_video", project_id=project_id, status="queued")) + db_session.commit() + + deleted = client.delete(f"/api/projects/{project_id}") + assert deleted.status_code == 204 + assert db_session.query(Project).filter(Project.id == project_id).count() == 0 + assert db_session.query(Frame).filter(Frame.project_id == project_id).count() == 0 + assert db_session.query(Annotation).filter(Annotation.project_id == project_id).count() == 0 + assert db_session.query(Mask).count() == 0 + assert db_session.query(ProcessingTask).filter(ProcessingTask.project_id == project_id).count() == 0 + + def test_project_and_frame_404s(client): assert client.get("/api/projects/999").status_code == 404 assert client.patch("/api/projects/999", json={"name": "x"}).status_code == 404 diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index c6048d9..3f96bf0 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -51,7 +51,7 @@ | 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS | | 导入 DICOM 序列 | 部分可用 | 可上传 `.dcm` 并触发解析;体验和错误反馈较粗 | | 项目状态徽标 | 真实可用 | 项目状态统一为 `pending/parsing/ready/error`,前端兼容归一化旧状态值 | -| 更多按钮 | Mock / UI-only | 有图标,没有菜单或事件 | +| 删除项目按钮 | 真实可用 | 点击垃圾桶按钮会确认删除,调用 `DELETE /api/projects/{id}`,成功后从项目库移除;若删除的是当前项目,会清空工作区当前项目、帧、mask 和选区 | | alert 成功/失败提示 | 真实可用但粗糙 | 使用浏览器 `alert` | ## 工作区 VideoWorkspace @@ -66,7 +66,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 或当前帧第一个 mask 作为 seed,调用 `POST /api/ai/propagate`;当前启用 SAM 2 video predictor,完成后刷新已保存标注 | +| 传播对象/起止帧/按范围传播 | 真实可用 | 可选择“选中区域”或“当前帧全部”,并用起止帧定义从当前帧向前/向后的追踪范围;前端会按 seed mask 和方向顺序调用 `POST /api/ai/propagate`,当前启用 SAM 2 video predictor,完成后刷新已保存标注 | +| “传播全部可达”按钮 | 真实可用 | 一键把传播范围设为项目第 1 帧到最后 1 帧,并按当前传播对象把当前帧区域向前后所有可达帧传播 | | “结构化归档保存”按钮 | 真实可用 | 未保存 mask 写入 `POST /api/ai/annotate`;dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask,避免仍显示未保存 | ## CanvasArea 画布 @@ -97,7 +98,7 @@ | 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持拖动顶点、点击边中点插点、双击边界按位置插点 | | 多边形/矩形/圆/点/线 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成可保存的 polygon mask | | 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask,右下角显示已选数量和操作按钮;合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;使用 `polygon-clipping` 做 union / difference;合并会保留主 mask 并移除被合并 mask,去除会从主 mask 扣除后续选中 mask;内含扣除会保留 hole ring 并用 even-odd 规则渲染 | -| 正向选点/反向选点/框选 | 部分可用 | 会影响 Canvas 交互,并能触发已对齐的 AI 推理接口 | +| 正向选点/反向选点/框选 | 部分可用 | 会影响 Canvas 交互,并能触发已对齐的 AI 推理接口;点击工作区内已有 SAM 提示点会优先删除该提示点并重新推理,不会冒泡成新增提示点或 mask 选择 | | 魔法棒 SAM 触发 | 部分可用 | 切到 AI 页面;不是直接执行推理 | | 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`,支持工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y | @@ -109,7 +110,7 @@ | 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)` | | 顶部 range 拖动 | 真实可用 | 改变当前帧 | | 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` | -| 已编辑帧标记带 | 真实可用 | 根据当前项目帧内的 `masks` 计算有编辑/标注的帧,在顶部进度条和缩略图导航轴之间显示标记;点击标记可跳转到对应帧 | +| 已编辑帧进度线 | 真实可用 | 根据当前项目帧内的 `masks` 计算有编辑/标注的帧,并在顶部进度条上覆盖琥珀色竖线;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达,点击已编辑竖线可跳转到对应帧 | | 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps | | 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 | @@ -129,15 +130,18 @@ | 元素 | 状态 | 说明 | |------|------|------| | SAM 2.1 变体选择 / 模型状态 | 真实可用 | AI 页可选 tiny/small/base+/large,调用 `GET /api/ai/models/status?selected_model=` 展示所选变体和 GPU 状态;只有本地存在 checkpoint 的变体显示可用 | -| 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口;AI 页中点击已有候选 mask 时也会继续添加当前正/反向提示点;SAM 2.1 框选后会携带原始框和累计正/反点细化同一个候选 mask | +| 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口;AI 页中点击已有候选 mask 时也会继续添加当前正/反向提示点,点击已有提示点会删除该点;SAM 2.1 框选后会携带原始框和累计正/反点细化同一个候选 mask | +| 边界框选 | 真实可用 | AI 页选择工具后可在画布拖拽蓝色虚线框;执行分割时会随 `/api/ai/predict` 发送 `box`,框选后继续添加正/反点会发送 interactive prompt | | SAM 3 入口 | 当前禁用 | 因当前系统不提供文本提示,前端不再显示 SAM 3 模型选择、文本输入或 SAM 3 框选入口;后端 `model=sam3` 返回不支持 | | 语义文本输入 | 当前禁用 | AI 页不再提供文本语义输入;后端收到 `semantic` prompt 会返回 400 | -| 参数开关 | 真实可用 | `cropMode` 会随 `/api/ai/predict` 发送 `crop_to_prompt`,后端对点/框 prompt 裁剪推理区域并回映射 polygon;`autoDeleteBg` 会发送 `auto_filter_background` 和 `min_score`,后端过滤低分结果和覆盖负向点的结果 | +| 参数开关 | 真实可用 | UI 展示为“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”,只是为了让用户更容易理解,不重命名内部字段;`cropMode` 会随 `/api/ai/predict` 发送 `crop_to_prompt`,后端对点/框 prompt 裁剪推理区域并回映射 polygon;`autoDeleteBg` 会发送 `auto_filter_background` 和 `min_score`,后端过滤低分结果和覆盖负向点的结果 | | 遮罩清晰度 | 真实可用 | 调节 AI 页候选 mask 的预览透明度,只影响本页显示,不改变 mask 几何、分类或保存数据 | -| 执行高精度语义分割 | 真实可用 | 使用当前项目帧和所选 SAM 2.1 变体调用 `/api/ai/predict`;SAM 2.1 需要点/框提示且只采用最高分候选;AI 页只渲染本页新生成候选,不显示工作区已有 mask;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 | +| 执行高精度语义分割 | 真实可用 | 使用当前项目帧和所选 SAM 2.1 变体调用 `/api/ai/predict`;SAM 2.1 需要点/框提示且只采用最高分候选;AI 页只渲染本页最新候选,不显示工作区已有 mask,重复执行会替换上一次 AI 页候选而不是叠加;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 | | 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的未保存 mask;工作区回显后端标注时不会覆盖这类 draft mask | | 上传替换底图 | Mock / UI-only | 按钮无事件 | | 撤销/重做 | 真实可用 | 绑定全局 mask 历史栈 | +| 删除最近锚点 | 真实可用 | 删除 AI 页最近一次放置的正/反向提示点,不影响已生成候选 mask 或工作区 mask | +| 删除选中候选 | 真实可用 | 删除 AI 页当前选中的本页候选 mask;不会删除工作区已有 mask,Delete/Backspace 也遵循同一范围 | | 清空全体锚点 | 真实可用 | 清空 AI 页提示点和本页生成的候选 mask,不删除工作区已有 mask | | 退档推送至工作区重组 | 部分可用 | 只切回工作区,共用 masks store,但没有保存/确认流程 | | 背景图 | 部分可用 | 优先显示当前项目帧;没有项目帧时仍回退到 Unsplash 演示图 | diff --git a/doc/04-api-contracts.md b/doc/04-api-contracts.md index fa05902..9da33ab 100644 --- a/doc/04-api-contracts.md +++ b/doc/04-api-contracts.md @@ -228,7 +228,9 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同 ### 视频片段传播请求体 -工作区“传播片段”调用: +后端接口仍以单个 seed 为单位。工作区前端的“按范围传播/传播全部可达”会在本地根据当前帧、起止帧和传播对象,把多个 seed 或前后双向范围拆成多次顺序调用,避免同时启动多个视频 tracker。 + +单次调用示例: ```json { diff --git a/doc/05-implementation-plan.md b/doc/05-implementation-plan.md index 2e4d3be..47b9500 100644 --- a/doc/05-implementation-plan.md +++ b/doc/05-implementation-plan.md @@ -129,20 +129,21 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前 ## 阶段 7.5:视频片段传播(已完成基础闭环) -当前工作区“传播片段”会使用当前选中 mask 或当前帧第一个 mask 作为 seed,默认向后传播 30 帧并把结果写入后端标注表。 +当前工作区传播功能会使用当前帧的选中 mask 或当前帧全部 mask 作为 seed,按用户设置的起止帧向前、向后或双向传播,并把结果写入后端标注表。“传播全部可达”会把范围设置为项目第 1 帧到最后 1 帧。 已完成: 1. 前端 `propagateMasks()` 已接入 `POST /api/ai/propagate`。 -2. 工作区按钮会把 seed mask 的 normalized polygon、bbox、label、color 和 class 元数据传给后端。 +2. 工作区会把 seed mask 的 normalized polygon、bbox、label、color 和 class 元数据传给后端。 3. SAM 2 路径使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。 4. SAM 3 video tracker 路径已从当前产品入口禁用,相关 helper 仅保留作后续恢复参考。 5. 后端会跳过源帧,把传播结果保存到后续帧 `annotations`,并在完成后由前端刷新回显。 +6. 前端已经支持传播对象选择、起止帧范围和“传播全部可达”;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。 剩余建议: 1. 把传播任务改为异步任务,接入 Dashboard 和 WebSocket 进度。 -2. 前端增加传播方向、帧数和覆盖已有标注策略设置。 +2. 增加覆盖已有标注策略设置,例如跳过已有、覆盖同类、全部覆盖。 3. 用真实长视频做 SAM 2 tracker smoke test 和质量评估;如果未来恢复 SAM 3,再单独补充 SAM 3 tracker 评估。 ## 阶段 8:清理 UI 文案与 Mock diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md index 28597c1..58ac972 100644 --- a/doc/07-current-requirements-freeze.md +++ b/doc/07-current-requirements-freeze.md @@ -20,7 +20,9 @@ - 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。 - 用户可以对已导入且尚未生成帧的视频项目点击“生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务。 - 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。 +- 用户可以在项目卡片上删除项目;前端调用 `DELETE /api/projects/{id}`,删除成功后从项目库移除,若删除当前项目则清空工作区当前项目、帧、mask 和选区。 - 后端支持项目创建、列表、详情、局部更新和删除。 +- 后端删除项目时通过 ORM 级联删除项目帧、标注、导出 mask 元数据和后台任务记录。 - 后端支持项目帧创建、列表和单帧查询。 ## R3 媒体上传与拆帧 @@ -49,7 +51,7 @@ - 时间轴支持缩略图点击切帧、range 拖动切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。 - 播放帧率使用项目 `parse_fps` 或 `original_fps`,限制在 1 到 30 FPS。 - 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps` 或 `original_fps`,格式为 `mm:ss.cc`。 -- 时间轴在顶部进度条和底部缩略图导航轴之间显示“已编辑”标记带,基于当前项目帧内的 `masks` 标出已有编辑/标注的帧;点击标记可跳转到对应帧。 +- 时间轴在顶部进度条上覆盖琥珀色竖线标记,基于当前项目帧内的 `masks` 标出已有编辑/标注的帧;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达,点击已编辑竖线可跳转到对应帧。 ## R5 工具栏 @@ -78,20 +80,28 @@ - 前端发送后端契约:`image_id`、`prompt_type`、`prompt_data`、`model`。 - 点提示传 `{ points, labels }`,正向点 label 为 1,反向点 label 为 0。 - AI 页面在已有候选 mask 上点击正向/反向选点时,应继续添加提示点,不应被 mask 选择事件拦截。 +- AI 页面点击已有提示点应删除对应点;“删除最近锚点”只移除最近放置的提示点,不删除候选 mask 或工作区 mask。 +- AI 页面“删除选中候选”和 Delete/Backspace 只删除本页生成且已选中的 AI 候选 mask,不删除工作区已有 mask。 +- 工作区点击已有 SAM 提示点应优先删除该点并按剩余提示重新推理;该事件不得冒泡成新增提示点、mask 选择或其它画布点击行为。 - 框选提示传归一化 `[x1, y1, x2, y2]`。 +- AI 页面边界框选应支持画布拖拽预览框;执行分割时只框选不加点发送 `box` prompt,框选后继续加点发送 `interactive` prompt。 - 工作区 SAM 2.1 框选会建立一个候选 mask;后续正向点/反向点会携带原始框和累计点,以 `interactive` prompt 细化并替换同一个候选 mask。 - 工作区 SAM 2.1 一旦包含反向点,会随请求启用 `auto_filter_background` 和 `min_score=0.05`;若后端判定反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask,避免继续显示已被否定的区域。 - SAM 2.1 不支持文本语义提示;当前 AI 页面不提供文本语义输入,必须使用点/框提示。 - SAM 2.1 点提示和 auto fallback 默认只采用一个最高分候选 mask,避免多个候选 mask 作为同一结果重叠显示。 -- AI 页面只渲染本页新生成的候选 mask;工作区已有手工、保存、传播或 GT 导入 mask 不会自动进入 AI 画布。 +- AI 页面只渲染本页最新生成的候选 mask;重复执行高精度分割会替换上一次 AI 页候选,工作区已有手工、保存、传播或 GT 导入 mask 不会自动进入 AI 画布,也不会被替换。 - AI 页面提供“遮罩清晰度”滑杆,调节本页候选 mask 的预览透明度,不改变 mask 几何、分类或保存数据。 +- AI 页面参数开关展示文案使用“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”;这是 UI 可读性文案,不改变 `cropMode`、`autoDeleteBg` 或后端 `options` 字段。 - AI 页面生成的 SAM 2.1 mask 会写入全局 `masks`,自动同步到当前项目帧,并写入全局 `selectedMaskIds`;右侧语义分类树可以直接给新生成 mask 换标签。 - AI 页“清空全体锚点”只清空本页提示点和本页生成的候选 mask,不删除工作区已有 mask。 - AI 页面“推送至工作区编辑”会切回工作区并把工具切到“调整多边形”,保留当前选中的 AI mask 以便继续编辑轮廓和归档保存。 - 工作区加载后端已保存标注时,必须保留当前项目帧里尚未保存的 AI/手工 draft mask,避免 AI 页推送到工作区的候选 mask 被异步回显流程覆盖。 - 语义文本提示 `semantic` 当前被后端禁用并返回 400。 - SAM 3 源码和历史测试保留,但不属于当前产品可用功能;前端不再展示 SAM 3 入口,后端 registry 不暴露 `sam3`。 -- 工作区“传播片段”会把当前选中区域或当前帧第一个区域作为 seed,调用 `POST /api/ai/propagate`,默认从当前帧向后传播 30 帧并保存结果标注。 +- 工作区传播功能允许选择传播对象:“选中区域”或“当前帧全部”;选中区域模式需要当前帧至少一个已选 mask,全部模式会使用当前帧所有 mask。 +- 工作区传播功能允许设置起止帧;前端以当前帧为 seed,只向起止范围内位于当前帧之前和之后的帧传播,源帧不重复保存。 +- 工作区“传播全部可达”会把起止帧设为项目第 1 帧到最后 1 帧,并按当前传播对象传播到所有可达前后帧。 +- 前端复用单 seed 后端接口;多个 seed 或双向范围会被拆成多次顺序调用 `POST /api/ai/propagate`,避免并发抢占 GPU。 - `POST /api/ai/propagate` 当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。 - 传播结果会写入后续帧 `annotations`,`mask_data.source` 标记为 `_propagation`,并保留 label、color 和 class 元数据。 - AI 页面会对未放置点提示、后端错误和返回 0 个 mask 的情况显示明确反馈。 diff --git a/doc/08-current-design-freeze.md b/doc/08-current-design-freeze.md index eb16a02..ae60001 100644 --- a/doc/08-current-design-freeze.md +++ b/doc/08-current-design-freeze.md @@ -26,7 +26,7 @@ | 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态 | | 登录页 | `src/components/Login.tsx` | 调用登录 API,写入 store | | Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 | -| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、导入视频/DICOM、显式生成帧 | +| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、删除、导入视频/DICOM、显式生成帧 | | 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板,组织工具栏、Canvas、本体面板、时间轴 | | Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask | | 工具栏 | `src/components/ToolsPalette.tsx` | 切换工具、跳转 AI 页面、触发 mask 撤销/重做 | @@ -103,7 +103,7 @@ 3. 帧数据映射为 store `Frame[]`,包含 `timestampMs` 和 `sourceFrameNumber`,供时间轴和后续视频传播使用。 4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask,但保留没有 `annotationId` 的未保存 draft mask;这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。 5. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态。 -6. `FrameTimeline` 根据当前项目 `frames` 和全局 `masks` 计算有编辑/标注的帧,在进度条与缩略图导航轴之间渲染可点击标记。 +6. `FrameTimeline` 根据当前项目 `frames` 和全局 `masks` 计算有编辑/标注的帧,并在顶部时间进度条上覆盖可点击的琥珀色竖线;当前帧不额外渲染竖线,由播放进度条末端、时间提示和缩略图高亮表达。 7. 当前帧传入 `CanvasArea`。 ### AI 点/框推理 @@ -116,24 +116,32 @@ 6. 后端加载帧图片并通过 SAM registry 分发到所选 SAM 2.1 变体;`model=sam2` 会兼容归一化为 tiny,`model=sam3` 会被拒绝。 7. 前端把 `polygons` 转为 mask;交互式细化会替换同一个候选 mask,而不是新增多个 mask。 8. 若带反向点的 SAM 2.1 细化返回空结果,前端会删除当前旧候选 mask 并提示反向点已排除该区域。 -9. AI 页面只按本页生成的候选 id 渲染 mask,不把工作区已有 mask 带入 AI 画布。 +9. AI 页面只按本页最新生成的候选 id 渲染 mask,不把工作区已有 mask 带入 AI 画布;每次 `runInference()` 都先过滤掉旧 `aiMaskIds` 对应候选,再写入本次最高分候选。 10. AI 页面候选 mask 的 Path 点击事件会先判断当前工具;正向/反向选点工具下点击 mask 会继续追加提示点,其他工具下才选中 mask。 -11. AI 页面“遮罩清晰度”滑杆只调节候选 mask 的 Konva preview opacity,不写入 `Mask.segmentation`、分类元数据或后端 payload。 -12. Canvas 按当前帧过滤并渲染 mask。 -13. 新 mask 会带上当前选择的模板分类元数据,包括 `classId`、`className`、`classZIndex`、`metadata.source=ai_segmentation` 和保存状态 `draft`。 -14. 用户点击“结构化归档保存”后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 调用 `PATCH /api/ai/annotations/{annotation_id}`;保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。 -15. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。 -16. 工作区“清空遮罩”删除当前帧已保存标注,并清除当前帧本地 mask。 +11. 工作区 SAM 提示点由 `CanvasArea` 本地 `points` 状态维护;点击已渲染提示点会先 `cancelBubble`,再删除对应点并按剩余提示重新调用 `runInference()`,避免同一次点击继续触发 Stage 加点或 Path 选择。 +12. AI 页面边界框选由 `promptBox/boxStart/boxCurrent` 维护;拖拽时渲染蓝色虚线框,鼠标释放后固化 `promptBox` 并清空旧提示点,避免旧点误绑定到新框。 +13. AI 页面执行分割时,如果只有 `promptBox` 则发送 `box` prompt;如果 `promptBox` 和 `points` 同时存在,`predictMask()` 会发送 interactive prompt。 +14. AI 页面提示点由本地 `points` 状态维护;点击已渲染提示点会按 index 删除对应点,“删除最近锚点”会删除数组最后一个点,不改动候选 mask 列表。 +15. AI 页面候选 mask 删除只接受当前 `aiMaskIds` 范围内的已选 id;“删除选中候选”和 Delete/Backspace 都复用该范围过滤,避免删除工作区已有 mask。 +16. AI 页面参数开关文案只做展示增强:“局部专注模式(自动裁剪无锚区域)”仍控制 `cropMode/crop_to_prompt`,“严格除杂模式(自动清理干涉点)”仍控制 `autoDeleteBg/auto_filter_background/min_score`。 +17. AI 页面“遮罩清晰度”滑杆只调节候选 mask 的 Konva preview opacity,不写入 `Mask.segmentation`、分类元数据或后端 payload。 +18. Canvas 按当前帧过滤并渲染 mask。 +19. 新 mask 会带上当前选择的模板分类元数据,包括 `classId`、`className`、`classZIndex`、`metadata.source=ai_segmentation` 和保存状态 `draft`。 +20. 用户点击“结构化归档保存”后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 调用 `PATCH /api/ai/annotations/{annotation_id}`;保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。 +21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。 +22. 工作区“清空遮罩”删除当前帧已保存标注,并清除当前帧本地 mask。 ### 视频片段传播 -1. 用户在工作区选中一个当前帧 mask;如果未显式选中,前端使用当前帧第一个 mask。 -2. `VideoWorkspace` 用 `buildAnnotationPayload()` 把 seed mask 转成 normalized polygon、bbox、label、color 和 class 元数据。 -3. 前端调用 `POST /api/ai/propagate`,默认 `direction=forward`、`max_frames=30`、`include_source=false`。 -4. 后端按项目帧序列截取片段,下载对应帧到临时 `frame_%06d.jpg` 目录,保持当前帧在片段中的相对索引。 -5. `model` 为任一 SAM 2.1 变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播。 -6. `model=sam3` 当前不支持;SAM 3 video tracker 代码保留但没有接入产品路径。 -7. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧,`mask_data.source` 记录模型传播来源。 +1. 用户在工作区选择传播对象:`selected` 表示当前帧已选 mask,`all` 表示当前帧所有 mask。 +2. 用户设置起止帧;“传播全部可达”会把起止帧设为 1 到项目总帧数。 +3. `VideoWorkspace` 以当前帧为 seed,将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。 +4. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color 和 class 元数据。 +5. 前端对每个 seed、每个方向顺序调用 `POST /api/ai/propagate`,`include_source=false`、`save_annotations=true`;顺序调用是为了避免多个视频 tracker 并发抢占 GPU。 +6. 后端按项目帧序列截取片段,下载对应帧到临时 `frame_%06d.jpg` 目录,保持当前帧在片段中的相对索引。 +7. `model` 为任一 SAM 2.1 变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播。 +8. `model=sam3` 当前不支持;SAM 3 video tracker 代码保留但没有接入产品路径。 +9. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧,`mask_data.source` 记录模型传播来源。 8. 前端传播完成后重新调用 `GET /api/ai/annotations` 并回显新标注。 ### 手工绘制与历史栈 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index 4fab297..a9ad834 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -15,11 +15,11 @@ | 需求 | 测试文件 | 覆盖点 | |------|----------|--------| | R1 登录与会话 | `src/components/Login.test.tsx`, `backend/tests/test_auth.py` | 成功登录、失败提示、后端 401 | -| R2 项目管理 | `src/lib/api.test.ts`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、后端 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`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、显式生成帧 FPS 选择、扩展名校验、自动建项目、关联项目、创建异步任务、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | -| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、回显已保存标注时保留本地未保存 draft mask、缩略图/range/已编辑帧标记/左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | -| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 工具切换、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、polygon 顶点拖动/删除、边中点插点、双击边界按位置插点、整块 mask 删除、区域合并/去除、内含去除 hole 渲染、合并模式隐藏编辑手柄、撤销/重做历史栈 | -| 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、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页清空只移除本页候选、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频传播、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry | +| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、回显已保存标注时保留本地未保存 draft mask、缩略图/range/已编辑帧进度条竖线标记、当前帧由进度条末端和缩略图高亮表达/左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | +| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 工具切换、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、polygon 顶点拖动/删除、边中点插点、双击边界按位置插点、整块 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 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按选中/全量 seed 和起止帧范围传播、传播全部可达、空提示/空结果反馈、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/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、JSON 分类导入、mapping_rules 解包/打包、后端模板 CRUD | | R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 模板选择、分类展示、具体分类选择、Canvas 选区同步、点击分类给已选 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 生成帧、视频/DICOM 拆帧任务、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `test_media.py`, `test_tasks.py` | 已覆盖 | -| R4 | 工作区加载帧、无帧项目不自动解析、后端标注回显保留本地未保存 draft mask、Canvas 底图、缩略图/range/已编辑帧标记/左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | +| R4 | 工作区加载帧、无帧项目不自动解析、后端标注回显保留本地未保存 draft mask、Canvas 底图、缩略图/range/已编辑帧进度条竖线标记、当前帧由进度条末端和缩略图高亮表达/左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | | R5 | 工具切换、调整多边形入口、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | -| R5 | 顶点编辑、边中点插点、双击边界按位置插点、顶点删除、整块删除、撤销/重做、区域合并、区域去除、hole even-odd 渲染 | `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 | -| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页清空只移除本页候选、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频传播、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_sam2_engine.py` | 已覆盖 | +| R5 | 顶点编辑、边中点插点、双击边界按位置插点、顶点删除、整块删除、工作区 SAM 提示点删除优先级、撤销/重做、区域合并、区域去除、hole even-odd 渲染 | `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 | +| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按范围/全部可达传播、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.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 分类导入、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `api.test.ts`, `test_templates.py` | 已覆盖 | | R9 | 模板选择、分类展示、分类选择、已选 mask 换标签、自定义本地分类、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 | diff --git a/src/components/AISegmentation.test.tsx b/src/components/AISegmentation.test.tsx index 3dc854b..737c0c4 100644 --- a/src/components/AISegmentation.test.tsx +++ b/src/components/AISegmentation.test.tsx @@ -46,6 +46,9 @@ describe('AISegmentation', () => { apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); render(); + expect(screen.getByText('局部专注模式(自动裁剪无锚区域)')).toBeInTheDocument(); + expect(screen.getByText('严格除杂模式(自动清理干涉点)')).toBeInTheDocument(); + fireEvent.click(screen.getByText('正向选点')); fireEvent.click(screen.getByTestId('konva-stage')); fireEvent.click(await screen.findByText('执行高精度语义分割')); @@ -107,7 +110,148 @@ describe('AISegmentation', () => { fireEvent.click(await screen.findByText('执行高精度语义分割')); expect(apiMock.predictMask).not.toHaveBeenCalled(); - expect(await screen.findByText('请先放置正/反向提示点。')).toBeInTheDocument(); + expect(await screen.findByText('请先放置正/反向提示点或框选区域。')).toBeInTheDocument(); + }); + + it('uses a dragged box prompt for AI page inference without adding a point on click', async () => { + apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); + render(); + + fireEvent.click(screen.getByText('边界框选')); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); + fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 }); + fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); + + expect(screen.getByTestId('konva-rect')).toHaveAttribute('data-width', '140'); + expect(await screen.findByText('已框选区域,可执行分割,或继续添加正/反向点细化。')).toBeInTheDocument(); + expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0); + expect(apiMock.predictMask).not.toHaveBeenCalled(); + + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({ + imageId: 'frame-1', + imageWidth: 640, + imageHeight: 360, + model: 'sam2.1_hiera_tiny', + points: undefined, + box: { x1: 120, y1: 80, x2: 260, y2: 200 }, + options: { + crop_to_prompt: false, + auto_filter_background: true, + min_score: 0.05, + }, + })); + }); + + it('combines the AI page box prompt with later positive and negative refinement points', async () => { + apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); + render(); + + fireEvent.click(screen.getByText('边界框选')); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 100, clientY: 60 }); + fireEvent.mouseMove(stage, { clientX: 300, clientY: 180 }); + fireEvent.mouseUp(stage, { clientX: 300, clientY: 180 }); + + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(stage, { clientX: 160, clientY: 100 }); + fireEvent.click(screen.getByText('反向选点')); + fireEvent.click(stage, { clientX: 260, clientY: 150 }); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({ + points: [ + { x: 160, y: 100, type: 'pos' }, + { x: 260, y: 150, type: 'neg' }, + ], + box: { x1: 100, y1: 60, x2: 300, y2: 180 }, + })); + }); + + it('replaces the previous AI page candidate when running the same box prompt again', async () => { + useStore.setState({ + masks: [ + { + id: 'workspace-mask', + frameId: 'frame-1', + pathData: 'M 0 0 L 10 0 L 10 10 Z', + label: 'Manual Mask', + color: '#ff0000', + segmentation: [[0, 0, 10, 0, 10, 10]], + metadata: { source: 'manual' }, + }, + ], + }); + apiMock.predictMask + .mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-first', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }) + .mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-second', + pathData: 'M 20 20 L 50 20 L 50 50 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[20, 20, 50, 20, 50, 50]], + bbox: [20, 20, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('边界框选')); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); + fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 }); + fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); + + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-first'])); + expect(useStore.getState().selectedMaskIds).toEqual(['sam2-first']); + + fireEvent.click(screen.getByText('执行高精度语义分割')); + + await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-second'])); + expect(useStore.getState().selectedMaskIds).toEqual(['sam2-second']); + expect(screen.getAllByTestId('konva-path')).toHaveLength(1); + }); + + it('deletes prompt points individually and can remove the latest point', async () => { + apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); + render(); + + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 }); + fireEvent.click(screen.getByText('反向选点')); + fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 220, clientY: 140 }); + + await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(4)); + fireEvent.click(screen.getAllByTestId('konva-circle')[0]); + + await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(2)); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({ + points: [{ x: 220, y: 140, type: 'neg' }], + })); + + fireEvent.click(screen.getByLabelText('删除最近锚点')); + + await waitFor(() => expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0)); }); it('keeps only the best SAM2 candidate when the backend returns overlapping alternatives', async () => { @@ -141,7 +285,7 @@ describe('AISegmentation', () => { await waitFor(() => expect(useStore.getState().masks).toHaveLength(1)); expect(useStore.getState().masks[0].id).toBe('sam2-best'); - expect(useStore.getState().masks[0].metadata).toEqual({ source: 'ai_segmentation' }); + expect(useStore.getState().masks[0].metadata).toEqual(expect.objectContaining({ source: 'ai_segmentation' })); expect(useStore.getState().selectedMaskIds).toEqual(['sam2-best']); expect(await screen.findByText('SAM 2.1 Tiny 返回 2 个候选,已采用最高分区域。')).toBeInTheDocument(); }); @@ -253,6 +397,74 @@ describe('AISegmentation', () => { expect(useStore.getState().selectedMaskIds).toEqual([]); }); + it('deletes only the selected AI candidate and preserves workspace masks', async () => { + useStore.setState({ + masks: [ + { + id: 'workspace-mask', + frameId: 'frame-1', + pathData: 'M 0 0 L 10 0 L 10 10 Z', + label: 'Manual Mask', + color: '#ff0000', + segmentation: [[0, 0, 10, 0, 10, 10]], + metadata: { source: 'manual' }, + }, + ], + }); + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask'])); + + fireEvent.click(screen.getByLabelText('删除选中候选')); + + await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask'])); + expect(useStore.getState().selectedMaskIds).toEqual([]); + }); + + it('lets Delete remove the selected AI candidate after a mask click selects it', async () => { + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('视口控制')); + fireEvent.click(screen.getByTestId('konva-path')); + fireEvent.keyDown(window, { key: 'Delete' }); + + await waitFor(() => expect(useStore.getState().masks).toEqual([])); + }); + it('lets a SAM2 result be selected and relabeled from the ontology panel', async () => { useStore.setState({ templates: [ diff --git a/src/components/AISegmentation.tsx b/src/components/AISegmentation.tsx index 5eda51c..62bee73 100644 --- a/src/components/AISegmentation.tsx +++ b/src/components/AISegmentation.tsx @@ -1,21 +1,23 @@ import React, { useState, useCallback, useEffect } from 'react'; -import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2 } from 'lucide-react'; +import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react'; import { cn } from '../lib/utils'; -import { Stage, Layer, Image as KonvaImage, Circle, Path, Group } from 'react-konva'; +import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva'; import useImage from 'use-image'; import { OntologyInspector } from './OntologyInspector'; -import { SAM2_MODEL_OPTIONS, useStore } from '../store/useStore'; +import { SAM2_MODEL_OPTIONS, useStore, type Mask } from '../store/useStore'; import { getAiModelStatus, predictMask, type AiRuntimeStatus } from '../lib/api'; interface AISegmentationProps { onSendToWorkspace: () => void; } +type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' }; +type PromptBox = { x1: number; y1: number; x2: number; y2: number }; + export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { const storeActiveTool = useStore((state) => state.activeTool); const setActiveTool = useStore((state) => state.setActiveTool); const masks = useStore((state) => state.masks); - const addMask = useStore((state) => state.addMask); const setMasks = useStore((state) => state.setMasks); const selectedMaskIds = useStore((state) => state.selectedMaskIds); const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds); @@ -41,7 +43,10 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { // Canvas state const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); - const [points, setPoints] = useState<{ x: number, y: number, type: 'pos'|'neg' }[]>([]); + const [points, setPoints] = useState([]); + const [promptBox, setPromptBox] = useState(null); + const [boxStart, setBoxStart] = useState<{ x: number; y: number } | null>(null); + const [boxCurrent, setBoxCurrent] = useState<{ x: number; y: number } | null>(null); const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); const currentFrame = frames[currentFrameIndex] || null; const previewUrl = currentFrame?.url || 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop'; @@ -55,6 +60,24 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { const effectiveTool = storeActiveTool; + const boxRect = React.useMemo(() => { + const activeBox = boxStart && boxCurrent + ? { + x1: Math.min(boxStart.x, boxCurrent.x), + y1: Math.min(boxStart.y, boxCurrent.y), + x2: Math.max(boxStart.x, boxCurrent.x), + y2: Math.max(boxStart.y, boxCurrent.y), + } + : promptBox; + if (!activeBox) return null; + return { + x: activeBox.x1, + y: activeBox.y1, + width: activeBox.x2 - activeBox.x1, + height: activeBox.y2 - activeBox.y1, + }; + }, [boxCurrent, boxStart, promptBox]); + useEffect(() => { let cancelled = false; getAiModelStatus(aiModel) @@ -105,11 +128,17 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { const imageY = (pos.y - position.y) / scale; setCursorPos({ x: imageX, y: imageY }); } + if (effectiveTool === 'box_select' && boxStart) { + const relPos = stage.getRelativePointerPosition?.(); + if (relPos) { + setBoxCurrent({ x: relPos.x, y: relPos.y }); + } + } }; const runInference = useCallback(async () => { - if (points.length === 0) { - setInferenceMessage('请先放置正/反向提示点。'); + if (points.length === 0 && !promptBox) { + setInferenceMessage('请先放置正/反向提示点或框选区域。'); return; } if (!currentFrame?.id) { @@ -134,7 +163,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { imageWidth, imageHeight, model: aiModel, - points: points.map((p) => ({ x: p.x, y: p.y, type: p.type })), + points: points.length > 0 ? points.map((p) => ({ x: p.x, y: p.y, type: p.type })) : undefined, + box: promptBox || undefined, options: { crop_to_prompt: cropMode, auto_filter_background: autoDeleteBg, @@ -151,12 +181,10 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { ? `${selectedModelStatus?.label || 'SAM 2.1'} 返回 ${result.masks.length} 个候选,已采用最高分区域。` : `已生成 ${masksToApply.length} 个候选区域。`); } - const generatedMaskIds: string[] = []; - masksToApply.forEach((m) => { + const generatedMasks: Mask[] = masksToApply.map((m) => { const label = activeClass?.name || m.label; const color = activeClass?.color || m.color; - generatedMaskIds.push(m.id); - addMask({ + return { id: m.id, frameId: currentFrame.id, templateId: activeTemplateId || undefined, @@ -171,12 +199,26 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { segmentation: m.segmentation, bbox: m.bbox, area: m.area, - metadata: { source: 'ai_segmentation' }, - }); + metadata: { + source: 'ai_segmentation', + promptBox: promptBox || null, + promptPointCount: points.length, + promptNegativePointCount: points.filter((point) => point.type === 'neg').length, + }, + }; }); + + const previousAiMaskIds = new Set(aiMaskIds); + const generatedMaskIds = generatedMasks.map((mask) => mask.id); + setMasks([ + ...masks.filter((mask) => !previousAiMaskIds.has(mask.id)), + ...generatedMasks, + ]); + setAiMaskIds(generatedMaskIds); if (generatedMaskIds.length > 0) { - setAiMaskIds((existingIds) => [...existingIds, ...generatedMaskIds]); setSelectedMaskIds(generatedMaskIds); + } else { + setSelectedMaskIds(selectedMaskIds.filter((id) => !previousAiMaskIds.has(id))); } } catch (err) { console.error('AI inference failed:', err); @@ -185,10 +227,13 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { } finally { setIsInferencing(false); } - }, [activeClass, activeTemplateId, addMask, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, points, selectedModelStatus?.label, setSelectedMaskIds]); + }, [activeClass, activeTemplateId, aiMaskIds, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, points, promptBox, selectedMaskIds, selectedModelStatus?.label, setMasks, setSelectedMaskIds]); const clearAiLayer = useCallback(() => { setPoints([]); + setPromptBox(null); + setBoxStart(null); + setBoxCurrent(null); if (aiMaskIds.length === 0) return; const idsToRemove = new Set(aiMaskIds); setMasks(masks.filter((mask) => !idsToRemove.has(mask.id))); @@ -196,6 +241,27 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { setAiMaskIds([]); }, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]); + const deleteAiMasksById = useCallback((maskIds: string[]) => { + const aiIds = new Set(aiMaskIds); + const idsToRemove = new Set(maskIds.filter((id) => aiIds.has(id))); + if (idsToRemove.size === 0) return; + setMasks(masks.filter((mask) => !idsToRemove.has(mask.id))); + setAiMaskIds((currentIds) => currentIds.filter((id) => !idsToRemove.has(id))); + setSelectedMaskIds(selectedMaskIds.filter((id) => !idsToRemove.has(id))); + }, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]); + + const deleteSelectedAiMasks = useCallback(() => { + deleteAiMasksById(selectedMaskIds); + }, [deleteAiMasksById, selectedMaskIds]); + + const removePromptPoint = useCallback((pointIndex: number) => { + setPoints((currentPoints) => currentPoints.filter((_, index) => index !== pointIndex)); + }, []); + + const removeLastPromptPoint = useCallback(() => { + setPoints((currentPoints) => currentPoints.slice(0, -1)); + }, []); + const addPromptPointFromEvent = useCallback((event: any) => { if (effectiveTool !== 'point_pos' && effectiveTool !== 'point_neg') return false; const stage = event.target?.getStage?.(); @@ -208,8 +274,50 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { return true; }, [effectiveTool]); + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const tagName = target?.tagName?.toLowerCase(); + if (tagName === 'input' || tagName === 'textarea' || target?.isContentEditable) return; + if (event.key !== 'Delete' && event.key !== 'Backspace') return; + const selectedAiIds = selectedMaskIds.filter((id) => aiMaskIds.includes(id)); + if (selectedAiIds.length === 0) return; + event.preventDefault(); + deleteAiMasksById(selectedAiIds); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [aiMaskIds, deleteAiMasksById, selectedMaskIds]); + + const handleStageMouseDown = useCallback((event: any) => { + if (effectiveTool !== 'box_select') return; + const stage = event.target?.getStage?.(); + const pos = stage?.getRelativePointerPosition?.(); + if (!pos) return; + setBoxStart({ x: pos.x, y: pos.y }); + setBoxCurrent({ x: pos.x, y: pos.y }); + setInferenceMessage(''); + }, [effectiveTool]); + + const handleStageMouseUp = useCallback(() => { + if (effectiveTool !== 'box_select' || !boxStart || !boxCurrent) return; + const x1 = Math.min(boxStart.x, boxCurrent.x); + const y1 = Math.min(boxStart.y, boxCurrent.y); + const x2 = Math.max(boxStart.x, boxCurrent.x); + const y2 = Math.max(boxStart.y, boxCurrent.y); + if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) { + setPromptBox({ x1, y1, x2, y2 }); + setPoints([]); + setInferenceMessage('已框选区域,可执行分割,或继续添加正/反向点细化。'); + } + setBoxStart(null); + setBoxCurrent(null); + }, [boxCurrent, boxStart, effectiveTool]); + const handleStageClick = (e: any) => { if (effectiveTool === 'move') return; + if (effectiveTool === 'box_select') return; addPromptPointFromEvent(e); }; @@ -308,14 +416,14 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {

核心参数设定

setCropMode(!cropMode)}> - 自动裁剪无锚区域 + 局部专注模式(自动裁剪无锚区域)
setAutoDeleteBg(!autoDeleteBg)}> - 自动清理干涉点 + 严格除杂模式(自动清理干涉点) @@ -400,6 +508,24 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { + + @@ -413,6 +539,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { height={window.innerHeight - 64 - 64} onWheel={handleWheel} onMouseMove={handleMouseMove} + onMouseDown={handleStageMouseDown} + onMouseUp={handleStageMouseUp} onClick={handleStageClick} scaleX={scale} scaleY={scale} @@ -430,6 +558,19 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { opacity={0.8} /> )} + + {boxRect && ( + + )} {/* AI Returned Masks */} {frameMasks.map((mask) => { @@ -475,10 +616,26 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { strokeWidth={2 / scale} shadowColor="black" shadowBlur={4} + onClick={(event: any) => { + event.cancelBubble = true; + removePromptPoint(i); + }} + onTap={(event: any) => { + event.cancelBubble = true; + removePromptPoint(i); + }} /> { + event.cancelBubble = true; + removePromptPoint(i); + }} + onTap={(event: any) => { + event.cancelBubble = true; + removePromptPoint(i); + }} /> ))} diff --git a/src/components/CanvasArea.test.tsx b/src/components/CanvasArea.test.tsx index 377741f..ee15494 100644 --- a/src/components/CanvasArea.test.tsx +++ b/src/components/CanvasArea.test.tsx @@ -209,6 +209,75 @@ describe('CanvasArea', () => { expect(await screen.findByText(/反向点已排除当前候选区域/)).toBeInTheDocument(); }); + it('deletes a workspace SAM2 prompt point before the stage can add another point', async () => { + apiMock.predictMask + .mockResolvedValueOnce({ + masks: [ + { + id: 'mask-prompt', + pathData: 'M 10 10 L 90 10 L 90 90 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 90, 10, 90, 90]], + bbox: [10, 10, 80, 80], + area: 6400, + }, + ], + }) + .mockResolvedValueOnce({ + masks: [ + { + id: 'mask-refined', + pathData: 'M 20 20 L 80 20 L 80 80 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[20, 20, 80, 20, 80, 80]], + bbox: [20, 20, 60, 60], + area: 3600, + }, + ], + }) + .mockResolvedValueOnce({ + masks: [ + { + id: 'mask-after-delete', + pathData: 'M 30 30 L 70 30 L 70 70 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[30, 30, 70, 30, 70, 70]], + bbox: [30, 30, 40, 40], + area: 1600, + }, + ], + }); + + const { rerender } = render(); + const stage = screen.getByTestId('konva-stage'); + fireEvent.click(stage, { clientX: 120, clientY: 80 }); + await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(1)); + + rerender(); + fireEvent.click(stage, { clientX: 220, clientY: 140 }); + await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(2)); + const promptOuterCircles = () => screen.getAllByTestId('konva-circle') + .filter((element) => ['#22c55e', '#ef4444'].includes(element.getAttribute('data-fill') || '')); + expect(promptOuterCircles()).toHaveLength(2); + + fireEvent.click(promptOuterCircles()[0]); + + await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(3)); + expect(apiMock.predictMask).toHaveBeenLastCalledWith({ + imageId: 'frame-1', + imageWidth: 640, + imageHeight: 360, + model: 'sam2.1_hiera_tiny', + points: [{ x: 220, y: 140, type: 'neg' }], + box: undefined, + options: { auto_filter_background: true, min_score: 0.05 }, + }); + expect(promptOuterCircles()).toHaveLength(1); + }); + it('renders only masks that belong to the current frame', () => { useStore.setState({ masks: [ diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx index c3052d3..13ccb6d 100644 --- a/src/components/CanvasArea.tsx +++ b/src/components/CanvasArea.tsx @@ -706,6 +706,25 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota } }; + const removePromptPoint = useCallback((pointIndex: number, event?: any) => { + if (event) event.cancelBubble = true; + const nextPoints = points.filter((_, index) => index !== pointIndex); + setPoints(nextPoints); + + if (nextPoints.length > 0 || samPromptBox) { + runInference(nextPoints, samPromptBox || undefined); + return; + } + + if (samCandidateMaskId) { + setMasks(masks.filter((mask) => mask.id !== samCandidateMaskId)); + setSamCandidateMaskId(null); + setSelectedMaskId(null); + setSelectedMaskIds([]); + setInferenceMessage('已移除最后一个提示点和对应候选区域。'); + } + }, [masks, points, runInference, samCandidateMaskId, samPromptBox, setMasks]); + const updatePolygonMask = useCallback((mask: Mask, nextPoints: CanvasPoint[], polygonIndex = 0) => { if (nextPoints.length < 3) return; const nextSegmentation = [...(mask.segmentation || [])]; @@ -1128,10 +1147,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota strokeWidth={2 / scale} shadowColor="black" shadowBlur={4} + onClick={(event: any) => removePromptPoint(i, event)} + onTap={(event: any) => removePromptPoint(i, event)} /> removePromptPoint(i, event)} + onTap={(event: any) => removePromptPoint(i, event)} /> ))} diff --git a/src/components/FrameTimeline.test.tsx b/src/components/FrameTimeline.test.tsx index c84e86c..77802a6 100644 --- a/src/components/FrameTimeline.test.tsx +++ b/src/components/FrameTimeline.test.tsx @@ -51,8 +51,9 @@ describe('FrameTimeline', () => { expect(screen.getAllByText('00:00.20').length).toBeGreaterThan(0); }); - it('marks edited frames between the time progress bar and frame navigator', () => { + it('overlays edited frame markers as amber vertical lines on the time progress bar', () => { useStore.setState({ + currentFrameIndex: 1, frames: [ { id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 }, { id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 }, @@ -67,8 +68,11 @@ describe('FrameTimeline', () => { render(); - expect(screen.getByText('已编辑')).toBeInTheDocument(); - expect(screen.getByText('2 帧')).toBeInTheDocument(); + expect(screen.getByText('已编辑 2 帧')).toBeInTheDocument(); + expect(screen.queryByTestId('current-frame-line')).not.toBeInTheDocument(); + expect(screen.getByLabelText('跳转到已编辑帧 2').className).toContain('before:bg-amber-300'); + expect(screen.getByLabelText('跳转到已编辑帧 3').className).toContain('before:h-5'); + expect(screen.getByLabelText('跳转到已编辑帧 3').className).not.toContain('h-2 w-2'); fireEvent.click(screen.getByLabelText('跳转到已编辑帧 3')); expect(useStore.getState().currentFrameIndex).toBe(2); }); diff --git a/src/components/FrameTimeline.tsx b/src/components/FrameTimeline.tsx index d929958..b22c43f 100644 --- a/src/components/FrameTimeline.tsx +++ b/src/components/FrameTimeline.tsx @@ -95,8 +95,8 @@ export function FrameTimeline() { : []; return ( -
-
+
+
{formatTime(currentSeconds)}
@@ -117,25 +117,7 @@ export function FrameTimeline() { className="h-full bg-cyan-500 absolute left-0" style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }} /> -
0 ? (currentFrame / totalFrames) * 100 : 0}%` }} - /> -
0 ? (currentFrame / totalFrames) * 100 : 0}%` }} - > - {formatTime(currentSeconds)} -
-
-
- -
-
已编辑
-
-
{editedFrameMarkers.map(({ frame, index }) => { - const isCurrent = index === currentFrameIndex; const left = totalFrames > 0 ? ((index + 1) / totalFrames) * 100 : 0; return (
+
+ 已编辑 {editedFrameMarkers.length} 帧
-
{editedFrameMarkers.length} 帧
diff --git a/src/components/ProjectLibrary.test.tsx b/src/components/ProjectLibrary.test.tsx index 97f3c59..4e44d7f 100644 --- a/src/components/ProjectLibrary.test.tsx +++ b/src/components/ProjectLibrary.test.tsx @@ -10,6 +10,7 @@ const apiMock = vi.hoisted(() => ({ uploadMedia: vi.fn(), parseMedia: vi.fn(), uploadDicomBatch: vi.fn(), + deleteProject: vi.fn(), })); vi.mock('../lib/api', () => ({ @@ -18,6 +19,7 @@ vi.mock('../lib/api', () => ({ uploadMedia: apiMock.uploadMedia, parseMedia: apiMock.parseMedia, uploadDicomBatch: apiMock.uploadDicomBatch, + deleteProject: apiMock.deleteProject, })); describe('ProjectLibrary', () => { @@ -89,6 +91,32 @@ describe('ProjectLibrary', () => { await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p4', { parseFps: 12 })); }); + it('deletes a project from the project card without entering the workspace', async () => { + const onProjectSelect = vi.fn(); + apiMock.getProjects.mockResolvedValueOnce([ + { id: 'p5', name: 'Delete Me', status: 'ready', frames: 3, fps: '30FPS' }, + { id: 'p6', name: 'Keep Me', status: 'ready', frames: 1, fps: '30FPS' }, + ]); + apiMock.deleteProject.mockResolvedValueOnce(undefined); + useStore.setState({ + currentProject: { id: 'p5', name: 'Delete Me', status: 'ready' }, + frames: [{ id: 'f1', projectId: 'p5', index: 0, url: '/1.jpg', width: 640, height: 360 }], + masks: [{ id: 'm1', frameId: 'f1', pathData: 'M 0 0 Z', label: 'Mask', color: '#06b6d4' }], + selectedMaskIds: ['m1'], + }); + + render(); + fireEvent.click(await screen.findByRole('button', { name: '删除项目 Delete Me' })); + + await waitFor(() => expect(apiMock.deleteProject).toHaveBeenCalledWith('p5')); + expect(onProjectSelect).not.toHaveBeenCalled(); + expect(useStore.getState().projects.map((project) => project.id)).toEqual(['p6']); + expect(useStore.getState().currentProject).toBeNull(); + expect(useStore.getState().frames).toEqual([]); + expect(useStore.getState().masks).toEqual([]); + expect(useStore.getState().selectedMaskIds).toEqual([]); + }); + it('imports only valid DICOM files and parses the returned project', async () => { apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 1, message: 'ok' }); apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 1 }); diff --git a/src/components/ProjectLibrary.tsx b/src/components/ProjectLibrary.tsx index 1120612..cb240bc 100644 --- a/src/components/ProjectLibrary.tsx +++ b/src/components/ProjectLibrary.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect, useRef } from 'react'; -import { UploadCloud, Film, Settings2, MoreHorizontal, Plus, Loader2, Activity, Images } from 'lucide-react'; +import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { useStore } from '../store/useStore'; -import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch } from '../lib/api'; +import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject } from '../lib/api'; import type { Project } from '../store/useStore'; interface ProjectLibraryProps { @@ -12,8 +12,12 @@ interface ProjectLibraryProps { export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { const projects = useStore((state) => state.projects); const setProjects = useStore((state) => state.setProjects); + const currentProject = useStore((state) => state.currentProject); const setCurrentProject = useStore((state) => state.setCurrentProject); const addProject = useStore((state) => state.addProject); + const setFrames = useStore((state) => state.setFrames); + const setMasks = useStore((state) => state.setMasks); + const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds); const [isLoading, setIsLoading] = useState(false); const [isCreating, setIsCreating] = useState(false); const [showModal, setShowModal] = useState(false); @@ -26,6 +30,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { const [showFrameConfig, setShowFrameConfig] = useState(false); const [frameParseFps, setFrameParseFps] = useState(30); const [isGeneratingFrames, setIsGeneratingFrames] = useState(false); + const [deletingProjectId, setDeletingProjectId] = useState(null); const videoInputRef = useRef(null); const dicomInputRef = useRef(null); @@ -58,6 +63,30 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { onProjectSelect(); }; + const handleDeleteProject = async (project: Project, event: React.MouseEvent) => { + event.stopPropagation(); + if (deletingProjectId) return; + const confirmed = window.confirm(`确认删除项目“${project.name}”?\n该操作会删除项目帧、标注、任务记录和相关 mask 元数据,无法撤销。`); + if (!confirmed) return; + + setDeletingProjectId(project.id); + try { + await deleteProject(project.id); + setProjects(projects.filter((item) => item.id !== project.id)); + if (currentProject?.id === project.id) { + setCurrentProject(null); + setFrames([]); + setMasks([]); + setSelectedMaskIds([]); + } + } catch (err) { + console.error('Delete project failed:', err); + alert('删除项目失败,请检查后端服务'); + } finally { + setDeletingProjectId(null); + } + }; + const handleVideoSelect = (file: File) => { setPendingFile(file); setShowVideoConfig(true); @@ -252,7 +281,16 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {

{proj.name}

- +
{proj.frames ?? 0} 帧节点 diff --git a/src/components/VideoWorkspace.test.tsx b/src/components/VideoWorkspace.test.tsx index 1af9877..40c2501 100644 --- a/src/components/VideoWorkspace.test.tsx +++ b/src/components/VideoWorkspace.test.tsx @@ -380,7 +380,7 @@ describe('VideoWorkspace', () => { ])); }); - it('propagates the selected current-frame mask through the backend video tracker', async () => { + it('propagates the selected current-frame mask through the configured frame range', async () => { apiMock.getProjectFrames.mockResolvedValueOnce([ { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 }, { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 }, @@ -417,14 +417,14 @@ describe('VideoWorkspace', () => { }); }); - fireEvent.click(screen.getByRole('button', { name: '传播片段' })); + fireEvent.click(screen.getByRole('button', { name: '按范围传播' })); await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledWith({ project_id: 1, frame_id: 10, model: 'sam2.1_hiera_tiny', direction: 'forward', - max_frames: 30, + max_frames: 2, include_source: false, save_annotations: true, seed: { @@ -437,6 +437,95 @@ describe('VideoWorkspace', () => { template_id: 2, }, })); - await waitFor(() => expect(screen.getByText('已传播并保存 2 个区域')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('已传播 1 个 seed,处理 3 帧次,保存 2 个区域')).toBeInTheDocument()); + }); + + it('propagates all current-frame masks to all reachable frames in both directions', async () => { + apiMock.getProjectFrames.mockResolvedValueOnce([ + { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 }, + { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 }, + { id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 }, + ]); + apiMock.propagateMasks.mockResolvedValue({ + model: 'sam2.1_hiera_tiny', + direction: 'forward', + source_frame_id: 11, + processed_frame_count: 2, + created_annotation_count: 1, + annotations: [], + }); + apiMock.buildAnnotationPayload + .mockReturnValueOnce({ + project_id: 1, + frame_id: 11, + mask_data: { + polygons: [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]], + label: '胆囊', + color: '#ff0000', + }, + bbox: [0.1, 0.1, 0.1, 0.1], + }) + .mockReturnValueOnce({ + project_id: 1, + frame_id: 11, + mask_data: { + polygons: [[[0.4, 0.4], [0.5, 0.4], [0.5, 0.5]]], + label: '肝脏', + color: '#00ff00', + }, + bbox: [0.4, 0.4, 0.1, 0.1], + }); + + render(); + await waitFor(() => expect(useStore.getState().frames).toHaveLength(3)); + act(() => { + useStore.setState({ + currentFrameIndex: 1, + masks: [ + { + id: 'mask-a', + frameId: '11', + pathData: 'M 0 0 Z', + label: '胆囊', + color: '#ff0000', + segmentation: [[64, 36, 128, 36, 128, 72]], + }, + { + id: 'mask-b', + frameId: '11', + pathData: 'M 1 1 Z', + label: '肝脏', + color: '#00ff00', + segmentation: [[256, 144, 320, 144, 320, 180]], + }, + ], + }); + }); + + fireEvent.change(screen.getByLabelText('传播对象'), { target: { value: 'all' } }); + fireEvent.click(screen.getByRole('button', { name: '传播全部可达' })); + + await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledTimes(4)); + expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(1, expect.objectContaining({ + direction: 'backward', + max_frames: 2, + seed: expect.objectContaining({ label: '胆囊' }), + })); + expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(2, expect.objectContaining({ + direction: 'forward', + max_frames: 2, + seed: expect.objectContaining({ label: '胆囊' }), + })); + expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(3, expect.objectContaining({ + direction: 'backward', + max_frames: 2, + seed: expect.objectContaining({ label: '肝脏' }), + })); + expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(4, expect.objectContaining({ + direction: 'forward', + max_frames: 2, + seed: expect.objectContaining({ label: '肝脏' }), + })); + await waitFor(() => expect(screen.getByText('已传播 2 个 seed,处理 8 帧次,保存 4 个区域')).toBeInTheDocument()); }); }); diff --git a/src/components/VideoWorkspace.tsx b/src/components/VideoWorkspace.tsx index c55a734..1516593 100644 --- a/src/components/VideoWorkspace.tsx +++ b/src/components/VideoWorkspace.tsx @@ -19,7 +19,10 @@ import { ToolsPalette } from './ToolsPalette'; import { OntologyInspector } from './OntologyInspector'; import { FrameTimeline } from './FrameTimeline'; import { ModelStatusBadge } from './ModelStatusBadge'; -import type { Frame } from '../store/useStore'; +import type { Frame, Mask } from '../store/useStore'; + +type PropagationTarget = 'selected' | 'all'; +type PropagationDirection = 'forward' | 'backward'; export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) { const gtMaskInputRef = React.useRef(null); @@ -49,6 +52,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void const [isImportingGt, setIsImportingGt] = useState(false); const [isPropagating, setIsPropagating] = useState(false); const [statusMessage, setStatusMessage] = useState(''); + const [propagationTarget, setPropagationTarget] = useState('selected'); + const [propagationStartFrame, setPropagationStartFrame] = useState(1); + const [propagationEndFrame, setPropagationEndFrame] = useState(1); const hydrateSavedAnnotations = useCallback(async ( projectId: string, @@ -135,8 +141,20 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }, [templates.length, setTemplates]); const currentFrame = frames[currentFrameIndex] || null; + const totalFrames = frames.length; const frameById = useMemo(() => new Map(frames.map((frame) => [frame.id, frame])), [frames]); const projectFrameIds = useMemo(() => new Set(frames.map((frame) => frame.id)), [frames]); + const currentFrameNumber = currentFrameIndex + 1; + + useEffect(() => { + if (totalFrames === 0) { + setPropagationStartFrame(1); + setPropagationEndFrame(1); + return; + } + setPropagationStartFrame(currentFrameNumber); + setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29)); + }, [currentFrameNumber, totalFrames]); const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => { if (!currentProject?.id) return 0; @@ -314,47 +332,93 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void } }; - const handlePropagateSegment = async () => { + const clampFrameNumber = useCallback((value: number) => { + if (totalFrames <= 0) return 1; + return Math.min(Math.max(value, 1), totalFrames); + }, [totalFrames]); + + const buildSeedPayload = useCallback((seedMask: Mask) => { + if (!currentProject?.id || !currentFrame) return null; + const seedPayload = buildAnnotationPayload(currentProject.id, seedMask, currentFrame, activeTemplateId); + if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) { + return null; + } + return { + polygons: seedPayload.mask_data?.polygons, + bbox: seedPayload.bbox, + points: seedPayload.points, + label: seedPayload.mask_data?.label, + color: seedPayload.mask_data?.color, + class_metadata: seedPayload.mask_data?.class, + template_id: seedPayload.template_id, + }; + }, [activeTemplateId, currentFrame, currentProject?.id]); + + const handlePropagateSegment = async (rangeOverride?: { startFrameNumber: number; endFrameNumber: number }) => { if (!currentProject?.id || !currentFrame?.id) return; const currentFrameMasks = masks.filter((mask) => mask.frameId === currentFrame.id); - const selectedMask = selectedMaskIds + const selectedMasks = selectedMaskIds .map((id) => currentFrameMasks.find((mask) => mask.id === id)) - .find((mask): mask is NonNullable => Boolean(mask)); - const seedMask = selectedMask || currentFrameMasks[0]; - if (!seedMask) { - setStatusMessage('请先选择或创建一个当前帧区域'); + .filter((mask): mask is Mask => Boolean(mask)); + const seedMasks = propagationTarget === 'all' ? currentFrameMasks : selectedMasks; + if (seedMasks.length === 0) { + setStatusMessage(propagationTarget === 'all' ? '当前帧没有可传播区域' : '请先选择一个或多个当前帧区域'); return; } - const seedPayload = buildAnnotationPayload(currentProject.id, seedMask, currentFrame, activeTemplateId); - if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) { - setStatusMessage('当前区域缺少可传播的 polygon 或 bbox'); + const startFrameNumber = clampFrameNumber(rangeOverride?.startFrameNumber ?? propagationStartFrame); + const endFrameNumber = clampFrameNumber(rangeOverride?.endFrameNumber ?? propagationEndFrame); + const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1; + const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1; + const propagationDirections: Array<{ direction: PropagationDirection; maxFrames: number }> = []; + if (rangeStartIndex < currentFrameIndex) { + propagationDirections.push({ + direction: 'backward', + maxFrames: currentFrameIndex - rangeStartIndex + 1, + }); + } + if (rangeEndIndex > currentFrameIndex) { + propagationDirections.push({ + direction: 'forward', + maxFrames: rangeEndIndex - currentFrameIndex + 1, + }); + } + if (propagationDirections.length === 0) { + setStatusMessage('传播范围只包含当前帧,请选择前后至少一帧'); + return; + } + + const seeds = seedMasks + .map((mask) => ({ mask, seed: buildSeedPayload(mask) })) + .filter((item): item is { mask: Mask; seed: NonNullable> } => Boolean(item.seed)); + if (seeds.length === 0) { + setStatusMessage('所选区域缺少可传播的 polygon 或 bbox'); return; } setIsPropagating(true); - setStatusMessage(`${aiModel.toUpperCase()} 正在传播当前区域...`); + setStatusMessage(`${aiModel.toUpperCase()} 正在传播 ${seeds.length} 个区域到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`); try { - const result = await propagateMasks({ - project_id: Number(currentProject.id), - frame_id: Number(currentFrame.id), - model: aiModel, - direction: 'forward', - max_frames: 30, - include_source: false, - save_annotations: true, - seed: { - polygons: seedPayload.mask_data?.polygons, - bbox: seedPayload.bbox, - points: seedPayload.points, - label: seedPayload.mask_data?.label, - color: seedPayload.mask_data?.color, - class_metadata: seedPayload.mask_data?.class, - template_id: seedPayload.template_id, - }, - }); + let createdCount = 0; + let processedCount = 0; + for (const { seed } of seeds) { + for (const { direction, maxFrames } of propagationDirections) { + const result = await propagateMasks({ + project_id: Number(currentProject.id), + frame_id: Number(currentFrame.id), + model: aiModel, + direction, + max_frames: maxFrames, + include_source: false, + save_annotations: true, + seed, + }); + createdCount += result.created_annotation_count; + processedCount += result.processed_frame_count; + } + } await hydrateSavedAnnotations(currentProject.id, frames); - setStatusMessage(`已传播并保存 ${result.created_annotation_count} 个区域`); + setStatusMessage(`已传播 ${seeds.length} 个 seed,处理 ${processedCount} 帧次,保存 ${createdCount} 个区域`); } catch (err) { console.error('Propagation failed:', err); setStatusMessage('传播失败,请检查模型状态或后端日志'); @@ -363,6 +427,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void } }; + const handlePropagateAllReachable = () => { + if (totalFrames <= 1) { + setStatusMessage('当前项目没有可传播的前后帧'); + return; + } + setPropagationStartFrame(1); + setPropagationEndFrame(totalFrames); + void handlePropagateSegment({ startFrameNumber: 1, endFrameNumber: totalFrames }); + }; + return (
{/* Top Header / Status bar */} @@ -393,12 +467,53 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void > {isImportingGt ? '导入中...' : '导入 GT Mask'} +
+ + + setPropagationStartFrame(clampFrameNumber(Number(event.target.value) || 1))} + disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0} + className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40" + /> + - + setPropagationEndFrame(clampFrameNumber(Number(event.target.value) || 1))} + disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0} + className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40" + /> +
+