feat: 完善工作区交互提示与后端属性分析

功能新增:
- 新增 POST /api/ai/analyze-mask 后端接口,基于 mask polygon、bbox、points 和 score 返回置信度来源、面积、拓扑锚点和后端分析提示。
- 前端新增 analyzeMask API 封装,并在本体检查面板读取选中 mask 的后端几何属性和重新提取拓扑锚点结果。
- 右侧语义分类树点击分类时,会给当前选中 mask 换标签、更新 class 元数据,并将选中 mask 移到前端渲染最上层,方便继续编辑。
- 分割工作区画布新增上下文操作提示,覆盖多边形 Enter 完成、Esc 取消、首节点闭合、拖拽图形、点区域、SAM 点/框提示、区域合并/去除选择顺序和多边形编辑。
- AI 智能分割画布新增正向点、反向点、边界框选和视口控制的上下文提示。
- 自动传播交互收敛为参考帧加起止帧范围加单个“自动传播”按钮,默认使用当前参考帧全部 mask 作为 seed。
- 时间轴改为用浅蓝色进度条区段标记自动传播生成的帧,而不是已编辑帧竖线提示。

Bugfix:
- AI 分割页无当前帧时移除外部演示背景图,改为明确空状态提示,避免误以为外部图片可参与真实推理。
- 工具栏魔法棒文案改为“打开 AI 智能分割”,避免误导为直接触发 SAM 推理。
- Canvas 底部当前图层信息改为显示真实选中 mask 标签和 annotation id,不再使用固定占位文本。
- 已保存标注回显时保留 mask metadata 中的传播来源、score 等字段,供时间轴和属性面板识别。
- 清理 server.ts 中遗留的 /api/login、/api/projects、/api/templates 内存 mock API,避免和 FastAPI 真实后端混淆。

测试:
- 补充 analyze-mask 后端测试,覆盖后端几何属性和锚点返回。
- 补充 api.analyzeMask 前端契约测试,覆盖 normalized polygon、bbox、points 和 extract_skeleton payload。
- 补充本体面板测试,覆盖后端属性读取、自定义分类写回后端模板、选中 mask 换标签和置顶显示。
- 补充 Canvas 测试,覆盖上下文提示、多边形完成提示、布尔选择顺序提示、当前图层真实显示和编辑优先级。
- 补充 AI 分割测试,覆盖无帧空状态和提示工具上下文提示。
- 更新 Konva 测试 mock,支持拖动过程、stroke/dash/fillRule 等渲染断言。

文档:
- 更新 README 和 AGENTS,说明 server.ts 不再保留业务 mock API。
- 更新 doc/02、doc/03、doc/04、doc/05、doc/07、doc/08、doc/09,记录后端属性分析、分类置顶显示、上下文提示、自动传播按钮、传播帧标记、测试覆盖和当前剩余限制。
This commit is contained in:
2026-05-02 02:10:37 +08:00
parent 4c21de02f8
commit b6a276cb8d
28 changed files with 796 additions and 231 deletions

View File

@@ -10,7 +10,7 @@
- **项目名称**: `react-example``package.json` 中的 `name` - **项目名称**: `react-example``package.json` 中的 `name`
- **前端入口**: `src/main.tsx``src/App.tsx` - **前端入口**: `src/main.tsx``src/App.tsx`
- **前端服务入口**: `server.ts`Express + Vite 中间件 / 生产静态服务,并保留少量旧版 mock API - **前端服务入口**: `server.ts`Express + Vite 中间件 / 生产静态服务旧版 mock API 已清理
- **后端入口**: `backend/main.py`FastAPI - **后端入口**: `backend/main.py`FastAPI
- **默认前端地址**: `http://localhost:3000` - **默认前端地址**: `http://localhost:3000`
- **默认后端地址**: `http://localhost:8000` - **默认后端地址**: `http://localhost:8000`
@@ -49,7 +49,7 @@
``` ```
Seg_Server/ Seg_Server/
├── server.ts # Express + Vite 前端入口;保留 /api/login、/api/projects、/api/templates mock ├── server.ts # Express + Vite 前端入口;不再提供旧版 /api mock
├── index.html # SPA HTML 入口 ├── index.html # SPA HTML 入口
├── vite.config.ts # Vite 配置;含 @/* 路径别名与 DISABLE_HMR 逻辑 ├── vite.config.ts # Vite 配置;含 @/* 路径别名与 DISABLE_HMR 逻辑
├── tsconfig.json # TypeScript 配置;@/* 映射到项目根目录 ├── tsconfig.json # TypeScript 配置;@/* 映射到项目根目录
@@ -134,7 +134,7 @@ npm run build
# Vite 预览 # Vite 预览
npm run preview npm run preview
# 生产模式运行 server.ts服务 dist/;仍保留 server.ts 中的旧版 mock API # 生产模式运行 server.ts服务 dist/
npm start npm start
# TypeScript 类型检查 # TypeScript 类型检查
@@ -222,10 +222,10 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
3. 上传资源:视频走 `/api/media/upload`只上传源文件并关联项目不自动拆帧DICOM 批量走 `/api/media/upload/dicom` 3. 上传资源:视频走 `/api/media/upload`只上传源文件并关联项目不自动拆帧DICOM 批量走 `/api/media/upload/dicom`
4. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery接口支持 `parse_fps``max_frames``target_width` 标准帧序列参数。 4. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery接口支持 `parse_fps``max_frames``target_width` 标准帧序列参数。
5. worker 执行Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallbackDICOM 使用 pydicom视频帧按 `frame_%06d.jpg` 连续命名并记录 `timestamp_ms``source_frame_number` 和任务 `frame_sequence` 元数据。 5. worker 执行Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallbackDICOM 使用 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` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在顶部进度条对应区段标为浅蓝色,当前帧位置由播放进度条末端、时间提示和缩略图高亮表达;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
7. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、点区域和线段生成 polygon mask多边形可按 Enter 或点击首节点闭合;绘制工具可在已有 mask 上继续落点;工具栏有“调整多边形”入口,点击 mask 可拖动/删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;区域合并/去除会隐藏编辑手柄并显示已选数量,使用 `polygon-clipping` 做 union/difference内含去除结果用 even-odd 规则渲染 holeZustand 维护 `maskHistory/maskFuture` 支持撤销/重做。 7. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、点区域和线段生成 polygon mask多边形可按 Enter 或点击首节点闭合;绘制工具可在已有 mask 上继续落点;工具栏有“调整多边形”入口,点击 mask 后可按住顶点直接拖动并实时更新 polygon可删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference内含去除结果用 even-odd 规则渲染 holeZustand 维护 `maskHistory/maskFuture` 支持撤销/重做。
8. AI 分割:前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;工作区和 AI 页面都可点击已有提示点删除单点AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 maskAI 页面框选会先固化 `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` 可按分数和负向点过滤结果。 8. AI 分割:前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;工作区和 AI 页面都可点击已有提示点删除单点AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 maskAI 页面框选会先固化 `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` 9. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed并用传播起始帧和传播结束帧指定追踪范围前端只保留一个“自动传播”按钮会按 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拖动后可归档更新。 10. GT 导入:工作区“导入 GT Mask”调用 `/api/ai/import-gt-mask`;后端按非零像素值和连通域生成 polygon 标注,并用 distance transform 生成 seed point前端回显 seed point拖动后可归档更新。
11. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色和 z-index`OntologyInspector.tsx` 在工作区显示当前模板分类树。 11. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色和 z-index`OntologyInspector.tsx` 在工作区显示当前模板分类树。
12. 导出:后端支持 COCO JSON 和 PNG mask ZIP 导出PNG ZIP 包含单标注 mask、按 zIndex 融合的语义 mask 和 `semantic_classes.json` 12. 导出:后端支持 COCO JSON 和 PNG mask ZIP 导出PNG ZIP 包含单标注 mask、按 zIndex 融合的语义 mask 和 `semantic_classes.json`
@@ -242,11 +242,11 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
- 前端 `importGtMask()` 已对齐后端 `/api/ai/import-gt-mask`;工作区“导入 GT Mask”会导入后端生成的多类别标注和 seed point 并回显。 - 前端 `importGtMask()` 已对齐后端 `/api/ai/import-gt-mask`;工作区“导入 GT Mask”会导入后端生成的多类别标注和 seed point 并回显。
- 前端 `exportCoco()` 已对齐后端 `/api/export/{project_id}/coco`;前端 `exportMasks()` 已对齐后端 `/api/export/{project_id}/masks`;工作区导出按钮会先保存当前待归档 mask。 - 前端 `exportCoco()` 已对齐后端 `/api/export/{project_id}/coco`;前端 `exportMasks()` 已对齐后端 `/api/export/{project_id}/masks`;工作区导出按钮会先保存当前待归档 mask。
- 工作区“结构化归档保存”按钮已接入 `POST /api/ai/annotate``PATCH /api/ai/annotations/{id}`;加载工作区时会通过 `GET /api/ai/annotations` 回显已保存标注。 - 工作区“结构化归档保存”按钮已接入 `POST /api/ai/annotate``PATCH /api/ai/annotations/{id}`;加载工作区时会通过 `GET /api/ai/annotations` 回显已保存标注。
- 工作区“传播片段”按钮已接入 `POST /api/ai/propagate`;当前启用所选 SAM 2.1 变体的视频 predictor完成后刷新后端已保存标注。 - 工作区“自动传播”按钮已接入 `POST /api/ai/propagate`;当前启用所选 SAM 2.1 变体的视频 predictor完成后刷新后端已保存标注。
- 工作区“清空遮罩”会调用 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。 - 工作区“清空遮罩”会调用 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。
- 项目状态已统一为 `pending``parsing``ready``error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready``Parsing``Error` - 项目状态已统一为 `pending``parsing``ready``error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready``Parsing``Error`
- 项目库的视频导入与生成帧是两个独立动作:导入视频只上传源文件,生成帧按钮才会带 `parse_fps` 调用 `/api/media/parse`;工作区不会再因“有视频但无帧”自动创建拆帧任务。 - 项目库的视频导入与生成帧是两个独立动作:导入视频只上传源文件,生成帧按钮才会带 `parse_fps` 调用 `/api/media/parse`;工作区不会再因“有视频但无帧”自动创建拆帧任务。
- `server.ts` 仍有旧版 `/api/login``/api/projects``/api/templates` mock当前前端真实 API 调用主要走 FastAPI 的 `/api/auth/*``/api/projects``/api/templates` 等接口。 - `server.ts` 不再提供旧版 `/api/login``/api/projects``/api/templates` mock当前前端真实 API 调用走 FastAPI 的 `/api/auth/*``/api/projects``/api/templates` 等接口。
- `Dashboard.tsx` 初始统计、任务进度和活动日志来自 `GET /api/dashboard/overview`;任务进度来自 `processing_tasks` queued/running/success/failed/cancelled处理中统计只计算 queued/running支持取消 queued/running 任务、重试 failed/cancelled 任务和查看失败详情。Celery worker 通过 Redis pub/sub 的 `seg:progress` 频道推送细粒度进度,再由 FastAPI 广播到 `/ws/progress`;前端 WebSocket 客户端通过 `onopen/onclose/onerror` 更新连接状态,并定时发送 `ping` 心跳。 - `Dashboard.tsx` 初始统计、任务进度和活动日志来自 `GET /api/dashboard/overview`;任务进度来自 `processing_tasks` queued/running/success/failed/cancelled处理中统计只计算 queued/running支持取消 queued/running 任务、重试 failed/cancelled 任务和查看失败详情。Celery worker 通过 Redis pub/sub 的 `seg:progress` 频道推送细粒度进度,再由 FastAPI 广播到 `/ws/progress`;前端 WebSocket 客户端通过 `onopen/onclose/onerror` 更新连接状态,并定时发送 `ping` 心跳。
--- ---
@@ -296,7 +296,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
- Axios 会附加 Bearer token但后端大多数业务路由当前没有鉴权依赖。 - Axios 会附加 Bearer token但后端大多数业务路由当前没有鉴权依赖。
- `backend/.env``.gitignore` 忽略不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。 - `backend/.env``.gitignore` 忽略不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。
- `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。 - `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。
- Express `server.ts` 的旧版 mock API 只适合开发/兼容场景,不能当生产鉴权或持久化方案 - Express `server.ts` 只负责前端开发/静态服务,不承担业务 API 或鉴权
--- ---

View File

@@ -14,10 +14,10 @@
- **多媒体资产管理** — 支持视频MP4/AVI/MOV和 DICOM 医学影像上传;视频导入与生成帧分离,生成帧时选择目标 FPS项目卡片可删除项目及其关联帧、标注和任务记录 - **多媒体资产管理** — 支持视频MP4/AVI/MOV和 DICOM 医学影像上传;视频导入与生成帧分离,生成帧时选择目标 FPS项目卡片可删除项目及其关联帧、标注和任务记录
- **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体tiny/small/base+/large选择支持点分割point、框分割box、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割auto和 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示 - **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体tiny/small/base+/large选择支持点分割point、框分割box、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割auto和 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示
- **交互式画布标注** — 基于 Konva 的高性能 Canvas支持缩放/平移/手工多边形/矩形/圆/点/线、polygon 顶点拖动/删除、边中点插点、双击边界插点、区域合并/去除、选点/框选、撤销/重做,实时渲染 Mask 遮罩 - **交互式画布标注** — 基于 Konva 的高性能 Canvas支持缩放/平移/手工多边形/矩形/圆/点/线、polygon 顶点直接拖动/删除、边中点插点、双击边界插点、区域合并/去除、选点/框选、撤销/重做,实时渲染 Mask 遮罩
- **GT Mask 导入** — 工作区可导入 GT mask 图片,后端按非零像素值和连通域生成 polygon 标注并用 distance transform 写入 seed point前端可回显和拖动 seed point - **GT Mask 导入** — 工作区可导入 GT mask 图片,后端按非零像素值和连通域生成 polygon 标注并用 distance transform 写入 seed point前端可回显和拖动 seed point
- **本体字典管理** — 可配置的分类体系、颜色映射、图层优先级z-index - **本体字典管理** — 可配置的分类体系、颜色映射、图层优先级z-index
- **项目工作区** — 项目创建、帧浏览、多图层标注、已编辑帧提示、进度追踪 - **项目工作区** — 项目创建、帧浏览、多图层标注、自动传播帧提示、进度追踪
- **数据导出** — 支持 COCO JSON 格式和 PNG Mask 批量导出PNG ZIP 包含单标注 mask、按 z-index 融合的语义 mask 和类别映射 - **数据导出** — 支持 COCO JSON 格式和 PNG Mask 批量导出PNG ZIP 包含单标注 mask、按 z-index 融合的语义 mask 和类别映射
--- ---
@@ -137,7 +137,7 @@ Seg_Server/
├── public/ ├── public/
│ └── logo.png # 侧边栏 Logo 静态资源 │ └── logo.png # 侧边栏 Logo 静态资源
├── start_services.sh # 一键启动所有服务脚本 ├── start_services.sh # 一键启动所有服务脚本
├── server.ts # Express + Vite 前端入口(也保留少量旧版 mock API ├── server.ts # Express + Vite 前端入口(不再提供旧版 mock API
├── index.html # SPA HTML 入口 ├── index.html # SPA HTML 入口
├── vite.config.ts # Vite 构建配置 ├── vite.config.ts # Vite 构建配置
├── package.json # npm 依赖与脚本 ├── package.json # npm 依赖与脚本
@@ -389,7 +389,7 @@ npm run build # 生产构建(输出到 dist/
npm run lint # TypeScript 类型检查 npm run lint # TypeScript 类型检查
npm run test # Vitest watch 模式 npm run test # Vitest watch 模式
npm run test:run # Vitest 单次运行 npm run test:run # Vitest 单次运行
npm start # Node.js 运行 server.ts生产静态服务 / 旧版 mock API npm start # Node.js 运行 server.ts生产静态服务
``` ```
### 后端 ### 后端
@@ -467,7 +467,7 @@ pip install -e . --no-build-isolation
- 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict` - 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict`
- 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。 - 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。
- AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签,“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择。 - AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签,“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择。
- 工作区传播功能会使用当前选中区域或当前帧全部区域作为 seed按用户设置的起止帧向前/向后追踪;“传播全部可达”会覆盖项目第 1 帧到最后 1 帧。前端会按 seed 和方向顺序调用 `/api/ai/propagate`,并在完成后刷新已保存标注。 - 工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed按用户设置的传播起始帧和传播结束帧向前/向后追踪;前端只保留一个“自动传播”按钮,会按 seed 和方向顺序调用 `/api/ai/propagate`,并在完成后刷新已保存标注。传播结果回显后,时间进度条会把自动传播生成的帧区段标为浅蓝色。
- 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco` - 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco`
- 工作区“导出 JSON 标注集”和“导出 PNG Mask ZIP”按钮已绑定下载流程导出前会先保存当前待归档的前端 mask。 - 工作区“导出 JSON 标注集”和“导出 PNG Mask ZIP”按钮已绑定下载流程导出前会先保存当前待归档的前端 mask。
- 工作区“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,导入后会刷新并回显已保存标注和 seed point。 - 工作区“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,导入后会刷新并回显已保存标注和 seed point。

View File

@@ -15,6 +15,8 @@ from minio_client import download_file
from models import Project, Frame, Template, Annotation from models import Project, Frame, Template, Annotation
from schemas import ( from schemas import (
AiRuntimeStatus, AiRuntimeStatus,
MaskAnalysisRequest,
MaskAnalysisResponse,
PredictRequest, PredictRequest,
PredictResponse, PredictResponse,
PropagateRequest, PropagateRequest,
@@ -78,6 +80,29 @@ def _polygon_bbox(polygon: list[list[float]]) -> list[float]:
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)] return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
def _polygon_area(polygon: list[list[float]]) -> float:
if len(polygon) < 3:
return 0.0
total = 0.0
for index, point in enumerate(polygon):
next_point = polygon[(index + 1) % len(polygon)]
total += _clamp01(point[0]) * _clamp01(next_point[1])
total -= _clamp01(next_point[0]) * _clamp01(point[1])
return abs(total) / 2.0
def _analysis_anchors(polygons: list[list[list[float]]], points: list[list[float]] | None) -> list[list[float]]:
if points:
return [[_clamp01(point[0]), _clamp01(point[1])] for point in points if len(point) >= 2]
anchors: list[list[float]] = []
for polygon in polygons:
if not polygon:
continue
step = max(1, len(polygon) // 12)
anchors.extend([[_clamp01(point[0]), _clamp01(point[1])] for point in polygon[::step]])
return anchors[:32]
def _frame_window( def _frame_window(
frames: list[Frame], frames: list[Frame],
source_position: int, source_position: int,
@@ -389,6 +414,60 @@ def model_status(selected_model: str | None = None) -> dict:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post(
"/analyze-mask",
response_model=MaskAnalysisResponse,
summary="Analyze mask geometry and prompt anchors",
)
def analyze_mask(payload: MaskAnalysisRequest, db: Session = Depends(get_db)) -> dict:
"""Return backend-computed mask properties for the frontend inspector."""
if payload.frame_id is not None:
frame = db.query(Frame).filter(Frame.id == payload.frame_id).first()
if not frame:
raise HTTPException(status_code=404, detail="Frame not found")
mask_data = payload.mask_data or {}
polygons = mask_data.get("polygons") or []
if not polygons:
raise HTTPException(status_code=400, detail="Mask analysis requires polygons")
valid_polygons = [
[[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2]
for polygon in polygons
]
valid_polygons = [polygon for polygon in valid_polygons if len(polygon) >= 3]
if not valid_polygons:
raise HTTPException(status_code=400, detail="Mask analysis requires at least one valid polygon")
area = sum(_polygon_area(polygon) for polygon in valid_polygons)
bbox = payload.bbox or _polygon_bbox(valid_polygons[0])
source = mask_data.get("source")
raw_score = mask_data.get("score")
confidence: float | None = None
confidence_source = "unavailable"
if isinstance(raw_score, (int, float)):
confidence = max(0.0, min(float(raw_score), 1.0))
confidence_source = "model_score"
elif source:
confidence_source = "source_without_score"
else:
confidence_source = "manual_or_imported"
anchors = _analysis_anchors(valid_polygons, payload.points)
message = "已从后端重新提取几何拓扑锚点" if payload.extract_skeleton else "已读取后端几何属性"
return {
"confidence": confidence,
"confidence_source": confidence_source,
"topology_anchor_count": len(anchors),
"topology_anchors": anchors,
"area": area,
"bbox": bbox,
"source": source,
"message": message,
}
@router.post( @router.post(
"/propagate", "/propagate",
response_model=PropagateResponse, response_model=PropagateResponse,

View File

@@ -190,6 +190,25 @@ class PredictResponse(BaseModel):
scores: Optional[list[float]] = None scores: Optional[list[float]] = None
class MaskAnalysisRequest(BaseModel):
frame_id: Optional[int] = None
mask_data: dict[str, Any]
points: Optional[list[list[float]]] = None
bbox: Optional[list[float]] = None
extract_skeleton: bool = False
class MaskAnalysisResponse(BaseModel):
confidence: Optional[float] = None
confidence_source: str
topology_anchor_count: int
topology_anchors: list[list[float]]
area: float
bbox: Optional[list[float]] = None
source: Optional[str] = None
message: str
class PropagationSeed(BaseModel): class PropagationSeed(BaseModel):
polygons: Optional[list[list[list[float]]]] = None polygons: Optional[list[list[list[float]]]] = None
bbox: Optional[list[float]] = None bbox: Optional[list[float]] = None

View File

@@ -198,6 +198,28 @@ def test_model_status_rejects_disabled_sam3(client):
assert "Unsupported model" in response.json()["detail"] assert "Unsupported model" in response.json()["detail"]
def test_analyze_mask_returns_backend_geometry_properties(client):
_, frame, _ = _create_project_and_frame(client)
response = client.post("/api/ai/analyze-mask", json={
"frame_id": frame["id"],
"mask_data": {
"polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]]],
"source": "sam2.1_hiera_tiny",
"score": 0.87,
},
"extract_skeleton": True,
})
assert response.status_code == 200
body = response.json()
assert body["confidence"] == 0.87
assert body["confidence_source"] == "model_score"
assert body["topology_anchor_count"] == 4
assert body["area"] > 0
assert body["message"] == "已从后端重新提取几何拓扑锚点"
def test_propagate_saves_tracked_annotations(client, monkeypatch): def test_propagate_saves_tracked_annotations(client, monkeypatch):
project = client.post("/api/projects", json={"name": "Video Project"}).json() project = client.post("/api/projects", json={"name": "Video Project"}).json()
frames = [ frames = [

View File

@@ -9,7 +9,7 @@
- 前端服务:`server.ts` - 前端服务:`server.ts`
- 默认访问:`http://localhost:3000` - 默认访问:`http://localhost:3000`
`server.ts` 的角色比较特殊:它既负责在开发模式下创建 Vite middleware也在生产模式下服务 `dist/`同时它还保留了旧版 mock API`/api/login``/api/projects``/api/templates`。当前前端业务 API 主要不走这些 mock而是`src/lib/api.ts` 指向的 FastAPI。 `server.ts` 的角色比较特殊:它既负责在开发模式下创建 Vite middleware也在生产模式下服务 `dist/`当前旧版 `/api/login``/api/projects``/api/templates` mock 已清理;前端业务 API `src/lib/api.ts` 指向的 FastAPI。
### 后端入口 ### 后端入口

View File

@@ -66,8 +66,7 @@
| “导出 JSON 标注集”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `exportCoco()` 下载 JSON | | “导出 JSON 标注集”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `exportCoco()` 下载 JSON |
| “导出 PNG Mask ZIP”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `GET /api/export/{project_id}/masks` 下载 ZIP后端同时包含单标注 mask、每帧语义融合 mask 和 `semantic_classes.json` | | “导出 PNG Mask ZIP”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `GET /api/export/{project_id}/masks` 下载 ZIP后端同时包含单标注 mask、每帧语义融合 mask 和 `semantic_classes.json` |
| “导入 GT Mask”按钮 | 真实可用 | 选择图片后调用 `POST /api/ai/import-gt-mask`,后端按非零像素值和连通域生成 polygon 标注与距离变换 seed point再回显到工作区 | | “导入 GT Mask”按钮 | 真实可用 | 选择图片后调用 `POST /api/ai/import-gt-mask`,后端按非零像素值和连通域生成 polygon 标注与距离变换 seed point再回显到工作区 |
| 传播对象/起止帧/按范围传播 | 真实可用 | 可选择“选中区域”或“当前帧全部”,并用起止帧定义从当前帧向前/向后的追踪范围;前端会按 seed mask 和方向顺序调用 `POST /api/ai/propagate`,当前启用 SAM 2 video predictor完成后刷新已保存标注 | | 参考帧/起止帧/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed用户设置传播起始帧和传播结束帧后单个“自动传播”按钮会按 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避免仍显示未保存 | | “结构化归档保存”按钮 | 真实可用 | 未保存 mask 写入 `POST /api/ai/annotate`dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask避免仍显示未保存 |
## CanvasArea 画布 ## CanvasArea 画布
@@ -82,22 +81,23 @@
| 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 | | 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 |
| AI 推理中提示 | 真实可用 | 请求期间会显示 | | AI 推理中提示 | 真实可用 | 请求期间会显示 |
| 手工多边形/矩形/圆/点/线 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆/线拖拽生成 polygon点工具生成小区域绘制工具可在已有 mask 上继续落点;均写入 `Mask.segmentation`,可归档保存 | | 手工多边形/矩形/圆/点/线 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆/线拖拽生成 polygon点工具生成小区域绘制工具可在已有 mask 上继续落点;均写入 `Mask.segmentation`,可归档保存 |
| 画布上下文提示 | 真实可用 | 切换到多边形、矩形、圆、线、点、正/反向选点、框选、区域合并/去除、调整多边形等隐性操作工具时,画布左上角显示当前工具的完成/取消/选择顺序提示 |
| Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染 | | Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染 |
| Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;拖动顶点会重算 `pathData/segmentation/bbox/area`,已保存 mask 标为 dirty选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask已保存 mask 会同步调用后端删除 | | Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`不需要先单击选中顶点,已保存 mask 标为 dirty选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask已保存 mask 会同步调用后端删除 |
| GT seed point 回显/编辑 | 真实可用 | 已保存标注的 `points` 会显示为黄色 seed 点;拖动后标记为 dirty归档保存会更新后端 | | GT seed point 回显/编辑 | 真实可用 | 已保存标注的 `points` 会显示为黄色 seed 点;拖动后标记为 dirty归档保存会更新后端 |
| 应用分类 | 真实可用 | Canvas 右下角按钮可将当前选择的模板分类应用到本帧 mask右侧语义分类树点击分类时会优先改当前已选 mask已保存 mask 会标为 dirty归档保存时更新后端 | | 应用分类 | 真实可用 | Canvas 右下角按钮可将当前选择的模板分类应用到本帧 mask右侧语义分类树点击分类时会优先改当前已选 mask,并把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty归档保存时更新后端 |
| 清空遮罩 | 真实可用 | 工作区中会删除当前帧已保存标注并清空当前帧本地 mask | | 清空遮罩 | 真实可用 | 工作区中会删除当前帧已保存标注并清空当前帧本地 mask |
| 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 | | 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 |
| 当前图层树文字 | Mock / UI-only | 固定显示 `OBJECT_VEHICLE_01` | | 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id未保存 mask 显示“未保存”,未选中时显示“未选择” |
## ToolsPalette 工具栏 ## ToolsPalette 工具栏
| 元素 | 状态 | 说明 | | 元素 | 状态 | 说明 |
|------|------|------| |------|------|------|
| 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 | | 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 |
| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持拖动顶点、点击边中点插点、双击边界按位置插点 | | 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持按住顶点直接拖动、点击边中点插点、双击边界按位置插点 |
| 多边形/矩形/圆/点/线 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成可保存的 polygon mask | | 多边形/矩形/圆/点/线 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成可保存的 polygon mask |
| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask右下角显示已选数量和操作按钮合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;使用 `polygon-clipping` 做 union / difference合并会保留主 mask 并移除被合并 mask去除会从主 mask 扣除后续选中 mask内含扣除会保留 hole ring 并用 even-odd 规则渲染 | | 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask右下角显示已选数量和操作按钮合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;布尔选择态中第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,避免主区域和扣除区域看起来像随机阴影差异;使用 `polygon-clipping` 做 union / difference合并会保留主 mask 并移除被合并 mask去除会从主 mask 扣除后续选中 mask内含扣除会保留 hole ring 并用 even-odd 规则渲染 |
| 正向选点/反向选点/框选 | 部分可用 | 会影响 Canvas 交互,并能触发已对齐的 AI 推理接口;点击工作区内已有 SAM 提示点会优先删除该提示点并重新推理,不会冒泡成新增提示点或 mask 选择 | | 正向选点/反向选点/框选 | 部分可用 | 会影响 Canvas 交互,并能触发已对齐的 AI 推理接口;点击工作区内已有 SAM 提示点会优先删除该提示点并重新推理,不会冒泡成新增提示点或 mask 选择 |
| 魔法棒 SAM 触发 | 部分可用 | 切到 AI 页面;不是直接执行推理 | | 魔法棒 SAM 触发 | 部分可用 | 切到 AI 页面;不是直接执行推理 |
| 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`支持工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y | | 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`支持工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y |
@@ -110,7 +110,7 @@
| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)` | | 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)` |
| 顶部 range 拖动 | 真实可用 | 改变当前帧 | | 顶部 range 拖动 | 真实可用 | 改变当前帧 |
| 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` | | 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` |
| 已编辑帧进度线 | 真实可用 | 根据当前项目帧内`masks` 计算有编辑/标注的帧,并在顶部进度条上覆盖琥珀色竖线;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达,点击已编辑竖线可跳转到对应帧 | | 自动传播帧进度条标记 | 真实可用 | 根据已保存标注回显`mask_data.source` / `propagated_from_frame_id` 识别自动传播生成的帧,并在顶部进度条对应帧区段覆盖浅蓝色;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达 |
| 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps | | 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps |
| 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 | | 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 |
@@ -119,11 +119,11 @@
| 元素 | 状态 | 说明 | | 元素 | 状态 | 说明 |
|------|------|------| |------|------|------|
| 模板选择 | 部分可用 | 读取全局 templates可切换 activeTemplateId | | 模板选择 | 部分可用 | 读取全局 templates可切换 activeTemplateId |
| 分类树展示 / 换标签 | 真实可用 | 显示模板 classes 和本地 customClasses;点击分类会设为后续新 mask 的 activeClass如果 Canvas 已选 mask则同步更新已选 mask 的标签、颜色和 class 元数据 | | 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes点击分类会设为后续新 mask 的 activeClass如果 Canvas 已选 mask则同步更新已选 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层 |
| 添加自定义分类 | 部分可用 | 只存在组件本地状态,不保存到后端 | | 添加自定义分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store |
| 置信度 | Mock / UI-only | 固定 `0.9412` | | 后端模型置信度 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,优先显示后端返回的模型分数;手工/导入 mask 无模型分数时显示“无模型分数” |
| 拓扑锚点数量 | Mock / UI-only | 固定 `12 节点` | | 后端拓扑锚点数量 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,由后端根据 seed points 或 polygon 顶点采样返回锚点数量 |
| 重新提取骨架按钮 | Mock / UI-only | 无事件 | | 重新提取拓扑锚点按钮 | 真实可用 | 调用 `POST /api/ai/analyze-mask` 并带 `extract_skeleton=true`,刷新后端几何锚点统计 |
## AISegmentation 独立 AI 页 ## AISegmentation 独立 AI 页
@@ -132,19 +132,18 @@
| SAM 2.1 变体选择 / 模型状态 | 真实可用 | AI 页可选 tiny/small/base+/large调用 `GET /api/ai/models/status?selected_model=<variant>` 展示所选变体和 GPU 状态;只有本地存在 checkpoint 的变体显示可用 | | SAM 2.1 变体选择 / 模型状态 | 真实可用 | AI 页可选 tiny/small/base+/large调用 `GET /api/ai/models/status?selected_model=<variant>` 展示所选变体和 GPU 状态;只有本地存在 checkpoint 的变体显示可用 |
| 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口AI 页中点击已有候选 mask 时也会继续添加当前正/反向提示点点击已有提示点会删除该点SAM 2.1 框选后会携带原始框和累计正/反点细化同一个候选 mask | | 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口AI 页中点击已有候选 mask 时也会继续添加当前正/反向提示点点击已有提示点会删除该点SAM 2.1 框选后会携带原始框和累计正/反点细化同一个候选 mask |
| 边界框选 | 真实可用 | AI 页选择工具后可在画布拖拽蓝色虚线框;执行分割时会随 `/api/ai/predict` 发送 `box`,框选后继续添加正/反点会发送 interactive prompt | | 边界框选 | 真实可用 | AI 页选择工具后可在画布拖拽蓝色虚线框;执行分割时会随 `/api/ai/predict` 发送 `box`,框选后继续添加正/反点会发送 interactive prompt |
| AI 画布上下文提示 | 真实可用 | 选择正向点、反向点、边界框选或视口控制时,画布左上角提示点击/拖拽、删除提示点和执行推理的操作方式 |
| SAM 3 入口 | 当前禁用 | 因当前系统不提供文本提示,前端不再显示 SAM 3 模型选择、文本输入或 SAM 3 框选入口;后端 `model=sam3` 返回不支持 | | SAM 3 入口 | 当前禁用 | 因当前系统不提供文本提示,前端不再显示 SAM 3 模型选择、文本输入或 SAM 3 框选入口;后端 `model=sam3` 返回不支持 |
| 语义文本输入 | 当前禁用 | AI 页不再提供文本语义输入;后端收到 `semantic` prompt 会返回 400 | | 语义文本输入 | 当前禁用 | AI 页不再提供文本语义输入;后端收到 `semantic` prompt 会返回 400 |
| 参数开关 | 真实可用 | UI 展示为“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”,只是为了让用户更容易理解,不重命名内部字段;`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 几何、分类或保存数据 | | 遮罩清晰度 | 真实可用 | 调节 AI 页候选 mask 的预览透明度,只影响本页显示,不改变 mask 几何、分类或保存数据 |
| 执行高精度语义分割 | 真实可用 | 使用当前项目帧和所选 SAM 2.1 变体调用 `/api/ai/predict`SAM 2.1 需要点/框提示且只采用最高分候选AI 页只渲染本页最新候选,不显示工作区已有 mask重复执行会替换上一次 AI 页候选而不是叠加;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 | | 执行高精度语义分割 | 真实可用 | 使用当前项目帧和所选 SAM 2.1 变体调用 `/api/ai/predict`SAM 2.1 需要点/框提示且只采用最高分候选AI 页只渲染本页最新候选,不显示工作区已有 mask重复执行会替换上一次 AI 页候选而不是叠加;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 |
| 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的未保存 mask工作区回显后端标注时不会覆盖这类 draft mask | | 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的未保存 mask工作区回显后端标注时不会覆盖这类 draft mask |
| 上传替换底图 | Mock / UI-only | 按钮无事件 |
| 撤销/重做 | 真实可用 | 绑定全局 mask 历史栈 | | 撤销/重做 | 真实可用 | 绑定全局 mask 历史栈 |
| 删除最近锚点 | 真实可用 | 删除 AI 页最近一次放置的正/反向提示点,不影响已生成候选 mask 或工作区 mask | | 删除最近锚点 | 真实可用 | 删除 AI 页最近一次放置的正/反向提示点,不影响已生成候选 mask 或工作区 mask |
| 删除选中候选 | 真实可用 | 删除 AI 页当前选中的本页候选 mask不会删除工作区已有 maskDelete/Backspace 也遵循同一范围 | | 删除选中候选 | 真实可用 | 删除 AI 页当前选中的本页候选 mask不会删除工作区已有 maskDelete/Backspace 也遵循同一范围 |
| 清空全体锚点 | 真实可用 | 清空 AI 页提示点和本页生成的候选 mask不删除工作区已有 mask | | 清空全体锚点 | 真实可用 | 清空 AI 页提示点和本页生成的候选 mask不删除工作区已有 mask |
| 退档推送至工作区重组 | 部分可用 | 只切回工作区,共用 masks store但没有保存/确认流程 | | 背景图 / 空状态 | 真实可用 | 优先显示当前项目帧;没有项目帧时显示空状态提示,不再回退到外部演示图片 |
| 背景图 | 部分可用 | 优先显示当前项目帧;没有项目帧时仍回退到 Unsplash 演示图 |
## TemplateRegistry 模板库 ## TemplateRegistry 模板库
@@ -158,10 +157,10 @@
| 拖拽排序 | 真实可用 | 重算 zIndex保存时写后端 | | 拖拽排序 | 真实可用 | 重算 zIndex保存时写后端 |
| JSON 批量导入 | 部分可用 | 前端解析 JSON 并加入编辑态,保存后才落库 | | JSON 批量导入 | 部分可用 | 前端解析 JSON 并加入编辑态,保存后才落库 |
| 载入腹腔镜 35 分类 | 真实可用 | 前端内置数据;后端也 seed 默认模板 | | 载入腹腔镜 35 分类 | 真实可用 | 前端内置数据;后端也 seed 默认模板 |
| mapping rules | 部分可用 | 可存 `rules`,但无实际映射执行引擎 | | mapping rules | 部分可用 | 可存 `rules`,但当前没有运行时映射执行引擎;适合后续用于导入外部标签、别名归一化或跨数据集类别映射 |
## 总体结论 ## 总体结论
当前前端真实可用的主链路是登录、Dashboard 后端概览、项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、COCO 导出、PNG mask ZIP 导出、模板 CRUD。 当前前端真实可用的主链路是登录、Dashboard 后端概览、项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、COCO 导出、PNG mask ZIP 导出、模板 CRUD。
当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单和若干检查面板指标仍未落地 当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单和 mapping rules 运行时映射执行引擎仍未落地。登录页“安全审计说明文字”仍只是 UI 文案

View File

@@ -40,6 +40,7 @@ Authorization: Bearer <token>
| `predictMask(payload)` | `POST /api/ai/predict` | 对齐 | 前端发送 `image_id/prompt_type/prompt_data/model`,并把后端 `polygons` 转为 `masks[].pathData` | | `predictMask(payload)` | `POST /api/ai/predict` | 对齐 | 前端发送 `image_id/prompt_type/prompt_data/model`,并把后端 `polygons` 转为 `masks[].pathData` |
| `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 当前帧 seed mask 向视频片段传播,并保存后续帧标注 | | `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 当前帧 seed mask 向视频片段传播,并保存后续帧标注 |
| `getAiModelStatus(selectedModel?)` | `GET /api/ai/models/status` | 对齐 | 返回 GPU 和四个 SAM 2.1 变体状态;`selected_model=sam3` 返回不支持 | | `getAiModelStatus(selectedModel?)` | `GET /api/ai/models/status` | 对齐 | 返回 GPU 和四个 SAM 2.1 变体状态;`selected_model=sam3` 返回不支持 |
| `analyzeMask(mask, frame, options?)` | `POST /api/ai/analyze-mask` | 对齐 | 后端计算选中 mask 的置信度来源、拓扑锚点数量、面积和 bbox |
| `getProjectAnnotations(projectId, frameId?)` | `GET /api/ai/annotations` | 对齐 | 前端加载工作区时用于回显已保存标注 | | `getProjectAnnotations(projectId, frameId?)` | `GET /api/ai/annotations` | 对齐 | 前端加载工作区时用于回显已保存标注 |
| `saveAnnotation(payload)` | `POST /api/ai/annotate` | 对齐 | 工作区归档保存当前项目未保存 mask | | `saveAnnotation(payload)` | `POST /api/ai/annotate` | 对齐 | 工作区归档保存当前项目未保存 mask |
| `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask | | `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask |
@@ -78,6 +79,7 @@ Authorization: Bearer <token>
| POST | `/api/tasks/{task_id}/retry` | 重试失败或取消的后台任务 | | POST | `/api/tasks/{task_id}/retry` | 重试失败或取消的后台任务 |
| POST | `/api/ai/predict` | 当前启用 SAM 2 点/框/interactive 推理 | | POST | `/api/ai/predict` | 当前启用 SAM 2 点/框/interactive 推理 |
| POST | `/api/ai/propagate` | 当前启用 SAM 2 视频片段传播并保存标注 | | POST | `/api/ai/propagate` | 当前启用 SAM 2 视频片段传播并保存标注 |
| POST | `/api/ai/analyze-mask` | 分析前端选中 mask 的后端几何属性和拓扑锚点 |
| GET | `/api/ai/models/status` | GPU 和 SAM 模型状态 | | GET | `/api/ai/models/status` | GPU 和 SAM 模型状态 |
| POST | `/api/ai/auto` | 自动分割 | | POST | `/api/ai/auto` | 自动分割 |
| POST | `/api/ai/annotate` | 保存 AI 标注 | | POST | `/api/ai/annotate` | 保存 AI 标注 |
@@ -228,7 +230,7 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask避免同
### 视频片段传播请求体 ### 视频片段传播请求体
后端接口仍以单个 seed 为单位。工作区前端的“按范围传播/传播全部可达”会在本地根据当前帧、起止帧和传播对象,把多个 seed 或前后双向范围拆成多次顺序调用,避免同时启动多个视频 tracker。 后端接口仍以单个 seed 为单位。工作区前端当前只提供一个“自动传播”按钮:当前打开帧作为参考帧,该帧全部 mask 作为 seed用户设置传播起始帧和传播结束帧后前端会在本地把多个 seed 或前后双向范围拆成多次顺序调用,避免同时启动多个视频 tracker。
单次调用示例: 单次调用示例:
@@ -252,7 +254,7 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask避免同
} }
``` ```
SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source``<model_id>_propagation` SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source``<model_id>_propagation`,前端回显时会把该字段保留到 `Mask.metadata`,用于把自动传播帧在时间进度条上标为浅蓝色
## 已完成的接口对齐 ## 已完成的接口对齐

View File

@@ -129,7 +129,7 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
## 阶段 7.5:视频片段传播(已完成基础闭环) ## 阶段 7.5:视频片段传播(已完成基础闭环)
当前工作区传播功能会使用当前帧的选中 mask 或当前帧全部 mask 作为 seed按用户设置的起止帧向前、向后或双向传播,并把结果写入后端标注表。“传播全部可达”会把范围设置为项目第 1 帧到最后 1 帧 当前工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed按用户设置的传播起始帧和传播结束帧向前、向后或双向传播,并把结果写入后端标注表。前端只保留一个“自动传播”按钮,减少传播对象选择带来的歧义
已完成: 已完成:
@@ -138,7 +138,7 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
3. SAM 2 路径使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()` 3. SAM 2 路径使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()`
4. SAM 3 video tracker 路径已从当前产品入口禁用,相关 helper 仅保留作后续恢复参考。 4. SAM 3 video tracker 路径已从当前产品入口禁用,相关 helper 仅保留作后续恢复参考。
5. 后端会跳过源帧,把传播结果保存到后续帧 `annotations`,并在完成后由前端刷新回显。 5. 后端会跳过源帧,把传播结果保存到后续帧 `annotations`,并在完成后由前端刷新回显。
6. 前端已经支持传播对象选择、起止帧范围和“传播全部可达”;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。 6. 前端已经支持参考帧、起止帧范围和单按钮自动传播;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。
剩余建议: 剩余建议:
@@ -153,4 +153,4 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
- SAM/GPU 状态已改为 `GET /api/ai/models/status` 驱动。 - SAM/GPU 状态已改为 `GET /api/ai/models/status` 驱动。
- 撤销/重做按钮已接全局 mask 历史栈。 - 撤销/重做按钮已接全局 mask 历史栈。
- “重新提取内侧中轴树骨架”接真实接口,否则标为未实现。 - “重新提取内侧中轴树骨架”接真实接口,否则标为未实现。
- AI 独立页不要固定 Unsplash 图,应从当前项目帧或上传文件进入 - AI 独立页已移除固定 Unsplash 演示图;没有当前项目帧时显示空状态。后续如果要支持独立图片分析,应接正式上传入口和项目/帧关联

View File

@@ -51,7 +51,7 @@
- 时间轴支持缩略图点击切帧、range 拖动切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。 - 时间轴支持缩略图点击切帧、range 拖动切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。
- 播放帧率使用项目 `parse_fps``original_fps`,限制在 1 到 30 FPS。 - 播放帧率使用项目 `parse_fps``original_fps`,限制在 1 到 30 FPS。
- 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps``original_fps`,格式为 `mm:ss.cc` - 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps``original_fps`,格式为 `mm:ss.cc`
- 时间轴在顶部进度条上覆盖琥珀色竖线标记,基于当前项目帧内的 `masks` 标出已有编辑/标注的帧;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达,点击已编辑竖线可跳转到对应帧 - 时间轴根据已保存标注回显的传播来源字段,把自动传播生成的帧在顶部进度条对应区段标为浅蓝色;不再使用竖线模式标记已编辑帧,当前帧位置由播放进度条末端、时间提示和缩略图高亮表达。
## R5 工具栏 ## R5 工具栏
@@ -61,7 +61,7 @@
- 多边形、矩形、圆、点、线工具会在 Canvas 上生成可保存的 polygon mask。 - 多边形、矩形、圆、点、线工具会在 Canvas 上生成可保存的 polygon mask。
- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆、线通过拖拽生成;点工具生成小点区域。 - 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆、线通过拖拽生成;点工具生成小点区域。
- 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉。 - 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉。
- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;拖动顶点会更新 mask 几何并把已保存 mask 标记为 dirty。 - 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;按住顶点即可直接拖动并实时更新 mask 几何,不需要先单击选中顶点,已保存 mask 标记为 dirty。
- 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。 - 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。
- “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。 - “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。
- 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。 - 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。
@@ -69,7 +69,7 @@
- 撤销、重做绑定全局 `maskHistory/maskFuture`支持工具栏按钮、AI 页按钮和 Canvas 快捷键。 - 撤销、重做绑定全局 `maskHistory/maskFuture`支持工具栏按钮、AI 页按钮和 Canvas 快捷键。
- 区域合并工具支持多选当前帧 mask并使用 polygon union 生成合并后的主 mask。 - 区域合并工具支持多选当前帧 mask并使用 polygon union 生成合并后的主 mask。
- 区域去除工具支持多选当前帧 mask并从第一个选中的主 mask 中扣除后续选中 mask。 - 区域去除工具支持多选当前帧 mask并从第一个选中的主 mask 中扣除后续选中 mask。
- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击。 - 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击;第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓
- 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染。 - 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染。
## R6 AI 推理 ## R6 AI 推理
@@ -98,9 +98,9 @@
- 工作区加载后端已保存标注时,必须保留当前项目帧里尚未保存的 AI/手工 draft mask避免 AI 页推送到工作区的候选 mask 被异步回显流程覆盖。 - 工作区加载后端已保存标注时,必须保留当前项目帧里尚未保存的 AI/手工 draft mask避免 AI 页推送到工作区的候选 mask 被异步回显流程覆盖。
- 语义文本提示 `semantic` 当前被后端禁用并返回 400。 - 语义文本提示 `semantic` 当前被后端禁用并返回 400。
- SAM 3 源码和历史测试保留,但不属于当前产品可用功能;前端不再展示 SAM 3 入口,后端 registry 不暴露 `sam3` - SAM 3 源码和历史测试保留,但不属于当前产品可用功能;前端不再展示 SAM 3 入口,后端 registry 不暴露 `sam3`
- 工作区传播功能允许选择传播对象:“选中区域”或“当前帧全部”;选中区域模式需要当前帧至少一个已选 mask全部模式会使用当前帧所有 mask - 工作区传播功能以当前打开帧作为参考帧,并使用该帧全部 mask 作为 seed用户不再选择“选中区域/当前帧全部”传播对象
- 工作区传播功能允许设置起止帧;前端以当前帧为 seed只向起止范围内位于当前帧之前和之后的帧传播,源帧不重复保存。 - 工作区传播功能允许设置传播起始帧和传播结束帧;前端以当前参考帧为 seed只向起止范围内位于参考帧之前和之后的帧传播,源帧不重复保存。
- 工作区“传播全部可达”会把起止帧设为项目第 1 帧到最后 1 帧,并按当前传播对象传播到所有可达前后帧 - 工作区只保留一个“自动传播”按钮,点击后在指定范围内按前向/后向自动生成 mask
- 前端复用单 seed 后端接口;多个 seed 或双向范围会被拆成多次顺序调用 `POST /api/ai/propagate`,避免并发抢占 GPU。 - 前端复用单 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()` - `POST /api/ai/propagate` 当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()`
- 传播结果会写入后续帧 `annotations``mask_data.source` 标记为 `<model_id>_propagation`,并保留 label、color 和 class 元数据。 - 传播结果会写入后续帧 `annotations``mask_data.source` 标记为 `<model_id>_propagation`,并保留 label、color 和 class 元数据。
@@ -136,11 +136,11 @@
## R9 本体检查面板 ## R9 本体检查面板
- 工作区右侧可以选择模板。 - 工作区右侧可以选择模板。
- 面板显示模板分类和组件本地自定义分类 - 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes`
- 用户可以选择具体分类;新 AI mask 会记录 `classId``className``classZIndex`,并在保存时写入 `mask_data.class` - 用户可以选择具体分类;新 AI mask 会记录 `classId``className``classZIndex`,并在保存时写入 `mask_data.class`
- 如果 Canvas 当前已经选中一个或多个 mask点击语义分类树会把这些 mask 的 `label``color` 和 class 元数据改为该分类;已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。 - 如果 Canvas 当前已经选中一个或多个 mask点击语义分类树会把这些 mask 的 `label``color` 和 class 元数据改为该分类;已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。
- 添加自定义分类只存在组件本地状态,不保存到后端 - 添加自定义分类需要先选择模板,保存时调用 `PATCH /api/templates/{id}` 并同步全局模板 store
- 置信度、拓扑锚点和重新提取骨架按钮当前为展示/占位。 - 选中 mask 后,置信度、拓扑锚点和重新提取拓扑锚点按钮调用 `POST /api/ai/analyze-mask`,不再显示固定占位
## R10 Dashboard 与 WebSocket ## R10 Dashboard 与 WebSocket

View File

@@ -12,7 +12,7 @@
- FastAPI 后端 API。 - FastAPI 后端 API。
- PostgreSQL、MinIO、Redis、SAM 2 等外部基础设施。SAM 3 相关源码保留,但当前产品入口禁用。 - PostgreSQL、MinIO、Redis、SAM 2 等外部基础设施。SAM 3 相关源码保留,但当前产品入口禁用。
开发时前端通过 `server.ts` 启动 Express + Vite middleware后端通过 `backend/main.py` 启动 FastAPI。前端业务接口主要访问 FastAPI不依赖 `server.ts` 中保留的旧 mock API 开发时前端通过 `server.ts` 启动 Express + Vite middleware后端通过 `backend/main.py` 启动 FastAPI。前端业务接口访问 FastAPI`server.ts` 不再保留旧版 `/api/*` mock。
## 前端模块 ## 前端模块
@@ -30,8 +30,8 @@
| 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板组织工具栏、Canvas、本体面板、时间轴 | | 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板组织工具栏、Canvas、本体面板、时间轴 |
| Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask | | Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask |
| 工具栏 | `src/components/ToolsPalette.tsx` | 切换工具、跳转 AI 页面、触发 mask 撤销/重做 | | 工具栏 | `src/components/ToolsPalette.tsx` | 切换工具、跳转 AI 页面、触发 mask 撤销/重做 |
| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、已编辑帧标记、左右方向键切帧、播放和当前/总时长显示 | | 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、自动传播帧浅蓝区段标记、左右方向键切帧、播放和当前/总时长显示 |
| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、分类树、本地自定义分类 | | 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、分类树、后端自定义分类、mask 后端属性分析 |
| AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 | | AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 |
| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、排序 | | 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、排序 |
@@ -103,7 +103,7 @@
3. 帧数据映射为 store `Frame[]`,包含 `timestampMs``sourceFrameNumber`,供时间轴和后续视频传播使用。 3. 帧数据映射为 store `Frame[]`,包含 `timestampMs``sourceFrameNumber`,供时间轴和后续视频传播使用。
4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask但保留没有 `annotationId` 的未保存 draft mask这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。 4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask但保留没有 `annotationId` 的未保存 draft mask这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。
5. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态。 5. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态。
6. `FrameTimeline` 根据当前项目 `frames` 和全局 `masks` 计算有编辑/标注的帧,并在顶部时间进度条上覆盖可点击的琥珀色竖线;当前帧不额外渲染竖线,由播放进度条末端、时间提示和缩略图高亮表达。 6. `FrameTimeline` 根据已保存标注回显到 `Mask.metadata``source` / `propagated_from_frame_id` 计算自动传播生成的帧,并在顶部时间进度条对应帧区段覆盖浅蓝色;当前帧不额外渲染竖线,由播放进度条末端、时间提示和缩略图高亮表达。
7. 当前帧传入 `CanvasArea` 7. 当前帧传入 `CanvasArea`
### AI 点/框推理 ### AI 点/框推理
@@ -125,7 +125,8 @@
15. AI 页面候选 mask 删除只接受当前 `aiMaskIds` 范围内的已选 id“删除选中候选”和 Delete/Backspace 都复用该范围过滤,避免删除工作区已有 mask。 15. AI 页面候选 mask 删除只接受当前 `aiMaskIds` 范围内的已选 id“删除选中候选”和 Delete/Backspace 都复用该范围过滤,避免删除工作区已有 mask。
16. AI 页面参数开关文案只做展示增强:“局部专注模式(自动裁剪无锚区域)”仍控制 `cropMode/crop_to_prompt`,“严格除杂模式(自动清理干涉点)”仍控制 `autoDeleteBg/auto_filter_background/min_score` 16. AI 页面参数开关文案只做展示增强:“局部专注模式(自动裁剪无锚区域)”仍控制 `cropMode/crop_to_prompt`,“严格除杂模式(自动清理干涉点)”仍控制 `autoDeleteBg/auto_filter_background/min_score`
17. AI 页面“遮罩清晰度”滑杆只调节候选 mask 的 Konva preview opacity不写入 `Mask.segmentation`、分类元数据或后端 payload。 17. AI 页面“遮罩清晰度”滑杆只调节候选 mask 的 Konva preview opacity不写入 `Mask.segmentation`、分类元数据或后端 payload。
18. Canvas 按当前帧过滤并渲染 mask 18. AI 画布左上角根据正向点、反向点、边界框选和视口控制显示上下文提示,说明点击/拖拽、删除提示点和执行推理的操作方式
19. Canvas 按当前帧过滤并渲染 mask。
19. 新 mask 会带上当前选择的模板分类元数据,包括 `classId``className``classZIndex``metadata.source=ai_segmentation` 和保存状态 `draft` 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 回显替换。 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。 21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。
@@ -133,31 +134,32 @@
### 视频片段传播 ### 视频片段传播
1. 用户在工作区选择传播对象:`selected` 表示当前帧已选 mask`all` 表示当前帧所有 mask 1. 用户在工作区打开一帧作为参考帧;该帧全部 mask 都会作为传播 seed不再提供传播对象下拉
2. 用户设置起止帧;“传播全部可达”会把起止帧设为 1 到项目总帧数 2. 用户设置传播起始帧和传播结束帧,并点击唯一的“自动传播”按钮
3. `VideoWorkspace` 以当前帧为 seed将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。 3. `VideoWorkspace` 以当前参考帧为 seed将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。
4. `VideoWorkspace``buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color 和 class 元数据。 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。 5. 前端对每个 seed、每个方向顺序调用 `POST /api/ai/propagate``include_source=false``save_annotations=true`;顺序调用是为了避免多个视频 tracker 并发抢占 GPU。
6. 后端按项目帧序列截取片段,下载对应帧到临时 `frame_%06d.jpg` 目录,保持当前帧在片段中的相对索引。 6. 后端按项目帧序列截取片段,下载对应帧到临时 `frame_%06d.jpg` 目录,保持当前帧在片段中的相对索引。
7. `model` 为任一 SAM 2.1 变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask再用 `propagate_in_video()` 传播。 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 代码保留但没有接入产品路径。 8. `model=sam3` 当前不支持SAM 3 video tracker 代码保留但没有接入产品路径。
9. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧,`mask_data.source` 记录模型传播来源。 9. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧,`mask_data.source` 记录模型传播来源。
8. 前端传播完成后重新调用 `GET /api/ai/annotations` 并回显新标注。 10. 前端传播完成后重新调用 `GET /api/ai/annotations` 并回显新标注`annotationToMask()` 会保留传播来源 metadata供时间轴浅蓝色进度条区段显示
### 手工绘制与历史栈 ### 手工绘制与历史栈
1. 用户在 `ToolsPalette` 选择多边形、矩形、圆、点或线工具。 1. 用户在 `ToolsPalette` 选择多边形、矩形、圆、点或线工具。
2. `CanvasArea` 将交互坐标转换成像素 polygon。 2. `CanvasArea` 将交互坐标转换成像素 polygon。
3. 多边形工具逐次记录节点,三点后点击首节点或按 Enter 时生成闭合 polygon。 3. 多边形工具逐次记录节点,三点后点击首节点或按 Enter 时生成闭合 polygon。
4. mask path 只在 `move``edit_polygon``area_merge``area_remove` 工具下拦截点击;绘制和 AI prompt 工具点击已有 mask 时继续冒泡给 Stage 4. Canvas 左上角根据当前工具和操作阶段显示上下文提示;多边形提示会随已放置点数切换,明确 Enter 完成、Esc 取消和点击首节点闭合
5. mask 写入 `pathData`、像素 `segmentation``bbox``area` 和当前模板分类元数据 5. mask path 只在 `move``edit_polygon``area_merge``area_remove` 工具下拦截点击;绘制和 AI prompt 工具点击已有 mask 时继续冒泡给 Stage
6. `addMask()``setMasks()``updateMask()``clearMasks()` 会维护 `maskHistory/maskFuture` 6. 新 mask 写入 `pathData`、像素 `segmentation``bbox``area` 和当前模板分类元数据
7. 工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y 调用 `undoMasks()` / `redoMasks()` 7. `addMask()``setMasks()``updateMask()``clearMasks()` 会维护 `maskHistory/maskFuture`
8. 工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y 调用 `undoMasks()` / `redoMasks()`
### Polygon 逐点编辑 ### Polygon 逐点编辑
1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 第一条 polygon 的顶点控制点和边中点插入手柄。 1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 第一条 polygon 的顶点控制点和边中点插入手柄。
2. 拖动顶点后,前端重算 `pathData`、像素 `segmentation``bbox``area` 2. 顶点 `mousedown/dragstart` 会立即设置当前顶点选择;拖动过程中通过 `dragMove` 实时重算 `pathData`、像素 `segmentation``bbox``area`,不需要先单击顶点再拖动
3. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。 3. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。
4. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty``saved=false` 4. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty``saved=false`
5. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端。 5. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端。
@@ -168,10 +170,12 @@
1. 用户选择 `area_merge``area_remove` 后,点击多个当前帧 mask 组成选择集。 1. 用户选择 `area_merge``area_remove` 后,点击多个当前帧 mask 组成选择集。
2. 合并/去除模式隐藏 polygon 顶点和边中点编辑手柄,并在右下角显示已选数量;少于两个 mask 时操作按钮禁用。 2. 合并/去除模式隐藏 polygon 顶点和边中点编辑手柄,并在右下角显示已选数量;少于两个 mask 时操作按钮禁用。
3. `CanvasArea``Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon 3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除
4. `area_merge` 使用 union更新第一个选中的主 mask并从前端 store 移除后续被合并 mask如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注 4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常
5. `area_remove` 使用 difference从第一个选中的主 mask 中扣除后续选中 mask扣除对象本身保留如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring渲染时使用 even-odd fill 5. `CanvasArea``Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon
6. 结果会重算 `pathData``segmentation``bbox``area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算 6. `area_merge` 使用 union更新第一个选中的主 mask并从前端 store 移除后续被合并 mask如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注
7. `area_remove` 使用 difference从第一个选中的主 mask 中扣除后续选中 mask扣除对象本身保留如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring渲染时使用 even-odd fill。
8. 结果会重算 `pathData``segmentation``bbox``area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算。
### GT Mask 导入 ### GT Mask 导入
@@ -197,7 +201,8 @@
9. 工作区帧/标注异步加载完成后,`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask不会用后端回显结果直接覆盖整个 `masks` store。 9. 工作区帧/标注异步加载完成后,`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask不会用后端回显结果直接覆盖整个 `masks` store。
10. `OntologyInspector` 可以选择具体分类;选择结果进入全局 store`CanvasArea``AISegmentation` 新建/更新 mask 时使用。 10. `OntologyInspector` 可以选择具体分类;选择结果进入全局 store`CanvasArea``AISegmentation` 新建/更新 mask 时使用。
11. 如果 `selectedMaskIds` 中存在当前 store 的 mask点击分类时会立即更新这些 mask 的 `templateId``classId``className``classZIndex``label``color` 11. 如果 `selectedMaskIds` 中存在当前 store 的 mask点击分类时会立即更新这些 mask 的 `templateId``classId``className``classZIndex``label``color`
12. 已保存 mask 被重新分类后进入 `dirty``saved=false`,继续复用工作区归档保存的 PATCH 链路 12. 同一次点击会把这些已选 mask 移动到前端 `masks` 数组末尾;`CanvasArea` 按数组顺序渲染,后渲染的 Path 显示在最上层,方便用户继续编辑刚换标签的区域。该显示置顶不改变模板 `zIndex` 或后端导出语义覆盖规则
13. 已保存 mask 被重新分类后进入 `dirty``saved=false`,继续复用工作区归档保存的 PATCH 链路。
### 导出 ### 导出
@@ -251,5 +256,6 @@
- Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。 - Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。
- 已保存标注支持通过“应用分类”、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑和区域合并/去除进入 dirty 状态并归档更新;选中整块 mask 可用 Delete/Backspace 删除并同步后端;复杂洞结构编辑尚未实现。 - 已保存标注支持通过“应用分类”、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑和区域合并/去除进入 dirty 状态并归档更新;选中整块 mask 可用 Delete/Backspace 删除并同步后端;复杂洞结构编辑尚未实现。
- SAM 3 文本语义分割已从当前产品路径中禁用相关源码保留恢复时需要重新接入前端入口、registry、状态接口和测试。 - SAM 3 文本语义分割已从当前产品路径中禁用相关源码保留恢复时需要重新接入前端入口、registry、状态接口和测试。
- 自定义分类只存在本地组件状态 - 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`
- 选中 mask 后,本体面板调用 `POST /api/ai/analyze-mask` 显示后端模型置信度、拓扑锚点数量、面积等属性;“重新提取拓扑锚点”会带 `extract_skeleton=true` 重新请求后端分析。
- GT mask 导入已完成多类别像素值拆分、contour、distance transform seed point 和前端 seed point 拖拽编辑骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。 - GT mask 导入已完成多类别像素值拆分、contour、distance transform seed point 和前端 seed point 拖拽编辑骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。

View File

@@ -17,12 +17,12 @@
| R1 登录与会话 | `src/components/Login.test.tsx`, `backend/tests/test_auth.py` | 成功登录、失败提示、后端 401 | | R1 登录与会话 | `src/components/Login.test.tsx`, `backend/tests/test_auth.py` | 成功登录、失败提示、后端 401 |
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片删除、DELETE 契约、后端 CRUD、删除级联、帧列表 | | R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片删除、DELETE 契约、后端 CRUD、删除级联、帧列表 |
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、显式生成帧 FPS 选择、扩展名校验、自动建项目、关联项目、创建异步任务、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | | 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 显示当前/总时长 | | 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 提示点点击删除且不冒泡新增点、撤销/重做历史栈 | | R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 工具切换、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合、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 | | 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 和起止帧范围自动传播、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
| R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、seed point 回显/归一化、项目不存在、帧不存在 | | R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、seed point 回显/归一化、项目不存在、帧不存在 |
| R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、JSON 分类导入、mapping_rules 解包/打包、后端模板 CRUD | | 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 换标签、自定义分类本地添加 | | R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、分类展示、具体分类选择、Canvas 选区同步、点击分类给已选 mask 换标签并移动到前端渲染最上层、自定义分类 PATCH 后端模板、选中 mask 后端属性分析、重新提取拓扑锚点 |
| R10 Dashboard 与 WebSocket | `src/lib/api.test.ts`, `src/lib/websocket.test.ts`, `src/components/Dashboard.test.tsx`, `backend/tests/test_dashboard.py`, `backend/tests/test_main.py`, `backend/tests/test_progress_events.py`, `backend/tests/test_tasks.py` | 后端概览接口、任务表驱动进度区、最近完成任务保留显示、任务取消/重试/详情、cancelled 事件、Redis 进度事件 payload/发布、地址推导、消息订阅、连接状态回调、队列更新、heartbeat | | R10 Dashboard 与 WebSocket | `src/lib/api.test.ts`, `src/lib/websocket.test.ts`, `src/components/Dashboard.test.tsx`, `backend/tests/test_dashboard.py`, `backend/tests/test_main.py`, `backend/tests/test_progress_events.py`, `backend/tests/test_tasks.py` | 后端概览接口、任务表驱动进度区、最近完成任务保留显示、任务取消/重试/详情、cancelled 事件、Redis 进度事件 payload/发布、地址推导、消息订阅、连接状态回调、队列更新、heartbeat |
| R11 导出 | `src/components/VideoWorkspace.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_export.py` | COCO/PNG 按钮下载、导出前自动保存、导出路径、JSON 结构、mask ZIP、zIndex 语义融合 | | R11 导出 | `src/components/VideoWorkspace.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_export.py` | COCO/PNG 按钮下载、导出前自动保存、导出路径、JSON 结构、mask ZIP、zIndex 语义融合 |
| R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 | | R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 |
@@ -35,13 +35,13 @@
| R1 | 登录页、默认开发凭证、token 写入、失败提示、后端 401 | `Login.test.tsx`, `test_auth.py` | 已覆盖 | | R1 | 登录页、默认开发凭证、token 写入、失败提示、后端 401 | `Login.test.tsx`, `test_auth.py` | 已覆盖 |
| R2 | 项目列表/创建/选择、视频导入、DICOM 导入、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 | | R2 | 项目列表/创建/选择、视频导入、DICOM 导入、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、显式 FPS 生成帧、视频/DICOM 拆帧任务、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `test_media.py`, `test_tasks.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 | 工具切换、调整多边形入口、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
| R5 | 顶点编辑、边中点插点、双击边界按位置插点、顶点删除、整块删除、工作区 SAM 提示点删除优先级、撤销/重做、区域合并、区域去除、hole even-odd 渲染 | `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 | | 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` | 已覆盖 | | 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 和范围自动传播、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` | 已覆盖 | | 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` | 已覆盖 | | 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` | 已覆盖 | | R9 | 模板选择、分类展示、分类选择、已选 mask 换标签并置顶显示、自定义分类写入后端模板、后端属性分析、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 |
| R10 | Dashboard 概览、任务进度区、最近完成任务保留显示、活动日志、WebSocket progress/complete/error/status/cancelled、取消/重试/详情、连接状态回调、heartbeat | `Dashboard.test.tsx`, `websocket.test.ts`, `test_dashboard.py`, `test_main.py`, `test_progress_events.py`, `test_tasks.py` | 已覆盖 | | R10 | Dashboard 概览、任务进度区、最近完成任务保留显示、活动日志、WebSocket progress/complete/error/status/cancelled、取消/重试/详情、连接状态回调、heartbeat | `Dashboard.test.tsx`, `websocket.test.ts`, `test_dashboard.py`, `test_main.py`, `test_progress_events.py`, `test_tasks.py` | 已覆盖 |
| R11 | COCO/PNG ZIP 导出、导出前保存、路径和 JSON/ZIP 结构、zIndex 融合 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 | | R11 | COCO/PNG ZIP 导出、导出前保存、路径和 JSON/ZIP 结构、zIndex 融合 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 |
| R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 | | R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 |
@@ -56,12 +56,12 @@
- R6补充 `ModelStatusBadge.test.tsx` 中 SAM 3 不展示测试,避免禁用入口重新出现在前端。 - R6补充 `ModelStatusBadge.test.tsx` 中 SAM 3 不展示测试,避免禁用入口重新出现在前端。
- R6补充后端 `selected_model=sam3` 拒绝测试和 semantic 禁用测试,避免后端继续暴露 SAM 3 产品能力。 - R6补充后端 `selected_model=sam3` 拒绝测试和 semantic 禁用测试,避免后端继续暴露 SAM 3 产品能力。
- R6补充 `POST /api/ai/propagate` 后端测试,验证 seed mask 传播结果会保存为后续帧标注并保留 class 元数据。 - R6补充 `POST /api/ai/propagate` 后端测试,验证 seed mask 传播结果会保存为后续帧标注并保留 class 元数据。
- R6补充 `propagateMasks()` API 封装和 `VideoWorkspace` 传播按钮测试,验证当前选中区域会发送到后端视频传播接口。 - R6补充 `propagateMasks()` API 封装和 `VideoWorkspace` 自动传播按钮测试,验证当前参考帧全部 mask 会按范围发送到后端视频传播接口。
- R6`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。 - R6`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。
- R3补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps``max_frames``target_width` 会进入任务。 - R3补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps``max_frames``target_width` 会进入任务。
- R3补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms``source_frame_number``result.frame_sequence` 元数据。 - R3补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms``source_frame_number``result.frame_sequence` 元数据。
- R8补充 `TemplateRegistry.test.tsx` 中模板编辑、删除测试,验证前端调用真实 API 封装并更新全局 store。 - R8补充 `TemplateRegistry.test.tsx` 中模板编辑、删除测试,验证前端调用真实 API 封装并更新全局 store。
- R9补充 Canvas 选中 mask id 全局同步、本体树点击分类给已选 mask 换标签的测试,验证已保存 mask 会进入 dirty 状态。 - R9补充 Canvas 选中 mask id 全局同步、本体树点击分类给已选 mask 换标签并移到渲染最上层的测试,验证已保存 mask 会进入 dirty 状态。
## 运行命令 ## 运行命令

View File

@@ -8,40 +8,6 @@ async function startServer() {
app.use(express.json()); app.use(express.json());
// In-memory data store for the backend
const dataStore = {
projects: [
{ id: 1, name: 'Autonomous_Nav_Cam_Left.mp4', frames: 1240, status: 'Ready', fps: 30, thumbnail: 'bg-zinc-800' },
{ id: 2, name: 'Store_Checkout_Aisle_1.mkv', frames: 450, status: 'Processing', fps: 15, thumbnail: 'bg-zinc-800' },
{ id: 3, name: 'Medical_Scans_Series_A', frames: 80, status: 'Ready', fps: '图像序列', thumbnail: 'bg-zinc-800' },
{ id: 4, name: 'Drone_Survey_Forest.mp4', frames: 3200, status: 'Ready', fps: 60, thumbnail: 'bg-zinc-800' },
],
templates: [
{ id: 1, name: 'Cityscapes_v2_Mapping', classes: 34, rules: 8 },
{ id: 2, name: 'Medical_Cell_Segment', classes: 4, rules: 2 },
{ id: 3, name: 'COCO_Panoptic_Base', classes: 133, rules: 12 },
]
};
// Auth endpoint
app.post("/api/login", (req, res) => {
const { username, password } = req.body;
if (username === "admin" && password === "123456") {
res.json({ token: "fake-jwt-token-for-admin" });
} else {
res.status(401).json({ error: "Invalid credentials" });
}
});
// Data endpoints
app.get("/api/projects", (req, res) => {
res.json(dataStore.projects);
});
app.get("/api/templates", (req, res) => {
res.json(dataStore.templates);
});
// Vite middleware for development // Vite middleware for development
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
const vite = await createViteServer({ const vite = await createViteServer({

View File

@@ -42,6 +42,30 @@ describe('AISegmentation', () => {
expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny'); expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny');
}); });
it('does not render the legacy upload-replace-background mock button', () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.queryByText('上传替换底图')).not.toBeInTheDocument();
});
it('shows an empty state instead of a demo image when no project frame is selected', () => {
useStore.setState({ frames: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.getByText('请先在项目库选择项目并生成帧')).toBeInTheDocument();
});
it('shows contextual guidance for prompt tools', () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
expect(screen.getByText(/点击目标内部添加正向点/)).toBeInTheDocument();
fireEvent.click(screen.getByText('边界框选'));
expect(screen.getByText(/按住并拖拽建立框选区域/)).toBeInTheDocument();
});
it('passes enabled inference parameters to the backend', async () => { it('passes enabled inference parameters to the backend', async () => {
apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />); render(<AISegmentation onSendToWorkspace={vi.fn()} />);

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react'; import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva'; import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva';
import useImage from 'use-image'; import useImage from 'use-image';
@@ -13,6 +13,7 @@ interface AISegmentationProps {
type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' }; type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' };
type PromptBox = { x1: number; y1: number; x2: number; y2: number }; type PromptBox = { x1: number; y1: number; x2: number; y2: number };
type ToolHint = { title: string; body: string };
export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
const storeActiveTool = useStore((state) => state.activeTool); const storeActiveTool = useStore((state) => state.activeTool);
@@ -49,8 +50,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
const [boxCurrent, setBoxCurrent] = 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 [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
const currentFrame = frames[currentFrameIndex] || null; const currentFrame = frames[currentFrameIndex] || null;
const previewUrl = currentFrame?.url || 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop'; const [image] = useImage(currentFrame?.url || '');
const [image] = useImage(previewUrl);
const aiMaskIdSet = new Set(aiMaskIds); const aiMaskIdSet = new Set(aiMaskIds);
const frameMasks = currentFrame const frameMasks = currentFrame
? masks.filter((mask) => mask.frameId === currentFrame.id && aiMaskIdSet.has(mask.id)) ? masks.filter((mask) => mask.frameId === currentFrame.id && aiMaskIdSet.has(mask.id))
@@ -59,6 +59,33 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
const modelCanInfer = selectedModelStatus?.available ?? true; const modelCanInfer = selectedModelStatus?.available ?? true;
const effectiveTool = storeActiveTool; const effectiveTool = storeActiveTool;
const toolHint = React.useMemo<ToolHint | null>(() => {
if (!currentFrame) return null;
if (effectiveTool === 'point_pos') {
return {
title: '正向选点',
body: '点击目标内部添加正向点;点击已有提示点可删除。完成提示后点击“执行高精度语义分割”。',
};
}
if (effectiveTool === 'point_neg') {
return {
title: '反向选点',
body: '点击不应包含的区域添加反向点;可和框选/正向点一起使用来细化结果。',
};
}
if (effectiveTool === 'box_select') {
return {
title: promptBox ? '边界框已建立' : '边界框选',
body: promptBox
? '当前框会随推理一起发送;也可以继续添加正向/反向点细化。重新拖拽会替换框。'
: '按住并拖拽建立框选区域,松开后保留框,再点击“执行高精度语义分割”。',
};
}
if (effectiveTool === 'move') {
return { title: '视口控制', body: '拖拽移动画布,滚轮缩放;切回正向/反向点或框选后继续放置提示。' };
}
return null;
}, [currentFrame, effectiveTool, promptBox]);
const boxRect = React.useMemo(() => { const boxRect = React.useMemo(() => {
const activeBox = boxStart && boxCurrent const activeBox = boxStart && boxCurrent
@@ -505,9 +532,6 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
<Redo size={14} /> <Redo size={14} />
</button> </button>
<div className="w-px h-4 bg-white/10 mx-1"></div> <div className="w-px h-4 bg-white/10 mx-1"></div>
<button className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5">
<ImageIcon size={14} />
</button>
<button <button
className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5 disabled:opacity-30 disabled:hover:bg-white/5 disabled:hover:text-gray-400 disabled:cursor-not-allowed" className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5 disabled:opacity-30 disabled:hover:bg-white/5 disabled:hover:text-gray-400 disabled:cursor-not-allowed"
onClick={removeLastPromptPoint} onClick={removeLastPromptPoint}
@@ -534,6 +558,17 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
<div className="flex-1 relative p-8"> <div className="flex-1 relative p-8">
<div className="w-full h-full relative border border-white/5 rounded shadow-2xl bg-[#1e1e1e] overflow-hidden cursor-crosshair"> <div className="w-full h-full relative border border-white/5 rounded shadow-2xl bg-[#1e1e1e] overflow-hidden cursor-crosshair">
{!currentFrame && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-[#151515] text-xs text-gray-500">
</div>
)}
{toolHint && (
<div className="absolute top-4 left-4 z-20 max-w-sm rounded-lg border border-cyan-400/20 bg-[#0d0d0d]/95 px-3 py-2 shadow-xl pointer-events-none">
<div className="text-[10px] font-semibold uppercase tracking-widest text-cyan-300">{toolHint.title}</div>
<div className="mt-1 text-xs leading-relaxed text-gray-300">{toolHint.body}</div>
</div>
)}
<Stage <Stage
width={window.innerWidth - 320 - 64} width={window.innerWidth - 320 - 64}
height={window.innerHeight - 64 - 64} height={window.innerHeight - 64 - 64}

View File

@@ -297,9 +297,10 @@ describe('CanvasArea', () => {
masks: [ masks: [
{ {
id: 'm1', id: 'm1',
annotationId: '42',
frameId: 'frame-1', frameId: 'frame-1',
pathData: 'M 0 0 L 10 0 L 10 10 Z', pathData: 'M 0 0 L 10 0 L 10 10 Z',
label: 'A', label: '胆囊',
color: '#fff', color: '#fff',
segmentation: [[0, 0, 10, 0, 10, 10]], segmentation: [[0, 0, 10, 0, 10, 10]],
}, },
@@ -310,6 +311,7 @@ describe('CanvasArea', () => {
fireEvent.click(screen.getByTestId('konva-path')); fireEvent.click(screen.getByTestId('konva-path'));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1'])); await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
expect(screen.getByText('当前图层: 胆囊 #42')).toBeInTheDocument();
}); });
it('keeps a mask selected when opening the workspace polygon editor from AI results', () => { it('keeps a mask selected when opening the workspace polygon editor from AI results', () => {
@@ -389,6 +391,37 @@ describe('CanvasArea', () => {
})); }));
}); });
it('moves a polygon vertex directly while dragging without a prior vertex click', () => {
useStore.setState({
selectedMaskIds: ['draft-1'],
masks: [
{
id: 'draft-1',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: 'Draft',
color: '#06b6d4',
saveStatus: 'draft',
segmentation: [[10, 10, 90, 10, 90, 40]],
bbox: [10, 10, 80, 30],
},
],
});
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
const handles = screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
fireEvent.mouseDown(handles[0]);
fireEvent.mouseMove(handles[0], { clientX: 25, clientY: 35 });
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
pathData: 'M 25 35 L 90 10 L 90 40 Z',
segmentation: [[25, 35, 90, 10, 90, 40]],
saveStatus: 'draft',
}));
});
it('deletes a selected polygon vertex without dropping below three points', () => { it('deletes a selected polygon vertex without dropping below three points', () => {
useStore.setState({ useStore.setState({
masks: [ masks: [
@@ -626,6 +659,11 @@ describe('CanvasArea', () => {
const paths = screen.getAllByTestId('konva-path'); const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]); fireEvent.click(paths[0]);
fireEvent.click(paths[1]); fireEvent.click(paths[1]);
const selectedPaths = screen.getAllByTestId('konva-path');
expect(selectedPaths[0]).toHaveAttribute('data-stroke', '#facc15');
expect(selectedPaths[0]).toHaveAttribute('data-dash', '');
expect(selectedPaths[1]).toHaveAttribute('data-stroke', '#fb7185');
expect(selectedPaths[1]).toHaveAttribute('data-dash', '6,4');
fireEvent.click(screen.getByRole('button', { name: '从主区域去除' })); fireEvent.click(screen.getByRole('button', { name: '从主区域去除' }));
expect(useStore.getState().masks).toHaveLength(2); expect(useStore.getState().masks).toHaveLength(2);
@@ -796,9 +834,11 @@ describe('CanvasArea', () => {
it('finalizes a clicked polygon with Enter', () => { it('finalizes a clicked polygon with Enter', () => {
render(<CanvasArea activeTool="create_polygon" frame={frame} />); render(<CanvasArea activeTool="create_polygon" frame={frame} />);
const stage = screen.getByTestId('konva-stage'); const stage = screen.getByTestId('konva-stage');
expect(screen.getByText(/点击画布添加顶点/)).toBeInTheDocument();
fireEvent.click(stage, { clientX: 120, clientY: 80 }); fireEvent.click(stage, { clientX: 120, clientY: 80 });
fireEvent.click(stage, { clientX: 220, clientY: 80 }); fireEvent.click(stage, { clientX: 220, clientY: 80 });
fireEvent.click(stage, { clientX: 180, clientY: 160 }); fireEvent.click(stage, { clientX: 180, clientY: 160 });
expect(screen.getByText(/点击黄色首节点或按 Enter 闭合完成/)).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'Enter' }); fireEvent.keyDown(window, { key: 'Enter' });
expect(useStore.getState().masks).toHaveLength(1); expect(useStore.getState().masks).toHaveLength(1);
@@ -831,6 +871,35 @@ describe('CanvasArea', () => {
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0); expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
}); });
it('shows contextual guidance for boolean selection ordering', () => {
useStore.setState({
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 50 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[10, 10, 90, 10, 90, 50]],
},
{
id: 'm2',
frameId: 'frame-1',
pathData: 'M 50 30 L 120 30 L 120 80 Z',
label: 'B',
color: '#ff0000',
segmentation: [[50, 30, 120, 30, 120, 80]],
},
],
});
render(<CanvasArea activeTool="area_remove" frame={frame} />);
expect(screen.getByText(/先点击要保留的主区域/)).toBeInTheDocument();
fireEvent.click(screen.getAllByTestId('konva-path')[0]);
expect(screen.getByText(/第一个是保留主区域/)).toBeInTheDocument();
});
it('applies the selected class to current-frame masks and marks saved masks dirty', () => { it('applies the selected class to current-frame masks and marks saved masks dirty', () => {
useStore.setState({ useStore.setState({
activeTemplateId: '2', activeTemplateId: '2',

View File

@@ -16,6 +16,7 @@ interface CanvasAreaProps {
type CanvasPoint = { x: number; y: number }; type CanvasPoint = { x: number; y: number };
type PromptPoint = CanvasPoint & { type: 'pos' | 'neg' }; type PromptPoint = CanvasPoint & { type: 'pos' | 'neg' };
type PromptBox = { x1: number; y1: number; x2: number; y2: number }; type PromptBox = { x1: number; y1: number; x2: number; y2: number };
type ToolHint = { title: string; body: string };
const DRAG_MANUAL_TOOLS = new Set(['create_rectangle', 'create_circle', 'create_line']); const DRAG_MANUAL_TOOLS = new Set(['create_rectangle', 'create_circle', 'create_line']);
const POLYGON_TOOL = 'create_polygon'; const POLYGON_TOOL = 'create_polygon';
@@ -282,6 +283,81 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const dirtyMaskCount = frameMasks.filter((mask) => mask.saveStatus === 'dirty').length; const dirtyMaskCount = frameMasks.filter((mask) => mask.saveStatus === 'dirty').length;
const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool); const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool);
const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL; const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL;
const currentLayerLabel = selectedMask
? `${selectedMask.className || selectedMask.label}${selectedMask.annotationId ? ` #${selectedMask.annotationId}` : ' (未保存)'}`
: '未选择';
const toolHint = React.useMemo<ToolHint | null>(() => {
if (!frame) return null;
if (effectiveTool === POLYGON_TOOL) {
if (polygonPoints.length === 0) {
return {
title: '创建多边形',
body: '点击画布添加顶点;至少 3 个点后,点击首节点或按 Enter 完成,按 Esc 取消。',
};
}
if (polygonPoints.length < 3) {
return {
title: `创建多边形 · 已放置 ${polygonPoints.length}`,
body: '继续点击添加顶点;满 3 个点后才能闭合,按 Esc 可取消当前多边形。',
};
}
return {
title: `创建多边形 · 已放置 ${polygonPoints.length}`,
body: '点击黄色首节点或按 Enter 闭合完成;按 Esc 放弃当前多边形。',
};
}
if (effectiveTool === 'create_rectangle') {
return { title: '创建矩形', body: '按住并拖拽框出区域,松开鼠标后生成 mask切换工具可放弃当前操作。' };
}
if (effectiveTool === 'create_circle') {
return { title: '创建圆形', body: '按住并拖拽确定外接范围,松开鼠标后生成椭圆 mask。' };
}
if (effectiveTool === 'create_line') {
return { title: '创建线段', body: '按住并拖拽画出线段,松开后生成有宽度的线状 mask。' };
}
if (effectiveTool === POINT_TOOL) {
return { title: '创建点区域', body: '点击画布创建一个小型点区域;也可以在已有 mask 上继续落点。' };
}
if (effectiveTool === 'box_select') {
return {
title: samPromptBox ? '边界框已建立' : '边界框选',
body: samPromptBox
? '继续添加正向/反向点可细化同一个候选区域;重新拖拽会替换当前框。'
: '按住并拖拽建立框选区域,松开后会触发 SAM 推理。',
};
}
if (effectiveTool === 'point_pos') {
return { title: '正向选点', body: '点击目标内部添加正向点并触发细化;点击已有提示点可删除并重新推理。' };
}
if (effectiveTool === 'point_neg') {
return { title: '反向选点', body: '点击不应包含的区域添加反向点;点击已有提示点可删除并重新推理。' };
}
if (effectiveTool === 'area_merge') {
return {
title: '区域合并',
body: booleanSelectedMasks.length > 0
? `已选 ${booleanSelectedMasks.length} 个区域;第一个选中的是主区域,点击“合并选中”完成。`
: '依次点击多个 mask第一个选中的区域会作为合并后的主区域。',
};
}
if (effectiveTool === 'area_remove') {
return {
title: '重叠区域去除',
body: booleanSelectedMasks.length > 0
? `已选 ${booleanSelectedMasks.length} 个区域;第一个是保留主区域,后续区域会被扣除。`
: '先点击要保留的主区域,再点击要扣除的干涉区域。',
};
}
if (effectiveTool === EDIT_POLYGON_TOOL || (effectiveTool === 'move' && selectedMask)) {
return {
title: selectedMask ? '调整多边形' : '调整多边形',
body: selectedMask
? '可直接拖动白色顶点;点击青色边中点或双击边线新增顶点;选中顶点/区域后按 Delete 删除。'
: '点击一个 mask 后,可拖动顶点、点击边中点新增顶点,或按 Delete 删除选中区域。',
};
}
return null;
}, [booleanSelectedMasks.length, effectiveTool, frame, polygonPoints.length, samPromptBox, selectedMask]);
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
@@ -860,7 +936,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
setSelectedVertexIndex(null); setSelectedVertexIndex(null);
}; };
const handleVertexDragEnd = (mask: Mask, vertexIndex: number, event: any) => { const handleVertexDragStart = (mask: Mask, vertexIndex: number, event?: any) => {
if (event) event.cancelBubble = true;
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setSelectedVertexIndex(vertexIndex);
};
const handleVertexDrag = (mask: Mask, vertexIndex: number, event: any) => {
const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width; const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width;
const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height; const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height;
const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex); const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex);
@@ -874,6 +957,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
: point : point
)); ));
setSelectedMaskId(mask.id); setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setSelectedVertexIndex(vertexIndex); setSelectedVertexIndex(vertexIndex);
updatePolygonMask(mask, nextPoints, selectedPolygonIndex); updatePolygonMask(mask, nextPoints, selectedPolygonIndex);
}; };
@@ -977,6 +1061,12 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
{inferenceMessage} {inferenceMessage}
</div> </div>
)} )}
{toolHint && (
<div className="absolute top-4 left-4 z-20 max-w-sm rounded-lg border border-cyan-400/20 bg-[#0d0d0d]/95 px-3 py-2 shadow-xl pointer-events-none">
<div className="text-[10px] font-semibold uppercase tracking-widest text-cyan-300">{toolHint.title}</div>
<div className="mt-1 text-xs leading-relaxed text-gray-300">{toolHint.body}</div>
</div>
)}
<Stage <Stage
width={stageSize.width} width={stageSize.width}
@@ -1005,6 +1095,16 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
{/* AI Returned Masks */} {/* AI Returned Masks */}
{frameMasks.map((mask) => { {frameMasks.map((mask) => {
const selectedIndex = selectedMaskIds.indexOf(mask.id);
const isMaskSelected = selectedIndex >= 0;
const isBooleanPrimary = isBooleanTool && selectedIndex === 0;
const isBooleanSecondary = isBooleanTool && selectedIndex > 0;
const strokeColor = isBooleanPrimary
? '#facc15'
: isBooleanSecondary
? '#fb7185'
: mask.color;
const strokeDash = isBooleanSecondary ? [6 / scale, 4 / scale] : undefined;
const hasHoles = Boolean(mask.metadata?.hasHoles); const hasHoles = Boolean(mask.metadata?.hasHoles);
const paths = hasHoles const paths = hasHoles
? [{ data: segmentationPath(mask.segmentation), polygonIndex: 0, fillRule: 'evenodd' }] ? [{ data: segmentationPath(mask.segmentation), polygonIndex: 0, fillRule: 'evenodd' }]
@@ -1014,15 +1114,16 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
fillRule: undefined, fillRule: undefined,
})); }));
return ( return (
<Group key={mask.id} opacity={selectedMaskIds.includes(mask.id) ? 0.65 : 0.5}> <Group key={mask.id} opacity={isMaskSelected ? 0.65 : 0.5}>
{paths.map(({ data, polygonIndex, fillRule }) => ( {paths.map(({ data, polygonIndex, fillRule }) => (
<Path <Path
key={`${mask.id}-polygon-${polygonIndex}`} key={`${mask.id}-polygon-${polygonIndex}`}
data={data} data={data}
fill={mask.color} fill={mask.color}
fillRule={fillRule} fillRule={fillRule}
stroke={mask.color} stroke={strokeColor}
strokeWidth={(selectedMaskIds.includes(mask.id) ? 2 : 1) / scale} strokeWidth={(isMaskSelected ? 2 : 1) / scale}
dash={strokeDash}
onClick={(event: any) => handleMaskSelect(mask, event, polygonIndex)} onClick={(event: any) => handleMaskSelect(mask, event, polygonIndex)}
onTap={(event: any) => handleMaskSelect(mask, event, polygonIndex)} onTap={(event: any) => handleMaskSelect(mask, event, polygonIndex)}
onDblClick={(event: any) => handlePathDoubleClick(mask, event, polygonIndex)} onDblClick={(event: any) => handlePathDoubleClick(mask, event, polygonIndex)}
@@ -1125,6 +1226,9 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
stroke={selectedMask.color} stroke={selectedMask.color}
strokeWidth={2 / scale} strokeWidth={2 / scale}
draggable draggable
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onClick={(event: any) => { onClick={(event: any) => {
event.cancelBubble = true; event.cancelBubble = true;
setSelectedVertexIndex(index); setSelectedVertexIndex(index);
@@ -1133,7 +1237,8 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
event.cancelBubble = true; event.cancelBubble = true;
setSelectedVertexIndex(index); setSelectedVertexIndex(index);
}} }}
onDragEnd={(event: any) => handleVertexDragEnd(selectedMask, index, event)} onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event)}
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event)}
/> />
))} ))}
@@ -1163,7 +1268,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
<div className="absolute bottom-4 left-4 flex gap-4 text-[10px] font-mono text-gray-500 pointer-events-none"> <div className="absolute bottom-4 left-4 flex gap-4 text-[10px] font-mono text-gray-500 pointer-events-none">
<span>: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span> <span>: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
<span>当前图层树: OBJECT_VEHICLE_01</span> <span>: {currentLayerLabel}</span>
<span>: {(scale * 100).toFixed(0)}%</span> <span>: {(scale * 100).toFixed(0)}%</span>
<span>: {frameMasks.length}</span> <span>: {frameMasks.length}</span>
<span>: {savedMaskCount}</span> <span>: {savedMaskCount}</span>

View File

@@ -51,7 +51,7 @@ describe('FrameTimeline', () => {
expect(screen.getAllByText('00:00.20').length).toBeGreaterThan(0); expect(screen.getAllByText('00:00.20').length).toBeGreaterThan(0);
}); });
it('overlays edited frame markers as amber vertical lines on the time progress bar', () => { it('marks propagated frames as light-blue progress bar segments', () => {
useStore.setState({ useStore.setState({
currentFrameIndex: 1, currentFrameIndex: 1,
frames: [ frames: [
@@ -61,20 +61,26 @@ describe('FrameTimeline', () => {
], ],
masks: [ masks: [
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#06b6d4' }, { id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#06b6d4' },
{ id: 'm2', frameId: 'f3', annotationId: '9', pathData: 'M 0 0 Z', label: 'Saved', color: '#22c55e' }, {
id: 'm2',
frameId: 'f3',
annotationId: '9',
pathData: 'M 0 0 Z',
label: 'Saved',
color: '#22c55e',
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
},
{ id: 'outside', frameId: 'other-frame', pathData: 'M 0 0 Z', label: 'Other', color: '#fff' }, { id: 'outside', frameId: 'other-frame', pathData: 'M 0 0 Z', label: 'Other', color: '#fff' },
], ],
}); });
render(<FrameTimeline />); render(<FrameTimeline />);
expect(screen.getByText('已编辑 2 帧')).toBeInTheDocument(); expect(screen.getByText('自动传播 1 帧')).toBeInTheDocument();
expect(screen.queryByTestId('current-frame-line')).not.toBeInTheDocument(); expect(screen.queryByTestId('current-frame-line')).not.toBeInTheDocument();
expect(screen.getByLabelText('跳转到已编辑帧 2').className).toContain('before:bg-amber-300'); expect(screen.getAllByTestId('propagated-frame-segment')).toHaveLength(1);
expect(screen.getByLabelText('跳转到已编辑帧 3').className).toContain('before:h-5'); expect(screen.getByTestId('propagated-frame-segment').className).toContain('bg-sky-200');
expect(screen.getByLabelText('跳转到已编辑帧 3').className).not.toContain('h-2 w-2'); expect(screen.queryByLabelText('跳转到已编辑帧 3')).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText('跳转到已编辑帧 3'));
expect(useStore.getState().currentFrameIndex).toBe(2);
}); });
it('changes frames with left and right arrow keys without leaving bounds', () => { it('changes frames with left and right arrow keys without leaving bounds', () => {

View File

@@ -23,16 +23,20 @@ export function FrameTimeline() {
}, [currentProject?.original_fps, currentProject?.parse_fps]); }, [currentProject?.original_fps, currentProject?.parse_fps]);
const currentSeconds = totalFrames > 0 ? currentFrameIndex / timeBaseFps : 0; const currentSeconds = totalFrames > 0 ? currentFrameIndex / timeBaseFps : 0;
const totalSeconds = totalFrames > 0 ? Math.max(totalFrames - 1, 0) / timeBaseFps : 0; const totalSeconds = totalFrames > 0 ? Math.max(totalFrames - 1, 0) / timeBaseFps : 0;
const editedFrameMarkers = useMemo(() => { const propagatedFrameMarkers = useMemo(() => {
const frameIds = new Set(frames.map((frame) => frame.id)); const frameIds = new Set(frames.map((frame) => frame.id));
const editedIds = new Set( const propagatedIds = new Set(
masks masks
.filter((mask) => frameIds.has(mask.frameId)) .filter((mask) => frameIds.has(mask.frameId))
.filter((mask) => {
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : '';
return source.includes('_propagation') || mask.metadata?.propagated_from_frame_id !== undefined;
})
.map((mask) => mask.frameId), .map((mask) => mask.frameId),
); );
return frames return frames
.map((frame, index) => ({ frame, index })) .map((frame, index) => ({ frame, index }))
.filter(({ frame }) => editedIds.has(frame.id)); .filter(({ frame }) => propagatedIds.has(frame.id));
}, [frames, masks]); }, [frames, masks]);
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -117,21 +121,16 @@ export function FrameTimeline() {
className="h-full bg-cyan-500 absolute left-0" className="h-full bg-cyan-500 absolute left-0"
style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }} style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
/> />
{editedFrameMarkers.map(({ frame, index }) => { {propagatedFrameMarkers.map(({ frame, index }) => {
const left = totalFrames > 0 ? ((index + 1) / totalFrames) * 100 : 0; const left = totalFrames > 0 ? (index / totalFrames) * 100 : 0;
const width = totalFrames > 0 ? 100 / totalFrames : 0;
return ( return (
<button <div
key={frame.id} key={frame.id}
type="button" data-testid="propagated-frame-segment"
aria-label={`跳转到已编辑${index + 1}`} title={`自动传播${index + 1}`}
title={`已编辑帧 ${index + 1}`} className="absolute inset-y-0 z-10 bg-sky-200/80 shadow-[0_0_10px_rgba(186,230,253,0.55)]"
onClick={() => setCurrentFrame(index)} style={{ left: `${left}%`, width: `${width}%` }}
className={cn(
"absolute left-0 top-1/2 z-30 w-3 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-sm transition-all",
"before:absolute before:left-1/2 before:top-1/2 before:w-px before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:content-['']",
"before:h-5 before:bg-amber-300 before:shadow-[0_0_8px_rgba(251,191,36,0.5)] hover:before:h-7 hover:before:bg-amber-100"
)}
style={{ left: `${left}%` }}
/> />
); );
})} })}
@@ -143,7 +142,7 @@ export function FrameTimeline() {
</div> </div>
</div> </div>
<div className="absolute bottom-0 right-3 text-[9px] font-mono text-gray-500 pointer-events-none"> <div className="absolute bottom-0 right-3 text-[9px] font-mono text-gray-500 pointer-events-none">
{editedFrameMarkers.length} {propagatedFrameMarkers.length}
</div> </div>
</div> </div>

View File

@@ -1,12 +1,33 @@
import { fireEvent, render, screen, within } from '@testing-library/react'; import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils'; import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
import { OntologyInspector } from './OntologyInspector'; import { OntologyInspector } from './OntologyInspector';
const apiMock = vi.hoisted(() => ({
analyzeMask: vi.fn(),
updateTemplate: vi.fn(),
}));
vi.mock('../lib/api', () => ({
analyzeMask: apiMock.analyzeMask,
updateTemplate: apiMock.updateTemplate,
}));
describe('OntologyInspector', () => { describe('OntologyInspector', () => {
beforeEach(() => { beforeEach(() => {
resetStore(); resetStore();
vi.clearAllMocks();
apiMock.analyzeMask.mockResolvedValue({
confidence: 0.82,
confidence_source: 'model_score',
topology_anchor_count: 4,
topology_anchors: [],
area: 0.1,
bbox: [0, 0, 0.1, 0.1],
source: 'sam2.1_hiera_tiny',
message: '已读取后端几何属性',
});
useStore.setState({ useStore.setState({
templates: [ templates: [
{ {
@@ -49,6 +70,14 @@ describe('OntologyInspector', () => {
useStore.setState({ useStore.setState({
selectedMaskIds: ['m1'], selectedMaskIds: ['m1'],
masks: [ masks: [
{
id: 'm2',
frameId: 'frame-1',
pathData: 'M 10 10 Z',
label: '未选区域',
color: '#ffffff',
saveStatus: 'draft',
},
{ {
id: 'm1', id: 'm1',
annotationId: '99', annotationId: '99',
@@ -66,7 +95,8 @@ describe('OntologyInspector', () => {
fireEvent.click(screen.getByText('肝脏')); fireEvent.click(screen.getByText('肝脏'));
expect(useStore.getState().activeClassId).toBe('c2'); expect(useStore.getState().activeClassId).toBe('c2');
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['m2', 'm1']);
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
templateId: 't1', templateId: 't1',
classId: 'c2', classId: 'c2',
className: '肝脏', className: '肝脏',
@@ -80,16 +110,59 @@ describe('OntologyInspector', () => {
expect(screen.getByText('1')).toBeInTheDocument(); expect(screen.getByText('1')).toBeInTheDocument();
}); });
it('adds custom classes locally without backend persistence', () => { it('persists custom classes to the active backend template', async () => {
const { container } = render(<OntologyInspector />); apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '腹腔镜模板',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, category: '器官' },
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, category: '器官' },
{ id: 'custom-1', name: '新局部分类', color: '#06b6d4', zIndex: 30, category: '自定义' },
],
rules: [],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 't1' } });
const customSection = screen.getByText('自定义分类').parentElement!; const customSection = screen.getByText('自定义分类').parentElement!;
fireEvent.click(within(customSection).getByRole('button')); fireEvent.click(within(customSection).getByRole('button'));
fireEvent.change(screen.getByPlaceholderText('分类名称'), { target: { value: '新局部分类' } }); fireEvent.change(screen.getByPlaceholderText('分类名称'), { target: { value: '新局部分类' } });
fireEvent.keyDown(screen.getByPlaceholderText('分类名称'), { key: 'Enter' }); fireEvent.keyDown(screen.getByPlaceholderText('分类名称'), { key: 'Enter' });
expect(screen.getAllByText('新局部分类')).toHaveLength(2); expect(await screen.findByText('自定义分类已保存到后端模板')).toBeInTheDocument();
expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: expect.arrayContaining([expect.objectContaining({ name: '新局部分类', category: '自定义' })]),
}));
expect(useStore.getState().activeClass).toEqual(expect.objectContaining({ name: '新局部分类' })); expect(useStore.getState().activeClass).toEqual(expect.objectContaining({ name: '新局部分类' }));
expect(useStore.getState().templates[0].classes).toHaveLength(2); expect(useStore.getState().templates[0].classes).toHaveLength(3);
expect(container).toHaveTextContent('2 个分类来自模板 + 1 个自定义'); });
it('loads selected mask properties from the backend analyzer', async () => {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 20, 10, 20, 20]],
metadata: { source: 'sam2.1_hiera_tiny', score: 0.82 },
},
],
});
render(<OntologyInspector />);
expect(await screen.findByText('0.8200')).toBeInTheDocument();
expect(screen.getByText('4 节点')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '重新提取拓扑锚点' }));
expect(apiMock.analyzeMask).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'm1' }),
expect.objectContaining({ id: 'frame-1' }),
{ extractSkeleton: true },
);
}); });
}); });

View File

@@ -1,30 +1,39 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Layers, ChevronDown, Tag, Eye, Plus, X } from 'lucide-react'; import { Layers, ChevronDown, Tag, Eye, Plus, X, Loader2 } from 'lucide-react';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
import type { TemplateClass } from '../store/useStore'; import type { TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { getActiveTemplate } from '../lib/templateSelection'; import { getActiveTemplate } from '../lib/templateSelection';
import { analyzeMask, updateTemplate, type MaskAnalysisResult } from '../lib/api';
export function OntologyInspector() { export function OntologyInspector() {
const templates = useStore((state) => state.templates); const templates = useStore((state) => state.templates);
const activeTemplateId = useStore((state) => state.activeTemplateId); const activeTemplateId = useStore((state) => state.activeTemplateId);
const activeClassId = useStore((state) => state.activeClassId); const activeClassId = useStore((state) => state.activeClassId);
const activeClass = useStore((state) => state.activeClass); const activeClass = useStore((state) => state.activeClass);
const frames = useStore((state) => state.frames);
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
const masks = useStore((state) => state.masks); const masks = useStore((state) => state.masks);
const selectedMaskIds = useStore((state) => state.selectedMaskIds); const selectedMaskIds = useStore((state) => state.selectedMaskIds);
const setMasks = useStore((state) => state.setMasks); const setMasks = useStore((state) => state.setMasks);
const updateTemplateStore = useStore((state) => state.updateTemplate);
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId); const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
const setActiveClass = useStore((state) => state.setActiveClass); const setActiveClass = useStore((state) => state.setActiveClass);
// Project-level custom classes (in addition to template classes)
const [customClasses, setCustomClasses] = useState<TemplateClass[]>([]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newClassName, setNewClassName] = useState(''); const [newClassName, setNewClassName] = useState('');
const [newClassColor, setNewClassColor] = useState('#06b6d4'); const [newClassColor, setNewClassColor] = useState('#06b6d4');
const [isSavingClass, setIsSavingClass] = useState(false);
const [classSaveMessage, setClassSaveMessage] = useState('');
const [maskAnalysis, setMaskAnalysis] = useState<MaskAnalysisResult | null>(null);
const [isAnalyzingMask, setIsAnalyzingMask] = useState(false);
const [analysisMessage, setAnalysisMessage] = useState('');
const activeTemplate = getActiveTemplate(templates, activeTemplateId); const activeTemplate = getActiveTemplate(templates, activeTemplateId);
const templateClasses = activeTemplate?.classes || []; const templateClasses = activeTemplate?.classes || [];
const allClasses = [...templateClasses, ...customClasses].sort((a, b) => b.zIndex - a.zIndex); const allClasses = [...templateClasses].sort((a, b) => b.zIndex - a.zIndex);
const selectedMask = masks.find((mask) => selectedMaskIds.includes(mask.id)) || null;
const currentFrame = frames[currentFrameIndex] || null;
const handleSelectClass = (templateClass: TemplateClass) => { const handleSelectClass = (templateClass: TemplateClass) => {
if (activeTemplate && !activeTemplateId) { if (activeTemplate && !activeTemplateId) {
@@ -36,7 +45,7 @@ export function OntologyInspector() {
if (!hasSelectedMasks) return; if (!hasSelectedMasks) return;
const templateId = activeTemplate?.id || activeTemplateId || undefined; const templateId = activeTemplate?.id || activeTemplateId || undefined;
setMasks(masks.map((mask) => { const updatedMasks = masks.map((mask) => {
if (!selectedIdSet.has(mask.id)) return mask; if (!selectedIdSet.has(mask.id)) return mask;
return { return {
...mask, ...mask,
@@ -46,15 +55,53 @@ export function OntologyInspector() {
classZIndex: templateClass.zIndex, classZIndex: templateClass.zIndex,
label: templateClass.name, label: templateClass.name,
color: templateClass.color, color: templateClass.color,
saveStatus: mask.annotationId ? 'dirty' : 'draft', saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
saved: mask.annotationId ? false : mask.saved, saved: mask.annotationId ? false : mask.saved,
}; };
})); });
const selectedMasksOnTop = selectedMaskIds
.map((id) => updatedMasks.find((mask) => mask.id === id))
.filter((mask): mask is (typeof updatedMasks)[number] => Boolean(mask));
setMasks([
...updatedMasks.filter((mask) => !selectedIdSet.has(mask.id)),
...selectedMasksOnTop,
]);
}; };
const handleAddCustom = () => { const refreshMaskAnalysis = async (extractSkeleton = false) => {
if (!selectedMask || !currentFrame) {
setMaskAnalysis(null);
setAnalysisMessage(selectedMask ? '当前帧信息不可用,无法读取后端属性' : '请选择一个 mask 查看后端属性');
return;
}
setIsAnalyzingMask(true);
setAnalysisMessage('');
try {
const result = await analyzeMask(selectedMask, currentFrame, { extractSkeleton });
setMaskAnalysis(result);
setAnalysisMessage(result.message);
} catch (err) {
console.error('Mask analysis failed:', err);
setMaskAnalysis(null);
setAnalysisMessage('后端属性读取失败');
} finally {
setIsAnalyzingMask(false);
}
};
React.useEffect(() => {
void refreshMaskAnalysis(false);
// selectedMask is intentionally tracked by id and geometry fields to avoid
// re-running analysis for unrelated store changes.
}, [selectedMask?.id, selectedMask?.segmentation, selectedMask?.points, currentFrame?.id]);
const handleAddCustom = async () => {
if (!newClassName.trim()) return; if (!newClassName.trim()) return;
const maxZ = allClasses.length > 0 ? Math.max(...allClasses.map((c) => c.zIndex)) : 0; if (!activeTemplate) {
setClassSaveMessage('请先选择一个模板');
return;
}
const maxZ = templateClasses.length > 0 ? Math.max(...templateClasses.map((c) => c.zIndex)) : 0;
const newClass: TemplateClass = { const newClass: TemplateClass = {
id: `custom-${Date.now()}`, id: `custom-${Date.now()}`,
name: newClassName.trim(), name: newClassName.trim(),
@@ -62,10 +109,27 @@ export function OntologyInspector() {
zIndex: maxZ + 10, zIndex: maxZ + 10,
category: '自定义', category: '自定义',
}; };
setCustomClasses([...customClasses, newClass]); setIsSavingClass(true);
handleSelectClass(newClass); setClassSaveMessage('');
setNewClassName(''); try {
setShowAddForm(false); const updated = await updateTemplate(activeTemplate.id, {
name: activeTemplate.name,
description: activeTemplate.description,
classes: [...templateClasses, newClass],
rules: activeTemplate.rules || [],
});
updateTemplateStore(updated);
setActiveTemplateId(updated.id);
handleSelectClass(newClass);
setNewClassName('');
setShowAddForm(false);
setClassSaveMessage('自定义分类已保存到后端模板');
} catch (err) {
console.error('Save custom class failed:', err);
setClassSaveMessage('自定义分类保存失败');
} finally {
setIsSavingClass(false);
}
}; };
return ( return (
@@ -98,7 +162,6 @@ export function OntologyInspector() {
{activeTemplate && ( {activeTemplate && (
<div className="mt-2 text-[10px] text-gray-600"> <div className="mt-2 text-[10px] text-gray-600">
{activeTemplate.classes?.length ?? 0} {activeTemplate.classes?.length ?? 0}
{customClasses.length > 0 && ` + ${customClasses.length} 个自定义`}
</div> </div>
)} )}
</div> </div>
@@ -165,7 +228,7 @@ export function OntologyInspector() {
onKeyDown={(e) => e.key === 'Enter' && handleAddCustom()} onKeyDown={(e) => e.key === 'Enter' && handleAddCustom()}
/> />
<button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300"> <button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300">
<Plus size={14} /> {isSavingClass ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
</button> </button>
<button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300"> <button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
<X size={14} /> <X size={14} />
@@ -173,6 +236,9 @@ export function OntologyInspector() {
</div> </div>
</div> </div>
)} )}
{classSaveMessage && (
<div className="mt-2 text-[10px] text-gray-500">{classSaveMessage}</div>
)}
</div> </div>
{/* Current Active Object Properties */} {/* Current Active Object Properties */}
@@ -191,18 +257,30 @@ export function OntologyInspector() {
<span className="text-xs font-mono text-gray-300">{selectedMaskIds.length}</span> <span className="text-xs font-mono text-gray-300">{selectedMaskIds.length}</span>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] text-gray-500 uppercase"></label> <label className="text-[10px] text-gray-500 uppercase"></label>
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden"> <div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden">
<div className="h-full bg-green-500 w-[94%]" /> <div
className="h-full bg-green-500"
style={{ width: `${Math.round((maskAnalysis?.confidence ?? 0) * 100)}%` }}
/>
</div>
<div className="text-[10px] font-mono text-green-500 text-right">
{maskAnalysis?.confidence != null ? maskAnalysis.confidence.toFixed(4) : '无模型分数'}
</div> </div>
<div className="text-[10px] font-mono text-green-500 text-right">0.9412</div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] text-gray-500 uppercase">:</span> <span className="text-[10px] text-gray-500 uppercase">:</span>
<span className="text-xs font-mono text-gray-300">12 </span> <span className="text-xs font-mono text-gray-300">{maskAnalysis?.topology_anchor_count ?? 0} </span>
</div> </div>
<button className="w-full mt-2 bg-white/5 hover:bg-white/10 border border-white/10 text-xs text-gray-300 py-1.5 rounded transition-colors"> {analysisMessage && (
<div className="text-[10px] leading-relaxed text-gray-500">{analysisMessage}</div>
)}
<button
onClick={() => refreshMaskAnalysis(true)}
disabled={!selectedMask || isAnalyzingMask}
className="w-full mt-2 bg-white/5 hover:bg-white/10 border border-white/10 text-xs text-gray-300 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isAnalyzingMask ? '提取中...' : '重新提取拓扑锚点'}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -37,7 +37,7 @@ describe('ToolsPalette', () => {
const onTriggerAI = vi.fn(); const onTriggerAI = vi.fn();
render(<ToolsPalette activeTool="move" setActiveTool={setActiveTool} onTriggerAI={onTriggerAI} />); render(<ToolsPalette activeTool="move" setActiveTool={setActiveTool} onTriggerAI={onTriggerAI} />);
fireEvent.click(screen.getByTitle('触发 SAM 推理 (Enter)')); fireEvent.click(screen.getByTitle('打开 AI 智能分割'));
expect(setActiveTool).toHaveBeenCalledWith('sam_trigger'); expect(setActiveTool).toHaveBeenCalledWith('sam_trigger');
expect(onTriggerAI).toHaveBeenCalled(); expect(onTriggerAI).toHaveBeenCalled();

View File

@@ -91,7 +91,7 @@ export function ToolsPalette({
setActiveTool('sam_trigger'); setActiveTool('sam_trigger');
if (onTriggerAI) onTriggerAI(); if (onTriggerAI) onTriggerAI();
}} }}
title="触发 SAM 推理 (Enter)" title="打开 AI 智能分割"
className={cn( className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center transition-all", "w-10 h-10 rounded-lg flex items-center justify-center transition-all",
activeTool === 'sam_trigger' activeTool === 'sam_trigger'

View File

@@ -380,7 +380,7 @@ describe('VideoWorkspace', () => {
])); ]));
}); });
it('propagates the selected current-frame mask through the configured frame range', async () => { it('auto-propagates reference-frame masks through the configured frame range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 }, { 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 }, { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -404,7 +404,6 @@ describe('VideoWorkspace', () => {
useStore.setState({ useStore.setState({
aiModel: 'sam2.1_hiera_tiny', aiModel: 'sam2.1_hiera_tiny',
activeTemplateId: '2', activeTemplateId: '2',
selectedMaskIds: ['mask-1'],
masks: [{ masks: [{
id: 'mask-1', id: 'mask-1',
frameId: '10', frameId: '10',
@@ -417,7 +416,7 @@ describe('VideoWorkspace', () => {
}); });
}); });
fireEvent.click(screen.getByRole('button', { name: '按范围传播' })); fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledWith({ await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledWith({
project_id: 1, project_id: 1,
@@ -437,10 +436,10 @@ describe('VideoWorkspace', () => {
template_id: 2, template_id: 2,
}, },
})); }));
await waitFor(() => expect(screen.getByText('已传播 1 个 seed,处理 3 帧次,保存 2 个区域')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask,处理 3 帧次,保存 2 个区域')).toBeInTheDocument());
}); });
it('propagates all current-frame masks to all reachable frames in both directions', async () => { it('auto-propagates all reference-frame masks in both directions inside the selected range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 }, { 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: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -502,8 +501,9 @@ describe('VideoWorkspace', () => {
}); });
}); });
fireEvent.change(screen.getByLabelText('传播对象'), { target: { value: 'all' } }); fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
fireEvent.click(screen.getByRole('button', { name: '传播全部可达' })); fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledTimes(4)); await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledTimes(4));
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(1, expect.objectContaining({ expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(1, expect.objectContaining({
@@ -526,6 +526,6 @@ describe('VideoWorkspace', () => {
max_frames: 2, max_frames: 2,
seed: expect.objectContaining({ label: '肝脏' }), seed: expect.objectContaining({ label: '肝脏' }),
})); }));
await waitFor(() => expect(screen.getByText('已传播 2 个 seed,处理 8 帧次,保存 4 个区域')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('已自动传播 2 个参考 mask,处理 8 帧次,保存 4 个区域')).toBeInTheDocument());
}); });
}); });

View File

@@ -21,7 +21,6 @@ import { FrameTimeline } from './FrameTimeline';
import { ModelStatusBadge } from './ModelStatusBadge'; import { ModelStatusBadge } from './ModelStatusBadge';
import type { Frame, Mask } from '../store/useStore'; import type { Frame, Mask } from '../store/useStore';
type PropagationTarget = 'selected' | 'all';
type PropagationDirection = 'forward' | 'backward'; type PropagationDirection = 'forward' | 'backward';
export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) { export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) {
@@ -52,7 +51,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const [isImportingGt, setIsImportingGt] = useState(false); const [isImportingGt, setIsImportingGt] = useState(false);
const [isPropagating, setIsPropagating] = useState(false); const [isPropagating, setIsPropagating] = useState(false);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [propagationTarget, setPropagationTarget] = useState<PropagationTarget>('selected');
const [propagationStartFrame, setPropagationStartFrame] = useState(1); const [propagationStartFrame, setPropagationStartFrame] = useState(1);
const [propagationEndFrame, setPropagationEndFrame] = useState(1); const [propagationEndFrame, setPropagationEndFrame] = useState(1);
@@ -354,20 +352,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}; };
}, [activeTemplateId, currentFrame, currentProject?.id]); }, [activeTemplateId, currentFrame, currentProject?.id]);
const handlePropagateSegment = async (rangeOverride?: { startFrameNumber: number; endFrameNumber: number }) => { const handleAutoPropagate = async () => {
if (!currentProject?.id || !currentFrame?.id) return; if (!currentProject?.id || !currentFrame?.id) return;
const currentFrameMasks = masks.filter((mask) => mask.frameId === currentFrame.id); const seedMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
const selectedMasks = selectedMaskIds
.map((id) => currentFrameMasks.find((mask) => mask.id === id))
.filter((mask): mask is Mask => Boolean(mask));
const seedMasks = propagationTarget === 'all' ? currentFrameMasks : selectedMasks;
if (seedMasks.length === 0) { if (seedMasks.length === 0) {
setStatusMessage(propagationTarget === 'all' ? '当前帧没有可传播区域' : '请先选择一个或多个当前帧区域'); setStatusMessage('请先在当前参考帧创建或保存至少一个 mask');
return; return;
} }
const startFrameNumber = clampFrameNumber(rangeOverride?.startFrameNumber ?? propagationStartFrame); const startFrameNumber = clampFrameNumber(propagationStartFrame);
const endFrameNumber = clampFrameNumber(rangeOverride?.endFrameNumber ?? propagationEndFrame); const endFrameNumber = clampFrameNumber(propagationEndFrame);
const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1; const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1;
const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1; const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1;
const propagationDirections: Array<{ direction: PropagationDirection; maxFrames: number }> = []; const propagationDirections: Array<{ direction: PropagationDirection; maxFrames: number }> = [];
@@ -397,7 +391,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
} }
setIsPropagating(true); setIsPropagating(true);
setStatusMessage(`${aiModel.toUpperCase()} 正在传播 ${seeds.length}区域到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`); setStatusMessage(`${aiModel.toUpperCase()} 正在以第 ${currentFrameNumber} 帧为参考,自动传播 ${seeds.length} mask 到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`);
try { try {
let createdCount = 0; let createdCount = 0;
let processedCount = 0; let processedCount = 0;
@@ -418,7 +412,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
} }
} }
await hydrateSavedAnnotations(currentProject.id, frames); await hydrateSavedAnnotations(currentProject.id, frames);
setStatusMessage(`已传播 ${seeds.length} seed,处理 ${processedCount} 帧次,保存 ${createdCount} 个区域`); setStatusMessage(`自动传播 ${seeds.length}参考 mask,处理 ${processedCount} 帧次,保存 ${createdCount} 个区域`);
} catch (err) { } catch (err) {
console.error('Propagation failed:', err); console.error('Propagation failed:', err);
setStatusMessage('传播失败,请检查模型状态或后端日志'); setStatusMessage('传播失败,请检查模型状态或后端日志');
@@ -427,16 +421,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
} }
}; };
const handlePropagateAllReachable = () => {
if (totalFrames <= 1) {
setStatusMessage('当前项目没有可传播的前后帧');
return;
}
setPropagationStartFrame(1);
setPropagationEndFrame(totalFrames);
void handlePropagateSegment({ startFrameNumber: 1, endFrameNumber: totalFrames });
};
return ( return (
<div className="w-full h-full flex flex-col bg-[#0a0a0a]"> <div className="w-full h-full flex flex-col bg-[#0a0a0a]">
{/* Top Header / Status bar */} {/* Top Header / Status bar */}
@@ -468,16 +452,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
{isImportingGt ? '导入中...' : '导入 GT Mask'} {isImportingGt ? '导入中...' : '导入 GT Mask'}
</button> </button>
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1"> <div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
<select <span className="text-[10px] text-gray-500 whitespace-nowrap"> {currentFrameNumber || 0}</span>
aria-label="传播对象"
value={propagationTarget}
onChange={(event) => setPropagationTarget(event.target.value as PropagationTarget)}
disabled={isPropagating || isSaving || isExporting || isImportingGt}
className="h-6 bg-transparent text-[10px] text-gray-300 outline-none disabled:opacity-40"
>
<option value="selected"></option>
<option value="all"></option>
</select>
<span className="text-[10px] text-gray-600"></span> <span className="text-[10px] text-gray-600"></span>
<input <input
aria-label="传播起始帧" aria-label="传播起始帧"
@@ -502,18 +477,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
/> />
</div> </div>
<button <button
onClick={() => handlePropagateSegment()} onClick={handleAutoPropagate}
disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating} disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating}
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed" className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
> >
{isPropagating ? '传播中...' : '按范围传播'} {isPropagating ? '传播中...' : '自动传播'}
</button>
<button
onClick={handlePropagateAllReachable}
disabled={!currentProject?.id || !currentFrame?.id || totalFrames <= 1 || isSaving || isExporting || isImportingGt || isPropagating}
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
</button> </button>
<button <button
onClick={handleExportMasks} onClick={handleExportMasks}

View File

@@ -327,6 +327,8 @@ describe('api client contracts', () => {
label: '旧标签', label: '旧标签',
color: '#06b6d4', color: '#06b6d4',
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 }, class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 4,
}, },
points: [[0.5, 0.5]], points: [[0.5, 0.5]],
bbox: null, bbox: null,
@@ -347,6 +349,10 @@ describe('api client contracts', () => {
pathData: 'M 10 10 L 90 10 L 90 40 Z', pathData: 'M 10 10 L 90 10 L 90 40 Z',
points: [[50, 25]], points: [[50, 25]],
bbox: [10, 10, 80, 30], bbox: [10, 10, 80, 30],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 4,
},
})); }));
}); });
@@ -423,6 +429,48 @@ describe('api client contracts', () => {
}); });
}); });
it('sends normalized mask geometry to the backend analyzer', async () => {
const { analyzeMask } = await import('./api');
axiosMock.client.post.mockResolvedValueOnce({
data: {
confidence: 0.87,
confidence_source: 'model_score',
topology_anchor_count: 3,
topology_anchors: [],
area: 0.12,
bbox: [0.1, 0.2, 0.8, 0.6],
source: 'sam2.1_hiera_tiny',
message: 'ok',
},
});
const result = await analyzeMask({
id: 'm1',
frameId: '5',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 90, 10, 90, 40]],
bbox: [10, 10, 80, 30],
metadata: { source: 'sam2.1_hiera_tiny', score: 0.87 },
}, { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 }, { extractSkeleton: true });
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/analyze-mask', {
frame_id: 5,
mask_data: {
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
label: '胆囊',
color: '#ff0000',
source: 'sam2.1_hiera_tiny',
score: 0.87,
},
points: undefined,
bbox: [0.1, 0.2, 0.8, 0.6],
extract_skeleton: true,
});
expect(result.confidence).toBe(0.87);
});
it('normalizes combined box and point prompts for interactive SAM2 refinement', async () => { it('normalizes combined box and point prompts for interactive SAM2 refinement', async () => {
const { predictMask } = await import('./api'); const { predictMask } = await import('./api');
axiosMock.client.post.mockResolvedValueOnce({ data: { polygons: [], scores: [] } }); axiosMock.client.post.mockResolvedValueOnce({ data: { polygons: [], scores: [] } });

View File

@@ -294,6 +294,11 @@ export interface SavedAnnotation {
zIndex?: number; zIndex?: number;
category?: string; category?: string;
}; };
source?: string;
propagated_from_frame_id?: number;
propagated_from_frame_index?: number;
score?: number | null;
[key: string]: unknown;
} | null; } | null;
points: number[][] | null; points: number[][] | null;
bbox: number[] | null; bbox: number[] | null;
@@ -357,6 +362,17 @@ export interface PropagateMasksResult {
annotations: SavedAnnotation[]; annotations: SavedAnnotation[];
} }
export interface MaskAnalysisResult {
confidence: number | null;
confidence_source: string;
topology_anchor_count: number;
topology_anchors: number[][];
area: number;
bbox?: number[] | null;
source?: string | null;
message: string;
}
export interface DashboardTask { export interface DashboardTask {
id: string; id: string;
task_id?: number; task_id?: number;
@@ -498,6 +514,8 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
if (!firstPolygon || firstPolygon.length === 0) return null; if (!firstPolygon || firstPolygon.length === 0) return null;
const bbox = polygonToBbox(firstPolygon, frame.width, frame.height); const bbox = polygonToBbox(firstPolygon, frame.width, frame.height);
const classMetadata = annotation.mask_data?.class; const classMetadata = annotation.mask_data?.class;
const { polygons: _polygons, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
const hasMetadata = Object.keys(metadata).length > 0;
return { return {
id: `annotation-${annotation.id}`, id: `annotation-${annotation.id}`,
annotationId: String(annotation.id), annotationId: String(annotation.id),
@@ -515,9 +533,39 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
points: annotation.points?.map(([x, y]) => [x * frame.width, y * frame.height]), points: annotation.points?.map(([x, y]) => [x * frame.width, y * frame.height]),
bbox, bbox,
area: bbox[2] * bbox[3], area: bbox[2] * bbox[3],
metadata: hasMetadata ? metadata : undefined,
}; };
} }
export async function analyzeMask(mask: Mask, frame: Frame, options: { extractSkeleton?: boolean } = {}): Promise<MaskAnalysisResult> {
const polygons = pixelSegmentationToNormalizedPolygons(mask.segmentation, frame.width, frame.height);
const metadata = mask.metadata || {};
const response = await apiClient.post('/api/ai/analyze-mask', {
frame_id: Number(frame.id),
mask_data: {
polygons,
label: mask.label,
color: mask.color,
...(typeof metadata.source === 'string' ? { source: metadata.source } : {}),
...(typeof metadata.score === 'number' ? { score: metadata.score } : {}),
},
points: mask.points?.map(([x, y]) => [
clamp01(x / Math.max(frame.width, 1)),
clamp01(y / Math.max(frame.height, 1)),
]),
bbox: mask.bbox
? [
clamp01(mask.bbox[0] / Math.max(frame.width, 1)),
clamp01(mask.bbox[1] / Math.max(frame.height, 1)),
clamp01(mask.bbox[2] / Math.max(frame.width, 1)),
clamp01(mask.bbox[3] / Math.max(frame.height, 1)),
]
: undefined,
extract_skeleton: options.extractSkeleton ?? false,
});
return response.data;
}
export async function predictMask(payload: PredictMaskPayload): Promise<PredictMaskResult> { export async function predictMask(payload: PredictMaskPayload): Promise<PredictMaskResult> {
let prompt_type: 'point' | 'box' | 'semantic' | 'interactive'; let prompt_type: 'point' | 'box' | 'semantic' | 'interactive';
let prompt_data: unknown; let prompt_data: unknown;

View File

@@ -80,6 +80,22 @@ vi.mock('react-konva', () => ({
props.onClick?.(konvaEvent); props.onClick?.(konvaEvent);
if (konvaEvent.cancelBubble) event.stopPropagation(); if (konvaEvent.cancelBubble) event.stopPropagation();
}} }}
onMouseDown={(event) => {
const point = {
x: event.clientX || props.x || 120,
y: event.clientY || props.y || 80,
};
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
props.onMouseDown?.(konvaEvent);
props.onDragStart?.(konvaEvent);
if (konvaEvent.cancelBubble) event.stopPropagation();
}}
onMouseMove={(event) => props.onDragMove?.({
target: {
x: () => event.clientX || props.x || 0,
y: () => event.clientY || props.y || 0,
},
})}
onMouseUp={(event: React.MouseEvent<HTMLSpanElement>) => props.onDragEnd?.({ onMouseUp={(event: React.MouseEvent<HTMLSpanElement>) => props.onDragEnd?.({
target: { target: {
x: () => event.clientX || props.x || 0, x: () => event.clientX || props.x || 0,
@@ -100,6 +116,9 @@ vi.mock('react-konva', () => ({
data-testid="konva-path" data-testid="konva-path"
data-path={props.data} data-path={props.data}
data-fill={props.fill} data-fill={props.fill}
data-stroke={props.stroke}
data-stroke-width={props.strokeWidth}
data-dash={props.dash?.join(',') || ''}
data-fill-rule={props.fillRule} data-fill-rule={props.fillRule}
onClick={(event) => { onClick={(event) => {
const point = { const point = {