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:
18
AGENTS.md
18
AGENTS.md
@@ -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 fallback,DICOM 使用 pydicom;视频帧按 `frame_%06d.jpg` 连续命名并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据。
|
5. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;视频帧按 `frame_%06d.jpg` 连续命名并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据。
|
||||||
6. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`FrameTimeline` 会根据当前项目帧内的 `masks` 在顶部进度条上用琥珀色竖线标出已有编辑/标注的帧,当前帧位置由播放进度条末端、时间提示和缩略图高亮表达;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
|
6. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在顶部进度条对应区段标为浅蓝色,当前帧位置由播放进度条末端、时间提示和缩略图高亮表达;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
|
||||||
7. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、点区域和线段生成 polygon mask;多边形可按 Enter 或点击首节点闭合;绘制工具可在已有 mask 上继续落点;工具栏有“调整多边形”入口,点击 mask 可拖动/删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;区域合并/去除会隐藏编辑手柄并显示已选数量,使用 `polygon-clipping` 做 union/difference,内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。
|
7. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、点区域和线段生成 polygon mask;多边形可按 Enter 或点击首节点闭合;绘制工具可在已有 mask 上继续落点;工具栏有“调整多边形”入口,点击 mask 后可按住顶点直接拖动并实时更新 polygon,也可删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference,内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。
|
||||||
8. AI 分割:前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。
|
8. AI 分割:前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。
|
||||||
9. 视频片段传播:工作区可选择“选中区域”或“当前帧全部”作为 seed,并用起止帧指定追踪范围;“传播全部可达”会把范围设为第 1 帧到最后 1 帧。前端会按 seed mask 和前/后方向顺序调用单 seed `POST /api/ai/propagate`,避免多个视频 tracker 并发抢占 GPU;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`。
|
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 或鉴权。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -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。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
### 后端入口
|
### 后端入口
|
||||||
|
|
||||||
|
|||||||
@@ -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;不会删除工作区已有 mask,Delete/Backspace 也遵循同一范围 |
|
| 删除选中候选 | 真实可用 | 删除 AI 页当前选中的本页候选 mask;不会删除工作区已有 mask,Delete/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 文案。
|
||||||
|
|||||||
@@ -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`,用于把自动传播帧在时间进度条上标为浅蓝色。
|
||||||
|
|
||||||
## 已完成的接口对齐
|
## 已完成的接口对齐
|
||||||
|
|
||||||
|
|||||||
@@ -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 演示图;没有当前项目帧时显示空状态。后续如果要支持独立图片分析,应接正式上传入口和项目/帧关联。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 聚类和模板自动映射尚未实现。
|
||||||
|
|||||||
@@ -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 状态。
|
||||||
|
|
||||||
## 运行命令
|
## 运行命令
|
||||||
|
|
||||||
|
|||||||
34
server.ts
34
server.ts
@@ -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({
|
||||||
|
|||||||
@@ -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()} />);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: [] } });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user