Compare commits
65 Commits
8a9247075e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2c41a4ab | |||
| c8a78a1038 | |||
| 9cde34b2fc | |||
| 2a2e6b9b6c | |||
| 620e95ff91 | |||
| b1131c9126 | |||
| 6f4d4efeaf | |||
| d583b32221 | |||
| f6ee9fa924 | |||
| 10fe17476d | |||
| 5d73eacefe | |||
| 1ff757e2fa | |||
| ee27f29495 | |||
| 523beeb446 | |||
| 02635abab1 | |||
| 85de1ffbb2 | |||
| 1971640a67 | |||
| 84895bd9bd | |||
| 7fc4949677 | |||
| 87b82b882f | |||
| 141dd4ce4b | |||
| 56665283aa | |||
| b1bb792b71 | |||
| b7de163054 | |||
| b943f5e184 | |||
| f558f3b3c7 | |||
| 628bce23e0 | |||
| 94abad2794 | |||
| 46b055eba8 | |||
| 2f55ecfe6a | |||
| 0485ce4d92 | |||
| 5e570f789b | |||
| 07f3364718 | |||
| 81a47cd405 | |||
| 3dc6c3402e | |||
| a680510db8 | |||
| 45839a2e4c | |||
| 5603872821 | |||
| f42bf42989 | |||
| 2fe4623cae | |||
| 093ef6c63a | |||
| 061f4ed25b | |||
| b97c00900c | |||
| 5ae1d15336 | |||
| 275be62db5 | |||
| a22af5f7c8 | |||
| 3e998b9d6b | |||
| 0ba5a8c094 | |||
| 4d6bbf2b80 | |||
| 2da73f9acd | |||
| f88f9bdbb9 | |||
| 739953bc13 | |||
| d559cda2cb | |||
| cadacef04d | |||
| 7f8722410c | |||
| 0b4e10209a | |||
| 481ffa5b67 | |||
| afcddfaeb9 | |||
| 4c1d3dba73 | |||
| f365539ff2 | |||
| 4899c8a08a | |||
| c8c59f7ede | |||
| b6a276cb8d | |||
| 4c21de02f8 | |||
| 29a1a87e52 |
10
.env.example
10
.env.example
@@ -14,10 +14,10 @@ VITE_API_BASE_URL="http://192.168.3.11:8000"
|
|||||||
# Optional WebSocket override. If unset, it is derived from VITE_API_BASE_URL.
|
# Optional WebSocket override. If unset, it is derived from VITE_API_BASE_URL.
|
||||||
VITE_WS_PROGRESS_URL="ws://192.168.3.11:8000/ws/progress"
|
VITE_WS_PROGRESS_URL="ws://192.168.3.11:8000/ws/progress"
|
||||||
|
|
||||||
# Backend SAM runtime defaults. SAM 3 additionally requires the official sam3
|
# Backend SAM runtime defaults. Current product exposes SAM 2.1 variants only.
|
||||||
# package, Python 3.12+, PyTorch 2.7+, and a CUDA-capable GPU per Meta's repo.
|
sam_default_model="sam2.1_hiera_tiny"
|
||||||
sam_default_model="sam2"
|
sam_model_path="/home/wkmgc/Desktop/Seg_Server/models/sam2.1_hiera_tiny.pt"
|
||||||
sam_model_path="/home/wkmgc/Desktop/Seg_Server/models/sam2_hiera_tiny.pt"
|
sam_model_config="configs/sam2.1/sam2.1_hiera_t.yaml"
|
||||||
sam_model_config="configs/sam2/sam2_hiera_t.yaml"
|
|
||||||
sam3_model_version="sam3"
|
sam3_model_version="sam3"
|
||||||
sam3_checkpoint_path="/home/wkmgc/Desktop/Seg_Server/sam3权重/sam3.pt"
|
sam3_checkpoint_path="/home/wkmgc/Desktop/Seg_Server/sam3权重/sam3.pt"
|
||||||
|
sam3_external_enabled=false
|
||||||
|
|||||||
Binary file not shown.
172
README.md
172
README.md
@@ -4,21 +4,21 @@
|
|||||||
|
|
||||||
# 语义分割系统(SegServer)
|
# 语义分割系统(SegServer)
|
||||||
|
|
||||||
> 基于 React + FastAPI + 可选 SAM 2 / SAM 3 的全栈交互式图像/视频语义分割与标注平台。
|
> 基于 React + FastAPI + SAM 2 的全栈交互式图像/视频语义分割与标注平台。
|
||||||
>
|
>
|
||||||
> 支持本地多媒体资产上传、服务器端按帧解析、交互式 Canvas 标注、视频片段传播、GT mask 导入、模板分类管理和标注数据结构化导出;工作区点/框 AI 推理默认走 SAM 2,SAM 3 支持语义文本、框选提示和 video tracker,前端会显示真实 GPU/模型状态。
|
> 支持本地多媒体资产上传、服务器端按帧解析、交互式 Canvas 标注、视频片段传播、GT mask 导入、模板分类管理和标注数据结构化导出;工作区点/框 AI 推理走可选 SAM 2.1 tiny/small/base+/large,前端会显示真实 GPU/模型状态。SAM 3 源码和脚本在仓库中保留,但由于当前系统不提供文本提示,产品入口已隐藏,后端也不暴露 `sam3` 模型。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 核心功能
|
## 核心功能
|
||||||
|
|
||||||
- **多媒体资产管理** — 支持视频(MP4/AVI/MOV)和 DICOM 医学影像上传;视频导入与生成帧分离,生成帧时选择目标 FPS
|
- **多媒体资产管理** — 支持视频(MP4/AVI/MOV)和 DICOM 医学影像上传,项目库会在导入时显示上传进度条、已上传字节、DICOM 文件数量和上传后的解析任务进度;视频导入与生成帧分离,生成帧时选择目标 FPS,项目卡片可重命名、复制或删除项目及其关联帧、标注和任务记录;复制时可选择“新项目重置”只保留媒体/帧序列,或“全内容复制”连同标注和 mask 元数据一起复制
|
||||||
- **AI 智能分割引擎** — 后端提供 SAM 2 / SAM 3 模型选择;SAM 2 支持点分割(point)、框分割(box)、自动分割(auto)和 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示;SAM 3 入口支持文本语义提示、框选提示和 external video tracker,并按真实运行环境显示可用性
|
- **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体(tiny/small/base+/large)选择;侧栏和工作区跳转入口使用 Bot + Sparkles 组合图标强化 AI 识别;支持点分割(point)、框分割(box)、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割(auto)和 Celery 后台 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示
|
||||||
- **交互式画布标注** — 基于 Konva 的高性能 Canvas,支持缩放/平移/手工多边形/矩形/圆/点/线、polygon 顶点拖动/删除、边中点插点、双击边界插点、区域合并/去除、选点/框选、撤销/重做,实时渲染 Mask 遮罩
|
- **交互式画布标注** — 基于 Konva 的高性能 Canvas,工作区和 AI 画布会默认居中放大底图并保留边距;工作区支持缩放/平移/手工多边形/矩形/圆/画笔/橡皮擦、polygon 顶点直接拖动/删除、边中点插点、双击边界插点、区域合并/去除、撤销/重做;mask 不再显示黄色 seed point,也不提供 seed point 拖动;创建多边形/矩形/圆后会自动选中新 mask 并立即显示边界点;左侧工具栏提供等同 `Esc` 的“取消选中”实体按钮,用于清空选区和临时绘制状态;画笔和橡皮擦支持尺寸调节,画笔可按当前语义分类生成新的独立连续区域,即使与选中 mask 连通或重叠也不自动合并,画笔闭合成中空区域时会保留内洞 ring 并显示内外圈顶点,橡皮擦可从选中 mask 扣除区域;删除传播 seed 或传播结果时会同步删除同一传播链上的自动传播 mask,但保留其他帧独立 AI 推理/人工标注 mask;未选中特定 mask 时按右侧语义分类树的内部优先级叠放显示,AI 智能分割页单独提供正/反点和框选,实时渲染 Mask 遮罩
|
||||||
- **GT Mask 导入** — 工作区可导入 GT mask 图片,后端按非零像素值和连通域生成 polygon 标注并用 distance transform 写入 seed point;前端可回显和拖动 seed point
|
- **GT Mask 导入** — 工作区可导入 8-bit 二值/灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图;导入前会显示本地预览,不符合 8-bit 灰度/maskid 图要求时反馈错误,尺寸不同会按当前帧长宽最近邻拉伸;后端按 maskid 匹配当前模板类别并生成高精度 polygon 标注,导入后与普通 mask 一样不显示黄色 seed point,并共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存链路;超出现有类别的 maskid 可由用户选择舍弃或导入为“未定义类别”等待重新命名
|
||||||
- **本体字典管理** — 可配置的分类体系、颜色映射、图层优先级(z-index)
|
- **本体字典管理** — 可配置的分类体系、颜色映射、稳定且跨图一致的 maskid;所有模板默认带 `maskid: 0` 的黑色“待分类”保留类,该类固定在语义分类树最后、不可删除也不可拖到上层;模板库“生效中模板架构清单”可用鼠标复制现有模板为当前用户私有副本,详情页“语义分类树(拖拽调层级)”支持新建分类、拖拽调层级和垃圾桶删除 label,模板编辑弹窗和右侧分类树也可拖拽调整内部图层覆盖顺序,maskid 不参与排序
|
||||||
- **项目工作区** — 项目创建、帧浏览、多图层标注、进度追踪
|
- **项目工作区** — 项目创建、帧浏览、多图层标注、自动传播帧提示、进度追踪
|
||||||
- **数据导出** — 支持 COCO JSON 格式和 PNG Mask 批量导出;PNG ZIP 包含单标注 mask、按 z-index 融合的语义 mask 和类别映射
|
- **数据导出** — 工作区使用统一“分割结果导出”入口,可选择整体视频、特定范围帧或当前图片;特定范围帧支持输入帧号或在时间轴进度条上拖拽选择,并导出 COCO JSON、maskid/GT 像素值映射、原始图片、分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;GT/Pro/Mix 的重叠覆盖顺序和右侧语义分类树内部优先级一致,GT_label 固定为 8-bit uint8 PNG,背景为 0,类别值使用模板中的真实 maskid,`maskid: 0` 的“待分类”在 GT_label 中与背景同为 0,在 Pro_label 中与背景同为黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -36,12 +36,13 @@
|
|||||||
│ 业务逻辑层 (FastAPI + Python 3.11) │
|
│ 业务逻辑层 (FastAPI + Python 3.11) │
|
||||||
│ localhost:8000 │
|
│ localhost:8000 │
|
||||||
│ ├── /api/auth 登录认证 │
|
│ ├── /api/auth 登录认证 │
|
||||||
|
│ ├── /api/admin 管理员用户管理、审计日志、演示出厂重置 │
|
||||||
│ ├── /api/projects 项目 & 视频帧 CRUD │
|
│ ├── /api/projects 项目 & 视频帧 CRUD │
|
||||||
│ ├── /api/templates 本体字典(分类/颜色/z-index) │
|
│ ├── /api/templates 本体字典(分类/颜色/z-index) │
|
||||||
│ ├── /api/media 文件上传 & 异步拆帧任务创建 │
|
│ ├── /api/media 文件上传 & 异步拆帧任务创建 │
|
||||||
│ ├── /api/tasks Celery 后台任务状态/取消/重试/详情 │
|
│ ├── /api/tasks Celery 后台任务状态/取消/重试/详情 │
|
||||||
│ ├── /api/ai SAM 2 / SAM 3 推理与模型状态 │
|
│ ├── /api/ai SAM 2 推理与模型状态 │
|
||||||
│ └── /api/export COCO JSON / PNG Masks 导出 │
|
│ └── /api/export COCO JSON / PNG / 统一分割结果导出 │
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
│ SQLAlchemy 2.0
|
│ SQLAlchemy 2.0
|
||||||
┌──────────────────────────▼──────────────────────────────────┐
|
┌──────────────────────────▼──────────────────────────────────┐
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
| 队列 Broker | Redis | 6 |
|
| 队列 Broker | Redis | 6 |
|
||||||
| 后台任务 | Celery worker | 5.6+ |
|
| 后台任务 | Celery worker | 5.6+ |
|
||||||
| 对象存储 | MinIO | 2025+ |
|
| 对象存储 | MinIO | 2025+ |
|
||||||
| AI 推理 | SAM 2 / SAM 3 (Meta) + PyTorch | - |
|
| AI 推理 | SAM 2.1 (Meta) + PyTorch,可选 tiny/small/base+/large | - |
|
||||||
| 视频处理 | FFmpeg + OpenCV | 4.4+ |
|
| 视频处理 | FFmpeg + OpenCV | 4.4+ |
|
||||||
| DICOM 处理 | pydicom | 3.0+ |
|
| DICOM 处理 | pydicom | 3.0+ |
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ Seg_Server/
|
|||||||
│ ├── main.py # 应用入口(CORS/生命周期/路由注册/WebSocket)
|
│ ├── main.py # 应用入口(CORS/生命周期/路由注册/WebSocket)
|
||||||
│ ├── config.py # 环境变量配置(Pydantic Settings)
|
│ ├── config.py # 环境变量配置(Pydantic Settings)
|
||||||
│ ├── database.py # SQLAlchemy 引擎 + Session
|
│ ├── database.py # SQLAlchemy 引擎 + Session
|
||||||
│ ├── models.py # ORM 模型(Project/Frame/Template/Annotation/Mask/ProcessingTask)
|
│ ├── models.py # ORM 模型(User/Project/Frame/Template/Annotation/Mask/AuditLog/ProcessingTask)
|
||||||
│ ├── schemas.py # Pydantic 请求/响应校验模型
|
│ ├── schemas.py # Pydantic 请求/响应校验模型
|
||||||
│ ├── minio_client.py # MinIO 上传/下载/预签名URL封装
|
│ ├── minio_client.py # MinIO 上传/下载/预签名URL封装
|
||||||
│ ├── redis_client.py # Redis 连接封装
|
│ ├── redis_client.py # Redis 连接封装
|
||||||
@@ -94,20 +95,22 @@ Seg_Server/
|
|||||||
│ ├── celery_app.py # Celery app 配置
|
│ ├── celery_app.py # Celery app 配置
|
||||||
│ ├── worker_tasks.py # Celery 任务入口
|
│ ├── worker_tasks.py # Celery 任务入口
|
||||||
│ ├── download_sam2.py # SAM 2 模型权重自动下载脚本
|
│ ├── download_sam2.py # SAM 2 模型权重自动下载脚本
|
||||||
│ ├── setup_sam3_env.sh # SAM 3 独立 Python 3.12 环境安装脚本
|
│ ├── setup_sam3_env.sh # 历史保留的 SAM 3 独立 Python 3.12 环境安装脚本;当前产品入口禁用
|
||||||
│ ├── requirements.txt # Python 依赖
|
│ ├── requirements.txt # Python 依赖
|
||||||
│ ├── routers/ # API 路由
|
│ ├── routers/ # API 路由
|
||||||
│ │ ├── auth.py # 登录认证
|
│ │ ├── auth.py # 用户表、密码哈希、JWT 登录和 /api/auth/me
|
||||||
|
│ │ ├── admin.py # 管理员用户 CRUD 和审计日志
|
||||||
│ │ ├── projects.py # 项目 & 帧 CRUD
|
│ │ ├── projects.py # 项目 & 帧 CRUD
|
||||||
│ │ ├── templates.py # 本体字典管理
|
│ │ ├── templates.py # 本体字典管理
|
||||||
│ │ ├── media.py # 上传 & 解析
|
│ │ ├── media.py # 上传 & 解析
|
||||||
│ │ ├── ai.py # SAM 推理与模型状态接口
|
│ │ ├── ai.py # SAM 推理与模型状态接口
|
||||||
│ │ └── export.py # 数据导出
|
│ │ └── export.py # 数据导出
|
||||||
│ └── services/ # 业务服务
|
│ └── services/ # 业务服务
|
||||||
│ ├── sam2_engine.py # SAM 2 推理引擎(单帧推理 + video predictor 传播)
|
│ ├── propagation_task_runner.py # Celery 自动传播任务 runner
|
||||||
│ ├── sam3_engine.py # SAM 3 状态检测、外部环境桥接、文本语义推理、框选与 video tracker 适配器
|
│ ├── sam2_engine.py # SAM 2.1 变体选择、单帧推理 + video predictor 传播
|
||||||
│ ├── sam3_external_worker.py # 独立 sam3 conda 环境中执行的状态/推理 helper
|
│ ├── sam3_engine.py # 历史保留的 SAM 3 桥接实现;当前未接入 registry
|
||||||
│ ├── sam_registry.py # SAM 模型选择、GPU 状态与推理分发
|
│ ├── sam3_external_worker.py # 历史保留的独立 sam3 helper;当前未被产品入口调用
|
||||||
|
│ ├── sam_registry.py # 当前暴露 SAM 2.1 变体、GPU 状态与推理分发
|
||||||
│ └── frame_parser.py # FFmpeg 拆帧 / pydicom 读片
|
│ └── frame_parser.py # FFmpeg 拆帧 / pydicom 读片
|
||||||
├── src/ # React 前端
|
├── src/ # React 前端
|
||||||
│ ├── main.tsx # 应用挂载点
|
│ ├── main.tsx # 应用挂载点
|
||||||
@@ -121,23 +124,25 @@ Seg_Server/
|
|||||||
│ └── components/ # 组件(扁平化目录)
|
│ └── components/ # 组件(扁平化目录)
|
||||||
│ ├── Login.tsx # 登录页
|
│ ├── Login.tsx # 登录页
|
||||||
│ ├── Sidebar.tsx # 左侧导航栏
|
│ ├── Sidebar.tsx # 左侧导航栏
|
||||||
│ ├── Dashboard.tsx # 总体概况仪表盘(解析队列/任务控制)
|
│ ├── UserAdmin.tsx # 管理员用户管理后台
|
||||||
|
│ ├── Dashboard.tsx # 总体概况仪表盘(任务进度/任务控制)
|
||||||
│ ├── ProjectLibrary.tsx # 项目库列表
|
│ ├── ProjectLibrary.tsx # 项目库列表
|
||||||
│ ├── VideoWorkspace.tsx # 核心分割工作区布局
|
│ ├── VideoWorkspace.tsx # 核心分割工作区布局
|
||||||
│ ├── CanvasArea.tsx # Konva 画布(缩放/平移/手工绘制/选点/Mask渲染)
|
│ ├── CanvasArea.tsx # Konva 画布(缩放/平移/手工绘制/选点/Mask渲染)
|
||||||
│ ├── ToolsPalette.tsx # 左侧工具栏
|
│ ├── ToolsPalette.tsx # 左侧紧凑工具栏(高度不足时滚动)
|
||||||
│ ├── OntologyInspector.tsx # 右侧本体/属性检查面板
|
│ ├── OntologyInspector.tsx # 右侧本体/属性检查面板
|
||||||
│ ├── FrameTimeline.tsx # 底部时间轴
|
│ ├── FrameTimeline.tsx # 底部时间轴
|
||||||
│ ├── AISegmentation.tsx # AI 智能分割引擎界面
|
│ ├── AISegmentation.tsx # AI 智能分割引擎界面
|
||||||
|
│ ├── TransientNotice.tsx # 非阻塞自动消失短提示
|
||||||
│ └── TemplateRegistry.tsx # 模板库管理
|
│ └── TemplateRegistry.tsx # 模板库管理
|
||||||
├── models/ # SAM 2 模型权重(.pt 文件)
|
├── models/ # SAM 2 模型权重(.pt 文件)
|
||||||
├── uploads/ # 临时上传目录
|
├── uploads/ # 临时上传目录
|
||||||
├── frames/ # 临时帧目录
|
├── frames/ # 临时帧目录
|
||||||
├── doc/ # 当前实现审计、接口契约与后续实施文档
|
├── doc/ # 当前实现审计、接口契约与后续实施文档
|
||||||
├── public/
|
├── public/
|
||||||
│ └── logo.png # 侧边栏 Logo 静态资源
|
│ └── logo.png # 登录页、侧边栏与 favicon 使用的 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 依赖与脚本
|
||||||
@@ -154,6 +159,7 @@ Seg_Server/
|
|||||||
- `doc/03-frontend-element-audit.md` — 前端逐元素功能审计,标注真实可用、部分可用、Mock/UI-only、接口不通
|
- `doc/03-frontend-element-audit.md` — 前端逐元素功能审计,标注真实可用、部分可用、Mock/UI-only、接口不通
|
||||||
- `doc/04-api-contracts.md` — 前后端接口契约和已知不一致
|
- `doc/04-api-contracts.md` — 前后端接口契约和已知不一致
|
||||||
- `doc/06-fastapi-docs-explained.md` — `http://192.168.3.11:8000/docs` 的作用说明
|
- `doc/06-fastapi-docs-explained.md` — `http://192.168.3.11:8000/docs` 的作用说明
|
||||||
|
- `doc/10-installation.md` — 独立安装部署指南,覆盖 PostgreSQL、Redis、MinIO、FastAPI、Celery、前端和 SAM 2.1 权重
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -162,10 +168,10 @@ Seg_Server/
|
|||||||
### 系统要求
|
### 系统要求
|
||||||
|
|
||||||
- **OS**: Ubuntu 22.04 LTS
|
- **OS**: Ubuntu 22.04 LTS
|
||||||
- **GPU**: NVIDIA GPU(推荐 RTX 4090 或同等算力),用于 SAM 推理;SAM 3 官方要求 Python 3.12+、PyTorch 2.7+ 和 CUDA 12.6+ 环境
|
- **GPU**: NVIDIA GPU(推荐 RTX 4090 或同等算力),用于 SAM 2 推理
|
||||||
- **CUDA**: 12.x / 13.x
|
- **CUDA**: 12.x / 13.x
|
||||||
- **Node.js**: 22.x+
|
- **Node.js**: 22.x+
|
||||||
- **Python**: 主后端使用 3.11(通过 Miniconda/Anaconda 管理);SAM 3 使用独立 `sam3` Python 3.12 conda 环境
|
- **Python**: 主后端使用 3.11(通过 Miniconda/Anaconda 管理);历史保留的 SAM 3 环境不是当前必需运行条件
|
||||||
|
|
||||||
### 安装系统级依赖
|
### 安装系统级依赖
|
||||||
|
|
||||||
@@ -239,27 +245,30 @@ cd ~/Desktop/Seg_Server/backend
|
|||||||
python download_sam2.py
|
python download_sam2.py
|
||||||
|
|
||||||
# 模型将下载到 ~/Desktop/Seg_Server/models/
|
# 模型将下载到 ~/Desktop/Seg_Server/models/
|
||||||
# sam2_hiera_tiny.pt (149 MB)
|
# 推荐放置 SAM 2.1 文件名:
|
||||||
# sam2_hiera_small.pt (176 MB)
|
# sam2.1_hiera_tiny.pt
|
||||||
# sam2_hiera_base_plus.pt (309 MB)
|
# sam2.1_hiera_small.pt
|
||||||
# sam2_hiera_large.pt (856 MB)
|
# sam2.1_hiera_base_plus.pt
|
||||||
|
# sam2.1_hiera_large.pt
|
||||||
|
#
|
||||||
|
# 兼容旧版 SAM 2 文件名:
|
||||||
|
# sam2_hiera_tiny.pt / sam2_hiera_small.pt / sam2_hiera_base_plus.pt / sam2_hiera_large.pt
|
||||||
```
|
```
|
||||||
|
|
||||||
> **注意**:当前系统磁盘紧张时,建议仅保留 `sam2_hiera_tiny.pt`,删除其他模型以释放空间。
|
> **注意**:当前系统磁盘紧张时,建议仅保留 `sam2.1_hiera_tiny.pt` 或兼容旧名 `sam2_hiera_tiny.pt`,删除其他模型以释放空间。前端可以选择四个变体,但只有本地存在对应 checkpoint 的变体会显示可用。
|
||||||
|
|
||||||
### 步骤 5: 可选安装 SAM 3 环境
|
### 步骤 5: 历史保留的 SAM 3 环境
|
||||||
|
|
||||||
当前后端不会把 SAM 3 直接装进 `seg_server`,而是通过独立 `sam3` conda 环境执行 `backend/services/sam3_external_worker.py`。这样可以保留现有 Python 3.11 / SAM 2 环境。
|
当前产品入口不再启用 SAM 3:前端隐藏 SAM 3 相关入口,后端 registry 只暴露 SAM 2.1 变体,`model=sam3` 会返回不支持。以下脚本和 helper 仅作为以后恢复 SAM 3 研究路径的保留文件,正常部署不需要执行。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Desktop/Seg_Server
|
cd ~/Desktop/Seg_Server
|
||||||
./backend/setup_sam3_env.sh
|
./backend/setup_sam3_env.sh
|
||||||
|
|
||||||
# 如果已把权重放在 sam3权重/sam3.pt,可直接走本地 checkpoint;
|
# 仅在后续恢复 SAM 3 实验路径时使用。
|
||||||
# 未配置本地 checkpoint 时,才需要 Hugging Face gated repo 授权和登录。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
官方 `facebook/sam3` 权重约 3.45 GB,当前没有类似 SAM 2 `tiny/small/base/large` 的官方小权重梯度。当前仓库默认使用本机 `sam3权重/sam3.pt`,不会提交权重文件;未配置本地 checkpoint 且未获得 gated model 授权时,`GET /api/ai/models/status` 会把 SAM 3 标为不可用并说明 checkpoint access 不满足。
|
官方 `facebook/sam3` 权重约 3.45 GB,当前没有类似 SAM 2 `tiny/small/base/large` 的官方小权重梯度。本项目不会提交权重文件;由于当前系统不提供文本提示,SAM 3 不在模型状态接口和前端 UI 中展示。
|
||||||
|
|
||||||
### 步骤 6: 配置环境变量
|
### 步骤 6: 配置环境变量
|
||||||
|
|
||||||
@@ -273,15 +282,20 @@ minio_endpoint=192.168.3.11:9000
|
|||||||
minio_access_key=minioadmin
|
minio_access_key=minioadmin
|
||||||
minio_secret_key=minioadmin
|
minio_secret_key=minioadmin
|
||||||
minio_secure=false
|
minio_secure=false
|
||||||
sam_model_path=/home/wkmgc/Desktop/Seg_Server/models/sam2_hiera_tiny.pt
|
sam_model_path=/home/wkmgc/Desktop/Seg_Server/models/sam2.1_hiera_tiny.pt
|
||||||
sam_model_config=configs/sam2/sam2_hiera_t.yaml
|
sam_model_config=configs/sam2.1/sam2.1_hiera_t.yaml
|
||||||
sam_default_model=sam2
|
sam_default_model=sam2.1_hiera_tiny
|
||||||
sam3_model_version=sam3
|
# 以下 sam3_* 配置为历史保留项;当前产品入口不读取它们来暴露 SAM 3。
|
||||||
sam3_checkpoint_path=/home/wkmgc/Desktop/Seg_Server/sam3权重/sam3.pt
|
# sam3_model_version=sam3
|
||||||
sam3_external_enabled=true
|
# sam3_checkpoint_path=/home/wkmgc/Desktop/Seg_Server/sam3权重/sam3.pt
|
||||||
sam3_external_python=/home/wkmgc/miniconda3/envs/sam3/bin/python
|
# sam3_external_enabled=true
|
||||||
sam3_timeout_seconds=300
|
# sam3_external_python=/home/wkmgc/miniconda3/envs/sam3/bin/python
|
||||||
|
# sam3_timeout_seconds=300
|
||||||
cors_origins=["http://localhost:3000","http://192.168.3.11:3000"]
|
cors_origins=["http://localhost:3000","http://192.168.3.11:3000"]
|
||||||
|
jwt_secret_key=change-this-to-a-long-random-production-secret
|
||||||
|
access_token_expire_minutes=1440
|
||||||
|
default_admin_username=admin
|
||||||
|
default_admin_password=123456
|
||||||
```
|
```
|
||||||
|
|
||||||
前端根目录的 `.env.example` 包含 AI Studio 注入变量和前端 API 配置:
|
前端根目录的 `.env.example` 包含 AI Studio 注入变量和前端 API 配置:
|
||||||
@@ -309,9 +323,10 @@ nohup uvicorn main:app --host 0.0.0.0 --port 8000 > /tmp/fastapi.log 2>&1 &
|
|||||||
- 创建数据库表(如果不存在)
|
- 创建数据库表(如果不存在)
|
||||||
- 检查 MinIO bucket 是否存在
|
- 检查 MinIO bucket 是否存在
|
||||||
- 测试 Redis 连接
|
- 测试 Redis 连接
|
||||||
- 懒加载 SAM 模型;`GET /api/ai/models/status` 会返回 SAM 2、SAM 3、GPU 和 SAM 3 checkpoint access 的真实可用状态
|
- 懒加载所选 SAM 2.1 模型;`GET /api/ai/models/status` 会返回 tiny/small/base+/large 和 GPU 的真实可用状态,`selected_model=sam3` 会返回不支持
|
||||||
- `/api/ai/predict` 支持 AI 参数 `crop_to_prompt`、`auto_filter_background` 和 `min_score`,用于点/框 prompt 的局部裁剪推理、回映射和背景过滤
|
- `/api/ai/predict` 支持 AI 参数 `crop_to_prompt`、`auto_filter_background` 和 `min_score`,用于点/框 prompt 的局部裁剪推理、回映射和背景过滤
|
||||||
- `/api/ai/propagate` 支持从当前帧 seed 区域向视频片段传播:SAM 2 使用 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,SAM 3 通过独立 Python 3.12 helper 调用官方 `build_sam3_video_predictor()` video tracker
|
- `/api/ai/smooth-mask` 支持对当前 mask polygon 做后端边缘平滑,返回新的 polygon、bbox、面积和拓扑锚点;前端应用时会把结果写成新的实际 mask 几何,并走正常标注保存链路
|
||||||
|
- `/api/ai/propagate/task` 支持从当前帧 seed 区域向视频片段创建后台传播任务:当前使用所选 SAM 2.1 变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`;同步 `/api/ai/propagate` 仍作为单 seed 兼容接口保留
|
||||||
|
|
||||||
### 步骤 6.1: 启动 Celery Worker
|
### 步骤 6.1: 启动 Celery Worker
|
||||||
|
|
||||||
@@ -319,13 +334,13 @@ nohup uvicorn main:app --host 0.0.0.0 --port 8000 > /tmp/fastapi.log 2>&1 &
|
|||||||
cd ~/Desktop/Seg_Server/backend
|
cd ~/Desktop/Seg_Server/backend
|
||||||
source ~/miniconda3/etc/profile.d/conda.sh
|
source ~/miniconda3/etc/profile.d/conda.sh
|
||||||
conda activate seg_server
|
conda activate seg_server
|
||||||
celery -A celery_app:celery_app worker --loglevel=info --concurrency=1
|
celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1
|
||||||
|
|
||||||
# 或使用后台模式
|
# 或使用后台模式
|
||||||
nohup celery -A celery_app:celery_app worker --loglevel=info --concurrency=1 > /tmp/celery.log 2>&1 &
|
nohup celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 > /tmp/celery.log 2>&1 &
|
||||||
```
|
```
|
||||||
|
|
||||||
视频导入只创建项目并把源视频保存到 MinIO,不会自动拆帧;用户在项目库点击“生成帧”后,再选择目标 FPS 并调用 `POST /api/media/parse`。该接口只创建 `processing_tasks` 记录并把任务投递给 Celery;真正的 FFmpeg/OpenCV/pydicom 拆帧由 worker 执行。接口支持 `parse_fps`、`max_frames` 和 `target_width`,用于生成后续 SAM 2 / SAM 3 视频处理可复用的标准帧序列;视频帧按 `frame_%06d.jpg` 连续命名,帧表会记录 `timestamp_ms` 和 `source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道,FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的 WebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel`、`/api/tasks/{id}/retry` 和 `/api/tasks/{id}` 完成任务取消、重试与失败详情查看。
|
视频导入只创建项目并把源视频保存到 MinIO,不会自动拆帧;项目库导入面板会用 Axios 上传回调显示上传进度、百分比和字节数。用户在项目库点击“生成帧”或“重新生成帧”后,再选择目标 FPS 并调用 `POST /api/media/parse`;已有帧的视频重新生成时,worker 会先清空旧帧、旧标注和旧 mask,避免同一项目出现重复帧序列。DICOM 批量导入会在前端选择、后端上传、worker 下载和 pydicom 读取四个环节按文件名自然顺序排序,保证 `1.dcm、2.dcm、10.dcm` 这种序列按可见数字顺序转成项目帧;上传阶段同样显示进度条和本次有效 `.dcm` 文件数量,上传完成后项目库会轮询解析任务进度直到完成、失败或取消。项目卡片支持复制项目:`新项目重置` 会复制项目媒体字段和已生成帧序列但不复制标注,`全内容复制` 会额外复制标注和关联 mask 元数据,任务运行历史不复制。项目库和模板库的成功/失败反馈使用非阻塞短提示,会自动消失,不再用浏览器 `alert()` 阻塞后续操作;项目删除、模板删除、用户改密码/删除和演示出厂重置等高风险操作使用站内确认弹窗。该接口只创建 `processing_tasks` 记录并把任务投递给 Celery;真正的 FFmpeg/OpenCV/pydicom 拆帧由 worker 执行。接口支持 `parse_fps`、`max_frames` 和 `target_width`,用于生成后续 SAM 2 视频处理可复用的标准帧序列;视频/DICOM 解析后都按 `frame_%06d.jpg` 连续生成项目帧,帧表会记录 `timestamp_ms` 和 `source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。`GET /api/projects/{id}/frames` 默认返回完整帧序列,不会把工作区总帧数截断到 1000。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道,FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的任务进度区展示 queued/running/success/failed/cancelled 最近任务,处理中统计只计算 queued/running;WebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel`、`/api/tasks/{id}/retry` 和 `/api/tasks/{id}` 完成任务取消、重试与失败详情查看。
|
||||||
|
|
||||||
### 步骤 7: 安装前端依赖并构建
|
### 步骤 7: 安装前端依赖并构建
|
||||||
|
|
||||||
@@ -361,6 +376,34 @@ cd ~/Desktop/Seg_Server
|
|||||||
|
|
||||||
脚本将依次检查并启动:PostgreSQL → Redis → MinIO → FastAPI 后端 → Celery Worker → 前端。
|
脚本将依次检查并启动:PostgreSQL → Redis → MinIO → FastAPI 后端 → Celery Worker → 前端。
|
||||||
|
|
||||||
|
### 开发重启
|
||||||
|
|
||||||
|
如果只是确认本机开发服务使用最新代码,优先用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Desktop/Seg_Server
|
||||||
|
./restart_dev_services.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
热更新规则:
|
||||||
|
|
||||||
|
| 改动 | 需要重启什么 | 说明 |
|
||||||
|
|------|--------------|------|
|
||||||
|
| 前端组件/样式 | 一般不需要 | Vite 会热更新 |
|
||||||
|
| 前端依赖、`.env`、`vite.config.ts` | 重启前端 | 依赖和环境变量只在进程启动时读取 |
|
||||||
|
| FastAPI 路由/普通后端代码 | 需要重启后端 | 本项目开发重启脚本用独立后台进程运行后端,后端改动后应显式重启,确保运行态和代码一致 |
|
||||||
|
| `backend/.env`、模型路径、依赖 | 重启后端 | 配置和依赖在进程启动时生效 |
|
||||||
|
| Celery 任务、拆帧、自动传播、SAM runner | 必须重启 Celery worker | worker 不会自动加载代码改动 |
|
||||||
|
|
||||||
|
`restart_dev_services.sh` 会检查 PostgreSQL/Redis/MinIO,停止旧 FastAPI/Celery/前端进程,用 `setsid` 作为独立后台进程重新启动三者,并检查 `http://localhost:8000/health` 和 `http://localhost:3000`。脚本退出后服务仍会继续运行;pid 文件默认写到 `/tmp/seg_server_*.pid`,日志默认写到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/tmp/seg_server_fastapi.log
|
||||||
|
/tmp/seg_server_celery.log
|
||||||
|
/tmp/seg_server_frontend.log
|
||||||
|
/tmp/seg_server_minio.log
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 访问地址与默认凭证
|
## 访问地址与默认凭证
|
||||||
@@ -372,6 +415,12 @@ cd ~/Desktop/Seg_Server
|
|||||||
| MinIO 控制台 | http://localhost:9001 | minioadmin / minioadmin |
|
| MinIO 控制台 | http://localhost:9001 | minioadmin / minioadmin |
|
||||||
| PostgreSQL | localhost:5432 | seguser / segpass123 |
|
| PostgreSQL | localhost:5432 | seguser / segpass123 |
|
||||||
|
|
||||||
|
后端启动时会自动种子化默认管理员 `admin / 123456`,密码以哈希形式存入 `users` 表。登录成功返回签名 JWT,前端会把 token 写入 `localStorage` 并通过 `Authorization: Bearer <token>` 调用业务接口;页面刷新后会用 `/api/auth/me` 恢复当前用户。
|
||||||
|
|
||||||
|
当前项目、帧、标注、任务、Dashboard 和导出接口已经按当前 JWT 用户拥有的项目隔离;模板支持系统模板(`owner_user_id IS NULL`)和用户模板。角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可调用写入类业务接口,`viewer` 只能读取;管理员会在侧栏看到“用户管理”,可通过 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并通过 `/api/admin/audit-logs` 查看登录与用户管理审计。演示部署还提供“恢复演示出厂设置”,站内二次确认后调用 `/api/admin/demo-factory-reset`,直接从 `demo/` 读取“演视LC视频序列”和“演视DICOM序列”,只保留默认 admin、已生成帧的演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。生产部署时必须在 `backend/.env` 覆盖 `JWT_SECRET_KEY` 并修改默认管理员密码。
|
||||||
|
|
||||||
|
系统默认模板会在后端启动时幂等补齐,当前包括“腹腔镜胆囊切除术”和“头颈部CT分割”;头颈部 CT 默认分类名使用纯中文,不带括号英文翻译。所有新建、复制、导入和后端返回的模板都会归一化带上黑色 `maskid: 0` 的“待分类”保留类,并固定在语义分类树最后。恢复演示出厂设置只删除用户私有模板,并会按内置权威定义重建缺失的默认系统模板、覆盖恢复被修改或删减的默认语义分类树。模板库左侧“生效中模板架构清单”里的复制按钮会把任一模板复制成当前用户私有副本,并保留分类名称、颜色、maskid、内部层级顺序和规则。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 可用命令
|
## 可用命令
|
||||||
@@ -385,7 +434,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(生产静态服务)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
@@ -397,7 +446,7 @@ pip install -r requirements-dev.txt # 安装后端测试
|
|||||||
pytest tests # 后端接口测试
|
pytest tests # 后端接口测试
|
||||||
uvicorn main:app --host 0.0.0.0 --port 8000 --reload # 开发模式(热重载)
|
uvicorn main:app --host 0.0.0.0 --port 8000 --reload # 开发模式(热重载)
|
||||||
uvicorn main:app --host 0.0.0.0 --port 8000 # 生产模式
|
uvicorn main:app --host 0.0.0.0 --port 8000 # 生产模式
|
||||||
celery -A celery_app:celery_app worker --loglevel=info --concurrency=1 # 后台任务 worker
|
celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 # 后台任务 worker
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -417,9 +466,9 @@ sudo apt autoremove -y && sudo apt clean
|
|||||||
conda clean --all -y
|
conda clean --all -y
|
||||||
|
|
||||||
# 3. 仅保留最小模型
|
# 3. 仅保留最小模型
|
||||||
rm ~/Desktop/Seg_Server/models/sam2_hiera_large.pt
|
rm ~/Desktop/Seg_Server/models/sam2.1_hiera_large.pt
|
||||||
rm ~/Desktop/Seg_Server/models/sam2_hiera_base_plus.pt
|
rm ~/Desktop/Seg_Server/models/sam2.1_hiera_base_plus.pt
|
||||||
rm ~/Desktop/Seg_Server/models/sam2_hiera_small.pt
|
rm ~/Desktop/Seg_Server/models/sam2.1_hiera_small.pt
|
||||||
|
|
||||||
# 4. 如需安装 sam2 包,确保有 >5GB 可用空间后再执行
|
# 4. 如需安装 sam2 包,确保有 >5GB 可用空间后再执行
|
||||||
```
|
```
|
||||||
@@ -431,7 +480,7 @@ rm ~/Desktop/Seg_Server/models/sam2_hiera_small.pt
|
|||||||
**解决**:
|
**解决**:
|
||||||
```bash
|
```bash
|
||||||
# 1. 确认模型文件存在
|
# 1. 确认模型文件存在
|
||||||
ls ~/Desktop/Seg_Server/models/sam2_hiera_tiny.pt
|
ls ~/Desktop/Seg_Server/models/sam2.1_hiera_tiny.pt
|
||||||
|
|
||||||
# 2. 安装 sam2(需 >5GB 磁盘空间)
|
# 2. 安装 sam2(需 >5GB 磁盘空间)
|
||||||
cd /tmp
|
cd /tmp
|
||||||
@@ -461,13 +510,14 @@ pip install -e . --no-build-isolation
|
|||||||
|
|
||||||
- 前端 `predictMask()` 已发送后端需要的 `image_id`、`prompt_type`、`prompt_data`,并把后端 `polygons` 转成 Konva `pathData`。
|
- 前端 `predictMask()` 已发送后端需要的 `image_id`、`prompt_type`、`prompt_data`,并把后端 `polygons` 转成 Konva `pathData`。
|
||||||
- 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict`。
|
- 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict`。
|
||||||
- 工作区 SAM 2 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。
|
- 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。
|
||||||
- AI 页面生成的 SAM 2/SAM 3 mask 会写入全局 `masks` 并自动选中;右侧分类树可直接给生成结果换标签,“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择。
|
- AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签;如果换标签的 mask 属于传播链,系统会同步更新前后帧对应传播 mask 的分类元数据;“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择和当前帧视角,不会因工作区重新加载而跳回第一帧。
|
||||||
- 工作区“传播片段”会使用当前选中区域或当前帧第一个区域作为 seed,调用 `/api/ai/propagate`,并在完成后刷新已保存标注。
|
- 工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed,按用户设置的传播起始帧和传播结束帧向前/向后追踪;用户可直接修改数字框,也可先点击左侧工具栏“AI自动推理”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏可单独选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换;前端提交传播前会先保存当前项目中的 draft/dirty mask,使 seed 优先携带稳定的后端 `source_annotation_id`,再把传播权重 id、seed、seed 来源 id 和方向组装为 `/api/ai/propagate/task` 后台任务。后端入队时会规范化/校验权重 id,并把规范化后的 id 写入任务 payload/result;worker 会按 seed 来源、方向和 seed 签名去重,同权重且未改变的 mask 二次传播时直接跳过,已改变或换用其他权重的 mask 会先删除同源旧自动传播标注再重传;旧版本使用前端临时 `source_mask_id` 生成的传播结果会按同一参考帧、方向和语义信息兼容清理,中间帧人工新增或修改同一物体后重新传播时,也会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制,避免向前传播时与早先向后生成的旧 mask 叠加。若历史或外部 seed metadata 仍带 `geometry_smoothing`,后端仍会在 forward/backward 两个方向保存前应用同一平滑参数;当前工作区平滑按钮应用后会直接改写实际 polygon,因此后续传播以新几何参与签名和追踪。任务进度写入 `processing_tasks` 并可在 Dashboard 查看/取消/重试,工作区轮询任务状态并刷新已保存标注。传播结果回显后,视频处理进度条会把自动传播生成的帧区段标为蓝色,人工/AI 标注帧显示为红色竖线;每次自动传播成功处理过的范围会在当前会话中额外叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色片段,用于辨认第一次、第二次、第 N 次传播;这些传播历史片段会按当前仍存在的传播 mask 自动裁剪,删除 mask 后无 mask 的帧不会继续显示红/蓝颜色;普通状态下点击视频处理进度条或红/蓝帧标识可跳转到对应帧,底部缩略图也会用红色边框标识人工/AI 标注帧、蓝色边框标识传播/推理帧;如果同一帧同时有人工作业和传播结果,红色人工/AI 标注框优先保留,蓝色传播状态以内描边表达;当前帧如果同时是人工/AI 标注帧,会显示青色外框加红色内描边,固定为外层当前帧、内层标注框。
|
||||||
- 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco`。
|
- 右侧实例属性面板的“边缘平滑强度”滑杆会先防抖预览;点击“应用边缘平滑”后会把平滑结果作为新的实际 polygon 写入当前 mask,并同步写入同一传播链前后对应 mask,整次操作进入同一个撤销/重做历史步骤,应用后强度重置为 0,用户可继续用“调整多边形”编辑新多边形。分类和平滑这类跨传播链操作保存时会保留传播来源 metadata,避免原本的自动传播帧在时间轴上被改成红色人工/AI 标注帧。
|
||||||
- 工作区“导出 JSON 标注集”和“导出 PNG Mask ZIP”按钮已绑定下载流程;导出前会先保存当前待归档的前端 mask。
|
- 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco`,`exportMasks()` 已对齐到 `/api/export/{projectId}/masks`,统一导出 `exportSegmentationResults()` 已对齐到 `/api/export/{projectId}/results`。
|
||||||
- 工作区“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,导入后会刷新并回显已保存标注和 seed point。
|
- 工作区“分割结果导出”按钮已绑定下载流程;点击后可在下拉栏选择整体视频、特定范围帧或当前图片,默认选择当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择特定范围帧时,可直接修改帧号,也可在播放进度条或视频处理进度条上拖拽选择范围;选择 Mix_label 时可调遮罩透明度,默认 0.3,并显示当前/待导出第一帧预览。导出前会先保存当前待归档的前端 mask。下载 ZIP 命名为 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`,项目名会替换文件系统不安全字符;时间戳来自帧 `timestampMs` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 帧顺序,不使用原视频帧号。统一导出 ZIP 固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`;GT_label 固定输出 8-bit uint8 PNG,像素值使用类别真实 maskid,`maskid: 0` 的“待分类”保持 0,Pro_label 中保持黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数并写入映射 JSON,正整数 maskid 超出 1-255 会拒绝导出,因此同一模板导出的 GT_label 可直接再导入;选择分开 mask 时输出 `分开Mask分割结果/`,按帧子目录和类别 maskid 合并命名;选择 GT_label、Pro_label、Mix_label 时分别输出 `GT_label图/`、`Pro_label彩色分割结果/`、`Mix_label重叠覆盖彩色分割结果/`,重叠区域按右侧语义分类树内部优先级从低到高覆盖。
|
||||||
- 工作区“结构化归档保存”按钮会把当前项目未保存 mask 写入 `POST /api/ai/annotate`,并把 dirty mask 写入 `PATCH /api/ai/annotations/{id}`。
|
- 工作区左侧工具栏“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,入口位于“重叠区域去除”之后;选择文件后会先显示导入结果预览,并让用户决定未知 maskid 的处理方式,可“舍弃未知类别”或“导入为未定义”。后端使用 `cv2.IMREAD_UNCHANGED` 读取 mask 并校验 dtype;导入格式限定为 8-bit 灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,0 为背景,X 为 1-255 的 maskid;16-bit/uint16 GT_label 和全背景 0 图都会被拒绝,全背景错误信息为“GT Mask 图片中没有非背景 maskid 区域。”;尺寸不同会自动按当前帧长宽最近邻拉伸;导入时使用高精度 contour,尽量保留边缘细节,同时对单个轮廓设置点数上限以控制前端性能;导入后刷新并回显已保存标注,但不显示或拖动 seed point,后续可像普通 mask 一样做拓扑统计、边缘平滑和 polygon 编辑。
|
||||||
|
- 工作区保存状态按钮会按当前项目待保存数量显示“保存 X 个改动”或“已全部保存”;点击后会把未保存 mask 写入 `POST /api/ai/annotate`,并把 dirty mask 写入 `PATCH /api/ai/annotations/{id}`。
|
||||||
- 工作区“清空遮罩”会通过 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。
|
- 工作区“清空遮罩”会通过 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。
|
||||||
|
|
||||||
**验证**:
|
**验证**:
|
||||||
|
|||||||
@@ -14,17 +14,18 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# MinIO
|
# MinIO
|
||||||
minio_endpoint: str = "192.168.3.11:9000"
|
minio_endpoint: str = "192.168.3.11:9000"
|
||||||
|
minio_public_endpoint: str | None = None
|
||||||
minio_access_key: str = "minioadmin"
|
minio_access_key: str = "minioadmin"
|
||||||
minio_secret_key: str = "minioadmin"
|
minio_secret_key: str = "minioadmin"
|
||||||
minio_secure: bool = False
|
minio_secure: bool = False
|
||||||
|
|
||||||
# SAM
|
# SAM
|
||||||
sam_default_model: str = "sam2"
|
sam_default_model: str = "sam2.1_hiera_tiny"
|
||||||
sam_model_path: str = "/home/wkmgc/Desktop/Seg_Server/models/sam2_hiera_tiny.pt"
|
sam_model_path: str = "/home/wkmgc/Desktop/Seg_Server/models/sam2.1_hiera_tiny.pt"
|
||||||
sam_model_config: str = "configs/sam2/sam2_hiera_t.yaml"
|
sam_model_config: str = "configs/sam2.1/sam2.1_hiera_t.yaml"
|
||||||
sam3_model_version: str = "sam3"
|
sam3_model_version: str = "sam3"
|
||||||
sam3_checkpoint_path: str = "/home/wkmgc/Desktop/Seg_Server/sam3权重/sam3.pt"
|
sam3_checkpoint_path: str = "/home/wkmgc/Desktop/Seg_Server/sam3权重/sam3.pt"
|
||||||
sam3_external_enabled: bool = True
|
sam3_external_enabled: bool = False
|
||||||
sam3_external_python: str = "/home/wkmgc/miniconda3/envs/sam3/bin/python"
|
sam3_external_python: str = "/home/wkmgc/miniconda3/envs/sam3/bin/python"
|
||||||
sam3_timeout_seconds: int = 300
|
sam3_timeout_seconds: int = 300
|
||||||
sam3_status_cache_seconds: int = 30
|
sam3_status_cache_seconds: int = 30
|
||||||
@@ -33,6 +34,13 @@ class Settings(BaseSettings):
|
|||||||
# App
|
# App
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
cors_origins: list[str] = ["http://localhost:3000", "http://192.168.3.11:3000"]
|
cors_origins: list[str] = ["http://localhost:3000", "http://192.168.3.11:3000"]
|
||||||
|
jwt_secret_key: str = "seg-server-dev-secret-change-me"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
access_token_expire_minutes: int = 60 * 24
|
||||||
|
default_admin_username: str = "admin"
|
||||||
|
default_admin_password: str = "123456"
|
||||||
|
demo_video_path: str = "/home/wkmgc/Desktop/Seg_Server/demo/演视LC视频序列.mp4"
|
||||||
|
demo_dicom_dir: str = "/home/wkmgc/Desktop/Seg_Server/demo/演视DICOM序列"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
SAM 2 模型权重下载脚本
|
SAM 2.1 模型权重下载脚本
|
||||||
运行: python download_sam2.py
|
运行: python download_sam2.py
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
@@ -10,12 +10,12 @@ import sys
|
|||||||
MODEL_DIR = "/home/wkmgc/Desktop/Seg_Server/models"
|
MODEL_DIR = "/home/wkmgc/Desktop/Seg_Server/models"
|
||||||
os.makedirs(MODEL_DIR, exist_ok=True)
|
os.makedirs(MODEL_DIR, exist_ok=True)
|
||||||
|
|
||||||
# SAM 2 模型权重 (Meta AI 官方)
|
# SAM 2.1 模型权重 (Meta AI 官方)
|
||||||
MODELS = {
|
MODELS = {
|
||||||
"sam2_hiera_tiny.pt": "https://dl.fbaipublicfiles.com/segment_anything_2/072824/sam2_hiera_tiny.pt",
|
"sam2.1_hiera_tiny.pt": "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_tiny.pt",
|
||||||
"sam2_hiera_small.pt": "https://dl.fbaipublicfiles.com/segment_anything_2/072824/sam2_hiera_small.pt",
|
"sam2.1_hiera_small.pt": "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_small.pt",
|
||||||
"sam2_hiera_base_plus.pt": "https://dl.fbaipublicfiles.com/segment_anything_2/072824/sam2_hiera_base_plus.pt",
|
"sam2.1_hiera_base_plus.pt": "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_base_plus.pt",
|
||||||
"sam2_hiera_large.pt": "https://dl.fbaipublicfiles.com/segment_anything_2/072824/sam2_hiera_large.pt",
|
"sam2.1_hiera_large.pt": "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_large.pt",
|
||||||
}
|
}
|
||||||
|
|
||||||
def download_file(url: str, dest: str):
|
def download_file(url: str, dest: str):
|
||||||
@@ -35,7 +35,7 @@ def download_file(url: str, dest: str):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("SAM 2 模型权重下载")
|
print("SAM 2.1 模型权重下载")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
for name, url in MODELS.items():
|
for name, url in MODELS.items():
|
||||||
dest = os.path.join(MODEL_DIR, name)
|
dest = os.path.join(MODEL_DIR, name)
|
||||||
|
|||||||
208
backend/main.py
208
backend/main.py
@@ -4,8 +4,6 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -15,12 +13,11 @@ from sqlalchemy import inspect, text
|
|||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from database import Base, engine, SessionLocal
|
from database import Base, engine, SessionLocal
|
||||||
from minio_client import ensure_bucket_exists, upload_file
|
from minio_client import ensure_bucket_exists
|
||||||
from progress_events import PROGRESS_CHANNEL
|
from progress_events import PROGRESS_CHANNEL
|
||||||
from redis_client import get_redis_client, ping as redis_ping
|
from redis_client import get_redis_client, ping as redis_ping
|
||||||
from statuses import PROJECT_STATUS_PENDING, PROJECT_STATUS_READY
|
|
||||||
|
|
||||||
from routers import projects, templates, media, ai, export, auth, dashboard, tasks
|
from routers import projects, templates, media, ai, export, auth, dashboard, tasks, admin
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -28,104 +25,100 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_VIDEO_PATH = "/home/wkmgc/Desktop/Seg_Server/Data_MyVideo_1.mp4"
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_runtime_schema_columns() -> None:
|
def _ensure_runtime_schema_columns() -> None:
|
||||||
"""Add nullable columns introduced after initial create_all deployments."""
|
"""Add nullable columns introduced after initial create_all deployments."""
|
||||||
try:
|
try:
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
frame_columns = {column["name"] for column in inspector.get_columns("frames")}
|
frame_columns = {column["name"] for column in inspector.get_columns("frames")}
|
||||||
|
project_columns = {column["name"] for column in inspector.get_columns("projects")}
|
||||||
|
template_columns = {column["name"] for column in inspector.get_columns("templates")}
|
||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
if "timestamp_ms" not in frame_columns:
|
if "timestamp_ms" not in frame_columns:
|
||||||
connection.execute(text("ALTER TABLE frames ADD COLUMN timestamp_ms FLOAT"))
|
connection.execute(text("ALTER TABLE frames ADD COLUMN timestamp_ms FLOAT"))
|
||||||
if "source_frame_number" not in frame_columns:
|
if "source_frame_number" not in frame_columns:
|
||||||
connection.execute(text("ALTER TABLE frames ADD COLUMN source_frame_number INTEGER"))
|
connection.execute(text("ALTER TABLE frames ADD COLUMN source_frame_number INTEGER"))
|
||||||
|
if "owner_user_id" not in project_columns:
|
||||||
|
connection.execute(text("ALTER TABLE projects ADD COLUMN owner_user_id INTEGER"))
|
||||||
|
if "owner_user_id" not in template_columns:
|
||||||
|
connection.execute(text("ALTER TABLE templates ADD COLUMN owner_user_id INTEGER"))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Runtime schema column check failed: %s", exc)
|
logger.warning("Runtime schema column check failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _seed_default_project_sync() -> None:
|
def _seed_default_admin_sync() -> None:
|
||||||
"""Synchronously seed the default video project on first startup."""
|
"""Ensure the single default admin exists without rewriting project ownership metadata."""
|
||||||
import cv2
|
from routers.auth import ensure_default_admin
|
||||||
from models import Project, Frame
|
|
||||||
from services.frame_parser import parse_video, upload_frames_to_minio, extract_thumbnail
|
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
existing = db.query(Project).filter(Project.name == "Data_MyVideo_1").first()
|
admin = ensure_default_admin(db)
|
||||||
if existing is not None:
|
db.commit()
|
||||||
return
|
logger.info("Default admin ready id=%s", admin.id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("Failed to seed default admin: %s", exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
if not os.path.exists(DEFAULT_VIDEO_PATH):
|
|
||||||
logger.warning("Default video not found at %s", DEFAULT_VIDEO_PATH)
|
|
||||||
return
|
|
||||||
|
|
||||||
project = Project(
|
def _seed_default_project_sync() -> None:
|
||||||
name="Data_MyVideo_1",
|
"""Synchronously seed the bundled demo video and DICOM projects on first startup."""
|
||||||
description="默认演示视频",
|
from models import Project
|
||||||
status=PROJECT_STATUS_PENDING,
|
from routers.auth import ensure_default_admin
|
||||||
source_type="video",
|
from services.demo_media import (
|
||||||
parse_fps=30.0,
|
DEMO_DICOM_PROJECT_NAME,
|
||||||
|
DEMO_VIDEO_PROJECT_NAME,
|
||||||
|
LEGACY_DEMO_DICOM_PROJECT_NAMES,
|
||||||
|
LEGACY_DEMO_VIDEO_PROJECT_NAMES,
|
||||||
|
create_parsed_dicom_demo_project,
|
||||||
|
create_parsed_video_demo_project,
|
||||||
|
demo_dicom_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
admin = ensure_default_admin(db)
|
||||||
|
legacy_video = (
|
||||||
|
db.query(Project)
|
||||||
|
.filter(Project.name.in_(LEGACY_DEMO_VIDEO_PROJECT_NAMES))
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
db.add(project)
|
if legacy_video is not None:
|
||||||
db.commit()
|
legacy_video.name = DEMO_VIDEO_PROJECT_NAME
|
||||||
db.refresh(project)
|
|
||||||
|
|
||||||
with open(DEFAULT_VIDEO_PATH, "rb") as f:
|
|
||||||
data = f.read()
|
|
||||||
object_name = f"uploads/{project.id}/Data_MyVideo_1.mp4"
|
|
||||||
upload_file(object_name, data, content_type="video/mp4", length=len(data))
|
|
||||||
|
|
||||||
project.video_path = object_name
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Parse frames
|
|
||||||
tmp_dir = tempfile.mkdtemp(prefix=f"seg_seed_{project.id}_")
|
|
||||||
try:
|
|
||||||
local_path = os.path.join(tmp_dir, "video.mp4")
|
|
||||||
with open(local_path, "wb") as f:
|
|
||||||
f.write(data)
|
|
||||||
output_dir = os.path.join(tmp_dir, "frames")
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
frame_files, original_fps = parse_video(local_path, output_dir, fps=30, max_frames=100)
|
|
||||||
project.original_fps = original_fps
|
|
||||||
|
|
||||||
# Extract thumbnail
|
|
||||||
thumbnail_path = os.path.join(tmp_dir, "thumbnail.jpg")
|
|
||||||
try:
|
|
||||||
extract_thumbnail(local_path, thumbnail_path)
|
|
||||||
with open(thumbnail_path, "rb") as f:
|
|
||||||
thumb_data = f.read()
|
|
||||||
thumb_object = f"projects/{project.id}/thumbnail.jpg"
|
|
||||||
upload_file(thumb_object, thumb_data, content_type="image/jpeg", length=len(thumb_data))
|
|
||||||
project.thumbnail_url = thumb_object
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.warning("Thumbnail extraction failed: %s", exc)
|
|
||||||
|
|
||||||
object_names = upload_frames_to_minio(frame_files, project.id)
|
|
||||||
|
|
||||||
for idx, obj_name in enumerate(object_names):
|
|
||||||
img = cv2.imread(frame_files[idx])
|
|
||||||
h, w = img.shape[:2] if img is not None else (None, None)
|
|
||||||
timestamp_ms = idx * 1000.0 / 30.0
|
|
||||||
source_frame_number = int(round(idx * original_fps / 30.0)) if original_fps else None
|
|
||||||
frame = Frame(
|
|
||||||
project_id=project.id,
|
|
||||||
frame_index=idx,
|
|
||||||
image_url=obj_name,
|
|
||||||
width=w,
|
|
||||||
height=h,
|
|
||||||
timestamp_ms=timestamp_ms,
|
|
||||||
source_frame_number=source_frame_number,
|
|
||||||
)
|
|
||||||
db.add(frame)
|
|
||||||
|
|
||||||
project.status = PROJECT_STATUS_READY
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("Seeded default project id=%s with %d frames", project.id, len(object_names))
|
legacy_dicom = (
|
||||||
finally:
|
db.query(Project)
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
.filter(Project.name.in_(LEGACY_DEMO_DICOM_PROJECT_NAMES))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if legacy_dicom is not None:
|
||||||
|
legacy_dicom.name = DEMO_DICOM_PROJECT_NAME
|
||||||
|
db.commit()
|
||||||
|
existing_video = db.query(Project).filter(Project.name == DEMO_VIDEO_PROJECT_NAME).first()
|
||||||
|
if existing_video is None and os.path.exists(settings.demo_video_path):
|
||||||
|
video_project = create_parsed_video_demo_project(
|
||||||
|
db,
|
||||||
|
owner=admin,
|
||||||
|
video_path=settings.demo_video_path,
|
||||||
|
project_name=DEMO_VIDEO_PROJECT_NAME,
|
||||||
|
)
|
||||||
|
logger.info("Seeded default video project id=%s", video_project.id)
|
||||||
|
|
||||||
|
existing_dicom = db.query(Project).filter(Project.name == DEMO_DICOM_PROJECT_NAME).first()
|
||||||
|
if existing_dicom is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not demo_dicom_files(settings.demo_dicom_dir):
|
||||||
|
logger.warning("Default DICOM series not found at %s", settings.demo_dicom_dir)
|
||||||
|
return
|
||||||
|
|
||||||
|
project = create_parsed_dicom_demo_project(
|
||||||
|
db,
|
||||||
|
owner=admin,
|
||||||
|
dicom_dir=settings.demo_dicom_dir,
|
||||||
|
project_name=DEMO_DICOM_PROJECT_NAME,
|
||||||
|
)
|
||||||
|
logger.info("Seeded default DICOM project id=%s with %d frames", project.id, len(project.frames))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to seed default project: %s", exc)
|
logger.error("Failed to seed default project: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
@@ -134,57 +127,22 @@ def _seed_default_project_sync() -> None:
|
|||||||
|
|
||||||
def _seed_default_templates_sync() -> None:
|
def _seed_default_templates_sync() -> None:
|
||||||
"""Seed default ontology templates on first startup."""
|
"""Seed default ontology templates on first startup."""
|
||||||
from models import Template
|
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if db.query(Template).first() is not None:
|
ensure_default_templates(db)
|
||||||
return
|
|
||||||
|
|
||||||
# Laparoscopic cholecystectomy template (35 classes)
|
|
||||||
colors = [
|
|
||||||
(134, 124, 118), (0, 157, 142), (245, 161, 0), (255, 172, 159), (146, 175, 236), (155, 62, 0),
|
|
||||||
(255, 91, 0), (255, 234, 0), (85, 111, 181), (155, 132, 0), (181, 227, 14), (72, 0, 255),
|
|
||||||
(255, 0, 255), (29, 32, 136), (240, 16, 116), (160, 15, 95), (0, 155, 33), (0, 160, 233),
|
|
||||||
(52, 184, 178), (66, 115, 82), (90, 120, 41), (255, 0, 0), (117, 0, 0), (167, 24, 233),
|
|
||||||
(42, 8, 66), (112, 113, 150), (0, 255, 0), (255, 255, 255), (0, 255, 255), (181, 85, 105),
|
|
||||||
(113, 102, 140), (202, 202, 200), (197, 83, 181), (136, 162, 196), (138, 251, 213),
|
|
||||||
]
|
|
||||||
names = [
|
|
||||||
'针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
|
|
||||||
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
|
|
||||||
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
|
|
||||||
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
|
|
||||||
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
|
|
||||||
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
|
|
||||||
]
|
|
||||||
classes = []
|
|
||||||
for idx, (rgb, name) in enumerate(zip(colors, names)):
|
|
||||||
color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
|
|
||||||
classes.append({
|
|
||||||
"id": f"cls-lap-{idx}",
|
|
||||||
"name": name,
|
|
||||||
"color": color_hex,
|
|
||||||
"zIndex": (len(names) - idx) * 10,
|
|
||||||
"category": "腹腔镜胆囊切除术",
|
|
||||||
})
|
|
||||||
|
|
||||||
template = Template(
|
|
||||||
name="腹腔镜胆囊切除术",
|
|
||||||
description="腹腔镜胆囊切除术(LC)手术器械与解剖结构语义分割模板,共35个分类",
|
|
||||||
color="#06b6d4",
|
|
||||||
z_index=0,
|
|
||||||
mapping_rules={"classes": classes, "rules": []},
|
|
||||||
)
|
|
||||||
db.add(template)
|
|
||||||
db.commit()
|
|
||||||
logger.info("Seeded default template '腹腔镜胆囊切除术' with %d classes", len(classes))
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to seed default templates: %s", exc)
|
logger.error("Failed to seed default templates: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_templates(db) -> None:
|
||||||
|
"""Ensure all bundled system templates exist."""
|
||||||
|
from services.default_templates import ensure_default_templates as _ensure_default_templates
|
||||||
|
|
||||||
|
_ensure_default_templates(db)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: startup and shutdown hooks."""
|
"""Application lifespan: startup and shutdown hooks."""
|
||||||
@@ -196,6 +154,7 @@ async def lifespan(app: FastAPI):
|
|||||||
try:
|
try:
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_ensure_runtime_schema_columns()
|
_ensure_runtime_schema_columns()
|
||||||
|
_seed_default_admin_sync()
|
||||||
logger.info("Database tables initialized.")
|
logger.info("Database tables initialized.")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error("Database initialization failed: %s", exc)
|
logger.error("Database initialization failed: %s", exc)
|
||||||
@@ -265,6 +224,7 @@ app.include_router(ai.router)
|
|||||||
app.include_router(export.router)
|
app.include_router(export.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(tasks.router)
|
app.include_router(tasks.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["Health"])
|
@app.get("/health", tags=["Health"])
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
|
|||||||
BUCKET_NAME = "seg-media"
|
BUCKET_NAME = "seg-media"
|
||||||
|
|
||||||
_minio_client: Optional[Minio] = None
|
_minio_client: Optional[Minio] = None
|
||||||
|
_minio_public_client: Optional[Minio] = None
|
||||||
|
|
||||||
|
|
||||||
def get_minio_client() -> Minio:
|
def get_minio_client() -> Minio:
|
||||||
@@ -29,6 +30,20 @@ def get_minio_client() -> Minio:
|
|||||||
return _minio_client
|
return _minio_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_public_client() -> Minio:
|
||||||
|
"""Return a MinIO client configured for browser-facing presigned URLs."""
|
||||||
|
global _minio_public_client
|
||||||
|
if _minio_public_client is None:
|
||||||
|
endpoint = settings.minio_public_endpoint or settings.minio_endpoint
|
||||||
|
_minio_public_client = Minio(
|
||||||
|
endpoint,
|
||||||
|
access_key=settings.minio_access_key,
|
||||||
|
secret_key=settings.minio_secret_key,
|
||||||
|
secure=settings.minio_secure,
|
||||||
|
)
|
||||||
|
return _minio_public_client
|
||||||
|
|
||||||
|
|
||||||
def ensure_bucket_exists() -> None:
|
def ensure_bucket_exists() -> None:
|
||||||
"""Create the bucket if it does not already exist."""
|
"""Create the bucket if it does not already exist."""
|
||||||
client = get_minio_client()
|
client = get_minio_client()
|
||||||
@@ -97,7 +112,7 @@ def get_presigned_url(
|
|||||||
Returns:
|
Returns:
|
||||||
Presigned URL string.
|
Presigned URL string.
|
||||||
"""
|
"""
|
||||||
client = get_minio_client()
|
client = get_minio_public_client()
|
||||||
try:
|
try:
|
||||||
url = client.get_presigned_url(method, BUCKET_NAME, object_name, expires=timedelta(seconds=expires))
|
url = client.get_presigned_url(method, BUCKET_NAME, object_name, expires=timedelta(seconds=expires))
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -17,6 +17,25 @@ from database import Base
|
|||||||
from statuses import PROJECT_STATUS_PENDING
|
from statuses import PROJECT_STATUS_PENDING
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""Application user used for authentication and data ownership."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(150), unique=True, index=True, nullable=False)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
role = Column(String(50), default="annotator", nullable=False)
|
||||||
|
is_active = Column(Integer, default=1, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
projects = relationship("Project", back_populates="owner")
|
||||||
|
templates = relationship("Template", back_populates="owner")
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
"""Project model representing a segmentation project."""
|
"""Project model representing a segmentation project."""
|
||||||
|
|
||||||
@@ -31,11 +50,13 @@ class Project(Base):
|
|||||||
source_type = Column(String(20), default="video", nullable=False) # video | dicom
|
source_type = Column(String(20), default="video", nullable=False) # video | dicom
|
||||||
original_fps = Column(Float, nullable=True)
|
original_fps = Column(Float, nullable=True)
|
||||||
parse_fps = Column(Float, default=30.0, nullable=False)
|
parse_fps = Column(Float, default=30.0, nullable=False)
|
||||||
|
owner_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(
|
updated_at = Column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
owner = relationship("User", back_populates="projects")
|
||||||
frames = relationship("Frame", back_populates="project", cascade="all, delete-orphan")
|
frames = relationship("Frame", back_populates="project", cascade="all, delete-orphan")
|
||||||
annotations = relationship(
|
annotations = relationship(
|
||||||
"Annotation", back_populates="project", cascade="all, delete-orphan"
|
"Annotation", back_populates="project", cascade="all, delete-orphan"
|
||||||
@@ -77,8 +98,10 @@ class Template(Base):
|
|||||||
color = Column(String(50), nullable=False)
|
color = Column(String(50), nullable=False)
|
||||||
z_index = Column(Integer, default=0, nullable=False)
|
z_index = Column(Integer, default=0, nullable=False)
|
||||||
mapping_rules = Column(JSON, nullable=True)
|
mapping_rules = Column(JSON, nullable=True)
|
||||||
|
owner_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
owner = relationship("User", back_populates="templates")
|
||||||
annotations = relationship(
|
annotations = relationship(
|
||||||
"Annotation", back_populates="template", cascade="all, delete-orphan"
|
"Annotation", back_populates="template", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
@@ -129,6 +152,22 @@ class Mask(Base):
|
|||||||
annotation = relationship("Annotation", back_populates="masks")
|
annotation = relationship("Annotation", back_populates="masks")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
"""Audit trail for security and administrative actions."""
|
||||||
|
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
action = Column(String(120), nullable=False)
|
||||||
|
target_type = Column(String(80), nullable=True)
|
||||||
|
target_id = Column(String(120), nullable=True)
|
||||||
|
detail = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
actor = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class ProcessingTask(Base):
|
class ProcessingTask(Base):
|
||||||
"""Background task state persisted for dashboard and polling."""
|
"""Background task state persisted for dashboard and polling."""
|
||||||
|
|
||||||
|
|||||||
299
backend/routers/admin.py
Normal file
299
backend/routers/admin.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
"""Administrator-only user and audit management endpoints."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User
|
||||||
|
from routers.auth import SUPPORTED_ROLES, ensure_default_admin, hash_password, normalize_user_role, require_admin, write_audit_log
|
||||||
|
from schemas import (
|
||||||
|
AdminUserCreate,
|
||||||
|
AdminUserUpdate,
|
||||||
|
AuditLogOut,
|
||||||
|
DemoFactoryResetOut,
|
||||||
|
DemoFactoryResetRequest,
|
||||||
|
UserOut,
|
||||||
|
)
|
||||||
|
from services.demo_media import (
|
||||||
|
DEMO_DICOM_PROJECT_NAME,
|
||||||
|
DEMO_VIDEO_PROJECT_NAME,
|
||||||
|
create_parsed_dicom_demo_project,
|
||||||
|
create_parsed_video_demo_project,
|
||||||
|
demo_dicom_files,
|
||||||
|
)
|
||||||
|
from services.default_templates import restore_default_templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||||
|
|
||||||
|
DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY"
|
||||||
|
DEMO_PROJECT_NAME = DEMO_DICOM_PROJECT_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_role(role: str | None) -> str:
|
||||||
|
normalized = (role or "annotator").strip().lower()
|
||||||
|
if normalized not in SUPPORTED_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported role: {role}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_non_admin_role(role: str) -> None:
|
||||||
|
if role == "admin":
|
||||||
|
raise HTTPException(status_code=400, detail="Only the default admin account can have admin role")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=List[UserOut], summary="List users")
|
||||||
|
def list_users(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin),
|
||||||
|
) -> List[User]:
|
||||||
|
"""Return all users for the administrator console."""
|
||||||
|
_ = admin_user
|
||||||
|
users = db.query(User).order_by(User.id).all()
|
||||||
|
return [normalize_user_role(db, user) for user in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/users",
|
||||||
|
response_model=UserOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create user",
|
||||||
|
)
|
||||||
|
def create_user(
|
||||||
|
payload: AdminUserCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin),
|
||||||
|
) -> User:
|
||||||
|
"""Create a user with an initial password and role."""
|
||||||
|
username = payload.username.strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=400, detail="Username is required")
|
||||||
|
if len(payload.password) < 6:
|
||||||
|
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||||
|
role = _normalize_role(payload.role)
|
||||||
|
_assert_non_admin_role(role)
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
role=role,
|
||||||
|
is_active=1 if payload.is_active else 0,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="Username already exists") from exc
|
||||||
|
db.refresh(user)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
actor=admin_user,
|
||||||
|
action="admin.user_created",
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
detail={"username": user.username, "role": user.role, "is_active": bool(user.is_active)},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}", response_model=UserOut, summary="Update user")
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
payload: AdminUserUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin),
|
||||||
|
) -> User:
|
||||||
|
"""Update username, password, role or active state."""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
user = normalize_user_role(db, user)
|
||||||
|
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
audit_detail: dict = {"before": {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}}
|
||||||
|
if "username" in updates:
|
||||||
|
username = (updates["username"] or "").strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=400, detail="Username is required")
|
||||||
|
if user.role == "admin" and username != settings.default_admin_username:
|
||||||
|
raise HTTPException(status_code=400, detail="Default admin username cannot be changed")
|
||||||
|
user.username = username
|
||||||
|
if "password" in updates:
|
||||||
|
password = updates["password"] or ""
|
||||||
|
if len(password) < 6:
|
||||||
|
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||||
|
user.password_hash = hash_password(password)
|
||||||
|
if "role" in updates:
|
||||||
|
next_role = _normalize_role(updates["role"])
|
||||||
|
if user.username == settings.default_admin_username:
|
||||||
|
if next_role != "admin":
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot remove the default admin role")
|
||||||
|
else:
|
||||||
|
_assert_non_admin_role(next_role)
|
||||||
|
user.role = next_role
|
||||||
|
if "is_active" in updates:
|
||||||
|
if user.id == admin_user.id and not updates["is_active"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
||||||
|
user.is_active = 1 if updates["is_active"] else 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="Username already exists") from exc
|
||||||
|
db.refresh(user)
|
||||||
|
audit_detail["after"] = {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}
|
||||||
|
audit_detail["password_changed"] = "password" in updates
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
actor=admin_user,
|
||||||
|
action="admin.user_updated",
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
detail=audit_detail,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete user")
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a user when it is safe to remove the account."""
|
||||||
|
if user_id == admin_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
user = normalize_user_role(db, user)
|
||||||
|
if user.role == "admin":
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete the default admin account")
|
||||||
|
username = user.username
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
actor=admin_user,
|
||||||
|
action="admin.user_deleted",
|
||||||
|
target_type="user",
|
||||||
|
target_id=user_id,
|
||||||
|
detail={"username": username},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-logs", response_model=List[AuditLogOut], summary="List audit logs")
|
||||||
|
def list_audit_logs(
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin),
|
||||||
|
) -> List[AuditLog]:
|
||||||
|
"""Return recent audit events for administrators."""
|
||||||
|
_ = admin_user
|
||||||
|
safe_limit = min(max(int(limit or 100), 1), 500)
|
||||||
|
return db.query(AuditLog).order_by(AuditLog.created_at.desc(), AuditLog.id.desc()).limit(safe_limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/demo-factory-reset",
|
||||||
|
response_model=DemoFactoryResetOut,
|
||||||
|
summary="Reset demo data to factory defaults",
|
||||||
|
)
|
||||||
|
def reset_demo_factory(
|
||||||
|
payload: DemoFactoryResetRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset a demo deployment to one admin account, the demo video, and the demo DICOM project."""
|
||||||
|
if payload.confirmation != DEMO_RESET_CONFIRMATION:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid reset confirmation")
|
||||||
|
|
||||||
|
if not os.path.exists(settings.demo_video_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Demo video not found: {settings.demo_video_path}",
|
||||||
|
)
|
||||||
|
if not demo_dicom_files(settings.demo_dicom_dir):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Demo DICOM series not found: {settings.demo_dicom_dir}",
|
||||||
|
)
|
||||||
|
|
||||||
|
requested_by = admin_user.username
|
||||||
|
preserved_admin = ensure_default_admin(db)
|
||||||
|
preserved_admin.username = settings.default_admin_username
|
||||||
|
preserved_admin.password_hash = hash_password(settings.default_admin_password)
|
||||||
|
preserved_admin.role = "admin"
|
||||||
|
preserved_admin.is_active = 1
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
deleted_counts = {
|
||||||
|
"masks": db.query(Mask).delete(synchronize_session=False),
|
||||||
|
"annotations": db.query(Annotation).delete(synchronize_session=False),
|
||||||
|
"frames": db.query(Frame).delete(synchronize_session=False),
|
||||||
|
"tasks": db.query(ProcessingTask).delete(synchronize_session=False),
|
||||||
|
"projects": db.query(Project).delete(synchronize_session=False),
|
||||||
|
"user_templates": db.query(Template).filter(Template.owner_user_id.is_not(None)).delete(synchronize_session=False),
|
||||||
|
"audit_logs": db.query(AuditLog).delete(synchronize_session=False),
|
||||||
|
"users": db.query(User).filter(User.id != preserved_admin.id).delete(synchronize_session=False),
|
||||||
|
}
|
||||||
|
db.flush()
|
||||||
|
db.expunge_all()
|
||||||
|
|
||||||
|
preserved_admin = db.query(User).filter(User.username == settings.default_admin_username).first()
|
||||||
|
if not preserved_admin:
|
||||||
|
raise HTTPException(status_code=500, detail="Default admin was not preserved")
|
||||||
|
|
||||||
|
restored_templates = restore_default_templates(db)
|
||||||
|
|
||||||
|
video_project = create_parsed_video_demo_project(
|
||||||
|
db,
|
||||||
|
owner=preserved_admin,
|
||||||
|
video_path=settings.demo_video_path,
|
||||||
|
project_name=DEMO_VIDEO_PROJECT_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
dicom_project = create_parsed_dicom_demo_project(
|
||||||
|
db,
|
||||||
|
owner=preserved_admin,
|
||||||
|
dicom_dir=settings.demo_dicom_dir,
|
||||||
|
project_name=DEMO_PROJECT_NAME,
|
||||||
|
)
|
||||||
|
db.refresh(preserved_admin)
|
||||||
|
db.refresh(video_project)
|
||||||
|
db.refresh(dicom_project)
|
||||||
|
video_project.frame_count = len(video_project.frames)
|
||||||
|
dicom_project.frame_count = len(dicom_project.frames)
|
||||||
|
projects = [video_project, dicom_project]
|
||||||
|
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
actor=preserved_admin,
|
||||||
|
action="admin.demo_factory_reset",
|
||||||
|
target_type="project",
|
||||||
|
target_id=dicom_project.id,
|
||||||
|
detail={
|
||||||
|
"project_names": [project.name for project in projects],
|
||||||
|
"video_path": video_project.video_path,
|
||||||
|
"dicom_path": dicom_project.video_path,
|
||||||
|
"source_types": [project.source_type for project in projects],
|
||||||
|
"frame_counts": {project.name: len(project.frames) for project in projects},
|
||||||
|
"deleted_counts": deleted_counts,
|
||||||
|
"restored_templates": [template.name for template in restored_templates],
|
||||||
|
"requested_by": requested_by,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"admin_user": preserved_admin,
|
||||||
|
"project": dicom_project,
|
||||||
|
"projects": projects,
|
||||||
|
"deleted_counts": deleted_counts,
|
||||||
|
"message": "演示环境已恢复出厂设置",
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""AI inference endpoints using selectable SAM runtimes."""
|
"""AI inference endpoints using selectable SAM runtimes."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
@@ -8,25 +9,141 @@ from typing import Any, List
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from minio_client import download_file
|
from minio_client import download_file
|
||||||
from models import Project, Frame, Template, Annotation
|
from models import Project, Frame, Template, Annotation, ProcessingTask, User
|
||||||
|
from routers.auth import get_current_user, require_editor
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AiRuntimeStatus,
|
AiRuntimeStatus,
|
||||||
|
MaskAnalysisRequest,
|
||||||
|
MaskAnalysisResponse,
|
||||||
|
SmoothMaskRequest,
|
||||||
|
SmoothMaskResponse,
|
||||||
PredictRequest,
|
PredictRequest,
|
||||||
PredictResponse,
|
PredictResponse,
|
||||||
PropagateRequest,
|
PropagateRequest,
|
||||||
PropagateResponse,
|
PropagateResponse,
|
||||||
|
PropagateTaskRequest,
|
||||||
|
ProcessingTaskOut,
|
||||||
AnnotationOut,
|
AnnotationOut,
|
||||||
AnnotationCreate,
|
AnnotationCreate,
|
||||||
AnnotationUpdate,
|
AnnotationUpdate,
|
||||||
)
|
)
|
||||||
|
from progress_events import publish_task_progress_event
|
||||||
|
from statuses import TASK_STATUS_QUEUED
|
||||||
|
from worker_tasks import propagate_project_masks
|
||||||
from services.sam_registry import ModelUnavailableError, sam_registry
|
from services.sam_registry import ModelUnavailableError, sam_registry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/ai", tags=["AI"])
|
router = APIRouter(prefix="/api/ai", tags=["AI"])
|
||||||
|
GT_MASK_EMPTY_DETAIL = "GT Mask 图片中没有非背景 maskid 区域。"
|
||||||
|
GT_IMPORT_MAX_CONTOUR_POINTS = 2048
|
||||||
|
GT_IMPORT_CONTOUR_EPSILON_RATIO = 0.00075
|
||||||
|
GT_IMPORT_MIN_CONTOUR_EPSILON = 0.35
|
||||||
|
RESERVED_UNCLASSIFIED_CLASS = {
|
||||||
|
"id": "reserved-unclassified",
|
||||||
|
"name": "待分类",
|
||||||
|
"color": "#000000",
|
||||||
|
"zIndex": 0,
|
||||||
|
"maskId": 0,
|
||||||
|
"category": "系统保留",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _shared_project_or_404(project_id: int, db: Session, current_user: User) -> Project:
|
||||||
|
_ = current_user
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def _shared_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame:
|
||||||
|
_ = current_user
|
||||||
|
query = (
|
||||||
|
db.query(Frame)
|
||||||
|
.join(Project, Project.id == Frame.project_id)
|
||||||
|
.filter(Frame.id == frame_id)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
query = query.filter(Frame.project_id == project_id)
|
||||||
|
frame = query.first()
|
||||||
|
if not frame:
|
||||||
|
raise HTTPException(status_code=404, detail="Frame not found")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_template_or_404(template_id: int, db: Session, current_user: User) -> Template:
|
||||||
|
template = db.query(Template).filter(
|
||||||
|
Template.id == template_id,
|
||||||
|
or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)),
|
||||||
|
).first()
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_hex_color(value: Any) -> str | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
text = value.strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if not text.startswith("#"):
|
||||||
|
text = f"#{text}"
|
||||||
|
if len(text) == 4:
|
||||||
|
text = "#" + "".join(char * 2 for char in text[1:])
|
||||||
|
if len(text) != 7:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
int(text[1:], 16)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_tuple_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||||
|
values = []
|
||||||
|
for channel in rgb:
|
||||||
|
value = int(channel)
|
||||||
|
if value > 255:
|
||||||
|
value = int(round(value / 257))
|
||||||
|
values.append(min(max(value, 0), 255))
|
||||||
|
return f"#{values[0]:02x}{values[1]:02x}{values[2]:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def _template_class_maps(template: Template | None) -> tuple[dict[int, dict[str, Any]], dict[str, dict[str, Any]], dict[str, Any]]:
|
||||||
|
classes = ((template.mapping_rules or {}).get("classes") if template else None) or []
|
||||||
|
by_maskid: dict[int, dict[str, Any]] = {}
|
||||||
|
by_color: dict[str, dict[str, Any]] = {}
|
||||||
|
unclassified = dict(RESERVED_UNCLASSIFIED_CLASS)
|
||||||
|
for index, item in enumerate(classes):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
maskid_value = item.get("maskId", item.get("maskid", item.get("mask_id")))
|
||||||
|
try:
|
||||||
|
maskid = int(maskid_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
maskid = index + 1
|
||||||
|
color = _normalize_hex_color(item.get("color")) or "#22c55e"
|
||||||
|
class_meta = {
|
||||||
|
"id": str(item.get("id") or f"maskid-{maskid}"),
|
||||||
|
"name": str(item.get("name") or f"类别 {maskid}"),
|
||||||
|
"color": color,
|
||||||
|
"zIndex": int(item.get("zIndex", item.get("z_index", index * 10))),
|
||||||
|
"maskId": maskid,
|
||||||
|
**({"category": item.get("category")} if item.get("category") else {}),
|
||||||
|
}
|
||||||
|
if maskid == 0 or class_meta["id"] == RESERVED_UNCLASSIFIED_CLASS["id"] or class_meta["name"] == RESERVED_UNCLASSIFIED_CLASS["name"]:
|
||||||
|
unclassified = dict(RESERVED_UNCLASSIFIED_CLASS)
|
||||||
|
continue
|
||||||
|
if maskid > 0:
|
||||||
|
by_maskid[maskid] = class_meta
|
||||||
|
by_color[color] = class_meta
|
||||||
|
return by_maskid, by_color, unclassified
|
||||||
|
|
||||||
|
|
||||||
def _load_frame_image(frame: Frame) -> np.ndarray:
|
def _load_frame_image(frame: Frame) -> np.ndarray:
|
||||||
@@ -44,13 +161,19 @@ def _load_frame_image(frame: Frame) -> np.ndarray:
|
|||||||
|
|
||||||
|
|
||||||
def _normalized_contour(contour: np.ndarray, width: int, height: int) -> list[list[float]]:
|
def _normalized_contour(contour: np.ndarray, width: int, height: int) -> list[list[float]]:
|
||||||
"""Approximate a contour and convert it to normalized polygon coordinates."""
|
"""Convert a contour to a detailed normalized polygon with a point-count cap."""
|
||||||
arc_length = cv2.arcLength(contour, True)
|
arc_length = cv2.arcLength(contour, True)
|
||||||
epsilon = max(1.0, arc_length * 0.01)
|
epsilon = max(GT_IMPORT_MIN_CONTOUR_EPSILON, arc_length * GT_IMPORT_CONTOUR_EPSILON_RATIO)
|
||||||
approx = cv2.approxPolyDP(contour, epsilon, True)
|
approx = cv2.approxPolyDP(contour, epsilon, True)
|
||||||
|
while len(approx) > GT_IMPORT_MAX_CONTOUR_POINTS and epsilon < arc_length * 0.02:
|
||||||
|
epsilon *= 1.5
|
||||||
|
approx = cv2.approxPolyDP(contour, epsilon, True)
|
||||||
points = approx.reshape(-1, 2)
|
points = approx.reshape(-1, 2)
|
||||||
if len(points) < 3:
|
if len(points) < 3:
|
||||||
points = contour.reshape(-1, 2)
|
points = contour.reshape(-1, 2)
|
||||||
|
if len(points) > GT_IMPORT_MAX_CONTOUR_POINTS:
|
||||||
|
step = int(math.ceil(len(points) / GT_IMPORT_MAX_CONTOUR_POINTS))
|
||||||
|
points = points[::step]
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
min(max(float(x) / max(width, 1), 0.0), 1.0),
|
min(max(float(x) / max(width, 1), 0.0), 1.0),
|
||||||
@@ -78,6 +201,131 @@ 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 _polygons_bbox(polygons: list[list[list[float]]]) -> list[float]:
|
||||||
|
points = [point for polygon in polygons for point in polygon if len(point) >= 2]
|
||||||
|
if not points:
|
||||||
|
return [0.0, 0.0, 0.0, 0.0]
|
||||||
|
xs = [_clamp01(point[0]) for point in points]
|
||||||
|
ys = [_clamp01(point[1]) for point in points]
|
||||||
|
left, right = min(xs), max(xs)
|
||||||
|
top, bottom = min(ys), max(ys)
|
||||||
|
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 _normalize_polygon(polygon: list[list[float]]) -> list[list[float]]:
|
||||||
|
return [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_polygons(polygons: list[list[list[float]]]) -> list[list[list[float]]]:
|
||||||
|
return [polygon for polygon in (_normalize_polygon(polygon) for polygon in polygons) if len(polygon) >= 3]
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_anchor_points(anchors: list[list[float]], limit: int = 64) -> list[list[float]]:
|
||||||
|
if len(anchors) <= limit:
|
||||||
|
return anchors
|
||||||
|
step = max(1, math.ceil(len(anchors) / limit))
|
||||||
|
return anchors[::step][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _analysis_anchor_summary(polygons: list[list[list[float]]]) -> tuple[int, list[list[float]]]:
|
||||||
|
anchors: list[list[float]] = []
|
||||||
|
for polygon in polygons:
|
||||||
|
if not polygon:
|
||||||
|
continue
|
||||||
|
anchors.extend([[_clamp01(point[0]), _clamp01(point[1])] for point in polygon])
|
||||||
|
return len(anchors), _sample_anchor_points(anchors)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_smoothing_options(strength: float | int | None, method: str | None = None) -> dict[str, Any]:
|
||||||
|
clamped_strength = max(0.0, min(float(strength or 0.0), 100.0))
|
||||||
|
normalized_method = (method or "chaikin").lower()
|
||||||
|
if normalized_method != "chaikin":
|
||||||
|
normalized_method = "chaikin"
|
||||||
|
return {
|
||||||
|
"strength": round(clamped_strength, 2),
|
||||||
|
"method": normalized_method,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _smoothing_ratio(strength: float, curve: float = 1.65) -> float:
|
||||||
|
normalized = max(0.0, min(float(strength or 0.0), 100.0)) / 100.0
|
||||||
|
return normalized ** curve
|
||||||
|
|
||||||
|
|
||||||
|
def _chaikin_smooth_polygon(polygon: list[list[float]], iterations: int, corner_cut: float = 0.25) -> list[list[float]]:
|
||||||
|
points = polygon
|
||||||
|
q = max(0.02, min(float(corner_cut), 0.25))
|
||||||
|
for _ in range(max(0, iterations)):
|
||||||
|
if len(points) < 3:
|
||||||
|
break
|
||||||
|
next_points: list[list[float]] = []
|
||||||
|
for index, current in enumerate(points):
|
||||||
|
following = points[(index + 1) % len(points)]
|
||||||
|
next_points.append([
|
||||||
|
_clamp01((1.0 - q) * current[0] + q * following[0]),
|
||||||
|
_clamp01((1.0 - q) * current[1] + q * following[1]),
|
||||||
|
])
|
||||||
|
next_points.append([
|
||||||
|
_clamp01(q * current[0] + (1.0 - q) * following[0]),
|
||||||
|
_clamp01(q * current[1] + (1.0 - q) * following[1]),
|
||||||
|
])
|
||||||
|
points = next_points
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _simplify_polygon(polygon: list[list[float]], strength: float) -> list[list[float]]:
|
||||||
|
if len(polygon) < 3 or strength <= 0:
|
||||||
|
return polygon
|
||||||
|
contour = np.array([[[point[0], point[1]]] for point in polygon], dtype=np.float32)
|
||||||
|
arc_length = cv2.arcLength(contour, True)
|
||||||
|
epsilon = arc_length * (0.00015 + _smoothing_ratio(strength) * 0.00735)
|
||||||
|
approx = cv2.approxPolyDP(contour, epsilon, True).reshape(-1, 2)
|
||||||
|
if len(approx) < 3:
|
||||||
|
return polygon
|
||||||
|
return [[_clamp01(float(x)), _clamp01(float(y))] for x, y in approx]
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth_polygon(polygon: list[list[float]], smoothing: dict[str, Any]) -> list[list[float]]:
|
||||||
|
strength = float(smoothing.get("strength") or 0.0)
|
||||||
|
if strength <= 0:
|
||||||
|
return _normalize_polygon(polygon)
|
||||||
|
effective_strength = _smoothing_ratio(strength, curve=1.45) * 100.0
|
||||||
|
if effective_strength >= 85:
|
||||||
|
iterations = 4
|
||||||
|
elif effective_strength >= 55:
|
||||||
|
iterations = 3
|
||||||
|
elif effective_strength >= 25:
|
||||||
|
iterations = 2
|
||||||
|
else:
|
||||||
|
iterations = 1
|
||||||
|
corner_cut = 0.03 + _smoothing_ratio(strength, curve=1.35) * 0.22
|
||||||
|
normalized = _normalize_polygon(polygon)
|
||||||
|
pre_simplified = _simplify_polygon(normalized, effective_strength * 0.25)
|
||||||
|
smoothed = _chaikin_smooth_polygon(pre_simplified, iterations, corner_cut)
|
||||||
|
simplified = _simplify_polygon(smoothed, effective_strength)
|
||||||
|
if len(simplified) > len(normalized):
|
||||||
|
for fallback_strength in (25.0, 35.0, 50.0, 70.0, 90.0, 100.0):
|
||||||
|
simplified = _simplify_polygon(simplified, max(effective_strength, fallback_strength))
|
||||||
|
if len(simplified) <= len(normalized):
|
||||||
|
break
|
||||||
|
return simplified if len(simplified) >= 3 else _normalize_polygon(polygon)
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth_polygons(polygons: list[list[list[float]]], smoothing: dict[str, Any]) -> list[list[list[float]]]:
|
||||||
|
return [polygon for polygon in (_smooth_polygon(polygon, smoothing) for polygon in polygons) if len(polygon) >= 3]
|
||||||
|
|
||||||
|
|
||||||
def _frame_window(
|
def _frame_window(
|
||||||
frames: list[Frame],
|
frames: list[Frame],
|
||||||
source_position: int,
|
source_position: int,
|
||||||
@@ -224,18 +472,20 @@ def _filter_predictions(
|
|||||||
response_model=PredictResponse,
|
response_model=PredictResponse,
|
||||||
summary="Run SAM inference with a prompt",
|
summary="Run SAM inference with a prompt",
|
||||||
)
|
)
|
||||||
def predict(payload: PredictRequest, db: Session = Depends(get_db)) -> dict:
|
def predict(
|
||||||
|
payload: PredictRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> dict:
|
||||||
"""Execute selected SAM segmentation given an image and a prompt.
|
"""Execute selected SAM segmentation given an image and a prompt.
|
||||||
|
|
||||||
- **point**: `prompt_data` is either a list of `[[x, y], ...]` normalized
|
- **point**: `prompt_data` is either a list of `[[x, y], ...]` normalized
|
||||||
coordinates or `{ "points": [[x, y], ...], "labels": [1, 0, ...] }`.
|
coordinates or `{ "points": [[x, y], ...], "labels": [1, 0, ...] }`.
|
||||||
- **box**: `prompt_data` is `[x1, y1, x2, y2]` normalized coordinates.
|
- **box**: `prompt_data` is `[x1, y1, x2, y2]` normalized coordinates.
|
||||||
- **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`.
|
- **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`.
|
||||||
- **semantic**: SAM 3 text prompt when model=`sam3`; SAM 2 falls back to auto.
|
- **semantic**: disabled in the current SAM 2.1 point/box product flow.
|
||||||
"""
|
"""
|
||||||
frame = db.query(Frame).filter(Frame.id == payload.image_id).first()
|
frame = _shared_frame_or_404(payload.image_id, db, current_user)
|
||||||
if not frame:
|
|
||||||
raise HTTPException(status_code=404, detail="Frame not found")
|
|
||||||
|
|
||||||
image = _load_frame_image(frame)
|
image = _load_frame_image(frame)
|
||||||
prompt_type = payload.prompt_type.lower()
|
prompt_type = payload.prompt_type.lower()
|
||||||
@@ -381,40 +631,133 @@ def predict(payload: PredictRequest, db: Session = Depends(get_db)) -> dict:
|
|||||||
response_model=AiRuntimeStatus,
|
response_model=AiRuntimeStatus,
|
||||||
summary="Get SAM model and GPU runtime status",
|
summary="Get SAM model and GPU runtime status",
|
||||||
)
|
)
|
||||||
def model_status(selected_model: str | None = None) -> dict:
|
def model_status(
|
||||||
"""Return real runtime availability for GPU, SAM 2, and SAM 3."""
|
selected_model: str | None = None,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Return real runtime availability for GPU and the currently enabled SAM model."""
|
||||||
try:
|
try:
|
||||||
return sam_registry.runtime_status(selected_model)
|
return sam_registry.runtime_status(selected_model)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
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),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Return backend-computed mask properties for the frontend inspector."""
|
||||||
|
if payload.frame_id is not None:
|
||||||
|
_shared_frame_or_404(payload.frame_id, db, current_user)
|
||||||
|
|
||||||
|
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 = _normalize_polygons(polygons)
|
||||||
|
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"
|
||||||
|
|
||||||
|
anchor_count, anchors = _analysis_anchor_summary(valid_polygons)
|
||||||
|
message = "已从后端重新提取几何拓扑锚点" if payload.extract_skeleton else "已读取后端几何属性"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"confidence": confidence,
|
||||||
|
"confidence_source": confidence_source,
|
||||||
|
"topology_anchor_count": anchor_count,
|
||||||
|
"topology_anchors": anchors,
|
||||||
|
"area": area,
|
||||||
|
"bbox": bbox,
|
||||||
|
"source": source,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/smooth-mask",
|
||||||
|
response_model=SmoothMaskResponse,
|
||||||
|
summary="Smooth editable mask polygons with backend geometry rules",
|
||||||
|
)
|
||||||
|
def smooth_mask(
|
||||||
|
payload: SmoothMaskRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> dict:
|
||||||
|
"""Return a smoothed polygon mask without persisting it.
|
||||||
|
|
||||||
|
The frontend keeps this as an explicit edit operation: users preview/apply it
|
||||||
|
to the current mask, then save through the normal annotation endpoint.
|
||||||
|
"""
|
||||||
|
if payload.frame_id is not None:
|
||||||
|
_shared_frame_or_404(payload.frame_id, db, current_user)
|
||||||
|
|
||||||
|
polygons = payload.mask_data.get("polygons") or []
|
||||||
|
valid_polygons = _normalize_polygons(polygons)
|
||||||
|
if not valid_polygons:
|
||||||
|
raise HTTPException(status_code=400, detail="Mask smoothing requires at least one valid polygon")
|
||||||
|
|
||||||
|
smoothing = _normalize_smoothing_options(payload.strength, payload.method)
|
||||||
|
smoothed_polygons = _smooth_polygons(valid_polygons, smoothing)
|
||||||
|
if not smoothed_polygons:
|
||||||
|
raise HTTPException(status_code=400, detail="Mask smoothing produced no valid polygons")
|
||||||
|
|
||||||
|
area = sum(_polygon_area(polygon) for polygon in smoothed_polygons)
|
||||||
|
bbox = _polygon_bbox(smoothed_polygons[0])
|
||||||
|
anchor_count, anchors = _analysis_anchor_summary(smoothed_polygons)
|
||||||
|
return {
|
||||||
|
"polygons": smoothed_polygons,
|
||||||
|
"topology_anchor_count": anchor_count,
|
||||||
|
"topology_anchors": anchors,
|
||||||
|
"area": area,
|
||||||
|
"bbox": bbox,
|
||||||
|
"smoothing": smoothing,
|
||||||
|
"message": f"已应用边缘平滑强度 {smoothing['strength']:.0f}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/propagate",
|
"/propagate",
|
||||||
response_model=PropagateResponse,
|
response_model=PropagateResponse,
|
||||||
summary="Propagate one current-frame region across a video frame segment",
|
summary="Propagate one current-frame region across a video frame segment",
|
||||||
)
|
)
|
||||||
def propagate(payload: PropagateRequest, db: Session = Depends(get_db)) -> dict:
|
def propagate(
|
||||||
|
payload: PropagateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> dict:
|
||||||
"""Track one selected region from the current frame across nearby frames.
|
"""Track one selected region from the current frame across nearby frames.
|
||||||
|
|
||||||
SAM 2 uses the official video predictor with the selected mask as the seed.
|
SAM 2 uses the official video predictor with the selected mask as the seed.
|
||||||
SAM 3 uses the external Python 3.12 video tracker with the seed bbox.
|
SAM 3 video tracking is currently disabled in this product flow.
|
||||||
"""
|
"""
|
||||||
direction = payload.direction.lower()
|
direction = payload.direction.lower()
|
||||||
if direction not in {"forward", "backward", "both"}:
|
if direction not in {"forward", "backward", "both"}:
|
||||||
raise HTTPException(status_code=400, detail="direction must be forward, backward, or both")
|
raise HTTPException(status_code=400, detail="direction must be forward, backward, or both")
|
||||||
max_frames = max(1, min(int(payload.max_frames or 30), 500))
|
max_frames = max(1, min(int(payload.max_frames or 30), 500))
|
||||||
|
|
||||||
project = db.query(Project).filter(Project.id == payload.project_id).first()
|
_shared_project_or_404(payload.project_id, db, current_user)
|
||||||
if not project:
|
source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
source_frame = db.query(Frame).filter(
|
|
||||||
Frame.id == payload.frame_id,
|
|
||||||
Frame.project_id == payload.project_id,
|
|
||||||
).first()
|
|
||||||
if not source_frame:
|
|
||||||
raise HTTPException(status_code=404, detail="Frame not found")
|
|
||||||
|
|
||||||
seed = payload.seed.model_dump(exclude_none=True)
|
seed = payload.seed.model_dump(exclude_none=True)
|
||||||
polygons = seed.get("polygons") or []
|
polygons = seed.get("polygons") or []
|
||||||
@@ -460,6 +803,20 @@ def propagate(payload: PropagateRequest, db: Session = Depends(get_db)) -> dict:
|
|||||||
label = seed.get("label") or "Propagated Mask"
|
label = seed.get("label") or "Propagated Mask"
|
||||||
color = seed.get("color") or "#06b6d4"
|
color = seed.get("color") or "#06b6d4"
|
||||||
model_id = sam_registry.normalize_model_id(payload.model)
|
model_id = sam_registry.normalize_model_id(payload.model)
|
||||||
|
source_annotation_id = seed.get("source_annotation_id")
|
||||||
|
source_mask_id = seed.get("source_mask_id")
|
||||||
|
source_instance_id = (
|
||||||
|
seed.get("source_instance_id")
|
||||||
|
or (f"annotation:{source_annotation_id}" if source_annotation_id is not None else None)
|
||||||
|
or (f"mask:{source_mask_id}" if source_mask_id else None)
|
||||||
|
)
|
||||||
|
seed_smoothing = seed.get("smoothing")
|
||||||
|
smoothing = _normalize_smoothing_options(
|
||||||
|
seed_smoothing.get("strength"),
|
||||||
|
seed_smoothing.get("method"),
|
||||||
|
) if isinstance(seed_smoothing, dict) else None
|
||||||
|
if smoothing and smoothing["strength"] <= 0:
|
||||||
|
smoothing = None
|
||||||
|
|
||||||
for frame_result in propagated:
|
for frame_result in propagated:
|
||||||
relative_index = int(frame_result.get("frame_index", -1))
|
relative_index = int(frame_result.get("frame_index", -1))
|
||||||
@@ -469,29 +826,49 @@ def propagate(payload: PropagateRequest, db: Session = Depends(get_db)) -> dict:
|
|||||||
if not payload.include_source and frame.id == source_frame.id:
|
if not payload.include_source and frame.id == source_frame.id:
|
||||||
continue
|
continue
|
||||||
result_polygons = frame_result.get("polygons") or []
|
result_polygons = frame_result.get("polygons") or []
|
||||||
|
result_holes = frame_result.get("holes") or []
|
||||||
scores = frame_result.get("scores") or []
|
scores = frame_result.get("scores") or []
|
||||||
|
polygons_to_save: list[list[list[float]]] = []
|
||||||
|
holes_to_save: list[list[list[list[float]]]] = []
|
||||||
|
score_values: list[float] = []
|
||||||
for polygon_index, polygon in enumerate(result_polygons):
|
for polygon_index, polygon in enumerate(result_polygons):
|
||||||
if len(polygon) < 3:
|
if len(polygon) < 3:
|
||||||
continue
|
continue
|
||||||
annotation = Annotation(
|
polygons_to_save.append(_smooth_polygon(polygon, smoothing) if smoothing else polygon)
|
||||||
project_id=payload.project_id,
|
hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else []
|
||||||
frame_id=frame.id,
|
holes_to_save.append(hole_group if isinstance(hole_group, list) else [])
|
||||||
template_id=template_id,
|
if polygon_index < len(scores):
|
||||||
mask_data={
|
try:
|
||||||
"polygons": [polygon],
|
score_values.append(float(scores[polygon_index]))
|
||||||
"label": label,
|
except (TypeError, ValueError):
|
||||||
"color": color,
|
pass
|
||||||
"source": f"{model_id}_propagation",
|
if not polygons_to_save:
|
||||||
"propagated_from_frame_id": source_frame.id,
|
continue
|
||||||
"propagated_from_frame_index": source_frame.frame_index,
|
annotation = Annotation(
|
||||||
"score": scores[polygon_index] if polygon_index < len(scores) else None,
|
project_id=payload.project_id,
|
||||||
**({"class": class_metadata} if class_metadata else {}),
|
frame_id=frame.id,
|
||||||
},
|
template_id=template_id,
|
||||||
points=None,
|
mask_data={
|
||||||
bbox=_polygon_bbox(polygon),
|
"polygons": polygons_to_save,
|
||||||
)
|
**({"holes": holes_to_save, "hasHoles": True} if any(holes_to_save) else {}),
|
||||||
db.add(annotation)
|
"label": label,
|
||||||
created.append(annotation)
|
"color": color,
|
||||||
|
"source": f"{model_id}_propagation",
|
||||||
|
"propagated_from_frame_id": source_frame.id,
|
||||||
|
"propagated_from_frame_index": source_frame.frame_index,
|
||||||
|
**({"instance_id": source_instance_id, "source_instance_id": source_instance_id} if source_instance_id else {}),
|
||||||
|
**({"source_annotation_id": source_annotation_id} if source_annotation_id is not None else {}),
|
||||||
|
**({"source_mask_id": source_mask_id} if source_mask_id else {}),
|
||||||
|
"score": max(score_values) if score_values else None,
|
||||||
|
**({"scores": score_values} if len(score_values) > 1 else {}),
|
||||||
|
**({"geometry_smoothing": smoothing} if smoothing else {}),
|
||||||
|
**({"class": class_metadata} if class_metadata else {}),
|
||||||
|
},
|
||||||
|
points=None,
|
||||||
|
bbox=_polygons_bbox(polygons_to_save),
|
||||||
|
)
|
||||||
|
db.add(annotation)
|
||||||
|
created.append(annotation)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
for annotation in created:
|
for annotation in created:
|
||||||
@@ -507,16 +884,74 @@ def propagate(payload: PropagateRequest, db: Session = Depends(get_db)) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/propagate/task",
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
response_model=ProcessingTaskOut,
|
||||||
|
summary="Queue a background video propagation task",
|
||||||
|
)
|
||||||
|
def queue_propagate_task(
|
||||||
|
payload: PropagateTaskRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> ProcessingTaskOut:
|
||||||
|
"""Queue multiple seed/direction propagation steps as one background task."""
|
||||||
|
_shared_project_or_404(payload.project_id, db, current_user)
|
||||||
|
source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
||||||
|
|
||||||
|
if not payload.steps:
|
||||||
|
raise HTTPException(status_code=400, detail="Propagation task requires at least one step")
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_id = sam_registry.normalize_model_id(payload.model)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
for step in payload.steps:
|
||||||
|
direction = step.direction.lower()
|
||||||
|
if direction not in {"forward", "backward"}:
|
||||||
|
raise HTTPException(status_code=400, detail="direction must be forward or backward")
|
||||||
|
seed = step.seed.model_dump(exclude_none=True)
|
||||||
|
if not (seed.get("polygons") or seed.get("bbox") or seed.get("points")):
|
||||||
|
raise HTTPException(status_code=400, detail="Propagation requires seed polygons, bbox, or points")
|
||||||
|
|
||||||
|
task_payload = payload.model_dump(exclude_none=True)
|
||||||
|
task_payload["model"] = model_id
|
||||||
|
task = ProcessingTask(
|
||||||
|
task_type="propagate_masks",
|
||||||
|
status=TASK_STATUS_QUEUED,
|
||||||
|
progress=0,
|
||||||
|
message="自动传播任务已入队",
|
||||||
|
project_id=payload.project_id,
|
||||||
|
payload=task_payload,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
publish_task_progress_event(task)
|
||||||
|
|
||||||
|
async_result = propagate_project_masks.delay(task.id)
|
||||||
|
task.celery_task_id = async_result.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
publish_task_progress_event(task)
|
||||||
|
|
||||||
|
logger.info("Queued propagation task id=%s project_id=%s celery_id=%s", task.id, payload.project_id, async_result.id)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/auto",
|
"/auto",
|
||||||
response_model=PredictResponse,
|
response_model=PredictResponse,
|
||||||
summary="Run automatic segmentation",
|
summary="Run automatic segmentation",
|
||||||
)
|
)
|
||||||
def auto_segment(image_id: int, db: Session = Depends(get_db)) -> dict:
|
def auto_segment(
|
||||||
|
image_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> dict:
|
||||||
"""Run automatic mask generation on a frame using a grid of point prompts."""
|
"""Run automatic mask generation on a frame using a grid of point prompts."""
|
||||||
frame = db.query(Frame).filter(Frame.id == image_id).first()
|
frame = _shared_frame_or_404(image_id, db, current_user)
|
||||||
if not frame:
|
|
||||||
raise HTTPException(status_code=404, detail="Frame not found")
|
|
||||||
|
|
||||||
image = _load_frame_image(frame)
|
image = _load_frame_image(frame)
|
||||||
try:
|
try:
|
||||||
@@ -536,16 +971,15 @@ def auto_segment(image_id: int, db: Session = Depends(get_db)) -> dict:
|
|||||||
def save_annotation(
|
def save_annotation(
|
||||||
payload: AnnotationCreate,
|
payload: AnnotationCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> Annotation:
|
) -> Annotation:
|
||||||
"""Persist an annotation (mask, points, bbox) into the database."""
|
"""Persist an annotation (mask, points, bbox) into the database."""
|
||||||
project = db.query(Project).filter(Project.id == payload.project_id).first()
|
_shared_project_or_404(payload.project_id, db, current_user)
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
if payload.frame_id:
|
if payload.frame_id:
|
||||||
frame = db.query(Frame).filter(Frame.id == payload.frame_id).first()
|
_shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
|
||||||
if not frame:
|
if payload.template_id:
|
||||||
raise HTTPException(status_code=404, detail="Frame not found")
|
_visible_template_or_404(payload.template_id, db, current_user)
|
||||||
|
|
||||||
annotation = Annotation(**payload.model_dump())
|
annotation = Annotation(**payload.model_dump())
|
||||||
db.add(annotation)
|
db.add(annotation)
|
||||||
@@ -567,8 +1001,10 @@ async def import_gt_mask(
|
|||||||
template_id: int | None = Form(None),
|
template_id: int | None = Form(None),
|
||||||
label: str = Form("GT Mask"),
|
label: str = Form("GT Mask"),
|
||||||
color: str = Form("#22c55e"),
|
color: str = Form("#22c55e"),
|
||||||
|
unknown_color_policy: str = Form("undefined"),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> List[Annotation]:
|
) -> List[Annotation]:
|
||||||
"""Convert a binary/label mask image into persisted polygon annotations.
|
"""Convert a binary/label mask image into persisted polygon annotations.
|
||||||
|
|
||||||
@@ -577,62 +1013,124 @@ async def import_gt_mask(
|
|||||||
the frontend an editable point-region representation instead of a static
|
the frontend an editable point-region representation instead of a static
|
||||||
bitmap layer.
|
bitmap layer.
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
_shared_project_or_404(project_id, db, current_user)
|
||||||
if not project:
|
frame = _shared_frame_or_404(frame_id, db, current_user, project_id)
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
frame = db.query(Frame).filter(Frame.id == frame_id, Frame.project_id == project_id).first()
|
if unknown_color_policy not in {"discard", "undefined"}:
|
||||||
if not frame:
|
raise HTTPException(status_code=400, detail="unknown_color_policy must be discard or undefined")
|
||||||
raise HTTPException(status_code=404, detail="Frame not found")
|
|
||||||
|
|
||||||
|
template: Template | None = None
|
||||||
if template_id is not None:
|
if template_id is not None:
|
||||||
template = db.query(Template).filter(Template.id == template_id).first()
|
template = _visible_template_or_404(template_id, db, current_user)
|
||||||
if not template:
|
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
|
||||||
|
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
|
image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
if image is None:
|
if image is None:
|
||||||
raise HTTPException(status_code=400, detail="Invalid mask image")
|
raise HTTPException(status_code=400, detail="Invalid mask image")
|
||||||
|
|
||||||
|
invalid_format_detail = (
|
||||||
|
"GT Mask 图片不符合要求:仅支持 8-bit 灰度图,或 8-bit RGB 三通道完全相同的 maskid 图"
|
||||||
|
"(背景 0,像素值为 1-255 的 maskid)。"
|
||||||
|
)
|
||||||
|
if image.dtype != np.uint8:
|
||||||
|
raise HTTPException(status_code=400, detail=invalid_format_detail)
|
||||||
|
|
||||||
|
if image.ndim == 2:
|
||||||
|
label_image = image
|
||||||
|
elif image.ndim == 3 and image.shape[2] >= 3:
|
||||||
|
channels = image[:, :, :3]
|
||||||
|
# GT label images are maskid maps: either grayscale or RGB/BGR where
|
||||||
|
# all three color channels contain the same maskid value [X, X, X].
|
||||||
|
if not (np.array_equal(channels[:, :, 0], channels[:, :, 1]) and np.array_equal(channels[:, :, 1], channels[:, :, 2])):
|
||||||
|
raise HTTPException(status_code=400, detail=invalid_format_detail)
|
||||||
|
label_image = channels[:, :, 0]
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=invalid_format_detail)
|
||||||
|
|
||||||
width = int(frame.width or image.shape[1])
|
width = int(frame.width or image.shape[1])
|
||||||
height = int(frame.height or image.shape[0])
|
height = int(frame.height or image.shape[0])
|
||||||
label_values = [int(value) for value in np.unique(image) if int(value) > 0]
|
original_height, original_width = int(label_image.shape[0]), int(label_image.shape[1])
|
||||||
if not label_values:
|
resized_to_frame = original_width != width or original_height != height
|
||||||
raise HTTPException(status_code=400, detail="No foreground mask regions found")
|
if resized_to_frame:
|
||||||
has_multiple_labels = len(label_values) > 1
|
label_image = cv2.resize(label_image, (width, height), interpolation=cv2.INTER_NEAREST)
|
||||||
|
|
||||||
|
by_maskid, _by_color, unclassified_class = _template_class_maps(template)
|
||||||
|
has_template_classes = bool(by_maskid)
|
||||||
|
fallback_color = _normalize_hex_color(color) or "#22c55e"
|
||||||
|
|
||||||
|
import_items: list[dict[str, Any]] = []
|
||||||
|
skipped_unknown = 0
|
||||||
|
label_values = [int(value) for value in np.unique(label_image) if int(value) > 0]
|
||||||
|
for label_value in label_values:
|
||||||
|
class_meta = by_maskid.get(label_value)
|
||||||
|
is_unknown = has_template_classes and class_meta is None
|
||||||
|
if is_unknown and unknown_color_policy == "discard":
|
||||||
|
skipped_unknown += 1
|
||||||
|
continue
|
||||||
|
if class_meta:
|
||||||
|
annotation_label = class_meta["name"]
|
||||||
|
annotation_color = class_meta["color"]
|
||||||
|
elif is_unknown:
|
||||||
|
annotation_label = unclassified_class["name"]
|
||||||
|
annotation_color = unclassified_class["color"]
|
||||||
|
class_meta = unclassified_class
|
||||||
|
else:
|
||||||
|
annotation_label = f"{label} {label_value}" if len(label_values) > 1 else label
|
||||||
|
annotation_color = fallback_color
|
||||||
|
import_items.append({
|
||||||
|
"token": label_value,
|
||||||
|
"binary": np.where(label_image == label_value, 255, 0).astype(np.uint8),
|
||||||
|
"label": annotation_label,
|
||||||
|
"color": annotation_color,
|
||||||
|
"class": class_meta,
|
||||||
|
"unknown": is_unknown,
|
||||||
|
"metadata": {
|
||||||
|
"gt_label_value": label_value,
|
||||||
|
"gt_original_size": {"width": original_width, "height": original_height},
|
||||||
|
"gt_resized_to_frame": resized_to_frame,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if not import_items:
|
||||||
|
if skipped_unknown > 0:
|
||||||
|
raise HTTPException(status_code=400, detail="No matching GT mask classes found")
|
||||||
|
raise HTTPException(status_code=400, detail=GT_MASK_EMPTY_DETAIL)
|
||||||
|
|
||||||
annotations: list[Annotation] = []
|
annotations: list[Annotation] = []
|
||||||
for label_value in label_values:
|
for item in import_items:
|
||||||
binary = np.where(image == label_value, 255, 0).astype(np.uint8)
|
binary = item["binary"]
|
||||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
||||||
annotation_label = f"{label} {label_value}" if has_multiple_labels else label
|
|
||||||
|
|
||||||
for contour in contours:
|
for contour in contours:
|
||||||
if cv2.contourArea(contour) < 1:
|
if cv2.contourArea(contour) < 1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
polygon = _normalized_contour(contour, image.shape[1], image.shape[0])
|
polygon = _normalized_contour(contour, binary.shape[1], binary.shape[0])
|
||||||
if len(polygon) < 3:
|
if len(polygon) < 3:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
component = np.zeros_like(binary, dtype=np.uint8)
|
component = np.zeros_like(binary, dtype=np.uint8)
|
||||||
cv2.drawContours(component, [contour], -1, 1, thickness=-1)
|
cv2.drawContours(component, [contour], -1, 1, thickness=-1)
|
||||||
seed_point = _component_seed_point(component, image.shape[1], image.shape[0])
|
seed_point = _component_seed_point(component, binary.shape[1], binary.shape[0])
|
||||||
bbox = _contour_bbox(contour, image.shape[1], image.shape[0])
|
bbox = _contour_bbox(contour, binary.shape[1], binary.shape[0])
|
||||||
|
mask_data = {
|
||||||
|
"polygons": [polygon],
|
||||||
|
"label": item["label"],
|
||||||
|
"color": item["color"],
|
||||||
|
"source": "gt_mask",
|
||||||
|
"image_size": {"width": width, "height": height},
|
||||||
|
**item["metadata"],
|
||||||
|
}
|
||||||
|
if item["class"]:
|
||||||
|
mask_data["class"] = item["class"]
|
||||||
|
if item["unknown"]:
|
||||||
|
mask_data["gt_unknown_class"] = True
|
||||||
|
|
||||||
annotation = Annotation(
|
annotation = Annotation(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
frame_id=frame_id,
|
frame_id=frame_id,
|
||||||
template_id=template_id,
|
template_id=template_id,
|
||||||
mask_data={
|
mask_data=mask_data,
|
||||||
"polygons": [polygon],
|
|
||||||
"label": annotation_label,
|
|
||||||
"color": color,
|
|
||||||
"source": "gt_mask",
|
|
||||||
"gt_label_value": label_value,
|
|
||||||
"image_size": {"width": width, "height": height},
|
|
||||||
},
|
|
||||||
points=[seed_point],
|
points=[seed_point],
|
||||||
bbox=bbox,
|
bbox=bbox,
|
||||||
)
|
)
|
||||||
@@ -640,7 +1138,7 @@ async def import_gt_mask(
|
|||||||
annotations.append(annotation)
|
annotations.append(annotation)
|
||||||
|
|
||||||
if not annotations:
|
if not annotations:
|
||||||
raise HTTPException(status_code=400, detail="No foreground mask regions found")
|
raise HTTPException(status_code=400, detail=GT_MASK_EMPTY_DETAIL)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
for annotation in annotations:
|
for annotation in annotations:
|
||||||
@@ -658,14 +1156,14 @@ def list_annotations(
|
|||||||
project_id: int,
|
project_id: int,
|
||||||
frame_id: int | None = None,
|
frame_id: int | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[Annotation]:
|
) -> List[Annotation]:
|
||||||
"""Return persisted annotations for a project, optionally scoped to one frame."""
|
"""Return persisted annotations for a project, optionally scoped to one frame."""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
_shared_project_or_404(project_id, db, current_user)
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
query = db.query(Annotation).filter(Annotation.project_id == project_id)
|
query = db.query(Annotation).filter(Annotation.project_id == project_id)
|
||||||
if frame_id is not None:
|
if frame_id is not None:
|
||||||
|
_shared_frame_or_404(frame_id, db, current_user, project_id)
|
||||||
query = query.filter(Annotation.frame_id == frame_id)
|
query = query.filter(Annotation.frame_id == frame_id)
|
||||||
return query.order_by(Annotation.id).all()
|
return query.order_by(Annotation.id).all()
|
||||||
|
|
||||||
@@ -679,17 +1177,21 @@ def update_annotation(
|
|||||||
annotation_id: int,
|
annotation_id: int,
|
||||||
payload: AnnotationUpdate,
|
payload: AnnotationUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> Annotation:
|
) -> Annotation:
|
||||||
"""Update mutable annotation fields persisted in the database."""
|
"""Update mutable annotation fields persisted in the database."""
|
||||||
annotation = db.query(Annotation).filter(Annotation.id == annotation_id).first()
|
annotation = (
|
||||||
|
db.query(Annotation)
|
||||||
|
.join(Project, Project.id == Annotation.project_id)
|
||||||
|
.filter(Annotation.id == annotation_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not annotation:
|
if not annotation:
|
||||||
raise HTTPException(status_code=404, detail="Annotation not found")
|
raise HTTPException(status_code=404, detail="Annotation not found")
|
||||||
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
if "template_id" in updates and updates["template_id"] is not None:
|
if "template_id" in updates and updates["template_id"] is not None:
|
||||||
template = db.query(Template).filter(Template.id == updates["template_id"]).first()
|
_visible_template_or_404(updates["template_id"], db, current_user)
|
||||||
if not template:
|
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
|
||||||
|
|
||||||
for field, value in updates.items():
|
for field, value in updates.items():
|
||||||
setattr(annotation, field, value)
|
setattr(annotation, field, value)
|
||||||
@@ -708,9 +1210,15 @@ def update_annotation(
|
|||||||
def delete_annotation(
|
def delete_annotation(
|
||||||
annotation_id: int,
|
annotation_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Delete an annotation and its derived mask rows through ORM cascade."""
|
"""Delete an annotation and its derived mask rows through ORM cascade."""
|
||||||
annotation = db.query(Annotation).filter(Annotation.id == annotation_id).first()
|
annotation = (
|
||||||
|
db.query(Annotation)
|
||||||
|
.join(Project, Project.id == Annotation.project_id)
|
||||||
|
.filter(Annotation.id == annotation_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not annotation:
|
if not annotation:
|
||||||
raise HTTPException(status_code=404, detail="Annotation not found")
|
raise HTTPException(status_code=404, detail="Annotation not found")
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
"""Authentication endpoints."""
|
"""Authentication endpoints and dependencies."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from models import AuditLog, User
|
||||||
|
from schemas import LoginResponse, UserOut
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["Auth"])
|
router = APIRouter(prefix="/api/auth", tags=["Auth"])
|
||||||
|
password_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
SUPPORTED_ROLES = {"admin", "annotator"}
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@@ -11,14 +26,197 @@ class LoginRequest(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
def hash_password(password: str) -> str:
|
||||||
token: str
|
"""Hash a plain password for storage."""
|
||||||
username: str
|
return password_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
"""Verify a plain password against a stored hash."""
|
||||||
|
return password_context.verify(password, password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user: User, expires_delta: timedelta | None = None) -> str:
|
||||||
|
"""Create a signed JWT access token for a user."""
|
||||||
|
expire = datetime.now(timezone.utc) + (
|
||||||
|
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sub": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"exp": expire,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_admin(db: Session) -> User:
|
||||||
|
"""Create and enforce the single default administrator account."""
|
||||||
|
existing = db.query(User).filter(User.username == settings.default_admin_username).first()
|
||||||
|
if existing:
|
||||||
|
changed = False
|
||||||
|
if existing.role != "admin":
|
||||||
|
existing.role = "admin"
|
||||||
|
changed = True
|
||||||
|
if not existing.is_active:
|
||||||
|
existing.is_active = 1
|
||||||
|
changed = True
|
||||||
|
extra_admins = db.query(User).filter(
|
||||||
|
User.role == "admin",
|
||||||
|
User.id != existing.id,
|
||||||
|
).all()
|
||||||
|
for user in extra_admins:
|
||||||
|
user.role = "annotator"
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
user = User(
|
||||||
|
username=settings.default_admin_username,
|
||||||
|
password_hash=hash_password(settings.default_admin_password),
|
||||||
|
role="admin",
|
||||||
|
is_active=1,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
extra_admins = db.query(User).filter(
|
||||||
|
User.role == "admin",
|
||||||
|
User.id != user.id,
|
||||||
|
).all()
|
||||||
|
if extra_admins:
|
||||||
|
for extra_user in extra_admins:
|
||||||
|
extra_user.role = "annotator"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_user_role(db: Session, user: User) -> User:
|
||||||
|
"""Keep legacy accounts within the current two-role policy."""
|
||||||
|
desired_role = "admin" if user.username == settings.default_admin_username else "annotator"
|
||||||
|
changed = False
|
||||||
|
if user.role != desired_role:
|
||||||
|
user.role = desired_role
|
||||||
|
changed = True
|
||||||
|
if user.username == settings.default_admin_username and not user.is_active:
|
||||||
|
user.is_active = 1
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""Resolve and validate the current user from the Bearer token."""
|
||||||
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
credentials.credentials,
|
||||||
|
settings.jwt_secret_key,
|
||||||
|
algorithms=[settings.jwt_algorithm],
|
||||||
|
)
|
||||||
|
user_id = int(payload.get("sub"))
|
||||||
|
except (JWTError, TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if user:
|
||||||
|
user = normalize_user_role(db, user)
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Inactive or missing user",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Require the current user to have the administrator role."""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_editor(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Require a user role that can modify segmentation data."""
|
||||||
|
if current_user.role not in SUPPORTED_ROLES:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Edit permission required")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def write_audit_log(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
actor: User | None,
|
||||||
|
action: str,
|
||||||
|
target_type: str | None = None,
|
||||||
|
target_id: str | int | None = None,
|
||||||
|
detail: dict[str, Any] | None = None,
|
||||||
|
) -> AuditLog:
|
||||||
|
"""Persist a compact audit event."""
|
||||||
|
log = AuditLog(
|
||||||
|
actor_user_id=actor.id if actor else None,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=str(target_id) if target_id is not None else None,
|
||||||
|
detail=detail or {},
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(log)
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
def login(payload: LoginRequest) -> dict:
|
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
"""Simple login for development."""
|
"""Authenticate a user and return a signed JWT."""
|
||||||
if payload.username == "admin" and payload.password == "123456":
|
ensure_default_admin(db)
|
||||||
return {"token": "fake-jwt-token-for-admin", "username": payload.username}
|
user = db.query(User).filter(User.username == payload.username).first()
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
if user:
|
||||||
|
user = normalize_user_role(db, user)
|
||||||
|
if not user or not user.is_active or not verify_password(payload.password, user.password_hash):
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
actor=None,
|
||||||
|
action="auth.login_failed",
|
||||||
|
target_type="user",
|
||||||
|
target_id=payload.username,
|
||||||
|
detail={"username": payload.username},
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
actor=user,
|
||||||
|
action="auth.login_success",
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
detail={"username": user.username},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"token": create_access_token(user),
|
||||||
|
"token_type": "bearer",
|
||||||
|
"username": user.username,
|
||||||
|
"user": user,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
def read_current_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Return the authenticated user profile."""
|
||||||
|
return current_user
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Annotation, Frame, ProcessingTask, Project, Template
|
from models import Annotation, Frame, ProcessingTask, Project, Template, User
|
||||||
|
from routers.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
|
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
ACTIVE_TASK_STATUSES = {"queued", "running"}
|
ACTIVE_TASK_STATUSES = {"queued", "running"}
|
||||||
MONITORED_TASK_STATUSES = {"queued", "running", "failed", "cancelled"}
|
MONITORED_TASK_STATUSES = {"queued", "running", "success", "failed", "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
def _system_load_percent() -> int:
|
def _system_load_percent() -> int:
|
||||||
@@ -36,6 +37,7 @@ def _iso_or_none(value: datetime | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _task_payload(task: ProcessingTask) -> dict[str, Any]:
|
def _task_payload(task: ProcessingTask) -> dict[str, Any]:
|
||||||
|
result = task.result or {}
|
||||||
return {
|
return {
|
||||||
"id": f"task-{task.id}",
|
"id": f"task-{task.id}",
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
@@ -44,29 +46,49 @@ def _task_payload(task: ProcessingTask) -> dict[str, Any]:
|
|||||||
"progress": task.progress,
|
"progress": task.progress,
|
||||||
"status": task.message or task.status,
|
"status": task.message or task.status,
|
||||||
"raw_status": task.status,
|
"raw_status": task.status,
|
||||||
"frame_count": (task.result or {}).get("frames_extracted", 0),
|
"frame_count": result.get("frames_extracted", result.get("processed_frame_count", 0)),
|
||||||
"error": task.error,
|
"error": task.error,
|
||||||
"updated_at": _iso_or_none(task.updated_at),
|
"updated_at": _iso_or_none(task.updated_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview", summary="Get dashboard overview")
|
@router.get("/overview", summary="Get dashboard overview")
|
||||||
def get_dashboard_overview(db: Session = Depends(get_db)) -> dict[str, Any]:
|
def get_dashboard_overview(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Return live dashboard data derived from persisted backend records."""
|
"""Return live dashboard data derived from persisted backend records."""
|
||||||
|
shared_project_ids_query = db.query(Project.id)
|
||||||
project_count = db.query(func.count(Project.id)).scalar() or 0
|
project_count = db.query(func.count(Project.id)).scalar() or 0
|
||||||
frame_count = db.query(func.count(Frame.id)).scalar() or 0
|
frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(shared_project_ids_query)).scalar() or 0
|
||||||
annotation_count = db.query(func.count(Annotation.id)).scalar() or 0
|
annotation_count = (
|
||||||
template_count = db.query(func.count(Template.id)).scalar() or 0
|
db.query(func.count(Annotation.id))
|
||||||
|
.filter(Annotation.project_id.in_(shared_project_ids_query))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
template_count = (
|
||||||
|
db.query(func.count(Template.id))
|
||||||
|
.filter(or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
active_task_count = (
|
active_task_count = (
|
||||||
db.query(func.count(ProcessingTask.id))
|
db.query(func.count(ProcessingTask.id))
|
||||||
|
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
||||||
.filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES))
|
.filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
|
||||||
projects = db.query(Project).order_by(Project.updated_at.desc()).all()
|
projects = (
|
||||||
|
db.query(Project)
|
||||||
|
.order_by(Project.updated_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
recent_tasks = (
|
recent_tasks = (
|
||||||
db.query(ProcessingTask)
|
db.query(ProcessingTask)
|
||||||
|
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
||||||
.order_by(ProcessingTask.created_at.desc())
|
.order_by(ProcessingTask.created_at.desc())
|
||||||
.limit(50)
|
.limit(50)
|
||||||
.all()
|
.all()
|
||||||
@@ -95,6 +117,7 @@ def get_dashboard_overview(db: Session = Depends(get_db)) -> dict[str, Any]:
|
|||||||
|
|
||||||
recent_annotations = (
|
recent_annotations = (
|
||||||
db.query(Annotation)
|
db.query(Annotation)
|
||||||
|
.filter(Annotation.project_id.in_(shared_project_ids_query))
|
||||||
.order_by(Annotation.updated_at.desc())
|
.order_by(Annotation.updated_at.desc())
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all()
|
.all()
|
||||||
@@ -111,6 +134,7 @@ def get_dashboard_overview(db: Session = Depends(get_db)) -> dict[str, Any]:
|
|||||||
|
|
||||||
recent_templates = (
|
recent_templates = (
|
||||||
db.query(Template)
|
db.query(Template)
|
||||||
|
.filter(or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)))
|
||||||
.order_by(Template.created_at.desc())
|
.order_by(Template.created_at.desc())
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all()
|
.all()
|
||||||
|
|||||||
@@ -4,17 +4,22 @@ import io
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Project, Annotation, Frame, Template
|
from minio_client import download_file
|
||||||
|
from models import Project, Annotation, Frame, Template, User
|
||||||
|
from routers.auth import get_current_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/export", tags=["Export"])
|
router = APIRouter(prefix="/api/export", tags=["Export"])
|
||||||
@@ -49,6 +54,30 @@ def _annotation_z_index(annotation: Annotation) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _annotation_mask_id(annotation: Annotation) -> int | None:
|
||||||
|
class_meta = (annotation.mask_data or {}).get("class") or {}
|
||||||
|
if isinstance(class_meta, dict):
|
||||||
|
for key in ("maskId", "maskid", "mask_id"):
|
||||||
|
if class_meta.get(key) is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = int(class_meta[key])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if value >= 0:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _annotation_category_name(annotation: Annotation) -> str:
|
||||||
|
class_meta = (annotation.mask_data or {}).get("class") or {}
|
||||||
|
if isinstance(class_meta, dict) and class_meta.get("category"):
|
||||||
|
return str(class_meta["category"])
|
||||||
|
if annotation.template and annotation.template.name:
|
||||||
|
return str(annotation.template.name)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _annotation_class_key(annotation: Annotation) -> str:
|
def _annotation_class_key(annotation: Annotation) -> str:
|
||||||
class_meta = (annotation.mask_data or {}).get("class") or {}
|
class_meta = (annotation.mask_data or {}).get("class") or {}
|
||||||
if isinstance(class_meta, dict):
|
if isinstance(class_meta, dict):
|
||||||
@@ -85,38 +114,160 @@ def _annotation_color(annotation: Annotation) -> str:
|
|||||||
return "#ffffff"
|
return "#ffffff"
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
def _hex_to_rgb(color: str) -> list[int]:
|
||||||
"/{project_id}/coco",
|
value = str(color or "").strip()
|
||||||
summary="Export annotations in COCO format",
|
if value.startswith("#"):
|
||||||
)
|
value = value[1:]
|
||||||
def export_coco(project_id: int, db: Session = Depends(get_db)) -> StreamingResponse:
|
if len(value) == 3:
|
||||||
"""Export all annotations for a project as a COCO-format JSON file."""
|
value = "".join(part * 2 for part in value)
|
||||||
|
if len(value) != 6:
|
||||||
|
return [255, 255, 255]
|
||||||
|
try:
|
||||||
|
return [int(value[i:i + 2], 16) for i in (0, 2, 4)]
|
||||||
|
except ValueError:
|
||||||
|
return [255, 255, 255]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename_part(value: Any, fallback: str = "unknown") -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
text = fallback
|
||||||
|
text = re.sub(r"[\\/:*?\"<>|\s]+", "_", text)
|
||||||
|
text = re.sub(r"_+", "_", text).strip("._")
|
||||||
|
return text or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _project_video_name(project: Project) -> str:
|
||||||
|
if project.video_path:
|
||||||
|
stem = Path(project.video_path).name
|
||||||
|
if "." in stem:
|
||||||
|
stem = ".".join(stem.split(".")[:-1])
|
||||||
|
if stem:
|
||||||
|
return _safe_filename_part(stem, f"project_{project.id}")
|
||||||
|
return _safe_filename_part(project.name, f"project_{project.id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _project_export_name(project: Project) -> str:
|
||||||
|
return _safe_filename_part(project.name, f"project_{project.id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_timestamp_ms(frame: Frame, project: Project) -> float:
|
||||||
|
if frame.timestamp_ms is not None:
|
||||||
|
return float(frame.timestamp_ms)
|
||||||
|
fps = project.parse_fps or project.original_fps or 30.0
|
||||||
|
return float(frame.frame_index) * 1000.0 / max(float(fps), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_frame_number(frame: Frame) -> int:
|
||||||
|
return int(frame.frame_index) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _format_timestamp_ms(value: float) -> str:
|
||||||
|
total_ms = max(0, int(round(float(value))))
|
||||||
|
hours = total_ms // 3_600_000
|
||||||
|
minutes = (total_ms % 3_600_000) // 60_000
|
||||||
|
seconds = (total_ms % 60_000) // 1_000
|
||||||
|
milliseconds = total_ms % 1_000
|
||||||
|
return f"{hours}h{minutes:02d}m{seconds:02d}s{milliseconds:03d}ms"
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_export_stem(project: Project, frame: Frame) -> str:
|
||||||
|
return "_".join([
|
||||||
|
_project_video_name(project),
|
||||||
|
_format_timestamp_ms(_frame_timestamp_ms(frame, project)),
|
||||||
|
f"frame{_project_frame_number(frame):06d}",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _segmentation_results_filename(project: Project, frames: list[Frame]) -> str:
|
||||||
|
if not frames:
|
||||||
|
return f"{_project_export_name(project)}_seg_T_0h00m00s000ms-0h00m00s000ms_P_0-0.zip"
|
||||||
|
first_frame = frames[0]
|
||||||
|
last_frame = frames[-1]
|
||||||
|
return (
|
||||||
|
f"{_project_export_name(project)}"
|
||||||
|
f"_seg_T_{_format_timestamp_ms(_frame_timestamp_ms(first_frame, project))}"
|
||||||
|
f"-{_format_timestamp_ms(_frame_timestamp_ms(last_frame, project))}"
|
||||||
|
f"_P_{_project_frame_number(first_frame)}-{_project_frame_number(last_frame)}.zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_content_disposition(filename: str) -> str:
|
||||||
|
ascii_fallback = filename.encode("ascii", "ignore").decode("ascii") or "segmentation_results.zip"
|
||||||
|
ascii_fallback = _safe_filename_part(ascii_fallback, "segmentation_results.zip")
|
||||||
|
if not ascii_fallback.endswith(".zip") and filename.endswith(".zip"):
|
||||||
|
ascii_fallback = f"{ascii_fallback}.zip"
|
||||||
|
return f"attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_image_extension(frame: Frame) -> str:
|
||||||
|
suffix = Path(frame.image_url or "").suffix.lower()
|
||||||
|
return suffix if suffix in {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"} else ".jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def _project_or_404(project_id: int, db: Session, current_user: User) -> Project:
|
||||||
|
_ = current_user
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return project
|
||||||
|
|
||||||
annotations = (
|
|
||||||
db.query(Annotation)
|
def _project_frames(project_id: int, db: Session) -> list[Frame]:
|
||||||
.filter(Annotation.project_id == project_id)
|
return (
|
||||||
.all()
|
|
||||||
)
|
|
||||||
frames = (
|
|
||||||
db.query(Frame)
|
db.query(Frame)
|
||||||
.filter(Frame.project_id == project_id)
|
.filter(Frame.project_id == project_id)
|
||||||
.order_by(Frame.frame_index)
|
.order_by(Frame.frame_index)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
templates = db.query(Template).all()
|
|
||||||
|
|
||||||
# Build COCO structure
|
|
||||||
|
def _filter_frames(
|
||||||
|
frames: list[Frame],
|
||||||
|
*,
|
||||||
|
scope: str = "all",
|
||||||
|
start_frame: int | None = None,
|
||||||
|
end_frame: int | None = None,
|
||||||
|
frame_id: int | None = None,
|
||||||
|
) -> list[Frame]:
|
||||||
|
if scope == "current":
|
||||||
|
if frame_id is None:
|
||||||
|
raise HTTPException(status_code=400, detail="frame_id is required for current-frame export")
|
||||||
|
selected = [frame for frame in frames if frame.id == frame_id]
|
||||||
|
if not selected:
|
||||||
|
raise HTTPException(status_code=404, detail="Frame not found")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
if scope == "range":
|
||||||
|
if start_frame is None or end_frame is None:
|
||||||
|
raise HTTPException(status_code=400, detail="start_frame and end_frame are required for range export")
|
||||||
|
start = max(1, min(int(start_frame), int(end_frame)))
|
||||||
|
end = max(1, max(int(start_frame), int(end_frame)))
|
||||||
|
return frames[start - 1:end]
|
||||||
|
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def _filtered_annotations(project_id: int, frame_ids: set[int], db: Session) -> list[Annotation]:
|
||||||
|
if not frame_ids:
|
||||||
|
return []
|
||||||
|
return (
|
||||||
|
db.query(Annotation)
|
||||||
|
.filter(Annotation.project_id == project_id)
|
||||||
|
.filter(Annotation.frame_id.in_(frame_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_coco(project: Project, frames: list[Frame], annotations: list[Annotation], templates: list[Template]) -> dict[str, Any]:
|
||||||
images = []
|
images = []
|
||||||
for idx, frame in enumerate(frames):
|
for frame in frames:
|
||||||
images.append({
|
images.append({
|
||||||
"id": frame.id,
|
"id": frame.id,
|
||||||
"file_name": frame.image_url,
|
"file_name": frame.image_url,
|
||||||
"width": frame.width or 1920,
|
"width": frame.width or 1920,
|
||||||
"height": frame.height or 1080,
|
"height": frame.height or 1080,
|
||||||
"frame_index": idx,
|
"frame_index": frame.frame_index,
|
||||||
})
|
})
|
||||||
|
|
||||||
categories = []
|
categories = []
|
||||||
@@ -131,14 +282,14 @@ def export_coco(project_id: int, db: Session = Depends(get_db)) -> StreamingResp
|
|||||||
|
|
||||||
coco_annotations = []
|
coco_annotations = []
|
||||||
ann_id = 1
|
ann_id = 1
|
||||||
|
selected_frame_ids = {frame.id for frame in frames}
|
||||||
for ann in annotations:
|
for ann in annotations:
|
||||||
if not ann.mask_data:
|
if ann.frame_id not in selected_frame_ids or not ann.mask_data:
|
||||||
continue
|
continue
|
||||||
polygons = ann.mask_data.get("polygons", [])
|
polygons = ann.mask_data.get("polygons", [])
|
||||||
if not polygons:
|
if not polygons:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Use first polygon for bbox / area approximation
|
|
||||||
first_poly = polygons[0]
|
first_poly = polygons[0]
|
||||||
xs = [p[0] for p in first_poly]
|
xs = [p[0] for p in first_poly]
|
||||||
ys = [p[1] for p in first_poly]
|
ys = [p[1] for p in first_poly]
|
||||||
@@ -171,7 +322,7 @@ def export_coco(project_id: int, db: Session = Depends(get_db)) -> StreamingResp
|
|||||||
})
|
})
|
||||||
ann_id += 1
|
ann_id += 1
|
||||||
|
|
||||||
coco = {
|
return {
|
||||||
"info": {
|
"info": {
|
||||||
"description": f"Annotations for {project.name}",
|
"description": f"Annotations for {project.name}",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
@@ -183,39 +334,242 @@ def export_coco(project_id: int, db: Session = Depends(get_db)) -> StreamingResp
|
|||||||
"categories": categories,
|
"categories": categories,
|
||||||
}
|
}
|
||||||
|
|
||||||
data = json.dumps(coco, ensure_ascii=False, indent=2).encode("utf-8")
|
|
||||||
filename = f"project_{project_id}_coco.json"
|
|
||||||
|
|
||||||
return StreamingResponse(
|
def _class_mapping_entry(annotation: Annotation) -> dict[str, Any]:
|
||||||
io.BytesIO(data),
|
return {
|
||||||
media_type="application/json",
|
"key": _annotation_class_key(annotation),
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
"className": _annotation_label(annotation),
|
||||||
|
"chineseName": _annotation_label(annotation),
|
||||||
|
"categoryName": _annotation_category_name(annotation),
|
||||||
|
"color": _annotation_color(annotation),
|
||||||
|
"internalPriority": _annotation_z_index(annotation),
|
||||||
|
"maskidHint": _annotation_mask_id(annotation),
|
||||||
|
"template_id": annotation.template_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gt_class_mapping(annotations: list[Annotation]) -> tuple[dict[str, int], list[dict[str, Any]]]:
|
||||||
|
entries_by_key: dict[str, dict[str, Any]] = {}
|
||||||
|
for annotation in annotations:
|
||||||
|
if not annotation.mask_data or not annotation.mask_data.get("polygons"):
|
||||||
|
continue
|
||||||
|
entry = _class_mapping_entry(annotation)
|
||||||
|
entries_by_key.setdefault(entry["key"], entry)
|
||||||
|
|
||||||
|
ordered = sorted(
|
||||||
|
entries_by_key.values(),
|
||||||
|
key=lambda item: (
|
||||||
|
item["maskidHint"] if isinstance(item.get("maskidHint"), int) and item["maskidHint"] >= 0 else 10_000_000,
|
||||||
|
str(item["className"]),
|
||||||
|
str(item["key"]),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
key_to_value: dict[str, int] = {}
|
||||||
|
classes: list[dict[str, Any]] = []
|
||||||
|
used_maskids: set[int] = set()
|
||||||
|
next_maskid = 1
|
||||||
|
|
||||||
|
def next_available_maskid() -> int:
|
||||||
|
nonlocal next_maskid
|
||||||
|
while next_maskid in used_maskids:
|
||||||
|
next_maskid += 1
|
||||||
|
if next_maskid > 255:
|
||||||
|
raise HTTPException(status_code=400, detail="GT_label 仅支持 8-bit maskid,类别值必须在 1-255 之间")
|
||||||
|
value = next_maskid
|
||||||
|
used_maskids.add(value)
|
||||||
|
next_maskid += 1
|
||||||
|
return value
|
||||||
|
|
||||||
|
for entry in ordered:
|
||||||
|
hinted_maskid = entry.get("maskidHint")
|
||||||
|
if isinstance(hinted_maskid, int) and hinted_maskid > 255:
|
||||||
|
raise HTTPException(status_code=400, detail="GT_label 仅支持 8-bit maskid,类别值必须在 1-255 之间")
|
||||||
|
if isinstance(hinted_maskid, int) and hinted_maskid == 0:
|
||||||
|
maskid = 0
|
||||||
|
used_maskids.add(maskid)
|
||||||
|
elif isinstance(hinted_maskid, int) and 0 < hinted_maskid <= 255 and hinted_maskid not in used_maskids:
|
||||||
|
maskid = hinted_maskid
|
||||||
|
used_maskids.add(maskid)
|
||||||
|
else:
|
||||||
|
maskid = next_available_maskid()
|
||||||
|
key_to_value[entry["key"]] = maskid
|
||||||
|
classes.append({
|
||||||
|
"gt_pixel_value": maskid,
|
||||||
|
"maskid": maskid,
|
||||||
|
"chineseName": entry["chineseName"],
|
||||||
|
"className": entry["className"],
|
||||||
|
"categoryName": entry["categoryName"],
|
||||||
|
"rgb": _hex_to_rgb(entry["color"]),
|
||||||
|
"color": entry["color"],
|
||||||
|
"key": entry["key"],
|
||||||
|
"template_id": entry["template_id"],
|
||||||
|
})
|
||||||
|
return key_to_value, classes
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
def _parse_result_outputs(mask_type: str, outputs: str | None) -> set[str]:
|
||||||
"/{project_id}/masks",
|
allowed = {"separate", "gt_label", "pro_label", "mix_label"}
|
||||||
summary="Export PNG masks as a ZIP archive",
|
if outputs:
|
||||||
)
|
parsed = {item.strip() for item in outputs.split(",") if item.strip()}
|
||||||
def export_masks(project_id: int, db: Session = Depends(get_db)) -> StreamingResponse:
|
invalid = parsed - allowed
|
||||||
"""Export individual masks plus z-index fused semantic masks inside a ZIP."""
|
if invalid:
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
raise HTTPException(status_code=400, detail=f"Invalid outputs: {', '.join(sorted(invalid))}")
|
||||||
if not project:
|
return parsed or allowed
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
|
if mask_type == "separate":
|
||||||
|
return {"separate"}
|
||||||
|
if mask_type == "gt_label":
|
||||||
|
return {"gt_label"}
|
||||||
|
if mask_type == "pro_label":
|
||||||
|
return {"pro_label"}
|
||||||
|
if mask_type == "mix_label":
|
||||||
|
return {"mix_label"}
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _write_original_frames(
|
||||||
|
zf: zipfile.ZipFile,
|
||||||
|
project: Project,
|
||||||
|
frames: list[Frame],
|
||||||
|
) -> dict[int, bytes]:
|
||||||
|
image_bytes_by_frame: dict[int, bytes] = {}
|
||||||
|
for frame in frames:
|
||||||
|
image_bytes = download_file(frame.image_url)
|
||||||
|
image_bytes_by_frame[frame.id] = image_bytes
|
||||||
|
zf.writestr(
|
||||||
|
f"原始图片/{_frame_export_stem(project, frame)}{_frame_image_extension(frame)}",
|
||||||
|
image_bytes,
|
||||||
|
)
|
||||||
|
return image_bytes_by_frame
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_original_image(image_bytes: bytes | None, width: int, height: int) -> np.ndarray:
|
||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
annotations = (
|
if image_bytes:
|
||||||
db.query(Annotation)
|
decoded = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||||
.filter(Annotation.project_id == project_id)
|
if decoded is not None:
|
||||||
.all()
|
if decoded.shape[1] != width or decoded.shape[0] != height:
|
||||||
)
|
decoded = cv2.resize(decoded, (width, height), interpolation=cv2.INTER_AREA)
|
||||||
frames = (
|
return decoded
|
||||||
db.query(Frame)
|
return np.zeros((height, width, 3), dtype=np.uint8)
|
||||||
.filter(Frame.project_id == project_id)
|
|
||||||
.order_by(Frame.frame_index)
|
|
||||||
.all()
|
def _write_result_mask_outputs(
|
||||||
)
|
zf: zipfile.ZipFile,
|
||||||
|
project: Project,
|
||||||
|
frames: list[Frame],
|
||||||
|
annotations: list[Annotation],
|
||||||
|
*,
|
||||||
|
outputs: set[str],
|
||||||
|
class_values: dict[str, int],
|
||||||
|
class_mapping: list[dict[str, Any]],
|
||||||
|
original_images: dict[int, bytes],
|
||||||
|
mix_opacity: float,
|
||||||
|
) -> None:
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
include_individual = "separate" in outputs
|
||||||
|
include_semantic = "gt_label" in outputs
|
||||||
|
include_pro_label = "pro_label" in outputs
|
||||||
|
include_mix_label = "mix_label" in outputs
|
||||||
|
class_rgb_by_key = {
|
||||||
|
item["key"]: item.get("rgb") or _hex_to_rgb(item.get("color", "#ffffff"))
|
||||||
|
for item in class_mapping
|
||||||
|
}
|
||||||
|
annotations_by_frame: dict[int, list[Annotation]] = {}
|
||||||
|
selected_frame_ids = {frame.id for frame in frames}
|
||||||
|
for annotation in annotations:
|
||||||
|
if annotation.frame_id not in selected_frame_ids or not annotation.mask_data:
|
||||||
|
continue
|
||||||
|
if not annotation.mask_data.get("polygons"):
|
||||||
|
continue
|
||||||
|
annotations_by_frame.setdefault(annotation.frame_id, []).append(annotation)
|
||||||
|
|
||||||
|
for frame in frames:
|
||||||
|
frame_annotations = annotations_by_frame.get(frame.id, [])
|
||||||
|
if not frame_annotations:
|
||||||
|
continue
|
||||||
|
width = frame.width or 1920
|
||||||
|
height = frame.height or 1080
|
||||||
|
frame_stem = _frame_export_stem(project, frame)
|
||||||
|
|
||||||
|
if include_individual:
|
||||||
|
class_masks: dict[str, np.ndarray] = {}
|
||||||
|
class_meta: dict[str, dict[str, Any]] = {}
|
||||||
|
for annotation in frame_annotations:
|
||||||
|
key = _annotation_class_key(annotation)
|
||||||
|
combined = class_masks.setdefault(key, np.zeros((height, width), dtype=np.uint8))
|
||||||
|
for poly in (annotation.mask_data or {}).get("polygons", []):
|
||||||
|
combined[:] = np.maximum(combined, _mask_from_polygon(poly, width, height))
|
||||||
|
class_meta.setdefault(key, _class_mapping_entry(annotation))
|
||||||
|
|
||||||
|
folder = f"分开Mask分割结果/{frame_stem}_分别导出"
|
||||||
|
for key, mask in sorted(class_masks.items(), key=lambda item: int(class_meta[item[0]]["internalPriority"])):
|
||||||
|
meta = class_meta[key]
|
||||||
|
maskid = class_values.get(key)
|
||||||
|
if maskid is None:
|
||||||
|
continue
|
||||||
|
_, encoded = cv2.imencode(".png", mask)
|
||||||
|
class_name = _safe_filename_part(meta["className"], "class")
|
||||||
|
zf.writestr(
|
||||||
|
f"{folder}/{frame_stem}_{class_name}_maskid{maskid}.png",
|
||||||
|
encoded.tobytes(),
|
||||||
|
)
|
||||||
|
|
||||||
|
needs_fused_output = include_semantic or include_pro_label or include_mix_label
|
||||||
|
semantic = np.zeros((height, width), dtype=np.uint8) if needs_fused_output else None
|
||||||
|
pro_label = np.zeros((height, width, 3), dtype=np.uint8) if (include_pro_label or include_mix_label) else None
|
||||||
|
|
||||||
|
if needs_fused_output:
|
||||||
|
for annotation in sorted(frame_annotations, key=_annotation_z_index):
|
||||||
|
key = _annotation_class_key(annotation)
|
||||||
|
value = class_values.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
combined = np.zeros((height, width), dtype=np.uint8)
|
||||||
|
for poly in (annotation.mask_data or {}).get("polygons", []):
|
||||||
|
combined = np.maximum(combined, _mask_from_polygon(poly, width, height))
|
||||||
|
if semantic is not None:
|
||||||
|
semantic[combined > 0] = value
|
||||||
|
if pro_label is not None:
|
||||||
|
rgb = class_rgb_by_key.get(key, [255, 255, 255])
|
||||||
|
bgr = np.array([rgb[2], rgb[1], rgb[0]], dtype=np.uint8)
|
||||||
|
pro_label[combined > 0] = bgr
|
||||||
|
|
||||||
|
if include_semantic and semantic is not None:
|
||||||
|
_, encoded = cv2.imencode(".png", semantic)
|
||||||
|
zf.writestr(f"GT_label图/{frame_stem}.png", encoded.tobytes())
|
||||||
|
|
||||||
|
if include_pro_label and pro_label is not None:
|
||||||
|
_, encoded = cv2.imencode(".png", pro_label)
|
||||||
|
zf.writestr(f"Pro_label彩色分割结果/{frame_stem}.png", encoded.tobytes())
|
||||||
|
|
||||||
|
if include_mix_label and pro_label is not None:
|
||||||
|
original = _decode_original_image(original_images.get(frame.id), width, height)
|
||||||
|
mask_pixels = np.any(pro_label > 0, axis=2)
|
||||||
|
mixed = original.copy()
|
||||||
|
opacity = min(max(float(mix_opacity), 0.0), 1.0)
|
||||||
|
mixed[mask_pixels] = (
|
||||||
|
original[mask_pixels].astype(np.float32) * (1.0 - opacity)
|
||||||
|
+ pro_label[mask_pixels].astype(np.float32) * opacity
|
||||||
|
).clip(0, 255).astype(np.uint8)
|
||||||
|
_, encoded = cv2.imencode(".png", mixed)
|
||||||
|
zf.writestr(f"Mix_label重叠覆盖彩色分割结果/{frame_stem}.png", encoded.tobytes())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_mask_pngs(
|
||||||
|
zf: zipfile.ZipFile,
|
||||||
|
frames: list[Frame],
|
||||||
|
annotations: list[Annotation],
|
||||||
|
*,
|
||||||
|
mask_type: str,
|
||||||
|
individual_prefix: str = "",
|
||||||
|
semantic_prefix: str = "",
|
||||||
|
semantic_file_stem: str = "semantic_frame",
|
||||||
|
semantic_dtype: Any = np.uint8,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
import cv2
|
||||||
|
|
||||||
class_values: dict[str, int] = {}
|
class_values: dict[str, int] = {}
|
||||||
semantic_classes: list[dict[str, Any]] = []
|
semantic_classes: list[dict[str, Any]] = []
|
||||||
@@ -235,46 +589,102 @@ def export_masks(project_id: int, db: Session = Depends(get_db)) -> StreamingRes
|
|||||||
})
|
})
|
||||||
return class_values[key]
|
return class_values[key]
|
||||||
|
|
||||||
zip_buffer = io.BytesIO()
|
include_individual = mask_type in {"separate", "both"}
|
||||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
include_semantic = mask_type in {"gt_label", "both"}
|
||||||
frame_masks: dict[int, list[tuple[Annotation, np.ndarray]]] = {}
|
frame_masks: dict[int, list[tuple[Annotation, np.ndarray]]] = {}
|
||||||
for ann in annotations:
|
selected_frame_ids = {frame.id for frame in frames}
|
||||||
if not ann.mask_data:
|
|
||||||
continue
|
|
||||||
polygons = ann.mask_data.get("polygons", [])
|
|
||||||
if not polygons:
|
|
||||||
continue
|
|
||||||
|
|
||||||
width = ann.frame.width if ann.frame else 1920
|
for ann in annotations:
|
||||||
height = ann.frame.height if ann.frame else 1080
|
if ann.frame_id not in selected_frame_ids or not ann.mask_data:
|
||||||
combined = np.zeros((height, width), dtype=np.uint8)
|
continue
|
||||||
|
polygons = ann.mask_data.get("polygons", [])
|
||||||
|
if not polygons:
|
||||||
|
continue
|
||||||
|
|
||||||
for poly in polygons:
|
width = ann.frame.width if ann.frame else 1920
|
||||||
mask = _mask_from_polygon(poly, width, height)
|
height = ann.frame.height if ann.frame else 1080
|
||||||
combined = np.maximum(combined, mask)
|
combined = np.zeros((height, width), dtype=np.uint8)
|
||||||
|
for poly in polygons:
|
||||||
|
mask = _mask_from_polygon(poly, width, height)
|
||||||
|
combined = np.maximum(combined, mask)
|
||||||
|
|
||||||
|
if include_individual:
|
||||||
_, encoded = cv2.imencode(".png", combined)
|
_, encoded = cv2.imencode(".png", combined)
|
||||||
fname = f"mask_{ann.id:06d}.png"
|
zf.writestr(f"{individual_prefix}mask_{ann.id:06d}.png", encoded.tobytes())
|
||||||
zf.writestr(fname, encoded.tobytes())
|
if include_semantic and ann.frame_id is not None:
|
||||||
if ann.frame_id is not None:
|
frame_masks.setdefault(ann.frame_id, []).append((ann, combined))
|
||||||
frame_masks.setdefault(ann.frame_id, []).append((ann, combined))
|
|
||||||
|
|
||||||
|
if include_semantic:
|
||||||
for frame in frames:
|
for frame in frames:
|
||||||
entries = frame_masks.get(frame.id, [])
|
entries = frame_masks.get(frame.id, [])
|
||||||
if not entries:
|
if not entries:
|
||||||
continue
|
continue
|
||||||
width = frame.width or 1920
|
width = frame.width or 1920
|
||||||
height = frame.height or 1080
|
height = frame.height or 1080
|
||||||
semantic = np.zeros((height, width), dtype=np.uint8)
|
semantic = np.zeros((height, width), dtype=semantic_dtype)
|
||||||
for ann, mask in sorted(entries, key=lambda item: _annotation_z_index(item[0])):
|
for ann, mask in sorted(entries, key=lambda item: _annotation_z_index(item[0])):
|
||||||
semantic[mask > 0] = class_value(ann)
|
semantic[mask > 0] = class_value(ann)
|
||||||
_, encoded = cv2.imencode(".png", semantic)
|
_, encoded = cv2.imencode(".png", semantic)
|
||||||
zf.writestr(f"semantic_frame_{frame.frame_index:06d}.png", encoded.tobytes())
|
zf.writestr(f"{semantic_prefix}{semantic_file_stem}_{frame.frame_index:06d}.png", encoded.tobytes())
|
||||||
|
|
||||||
|
if include_semantic:
|
||||||
zf.writestr(
|
zf.writestr(
|
||||||
"semantic_classes.json",
|
f"{semantic_prefix}semantic_classes.json",
|
||||||
json.dumps({"classes": semantic_classes}, ensure_ascii=False, indent=2).encode("utf-8"),
|
json.dumps({"classes": semantic_classes}, ensure_ascii=False, indent=2).encode("utf-8"),
|
||||||
)
|
)
|
||||||
|
return semantic_classes
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/coco",
|
||||||
|
summary="Export annotations in COCO format",
|
||||||
|
)
|
||||||
|
def export_coco(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Export all annotations for a project as a COCO-format JSON file."""
|
||||||
|
project = _project_or_404(project_id, db, current_user)
|
||||||
|
frames = _project_frames(project_id, db)
|
||||||
|
annotations = _filtered_annotations(project_id, {frame.id for frame in frames}, db)
|
||||||
|
templates = db.query(Template).all()
|
||||||
|
coco = _build_coco(project, frames, annotations, templates)
|
||||||
|
|
||||||
|
data = json.dumps(coco, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
filename = f"project_{project_id}_coco.json"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(data),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/masks",
|
||||||
|
summary="Export PNG masks as a ZIP archive",
|
||||||
|
)
|
||||||
|
def export_masks(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Export individual masks plus z-index fused semantic masks inside a ZIP."""
|
||||||
|
_project_or_404(project_id, db, current_user)
|
||||||
|
frames = _project_frames(project_id, db)
|
||||||
|
annotations = _filtered_annotations(project_id, {frame.id for frame in frames}, db)
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
_write_mask_pngs(
|
||||||
|
zf,
|
||||||
|
frames,
|
||||||
|
annotations,
|
||||||
|
mask_type="both",
|
||||||
|
semantic_prefix="",
|
||||||
|
individual_prefix="",
|
||||||
|
)
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
filename = f"project_{project_id}_masks.zip"
|
filename = f"project_{project_id}_masks.zip"
|
||||||
@@ -284,3 +694,71 @@ def export_masks(project_id: int, db: Session = Depends(get_db)) -> StreamingRes
|
|||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/results",
|
||||||
|
summary="Export segmentation results as a ZIP archive",
|
||||||
|
)
|
||||||
|
def export_results(
|
||||||
|
project_id: int,
|
||||||
|
scope: str = Query("all", pattern="^(all|range|current)$"),
|
||||||
|
mask_type: str = Query("both", pattern="^(separate|gt_label|pro_label|mix_label|both|all)$"),
|
||||||
|
outputs: str | None = Query(None),
|
||||||
|
mix_opacity: float = Query(0.3, ge=0.0, le=1.0),
|
||||||
|
start_frame: int | None = Query(None, ge=1),
|
||||||
|
end_frame: int | None = Query(None, ge=1),
|
||||||
|
frame_id: int | None = Query(None, ge=1),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Export JSON annotations plus selected PNG mask outputs inside one ZIP.
|
||||||
|
|
||||||
|
`scope=all` exports the whole video. `scope=range` uses 1-based frame
|
||||||
|
numbers from the sorted project frame sequence. `scope=current` uses the
|
||||||
|
concrete backend `frame_id`.
|
||||||
|
"""
|
||||||
|
project = _project_or_404(project_id, db, current_user)
|
||||||
|
frames = _filter_frames(
|
||||||
|
_project_frames(project_id, db),
|
||||||
|
scope=scope,
|
||||||
|
start_frame=start_frame,
|
||||||
|
end_frame=end_frame,
|
||||||
|
frame_id=frame_id,
|
||||||
|
)
|
||||||
|
annotations = _filtered_annotations(project_id, {frame.id for frame in frames}, db)
|
||||||
|
templates = db.query(Template).all()
|
||||||
|
coco = _build_coco(project, frames, annotations, templates)
|
||||||
|
class_values, class_mapping = _build_gt_class_mapping(annotations)
|
||||||
|
selected_outputs = _parse_result_outputs(mask_type, outputs)
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr(
|
||||||
|
"annotations_coco.json",
|
||||||
|
json.dumps(coco, ensure_ascii=False, indent=2).encode("utf-8"),
|
||||||
|
)
|
||||||
|
zf.writestr(
|
||||||
|
"maskid_GT像素值_类别映射.json",
|
||||||
|
json.dumps({"classes": class_mapping}, ensure_ascii=False, indent=2).encode("utf-8"),
|
||||||
|
)
|
||||||
|
original_images = _write_original_frames(zf, project, frames)
|
||||||
|
_write_result_mask_outputs(
|
||||||
|
zf,
|
||||||
|
project,
|
||||||
|
frames,
|
||||||
|
annotations,
|
||||||
|
outputs=selected_outputs,
|
||||||
|
class_values=class_values,
|
||||||
|
class_mapping=class_mapping,
|
||||||
|
original_images=original_images,
|
||||||
|
mix_opacity=mix_opacity,
|
||||||
|
)
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
filename = _segmentation_results_filename(project, frames)
|
||||||
|
return StreamingResponse(
|
||||||
|
zip_buffer,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": _download_content_disposition(filename)},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Media upload and parsing endpoints."""
|
"""Media upload and parsing endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
@@ -9,8 +10,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from minio_client import upload_file, get_presigned_url
|
from minio_client import upload_file, get_presigned_url
|
||||||
from models import ProcessingTask, Project
|
from models import ProcessingTask, Project, User
|
||||||
from progress_events import publish_task_progress_event
|
from progress_events import publish_task_progress_event
|
||||||
|
from routers.auth import require_editor
|
||||||
from schemas import ProcessingTaskOut
|
from schemas import ProcessingTaskOut
|
||||||
from statuses import PROJECT_STATUS_PARSING, PROJECT_STATUS_PENDING, TASK_STATUS_QUEUED
|
from statuses import PROJECT_STATUS_PARSING, PROJECT_STATUS_PENDING, TASK_STATUS_QUEUED
|
||||||
from worker_tasks import parse_project_media
|
from worker_tasks import parse_project_media
|
||||||
@@ -21,6 +23,13 @@ router = APIRouter(prefix="/api/media", tags=["Media"])
|
|||||||
ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".png", ".jpg", ".jpeg", ".dcm"}
|
ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".png", ".jpg", ".jpeg", ".dcm"}
|
||||||
|
|
||||||
|
|
||||||
|
def natural_filename_key(filename: str) -> tuple[object, ...]:
|
||||||
|
return tuple(
|
||||||
|
int(part) if part.isdigit() else part.casefold()
|
||||||
|
for part in re.split(r"(\d+)", Path(filename).name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_ext(filename: str) -> str:
|
def _get_ext(filename: str) -> str:
|
||||||
return Path(filename).suffix.lower()
|
return Path(filename).suffix.lower()
|
||||||
|
|
||||||
@@ -34,6 +43,7 @@ async def upload_media(
|
|||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
project_id: Optional[int] = Form(None),
|
project_id: Optional[int] = Form(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Accept a video, image, or DICOM file and store it in MinIO.
|
"""Accept a video, image, or DICOM file and store it in MinIO.
|
||||||
|
|
||||||
@@ -63,12 +73,11 @@ async def upload_media(
|
|||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
if project:
|
if not project:
|
||||||
project.video_path = object_name
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
db.commit()
|
project.video_path = object_name
|
||||||
logger.info("Linked upload to project_id=%s", project_id)
|
db.commit()
|
||||||
else:
|
logger.info("Linked upload to project_id=%s", project_id)
|
||||||
logger.warning("Project id=%s not found for upload linkage", project_id)
|
|
||||||
else:
|
else:
|
||||||
# Auto-create a project named after the file
|
# Auto-create a project named after the file
|
||||||
project = Project(
|
project = Project(
|
||||||
@@ -77,6 +86,7 @@ async def upload_media(
|
|||||||
status=PROJECT_STATUS_PENDING,
|
status=PROJECT_STATUS_PENDING,
|
||||||
video_path=object_name,
|
video_path=object_name,
|
||||||
source_type="video",
|
source_type="video",
|
||||||
|
owner_user_id=current_user.id,
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -109,6 +119,7 @@ async def upload_dicom_batch(
|
|||||||
files: List[UploadFile] = File(...),
|
files: List[UploadFile] = File(...),
|
||||||
project_id: Optional[int] = Form(None),
|
project_id: Optional[int] = Form(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Upload multiple .dcm files for a DICOM series.
|
"""Upload multiple .dcm files for a DICOM series.
|
||||||
|
|
||||||
@@ -118,6 +129,12 @@ async def upload_dicom_batch(
|
|||||||
if not files:
|
if not files:
|
||||||
raise HTTPException(status_code=400, detail="No files uploaded")
|
raise HTTPException(status_code=400, detail="No files uploaded")
|
||||||
|
|
||||||
|
sorted_files = sorted(
|
||||||
|
[file for file in files if file.filename and file.filename.lower().endswith(".dcm")],
|
||||||
|
key=lambda file: natural_filename_key(file.filename or ""),
|
||||||
|
)
|
||||||
|
if not sorted_files:
|
||||||
|
raise HTTPException(status_code=400, detail="No valid DICOM files uploaded")
|
||||||
uploaded = []
|
uploaded = []
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
@@ -126,12 +143,13 @@ async def upload_dicom_batch(
|
|||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
else:
|
else:
|
||||||
# Create new DICOM project
|
# Create new DICOM project
|
||||||
first_name = files[0].filename or "DICOM_Series"
|
first_name = sorted_files[0].filename or "DICOM_Series"
|
||||||
project = Project(
|
project = Project(
|
||||||
name=first_name,
|
name=first_name,
|
||||||
description=f"DICOM series with {len(files)} files",
|
description=f"DICOM series with {len(sorted_files)} files",
|
||||||
status=PROJECT_STATUS_PENDING,
|
status=PROJECT_STATUS_PENDING,
|
||||||
source_type="dicom",
|
source_type="dicom",
|
||||||
|
owner_user_id=current_user.id,
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -139,9 +157,7 @@ async def upload_dicom_batch(
|
|||||||
project_id = project.id
|
project_id = project.id
|
||||||
logger.info("Auto-created DICOM project id=%s", project_id)
|
logger.info("Auto-created DICOM project id=%s", project_id)
|
||||||
|
|
||||||
for file in files:
|
for file in sorted_files:
|
||||||
if not file.filename or not file.filename.lower().endswith(".dcm"):
|
|
||||||
continue
|
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
object_name = f"uploads/{project_id}/dicom/{file.filename}"
|
object_name = f"uploads/{project_id}/dicom/{file.filename}"
|
||||||
try:
|
try:
|
||||||
@@ -173,6 +189,7 @@ def parse_media(
|
|||||||
max_frames: Optional[int] = Query(None, gt=0),
|
max_frames: Optional[int] = Query(None, gt=0),
|
||||||
target_width: int = Query(640, ge=64, le=4096),
|
target_width: int = Query(640, ge=64, le=4096),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> ProcessingTask:
|
) -> ProcessingTask:
|
||||||
"""Create a background task for media frame extraction.
|
"""Create a background task for media frame extraction.
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,44 @@
|
|||||||
"""Project and Frame CRUD endpoints."""
|
"""Project and Frame CRUD endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Project, Frame
|
from models import Annotation, Mask, Project, Frame, User
|
||||||
from schemas import ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut
|
from routers.auth import get_current_user, require_editor
|
||||||
|
from schemas import ProjectCopyRequest, ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut
|
||||||
from minio_client import get_presigned_url
|
from minio_client import get_presigned_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/projects", tags=["Projects"])
|
router = APIRouter(prefix="/api/projects", tags=["Projects"])
|
||||||
|
|
||||||
|
|
||||||
|
def _next_project_copy_name(db: Session, source_name: str) -> str:
|
||||||
|
base_name = f"{source_name} 副本"
|
||||||
|
existing_names = {
|
||||||
|
row[0]
|
||||||
|
for row in db.query(Project.name)
|
||||||
|
.filter(Project.name.like(f"{base_name}%"))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
if base_name not in existing_names:
|
||||||
|
return base_name
|
||||||
|
suffix = 2
|
||||||
|
while f"{base_name} {suffix}" in existing_names:
|
||||||
|
suffix += 1
|
||||||
|
return f"{base_name} {suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_project_response(project: Project) -> Project:
|
||||||
|
project.frame_count = len(project.frames)
|
||||||
|
if project.thumbnail_url:
|
||||||
|
project.thumbnail_url = get_presigned_url(project.thumbnail_url, expires=3600)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Projects
|
# Projects
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -24,9 +48,13 @@ router = APIRouter(prefix="/api/projects", tags=["Projects"])
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Create a new project",
|
summary="Create a new project",
|
||||||
)
|
)
|
||||||
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)) -> Project:
|
def create_project(
|
||||||
|
payload: ProjectCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> Project:
|
||||||
"""Create a new segmentation project."""
|
"""Create a new segmentation project."""
|
||||||
project = Project(**payload.model_dump())
|
project = Project(**payload.model_dump(), owner_user_id=current_user.id)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(project)
|
db.refresh(project)
|
||||||
@@ -39,13 +67,21 @@ def create_project(payload: ProjectCreate, db: Session = Depends(get_db)) -> Pro
|
|||||||
response_model=List[ProjectOut],
|
response_model=List[ProjectOut],
|
||||||
summary="List all projects",
|
summary="List all projects",
|
||||||
)
|
)
|
||||||
def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)) -> List[Project]:
|
def list_projects(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> List[Project]:
|
||||||
"""Retrieve a paginated list of projects."""
|
"""Retrieve a paginated list of projects."""
|
||||||
projects = db.query(Project).offset(skip).limit(limit).all()
|
projects = (
|
||||||
|
db.query(Project)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for p in projects:
|
for p in projects:
|
||||||
p.frame_count = len(p.frames)
|
_prepare_project_response(p)
|
||||||
if p.thumbnail_url:
|
|
||||||
p.thumbnail_url = get_presigned_url(p.thumbnail_url, expires=3600)
|
|
||||||
return projects
|
return projects
|
||||||
|
|
||||||
|
|
||||||
@@ -54,15 +90,91 @@ def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)
|
|||||||
response_model=ProjectOut,
|
response_model=ProjectOut,
|
||||||
summary="Get a single project",
|
summary="Get a single project",
|
||||||
)
|
)
|
||||||
def get_project(project_id: int, db: Session = Depends(get_db)) -> Project:
|
def get_project(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> Project:
|
||||||
"""Retrieve a project by its ID."""
|
"""Retrieve a project by its ID."""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
project.frame_count = len(project.frames)
|
return _prepare_project_response(project)
|
||||||
if project.thumbnail_url:
|
|
||||||
project.thumbnail_url = get_presigned_url(project.thumbnail_url, expires=3600)
|
|
||||||
return project
|
@router.post(
|
||||||
|
"/{project_id}/copy",
|
||||||
|
response_model=ProjectOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Copy a project",
|
||||||
|
)
|
||||||
|
def copy_project(
|
||||||
|
project_id: int,
|
||||||
|
payload: ProjectCopyRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> Project:
|
||||||
|
"""Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata."""
|
||||||
|
source = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
next_name = (payload.name or "").strip() if payload.name is not None else ""
|
||||||
|
if not next_name:
|
||||||
|
next_name = _next_project_copy_name(db, source.name)
|
||||||
|
|
||||||
|
copied = Project(
|
||||||
|
name=next_name,
|
||||||
|
description=source.description,
|
||||||
|
video_path=source.video_path,
|
||||||
|
thumbnail_url=source.thumbnail_url,
|
||||||
|
status=source.status,
|
||||||
|
source_type=source.source_type,
|
||||||
|
original_fps=source.original_fps,
|
||||||
|
parse_fps=source.parse_fps,
|
||||||
|
owner_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(copied)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
frame_id_map: dict[int, int] = {}
|
||||||
|
for frame in sorted(source.frames, key=lambda item: item.frame_index):
|
||||||
|
copied_frame = Frame(
|
||||||
|
project_id=copied.id,
|
||||||
|
frame_index=frame.frame_index,
|
||||||
|
image_url=frame.image_url,
|
||||||
|
width=frame.width,
|
||||||
|
height=frame.height,
|
||||||
|
timestamp_ms=frame.timestamp_ms,
|
||||||
|
source_frame_number=frame.source_frame_number,
|
||||||
|
)
|
||||||
|
db.add(copied_frame)
|
||||||
|
db.flush()
|
||||||
|
frame_id_map[frame.id] = copied_frame.id
|
||||||
|
|
||||||
|
if payload.mode == "full":
|
||||||
|
for annotation in sorted(source.annotations, key=lambda item: item.id):
|
||||||
|
copied_annotation = Annotation(
|
||||||
|
project_id=copied.id,
|
||||||
|
frame_id=frame_id_map.get(annotation.frame_id) if annotation.frame_id is not None else None,
|
||||||
|
template_id=annotation.template_id,
|
||||||
|
mask_data=annotation.mask_data,
|
||||||
|
points=annotation.points,
|
||||||
|
bbox=annotation.bbox,
|
||||||
|
)
|
||||||
|
db.add(copied_annotation)
|
||||||
|
db.flush()
|
||||||
|
for mask in annotation.masks:
|
||||||
|
db.add(Mask(
|
||||||
|
annotation_id=copied_annotation.id,
|
||||||
|
mask_url=mask.mask_url,
|
||||||
|
format=mask.format,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(copied)
|
||||||
|
logger.info("Copied project id=%s to id=%s mode=%s", project_id, copied.id, payload.mode)
|
||||||
|
return _prepare_project_response(copied)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -74,6 +186,7 @@ def update_project(
|
|||||||
project_id: int,
|
project_id: int,
|
||||||
payload: ProjectUpdate,
|
payload: ProjectUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Update project fields partially."""
|
"""Update project fields partially."""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
@@ -81,6 +194,10 @@ def update_project(
|
|||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
if key == "name":
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise HTTPException(status_code=400, detail="Project name is required")
|
||||||
setattr(project, key, value)
|
setattr(project, key, value)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -94,7 +211,11 @@ def update_project(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Delete a project",
|
summary="Delete a project",
|
||||||
)
|
)
|
||||||
def delete_project(project_id: int, db: Session = Depends(get_db)) -> None:
|
def delete_project(
|
||||||
|
project_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> None:
|
||||||
"""Delete a project and all related frames and annotations."""
|
"""Delete a project and all related frames and annotations."""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
@@ -118,6 +239,7 @@ def create_frame(
|
|||||||
project_id: int,
|
project_id: int,
|
||||||
payload: FrameCreate,
|
payload: FrameCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> Frame:
|
) -> Frame:
|
||||||
"""Register a new frame under a project."""
|
"""Register a new frame under a project."""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
@@ -139,22 +261,24 @@ def create_frame(
|
|||||||
def list_frames(
|
def list_frames(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 1000,
|
limit: Optional[int] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[Frame]:
|
) -> List[Frame]:
|
||||||
"""Retrieve all frames belonging to a project."""
|
"""Retrieve all frames belonging to a project."""
|
||||||
project = db.query(Project).filter(Project.id == project_id).first()
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
frames = (
|
query = (
|
||||||
db.query(Frame)
|
db.query(Frame)
|
||||||
.filter(Frame.project_id == project_id)
|
.filter(Frame.project_id == project_id)
|
||||||
.order_by(Frame.frame_index)
|
.order_by(Frame.frame_index)
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
if limit is not None:
|
||||||
|
query = query.limit(limit)
|
||||||
|
frames = query.all()
|
||||||
for frame in frames:
|
for frame in frames:
|
||||||
frame.image_url = get_presigned_url(frame.image_url, expires=3600)
|
frame.image_url = get_presigned_url(frame.image_url, expires=3600)
|
||||||
return frames
|
return frames
|
||||||
@@ -165,11 +289,20 @@ def list_frames(
|
|||||||
response_model=FrameOut,
|
response_model=FrameOut,
|
||||||
summary="Get a single frame",
|
summary="Get a single frame",
|
||||||
)
|
)
|
||||||
def get_frame(project_id: int, frame_id: int, db: Session = Depends(get_db)) -> Frame:
|
def get_frame(
|
||||||
|
project_id: int,
|
||||||
|
frame_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> Frame:
|
||||||
"""Retrieve a specific frame by ID."""
|
"""Retrieve a specific frame by ID."""
|
||||||
frame = (
|
frame = (
|
||||||
db.query(Frame)
|
db.query(Frame)
|
||||||
.filter(Frame.project_id == project_id, Frame.id == frame_id)
|
.join(Project, Project.id == Frame.project_id)
|
||||||
|
.filter(
|
||||||
|
Frame.project_id == project_id,
|
||||||
|
Frame.id == frame_id,
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not frame:
|
if not frame:
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from celery_app import celery_app
|
from celery_app import celery_app
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import ProcessingTask, Project
|
from models import ProcessingTask, Project, User
|
||||||
from progress_events import publish_task_progress_event
|
from progress_events import publish_task_progress_event
|
||||||
|
from routers.auth import get_current_user, require_editor
|
||||||
from schemas import ProcessingTaskOut
|
from schemas import ProcessingTaskOut
|
||||||
from statuses import (
|
from statuses import (
|
||||||
PROJECT_STATUS_PARSING,
|
PROJECT_STATUS_PARSING,
|
||||||
@@ -21,7 +22,7 @@ from statuses import (
|
|||||||
TASK_STATUS_FAILED,
|
TASK_STATUS_FAILED,
|
||||||
TASK_STATUS_QUEUED,
|
TASK_STATUS_QUEUED,
|
||||||
)
|
)
|
||||||
from worker_tasks import parse_project_media
|
from worker_tasks import parse_project_media, propagate_project_masks
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
|
router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -31,8 +32,14 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _get_task_or_404(task_id: int, db: Session) -> ProcessingTask:
|
def _get_task_or_404(task_id: int, db: Session, current_user: User) -> ProcessingTask:
|
||||||
task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
|
_ = current_user
|
||||||
|
task = (
|
||||||
|
db.query(ProcessingTask)
|
||||||
|
.outerjoin(Project, Project.id == ProcessingTask.project_id)
|
||||||
|
.filter(ProcessingTask.id == task_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return task
|
return task
|
||||||
@@ -48,9 +55,11 @@ def list_tasks(
|
|||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[ProcessingTask]:
|
) -> List[ProcessingTask]:
|
||||||
"""Return recent background processing tasks."""
|
"""Return recent background processing tasks."""
|
||||||
query = db.query(ProcessingTask)
|
_ = current_user
|
||||||
|
query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id)
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
query = query.filter(ProcessingTask.project_id == project_id)
|
query = query.filter(ProcessingTask.project_id == project_id)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -59,15 +68,23 @@ def list_tasks(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}", response_model=ProcessingTaskOut, summary="Get processing task")
|
@router.get("/{task_id}", response_model=ProcessingTaskOut, summary="Get processing task")
|
||||||
def get_task(task_id: int, db: Session = Depends(get_db)) -> ProcessingTask:
|
def get_task(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> ProcessingTask:
|
||||||
"""Return one background task by id."""
|
"""Return one background task by id."""
|
||||||
return _get_task_or_404(task_id, db)
|
return _get_task_or_404(task_id, db, current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{task_id}/cancel", response_model=ProcessingTaskOut, summary="Cancel processing task")
|
@router.post("/{task_id}/cancel", response_model=ProcessingTaskOut, summary="Cancel processing task")
|
||||||
def cancel_task(task_id: int, db: Session = Depends(get_db)) -> ProcessingTask:
|
def cancel_task(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> ProcessingTask:
|
||||||
"""Cancel a queued/running background task and revoke the Celery job when possible."""
|
"""Cancel a queued/running background task and revoke the Celery job when possible."""
|
||||||
task = _get_task_or_404(task_id, db)
|
task = _get_task_or_404(task_id, db, current_user)
|
||||||
if task.status not in TASK_ACTIVE_STATUSES:
|
if task.status not in TASK_ACTIVE_STATUSES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
@@ -95,9 +112,13 @@ def cancel_task(task_id: int, db: Session = Depends(get_db)) -> ProcessingTask:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{task_id}/retry", response_model=ProcessingTaskOut, status_code=status.HTTP_202_ACCEPTED, summary="Retry processing task")
|
@router.post("/{task_id}/retry", response_model=ProcessingTaskOut, status_code=status.HTTP_202_ACCEPTED, summary="Retry processing task")
|
||||||
def retry_task(task_id: int, db: Session = Depends(get_db)) -> ProcessingTask:
|
def retry_task(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> ProcessingTask:
|
||||||
"""Create a fresh queued task from a failed or cancelled task."""
|
"""Create a fresh queued task from a failed or cancelled task."""
|
||||||
previous = _get_task_or_404(task_id, db)
|
previous = _get_task_or_404(task_id, db, current_user)
|
||||||
if previous.status not in {TASK_STATUS_FAILED, TASK_STATUS_CANCELLED}:
|
if previous.status not in {TASK_STATUS_FAILED, TASK_STATUS_CANCELLED}:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
@@ -109,7 +130,8 @@ def retry_task(task_id: int, db: Session = Depends(get_db)) -> ProcessingTask:
|
|||||||
project = db.query(Project).filter(Project.id == previous.project_id).first()
|
project = db.query(Project).filter(Project.id == previous.project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
if not project.video_path:
|
is_propagation_task = previous.task_type == "propagate_masks"
|
||||||
|
if not is_propagation_task and not project.video_path:
|
||||||
raise HTTPException(status_code=400, detail="Project has no media uploaded")
|
raise HTTPException(status_code=400, detail="Project has no media uploaded")
|
||||||
|
|
||||||
payload = dict(previous.payload or {})
|
payload = dict(previous.payload or {})
|
||||||
@@ -124,13 +146,14 @@ def retry_task(task_id: int, db: Session = Depends(get_db)) -> ProcessingTask:
|
|||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
project.status = PROJECT_STATUS_PARSING
|
if not is_propagation_task:
|
||||||
|
project.status = PROJECT_STATUS_PARSING
|
||||||
db.add(task)
|
db.add(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
publish_task_progress_event(task)
|
publish_task_progress_event(task)
|
||||||
|
|
||||||
async_result = parse_project_media.delay(task.id)
|
async_result = propagate_project_masks.delay(task.id) if is_propagation_task else parse_project_media.delay(task.id)
|
||||||
task.celery_task_id = async_result.id
|
task.celery_task_id = async_result.id
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
|
|||||||
@@ -4,23 +4,48 @@ import logging
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Template
|
from models import Template, User
|
||||||
|
from routers.auth import get_current_user, require_editor
|
||||||
from schemas import TemplateCreate, TemplateOut, TemplateUpdate
|
from schemas import TemplateCreate, TemplateOut, TemplateUpdate
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/templates", tags=["Templates"])
|
router = APIRouter(prefix="/api/templates", tags=["Templates"])
|
||||||
|
RESERVED_UNCLASSIFIED_CLASS = {
|
||||||
|
"id": "reserved-unclassified",
|
||||||
|
"name": "待分类",
|
||||||
|
"color": "#000000",
|
||||||
|
"zIndex": 0,
|
||||||
|
"maskId": 0,
|
||||||
|
"category": "系统保留",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_reserved_class(item: dict) -> bool:
|
||||||
|
return (
|
||||||
|
item.get("id") == RESERVED_UNCLASSIFIED_CLASS["id"]
|
||||||
|
or item.get("name") == RESERVED_UNCLASSIFIED_CLASS["name"]
|
||||||
|
or item.get("maskId") == 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_template_classes(classes: list[dict] | None) -> list[dict]:
|
||||||
|
normalized = [item for item in (classes or []) if not _is_reserved_class(item)]
|
||||||
|
return [*normalized, dict(RESERVED_UNCLASSIFIED_CLASS)]
|
||||||
|
|
||||||
|
|
||||||
def _pack_mapping_rules(data: dict) -> dict:
|
def _pack_mapping_rules(data: dict) -> dict:
|
||||||
"""Pack classes/rules into mapping_rules for DB storage."""
|
"""Pack classes/rules into mapping_rules for DB storage."""
|
||||||
mapping = data.get("mapping_rules") or {}
|
mapping = data.get("mapping_rules") or {}
|
||||||
if "classes" in data and data["classes"] is not None:
|
if "classes" in data and data["classes"] is not None:
|
||||||
mapping["classes"] = data.pop("classes")
|
mapping["classes"] = _normalize_template_classes(data.pop("classes"))
|
||||||
if "rules" in data and data["rules"] is not None:
|
if "rules" in data and data["rules"] is not None:
|
||||||
mapping["rules"] = data.pop("rules")
|
mapping["rules"] = data.pop("rules")
|
||||||
|
if "classes" in mapping:
|
||||||
|
mapping["classes"] = _normalize_template_classes(mapping.get("classes"))
|
||||||
data["mapping_rules"] = mapping
|
data["mapping_rules"] = mapping
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -29,7 +54,7 @@ def _unpack_template(template: Template) -> Template:
|
|||||||
"""Unpack mapping_rules into classes/rules for response."""
|
"""Unpack mapping_rules into classes/rules for response."""
|
||||||
mapping = template.mapping_rules or {}
|
mapping = template.mapping_rules or {}
|
||||||
# Set as attributes so Pydantic from_attributes can pick them up
|
# Set as attributes so Pydantic from_attributes can pick them up
|
||||||
template.classes = mapping.get("classes", [])
|
template.classes = _normalize_template_classes(mapping.get("classes", []))
|
||||||
template.rules = mapping.get("rules", [])
|
template.rules = mapping.get("rules", [])
|
||||||
return template
|
return template
|
||||||
|
|
||||||
@@ -40,11 +65,15 @@ def _unpack_template(template: Template) -> Template:
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Create a new template",
|
summary="Create a new template",
|
||||||
)
|
)
|
||||||
def create_template(payload: TemplateCreate, db: Session = Depends(get_db)) -> Template:
|
def create_template(
|
||||||
|
payload: TemplateCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> Template:
|
||||||
"""Create a new ontology template / segmentation class."""
|
"""Create a new ontology template / segmentation class."""
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
data = _pack_mapping_rules(data)
|
data = _pack_mapping_rules(data)
|
||||||
template = Template(**data)
|
template = Template(**data, owner_user_id=current_user.id)
|
||||||
db.add(template)
|
db.add(template)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(template)
|
db.refresh(template)
|
||||||
@@ -62,9 +91,16 @@ def list_templates(
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[Template]:
|
) -> List[Template]:
|
||||||
"""Retrieve all ontology templates."""
|
"""Retrieve all ontology templates."""
|
||||||
templates = db.query(Template).offset(skip).limit(limit).all()
|
templates = (
|
||||||
|
db.query(Template)
|
||||||
|
.filter(or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)))
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for t in templates:
|
for t in templates:
|
||||||
_unpack_template(t)
|
_unpack_template(t)
|
||||||
return templates
|
return templates
|
||||||
@@ -75,9 +111,16 @@ def list_templates(
|
|||||||
response_model=TemplateOut,
|
response_model=TemplateOut,
|
||||||
summary="Get a single template",
|
summary="Get a single template",
|
||||||
)
|
)
|
||||||
def get_template(template_id: int, db: Session = Depends(get_db)) -> Template:
|
def get_template(
|
||||||
|
template_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> Template:
|
||||||
"""Retrieve a template by its ID."""
|
"""Retrieve a template by its ID."""
|
||||||
template = db.query(Template).filter(Template.id == template_id).first()
|
template = db.query(Template).filter(
|
||||||
|
Template.id == template_id,
|
||||||
|
or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)),
|
||||||
|
).first()
|
||||||
if not template:
|
if not template:
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
_unpack_template(template)
|
_unpack_template(template)
|
||||||
@@ -93,9 +136,13 @@ def update_template(
|
|||||||
template_id: int,
|
template_id: int,
|
||||||
payload: TemplateUpdate,
|
payload: TemplateUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
) -> Template:
|
) -> Template:
|
||||||
"""Update template fields partially."""
|
"""Update template fields partially."""
|
||||||
template = db.query(Template).filter(Template.id == template_id).first()
|
template = db.query(Template).filter(
|
||||||
|
Template.id == template_id,
|
||||||
|
or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)),
|
||||||
|
).first()
|
||||||
if not template:
|
if not template:
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
@@ -118,9 +165,16 @@ def update_template(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Delete a template",
|
summary="Delete a template",
|
||||||
)
|
)
|
||||||
def delete_template(template_id: int, db: Session = Depends(get_db)) -> None:
|
def delete_template(
|
||||||
|
template_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_editor),
|
||||||
|
) -> None:
|
||||||
"""Delete a template. Associated annotations will have template_id set to NULL."""
|
"""Delete a template. Associated annotations will have template_id set to NULL."""
|
||||||
template = db.query(Template).filter(Template.id == template_id).first()
|
template = db.query(Template).filter(
|
||||||
|
Template.id == template_id,
|
||||||
|
or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)),
|
||||||
|
).first()
|
||||||
if not template:
|
if not template:
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,59 @@
|
|||||||
"""Pydantic schemas for request/response validation."""
|
"""Pydantic schemas for request/response validation."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Any
|
from typing import Literal, Optional, Any
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth / user schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
is_active: int
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
username: str
|
||||||
|
user: UserOut
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
role: str = "annotator"
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserUpdate(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
role: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
actor_user_id: Optional[int] = None
|
||||||
|
action: str
|
||||||
|
target_type: Optional[str] = None
|
||||||
|
target_id: Optional[str] = None
|
||||||
|
detail: Optional[dict[str, Any]] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DemoFactoryResetRequest(BaseModel):
|
||||||
|
confirmation: str
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Project schemas
|
# Project schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -34,15 +83,29 @@ class ProjectUpdate(BaseModel):
|
|||||||
parse_fps: Optional[float] = None
|
parse_fps: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCopyRequest(BaseModel):
|
||||||
|
mode: Literal["reset", "full"] = "reset"
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectOut(ProjectBase):
|
class ProjectOut(ProjectBase):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
owner_user_id: Optional[int] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
frame_count: int = 0
|
frame_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class DemoFactoryResetOut(BaseModel):
|
||||||
|
admin_user: UserOut
|
||||||
|
project: ProjectOut
|
||||||
|
projects: list[ProjectOut]
|
||||||
|
deleted_counts: dict[str, int]
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Frame schemas
|
# Frame schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -98,6 +161,7 @@ class TemplateOut(TemplateBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
owner_user_id: Optional[int] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -190,8 +254,47 @@ 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 SmoothMaskRequest(BaseModel):
|
||||||
|
frame_id: Optional[int] = None
|
||||||
|
mask_data: dict[str, Any]
|
||||||
|
points: Optional[list[list[float]]] = None
|
||||||
|
bbox: Optional[list[float]] = None
|
||||||
|
strength: float = 0.0
|
||||||
|
method: str = "chaikin"
|
||||||
|
|
||||||
|
|
||||||
|
class SmoothMaskResponse(BaseModel):
|
||||||
|
polygons: list[list[list[float]]]
|
||||||
|
topology_anchor_count: int
|
||||||
|
topology_anchors: list[list[float]]
|
||||||
|
area: float
|
||||||
|
bbox: Optional[list[float]] = None
|
||||||
|
smoothing: dict[str, Any]
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class PropagationSeed(BaseModel):
|
class PropagationSeed(BaseModel):
|
||||||
polygons: Optional[list[list[list[float]]]] = None
|
polygons: Optional[list[list[list[float]]]] = None
|
||||||
|
holes: Optional[list[list[list[list[float]]]]] = None
|
||||||
bbox: Optional[list[float]] = None
|
bbox: Optional[list[float]] = None
|
||||||
points: Optional[list[list[float]]] = None
|
points: Optional[list[list[float]]] = None
|
||||||
labels: Optional[list[int]] = None
|
labels: Optional[list[int]] = None
|
||||||
@@ -199,12 +302,17 @@ class PropagationSeed(BaseModel):
|
|||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
class_metadata: Optional[dict[str, Any]] = None
|
class_metadata: Optional[dict[str, Any]] = None
|
||||||
template_id: Optional[int] = None
|
template_id: Optional[int] = None
|
||||||
|
source_mask_id: Optional[str] = None
|
||||||
|
source_annotation_id: Optional[int] = None
|
||||||
|
source_instance_id: Optional[str] = None
|
||||||
|
propagation_seed_signature: Optional[str] = None
|
||||||
|
smoothing: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
class PropagateRequest(BaseModel):
|
class PropagateRequest(BaseModel):
|
||||||
project_id: int
|
project_id: int
|
||||||
frame_id: int
|
frame_id: int
|
||||||
model: Optional[str] = "sam2"
|
model: Optional[str] = "sam2.1_hiera_tiny"
|
||||||
seed: PropagationSeed
|
seed: PropagationSeed
|
||||||
direction: str = "forward"
|
direction: str = "forward"
|
||||||
max_frames: int = 30
|
max_frames: int = 30
|
||||||
@@ -221,6 +329,21 @@ class PropagateResponse(BaseModel):
|
|||||||
annotations: list[AnnotationOut]
|
annotations: list[AnnotationOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PropagateTaskStep(BaseModel):
|
||||||
|
seed: PropagationSeed
|
||||||
|
direction: str = "forward"
|
||||||
|
max_frames: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class PropagateTaskRequest(BaseModel):
|
||||||
|
project_id: int
|
||||||
|
frame_id: int
|
||||||
|
model: Optional[str] = "sam2.1_hiera_tiny"
|
||||||
|
steps: list[PropagateTaskStep]
|
||||||
|
include_source: bool = False
|
||||||
|
save_annotations: bool = True
|
||||||
|
|
||||||
|
|
||||||
class AiModelStatus(BaseModel):
|
class AiModelStatus(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
label: str
|
label: str
|
||||||
|
|||||||
164
backend/services/default_templates.py
Normal file
164
backend/services/default_templates.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""Bundled system ontology templates and restore helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models import Template
|
||||||
|
|
||||||
|
RESERVED_UNCLASSIFIED_CLASS = {
|
||||||
|
"id": "reserved-unclassified",
|
||||||
|
"name": "待分类",
|
||||||
|
"color": "#000000",
|
||||||
|
"zIndex": 0,
|
||||||
|
"maskId": 0,
|
||||||
|
"category": "系统保留",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _with_reserved_unclassified_class(classes: list[dict]) -> list[dict]:
|
||||||
|
filtered = [
|
||||||
|
item for item in classes
|
||||||
|
if item.get("id") != RESERVED_UNCLASSIFIED_CLASS["id"]
|
||||||
|
and item.get("name") != RESERVED_UNCLASSIFIED_CLASS["name"]
|
||||||
|
and item.get("maskId") != 0
|
||||||
|
]
|
||||||
|
return [*filtered, dict(RESERVED_UNCLASSIFIED_CLASS)]
|
||||||
|
|
||||||
|
|
||||||
|
def _template_classes(
|
||||||
|
template_name: str,
|
||||||
|
names: list[str],
|
||||||
|
colors: list[tuple[int, int, int]],
|
||||||
|
*,
|
||||||
|
id_prefix: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
classes = []
|
||||||
|
for idx, (rgb, name) in enumerate(zip(colors, names)):
|
||||||
|
color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
|
||||||
|
classes.append({
|
||||||
|
"id": f"{id_prefix}-{idx}",
|
||||||
|
"name": name,
|
||||||
|
"color": color_hex,
|
||||||
|
"zIndex": (len(names) - idx) * 10,
|
||||||
|
"maskId": idx + 1,
|
||||||
|
"category": template_name,
|
||||||
|
})
|
||||||
|
return classes
|
||||||
|
|
||||||
|
|
||||||
|
def bundled_default_template_definitions() -> list[dict]:
|
||||||
|
"""Return fresh definitions for all bundled system templates."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "腹腔镜胆囊切除术",
|
||||||
|
"description": "腹腔镜胆囊切除术(LC)手术器械与解剖结构语义分割模板,共35个分类",
|
||||||
|
"color": "#06b6d4",
|
||||||
|
"z_index": 0,
|
||||||
|
"classes": _with_reserved_unclassified_class(_template_classes(
|
||||||
|
"腹腔镜胆囊切除术",
|
||||||
|
[
|
||||||
|
"针", "线", "肿瘤", "血管阻断夹", "棉球", "双极电凝",
|
||||||
|
"肝脏", "胆囊", "分离钳", "脂肪", "止血海绵", "肝总管",
|
||||||
|
"吸引器", "剪刀", "超声刀", "止血纱布", "胆总管", "生物夹",
|
||||||
|
"无损伤钳", "钳夹", "喷洒", "胆囊管", "动脉", "电凝",
|
||||||
|
"静脉", "标本袋", "引流管", "纱布", "金属钛夹", "韧带",
|
||||||
|
"肝蒂", "推结器", "乳胶管-血管阻断", "吻合器", "术中超声",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
(134, 124, 118), (0, 157, 142), (245, 161, 0), (255, 172, 159), (146, 175, 236), (155, 62, 0),
|
||||||
|
(255, 91, 0), (255, 234, 0), (85, 111, 181), (155, 132, 0), (181, 227, 14), (72, 0, 255),
|
||||||
|
(255, 0, 255), (29, 32, 136), (240, 16, 116), (160, 15, 95), (0, 155, 33), (0, 160, 233),
|
||||||
|
(52, 184, 178), (66, 115, 82), (90, 120, 41), (255, 0, 0), (117, 0, 0), (167, 24, 233),
|
||||||
|
(42, 8, 66), (112, 113, 150), (0, 255, 0), (255, 255, 255), (0, 255, 255), (181, 85, 105),
|
||||||
|
(113, 102, 140), (202, 202, 200), (197, 83, 181), (136, 162, 196), (138, 251, 213),
|
||||||
|
],
|
||||||
|
id_prefix="cls-lap",
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "头颈部CT分割",
|
||||||
|
"description": "头颈部CT分割",
|
||||||
|
"color": "#ef4444",
|
||||||
|
"z_index": 10,
|
||||||
|
"classes": _with_reserved_unclassified_class(_template_classes(
|
||||||
|
"头颈部CT分割",
|
||||||
|
[
|
||||||
|
"肿瘤/结节",
|
||||||
|
"下颌骨",
|
||||||
|
"甲状腺",
|
||||||
|
"气管",
|
||||||
|
"颈椎",
|
||||||
|
"颈动脉",
|
||||||
|
"颈静脉",
|
||||||
|
"腮腺",
|
||||||
|
"下颌下腺",
|
||||||
|
"舌骨",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
(255, 0, 0),
|
||||||
|
(0, 255, 0),
|
||||||
|
(0, 0, 255),
|
||||||
|
(255, 255, 0),
|
||||||
|
(255, 0, 255),
|
||||||
|
(0, 255, 255),
|
||||||
|
(255, 128, 0),
|
||||||
|
(128, 0, 128),
|
||||||
|
(0, 128, 128),
|
||||||
|
(128, 128, 0),
|
||||||
|
],
|
||||||
|
id_prefix="cls-head-neck-ct",
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_legacy_head_neck_english_labels(template: Template) -> bool:
|
||||||
|
if template.name != "头颈部CT分割":
|
||||||
|
return False
|
||||||
|
classes = (template.mapping_rules or {}).get("classes") or []
|
||||||
|
return any(
|
||||||
|
isinstance(item, dict)
|
||||||
|
and isinstance(item.get("name"), str)
|
||||||
|
and "(" in item["name"]
|
||||||
|
and ")" in item["name"]
|
||||||
|
for item in classes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_templates(db: Session, *, restore_existing: bool = False) -> list[Template]:
|
||||||
|
"""Create bundled system templates, optionally restoring existing ones exactly."""
|
||||||
|
templates: list[Template] = []
|
||||||
|
for definition in bundled_default_template_definitions():
|
||||||
|
existing = db.query(Template).filter(
|
||||||
|
Template.name == definition["name"],
|
||||||
|
Template.owner_user_id.is_(None),
|
||||||
|
).first()
|
||||||
|
if existing is None:
|
||||||
|
existing = Template(owner_user_id=None)
|
||||||
|
db.add(existing)
|
||||||
|
elif not restore_existing and not _has_legacy_head_neck_english_labels(existing):
|
||||||
|
templates.append(existing)
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing.name = definition["name"]
|
||||||
|
existing.description = definition["description"]
|
||||||
|
existing.color = definition["color"]
|
||||||
|
existing.z_index = definition["z_index"]
|
||||||
|
existing.owner_user_id = None
|
||||||
|
existing.mapping_rules = {
|
||||||
|
"classes": deepcopy(definition["classes"]),
|
||||||
|
"rules": [],
|
||||||
|
}
|
||||||
|
templates.append(existing)
|
||||||
|
db.commit()
|
||||||
|
for template in templates:
|
||||||
|
db.refresh(template)
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def restore_default_templates(db: Session) -> list[Template]:
|
||||||
|
"""Restore bundled system templates after demo factory reset."""
|
||||||
|
return ensure_default_templates(db, restore_existing=True)
|
||||||
217
backend/services/demo_media.py
Normal file
217
backend/services/demo_media.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""Helpers for seeding the bundled demo media project."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from minio_client import upload_file
|
||||||
|
from models import Frame, Project, User
|
||||||
|
from services.frame_parser import (
|
||||||
|
extract_thumbnail,
|
||||||
|
natural_filename_key,
|
||||||
|
parse_dicom,
|
||||||
|
parse_video,
|
||||||
|
upload_frames_to_minio,
|
||||||
|
)
|
||||||
|
from statuses import PROJECT_STATUS_PENDING, PROJECT_STATUS_READY
|
||||||
|
|
||||||
|
DEMO_DICOM_PROJECT_NAME = "演视DICOM序列"
|
||||||
|
DEMO_DICOM_PARSE_FPS = 30.0
|
||||||
|
DEMO_VIDEO_PROJECT_NAME = "演视LC视频序列"
|
||||||
|
DEMO_VIDEO_PARSE_FPS = 30.0
|
||||||
|
DEMO_VIDEO_TARGET_WIDTH = 640
|
||||||
|
LEGACY_DEMO_VIDEO_PROJECT_NAMES = {"Data_MyVideo_1"}
|
||||||
|
LEGACY_DEMO_DICOM_PROJECT_NAMES = {"演示DICOM序列"}
|
||||||
|
|
||||||
|
|
||||||
|
def demo_dicom_files(dicom_dir: str) -> list[Path]:
|
||||||
|
"""Return .dcm files in natural file-name order."""
|
||||||
|
root = Path(dicom_dir)
|
||||||
|
if not root.exists() or not root.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
[path for path in root.iterdir() if path.is_file() and path.name.lower().endswith(".dcm")],
|
||||||
|
key=lambda path: natural_filename_key(path.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_unparsed_video_demo_project(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
owner: User,
|
||||||
|
video_path: str,
|
||||||
|
project_name: str = DEMO_VIDEO_PROJECT_NAME,
|
||||||
|
) -> Project:
|
||||||
|
"""Create the bundled demo video project without extracting frames."""
|
||||||
|
source = Path(video_path)
|
||||||
|
if not source.exists() or not source.is_file():
|
||||||
|
raise FileNotFoundError(f"Demo video not found: {video_path}")
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
name=project_name,
|
||||||
|
description="默认演示视频,尚未生成帧",
|
||||||
|
status=PROJECT_STATUS_PENDING,
|
||||||
|
source_type="video",
|
||||||
|
parse_fps=30.0,
|
||||||
|
original_fps=None,
|
||||||
|
owner_user_id=owner.id,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
data = source.read_bytes()
|
||||||
|
object_name = f"uploads/{project.id}/{source.name}"
|
||||||
|
upload_file(object_name, data, content_type="video/mp4", length=len(data))
|
||||||
|
project.video_path = object_name
|
||||||
|
project.thumbnail_url = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def create_parsed_video_demo_project(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
owner: User,
|
||||||
|
video_path: str,
|
||||||
|
project_name: str = DEMO_VIDEO_PROJECT_NAME,
|
||||||
|
) -> Project:
|
||||||
|
"""Create the bundled demo video project and register its extracted frame sequence."""
|
||||||
|
source = Path(video_path)
|
||||||
|
if not source.exists() or not source.is_file():
|
||||||
|
raise FileNotFoundError(f"Demo video not found: {video_path}")
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
name=project_name,
|
||||||
|
description="默认演示视频,已生成帧",
|
||||||
|
status=PROJECT_STATUS_PENDING,
|
||||||
|
source_type="video",
|
||||||
|
parse_fps=DEMO_VIDEO_PARSE_FPS,
|
||||||
|
original_fps=None,
|
||||||
|
owner_user_id=owner.id,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
data = source.read_bytes()
|
||||||
|
object_name = f"uploads/{project.id}/{source.name}"
|
||||||
|
upload_file(object_name, data, content_type="video/mp4", length=len(data))
|
||||||
|
project.video_path = object_name
|
||||||
|
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix=f"seg_demo_video_{project.id}_")
|
||||||
|
try:
|
||||||
|
output_dir = os.path.join(tmp_dir, "frames")
|
||||||
|
frame_files, original_fps = parse_video(
|
||||||
|
str(source),
|
||||||
|
output_dir,
|
||||||
|
fps=int(DEMO_VIDEO_PARSE_FPS),
|
||||||
|
target_width=DEMO_VIDEO_TARGET_WIDTH,
|
||||||
|
)
|
||||||
|
project.original_fps = original_fps
|
||||||
|
object_names = upload_frames_to_minio(frame_files, project.id)
|
||||||
|
|
||||||
|
for idx, obj_name in enumerate(object_names):
|
||||||
|
image = cv2.imread(frame_files[idx])
|
||||||
|
height, width = image.shape[:2] if image is not None else (None, None)
|
||||||
|
db.add(Frame(
|
||||||
|
project_id=project.id,
|
||||||
|
frame_index=idx,
|
||||||
|
image_url=obj_name,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
timestamp_ms=idx * 1000.0 / DEMO_VIDEO_PARSE_FPS,
|
||||||
|
source_frame_number=idx,
|
||||||
|
))
|
||||||
|
|
||||||
|
thumbnail_path = os.path.join(tmp_dir, "thumbnail.jpg")
|
||||||
|
try:
|
||||||
|
extract_thumbnail(str(source), thumbnail_path)
|
||||||
|
with open(thumbnail_path, "rb") as thumbnail_file:
|
||||||
|
thumbnail_data = thumbnail_file.read()
|
||||||
|
thumbnail_object = f"projects/{project.id}/thumbnail.jpg"
|
||||||
|
upload_file(
|
||||||
|
thumbnail_object,
|
||||||
|
thumbnail_data,
|
||||||
|
content_type="image/jpeg",
|
||||||
|
length=len(thumbnail_data),
|
||||||
|
)
|
||||||
|
project.thumbnail_url = thumbnail_object
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
if object_names:
|
||||||
|
project.thumbnail_url = object_names[0]
|
||||||
|
|
||||||
|
project.status = PROJECT_STATUS_READY
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
return project
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_parsed_dicom_demo_project(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
owner: User,
|
||||||
|
dicom_dir: str,
|
||||||
|
project_name: str = DEMO_DICOM_PROJECT_NAME,
|
||||||
|
) -> Project:
|
||||||
|
"""Create the demo DICOM project, upload the series, and register parsed frames."""
|
||||||
|
dcm_files = demo_dicom_files(dicom_dir)
|
||||||
|
if not dcm_files:
|
||||||
|
raise FileNotFoundError(f"Demo DICOM series not found: {dicom_dir}")
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
name=project_name,
|
||||||
|
description=f"默认演示 DICOM 序列,已按文件名自然顺序生成 {len(dcm_files)} 帧",
|
||||||
|
status=PROJECT_STATUS_PENDING,
|
||||||
|
source_type="dicom",
|
||||||
|
parse_fps=DEMO_DICOM_PARSE_FPS,
|
||||||
|
original_fps=None,
|
||||||
|
owner_user_id=owner.id,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
dicom_prefix = f"uploads/{project.id}/dicom"
|
||||||
|
for dcm_file in dcm_files:
|
||||||
|
data = dcm_file.read_bytes()
|
||||||
|
upload_file(
|
||||||
|
f"{dicom_prefix}/{dcm_file.name}",
|
||||||
|
data,
|
||||||
|
content_type="application/dicom",
|
||||||
|
length=len(data),
|
||||||
|
)
|
||||||
|
project.video_path = dicom_prefix
|
||||||
|
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix=f"seg_demo_dicom_{project.id}_")
|
||||||
|
try:
|
||||||
|
output_dir = os.path.join(tmp_dir, "frames")
|
||||||
|
frame_files = parse_dicom(dicom_dir, output_dir)
|
||||||
|
object_names = upload_frames_to_minio(frame_files, project.id)
|
||||||
|
|
||||||
|
for idx, obj_name in enumerate(object_names):
|
||||||
|
image = cv2.imread(frame_files[idx])
|
||||||
|
height, width = image.shape[:2] if image is not None else (None, None)
|
||||||
|
db.add(Frame(
|
||||||
|
project_id=project.id,
|
||||||
|
frame_index=idx,
|
||||||
|
image_url=obj_name,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
timestamp_ms=idx * 1000.0 / DEMO_DICOM_PARSE_FPS,
|
||||||
|
source_frame_number=idx,
|
||||||
|
))
|
||||||
|
if object_names:
|
||||||
|
project.thumbnail_url = object_names[0]
|
||||||
|
project.status = PROJECT_STATUS_READY
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
return project
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,6 +17,14 @@ from minio_client import upload_file, BUCKET_NAME
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def natural_filename_key(filename: str) -> Tuple[object, ...]:
|
||||||
|
"""Sort file names by their visible numeric order instead of pure lexicographic order."""
|
||||||
|
return tuple(
|
||||||
|
int(part) if part.isdigit() else part.casefold()
|
||||||
|
for part in re.split(r"(\d+)", Path(filename).name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_video_fps(video_path: str) -> float:
|
def get_video_fps(video_path: str) -> float:
|
||||||
"""Read the original frame rate of a video file."""
|
"""Read the original frame rate of a video file."""
|
||||||
cap = cv2.VideoCapture(video_path)
|
cap = cv2.VideoCapture(video_path)
|
||||||
@@ -150,7 +159,8 @@ def parse_dicom(
|
|||||||
"""
|
"""
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
dcm_files = sorted(
|
dcm_files = sorted(
|
||||||
[f for f in os.listdir(dicom_dir) if f.lower().endswith(".dcm")]
|
[f for f in os.listdir(dicom_dir) if f.lower().endswith(".dcm")],
|
||||||
|
key=natural_filename_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
frame_paths: List[str] = []
|
frame_paths: List[str] = []
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ from typing import Any
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from minio_client import BUCKET_NAME, download_file, get_minio_client, upload_file
|
from minio_client import BUCKET_NAME, download_file, get_minio_client, upload_file
|
||||||
from models import Frame, ProcessingTask, Project
|
from models import Annotation, Frame, Mask, ProcessingTask, Project
|
||||||
from progress_events import publish_task_progress_event
|
from progress_events import publish_task_progress_event
|
||||||
from services.frame_parser import (
|
from services.frame_parser import (
|
||||||
extract_thumbnail,
|
extract_thumbnail,
|
||||||
|
natural_filename_key,
|
||||||
parse_dicom,
|
parse_dicom,
|
||||||
parse_video,
|
parse_video,
|
||||||
upload_frames_to_minio,
|
upload_frames_to_minio,
|
||||||
@@ -108,6 +109,16 @@ def _frame_sequence_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_existing_project_outputs(db: Session, project: Project) -> None:
|
||||||
|
"""Remove stale frame sequence and annotations before regenerating frames."""
|
||||||
|
annotation_ids = db.query(Annotation.id).filter(Annotation.project_id == project.id)
|
||||||
|
db.query(Mask).filter(Mask.annotation_id.in_(annotation_ids)).delete(synchronize_session=False)
|
||||||
|
db.query(Annotation).filter(Annotation.project_id == project.id).delete(synchronize_session=False)
|
||||||
|
db.query(Frame).filter(Frame.project_id == project.id).delete(synchronize_session=False)
|
||||||
|
project.thumbnail_url = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_not_cancelled(db: Session, task: ProcessingTask) -> None:
|
def _ensure_not_cancelled(db: Session, task: ProcessingTask) -> None:
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
if task.status == TASK_STATUS_CANCELLED:
|
if task.status == TASK_STATUS_CANCELLED:
|
||||||
@@ -168,6 +179,7 @@ def run_parse_media_task(db: Session, task_id: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
_ensure_not_cancelled(db, task)
|
_ensure_not_cancelled(db, task)
|
||||||
project.status = PROJECT_STATUS_PARSING
|
project.status = PROJECT_STATUS_PARSING
|
||||||
|
_clear_existing_project_outputs(db, project)
|
||||||
_set_task_state(db, task, status=TASK_STATUS_RUNNING, progress=5, message="后台解析已启动", started=True)
|
_set_task_state(db, task, status=TASK_STATUS_RUNNING, progress=5, message="后台解析已启动", started=True)
|
||||||
|
|
||||||
payload = task.payload or {}
|
payload = task.payload or {}
|
||||||
@@ -188,7 +200,10 @@ def run_parse_media_task(db: Session, task_id: int) -> dict[str, Any]:
|
|||||||
os.makedirs(dcm_dir, exist_ok=True)
|
os.makedirs(dcm_dir, exist_ok=True)
|
||||||
|
|
||||||
client = get_minio_client()
|
client = get_minio_client()
|
||||||
objects = list(client.list_objects(BUCKET_NAME, prefix=project.video_path, recursive=True))
|
objects = sorted(
|
||||||
|
list(client.list_objects(BUCKET_NAME, prefix=project.video_path, recursive=True)),
|
||||||
|
key=lambda obj: natural_filename_key(obj.object_name),
|
||||||
|
)
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
_ensure_not_cancelled(db, task)
|
_ensure_not_cancelled(db, task)
|
||||||
if obj.object_name.lower().endswith(".dcm"):
|
if obj.object_name.lower().endswith(".dcm"):
|
||||||
|
|||||||
842
backend/services/propagation_task_runner.py
Normal file
842
backend/services/propagation_task_runner.py
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
"""Background SAM video propagation runner used by Celery workers."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from minio_client import download_file
|
||||||
|
from models import Annotation, Frame, ProcessingTask, Project
|
||||||
|
from progress_events import publish_task_progress_event
|
||||||
|
from services.sam_registry import ModelUnavailableError, sam_registry
|
||||||
|
from statuses import (
|
||||||
|
TASK_STATUS_CANCELLED,
|
||||||
|
TASK_STATUS_FAILED,
|
||||||
|
TASK_STATUS_RUNNING,
|
||||||
|
TASK_STATUS_SUCCESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PropagationTaskCancelled(RuntimeError):
|
||||||
|
"""Raised internally when a persisted propagation task has been cancelled."""
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_task_state(
|
||||||
|
db: Session,
|
||||||
|
task: ProcessingTask,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
progress: int | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
result: dict[str, Any] | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
started: bool = False,
|
||||||
|
finished: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if status is not None:
|
||||||
|
task.status = status
|
||||||
|
if progress is not None:
|
||||||
|
task.progress = max(0, min(100, progress))
|
||||||
|
if message is not None:
|
||||||
|
task.message = message
|
||||||
|
if result is not None:
|
||||||
|
task.result = result
|
||||||
|
if error is not None:
|
||||||
|
task.error = error
|
||||||
|
if started:
|
||||||
|
task.started_at = _now()
|
||||||
|
if finished:
|
||||||
|
task.finished_at = _now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
publish_task_progress_event(task)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_not_cancelled(db: Session, task: ProcessingTask) -> None:
|
||||||
|
db.refresh(task)
|
||||||
|
if task.status == TASK_STATUS_CANCELLED:
|
||||||
|
raise PropagationTaskCancelled("Task was cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp01(value: float) -> float:
|
||||||
|
return min(max(float(value), 0.0), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _polygon_bbox(polygon: list[list[float]]) -> list[float]:
|
||||||
|
xs = [_clamp01(point[0]) for point in polygon]
|
||||||
|
ys = [_clamp01(point[1]) for point in polygon]
|
||||||
|
left, right = min(xs), max(xs)
|
||||||
|
top, bottom = min(ys), max(ys)
|
||||||
|
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def _polygons_bbox(polygons: list[list[list[float]]]) -> list[float]:
|
||||||
|
points = [point for polygon in polygons for point in polygon if len(point) >= 2]
|
||||||
|
if not points:
|
||||||
|
return [0.0, 0.0, 0.0, 0.0]
|
||||||
|
xs = [_clamp01(point[0]) for point in points]
|
||||||
|
ys = [_clamp01(point[1]) for point in points]
|
||||||
|
left, right = min(xs), max(xs)
|
||||||
|
top, bottom = min(ys), max(ys)
|
||||||
|
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_polygon(polygon: list[list[float]]) -> list[list[float]]:
|
||||||
|
return [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_smoothing_options(value: Any) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
strength = max(0.0, min(float(value.get("strength") or 0.0), 100.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
strength = 0.0
|
||||||
|
if strength <= 0:
|
||||||
|
return None
|
||||||
|
method = str(value.get("method") or "chaikin").lower()
|
||||||
|
if method != "chaikin":
|
||||||
|
method = "chaikin"
|
||||||
|
return {"strength": round(strength, 2), "method": method}
|
||||||
|
|
||||||
|
|
||||||
|
def _smoothing_ratio(strength: float, curve: float = 1.65) -> float:
|
||||||
|
normalized = max(0.0, min(float(strength or 0.0), 100.0)) / 100.0
|
||||||
|
return normalized ** curve
|
||||||
|
|
||||||
|
|
||||||
|
def _chaikin_smooth_polygon(polygon: list[list[float]], iterations: int, corner_cut: float = 0.25) -> list[list[float]]:
|
||||||
|
points = _normalize_polygon(polygon)
|
||||||
|
q = max(0.02, min(float(corner_cut), 0.25))
|
||||||
|
for _ in range(max(0, iterations)):
|
||||||
|
if len(points) < 3:
|
||||||
|
break
|
||||||
|
next_points: list[list[float]] = []
|
||||||
|
for index, current in enumerate(points):
|
||||||
|
following = points[(index + 1) % len(points)]
|
||||||
|
next_points.append([
|
||||||
|
_clamp01((1.0 - q) * current[0] + q * following[0]),
|
||||||
|
_clamp01((1.0 - q) * current[1] + q * following[1]),
|
||||||
|
])
|
||||||
|
next_points.append([
|
||||||
|
_clamp01(q * current[0] + (1.0 - q) * following[0]),
|
||||||
|
_clamp01(q * current[1] + (1.0 - q) * following[1]),
|
||||||
|
])
|
||||||
|
points = next_points
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _simplify_polygon(polygon: list[list[float]], strength: float) -> list[list[float]]:
|
||||||
|
if len(polygon) < 3:
|
||||||
|
return polygon
|
||||||
|
contour = np.array([[[point[0], point[1]]] for point in polygon], dtype=np.float32)
|
||||||
|
arc_length = cv2.arcLength(contour, True)
|
||||||
|
epsilon = arc_length * (0.00015 + _smoothing_ratio(strength) * 0.00735)
|
||||||
|
approx = cv2.approxPolyDP(contour, epsilon, True).reshape(-1, 2)
|
||||||
|
if len(approx) < 3:
|
||||||
|
return polygon
|
||||||
|
return [[_clamp01(float(x)), _clamp01(float(y))] for x, y in approx]
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth_polygon(polygon: list[list[float]], smoothing: dict[str, Any] | None) -> list[list[float]]:
|
||||||
|
if not smoothing:
|
||||||
|
return _normalize_polygon(polygon)
|
||||||
|
strength = float(smoothing.get("strength") or 0.0)
|
||||||
|
if strength <= 0:
|
||||||
|
return _normalize_polygon(polygon)
|
||||||
|
effective_strength = _smoothing_ratio(strength, curve=1.45) * 100.0
|
||||||
|
if effective_strength >= 85:
|
||||||
|
iterations = 4
|
||||||
|
elif effective_strength >= 55:
|
||||||
|
iterations = 3
|
||||||
|
elif effective_strength >= 25:
|
||||||
|
iterations = 2
|
||||||
|
else:
|
||||||
|
iterations = 1
|
||||||
|
corner_cut = 0.03 + _smoothing_ratio(strength, curve=1.35) * 0.22
|
||||||
|
normalized = _normalize_polygon(polygon)
|
||||||
|
pre_simplified = _simplify_polygon(normalized, effective_strength * 0.25)
|
||||||
|
smoothed = _chaikin_smooth_polygon(pre_simplified, iterations, corner_cut)
|
||||||
|
simplified = _simplify_polygon(smoothed, effective_strength)
|
||||||
|
if len(simplified) > len(normalized):
|
||||||
|
for fallback_strength in (25.0, 35.0, 50.0, 70.0, 90.0, 100.0):
|
||||||
|
simplified = _simplify_polygon(simplified, max(effective_strength, fallback_strength))
|
||||||
|
if len(simplified) <= len(normalized):
|
||||||
|
break
|
||||||
|
return simplified if len(simplified) >= 3 else _normalize_polygon(polygon)
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox_area(bbox: list[float]) -> float:
|
||||||
|
return max(float(bbox[2]), 0.0) * max(float(bbox[3]), 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox_overlap_ratio(a: list[float], b: list[float]) -> float:
|
||||||
|
ax1, ay1, aw, ah = a
|
||||||
|
bx1, by1, bw, bh = b
|
||||||
|
ax2 = ax1 + aw
|
||||||
|
ay2 = ay1 + ah
|
||||||
|
bx2 = bx1 + bw
|
||||||
|
by2 = by1 + bh
|
||||||
|
overlap_width = max(0.0, min(ax2, bx2) - max(ax1, bx1))
|
||||||
|
overlap_height = max(0.0, min(ay2, by2) - max(ay1, by1))
|
||||||
|
overlap_area = overlap_width * overlap_height
|
||||||
|
smallest_area = min(_bbox_area(a), _bbox_area(b))
|
||||||
|
return overlap_area / smallest_area if smallest_area > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_json(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _canonicalize_signature_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, float):
|
||||||
|
return round(value, 6)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_canonicalize_signature_value(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _canonicalize_signature_value(value[key]) for key in sorted(value)}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_signature(seed: dict[str, Any]) -> str:
|
||||||
|
"""Return a stable signature for seed geometry and semantic attrs."""
|
||||||
|
inherited_signature = seed.get("propagation_seed_signature")
|
||||||
|
if inherited_signature:
|
||||||
|
return str(inherited_signature)
|
||||||
|
signature_payload = {
|
||||||
|
"polygons": seed.get("polygons") or [],
|
||||||
|
"holes": seed.get("holes") or [],
|
||||||
|
"bbox": seed.get("bbox") or [],
|
||||||
|
"points": seed.get("points") or [],
|
||||||
|
"labels": seed.get("labels") or [],
|
||||||
|
"label": seed.get("label"),
|
||||||
|
"color": seed.get("color"),
|
||||||
|
"class_metadata": seed.get("class_metadata") or {},
|
||||||
|
"template_id": seed.get("template_id"),
|
||||||
|
"smoothing": _normalize_smoothing_options(seed.get("smoothing")),
|
||||||
|
}
|
||||||
|
return hashlib.sha256(_stable_json(_canonicalize_signature_value(signature_payload)).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_key(seed: dict[str, Any]) -> str:
|
||||||
|
"""Prefer stable persisted ids; fall back to semantic attrs for legacy callers."""
|
||||||
|
source_instance_id = seed.get("source_instance_id")
|
||||||
|
if source_instance_id:
|
||||||
|
return f"instance:{source_instance_id}"
|
||||||
|
source_annotation_id = seed.get("source_annotation_id")
|
||||||
|
if source_annotation_id is not None:
|
||||||
|
return f"annotation:{source_annotation_id}"
|
||||||
|
source_mask_id = seed.get("source_mask_id")
|
||||||
|
if source_mask_id:
|
||||||
|
return f"mask:{source_mask_id}"
|
||||||
|
class_metadata = seed.get("class_metadata") or {}
|
||||||
|
class_id = class_metadata.get("id") or class_metadata.get("name")
|
||||||
|
return _stable_json({
|
||||||
|
"template_id": seed.get("template_id"),
|
||||||
|
"class_id": class_id,
|
||||||
|
"label": seed.get("label"),
|
||||||
|
"color": seed.get("color"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_seed_matches(mask_data: dict[str, Any], seed: dict[str, Any]) -> bool:
|
||||||
|
"""Best-effort match when a manually edited replacement lacks old lineage ids."""
|
||||||
|
class_metadata = seed.get("class_metadata") or {}
|
||||||
|
previous_class = mask_data.get("class") or {}
|
||||||
|
previous_class_id = previous_class.get("id") or previous_class.get("name")
|
||||||
|
class_id = class_metadata.get("id") or class_metadata.get("name")
|
||||||
|
if previous_class_id and class_id and str(previous_class_id) != str(class_id):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
mask_data.get("label") == seed.get("label")
|
||||||
|
and mask_data.get("color") == seed.get("color")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_seed_matches(mask_data: dict[str, Any], seed: dict[str, Any]) -> bool:
|
||||||
|
"""Best-effort match for propagation annotations created before seed keys."""
|
||||||
|
class_metadata = seed.get("class_metadata") or {}
|
||||||
|
previous_class = mask_data.get("class") or {}
|
||||||
|
previous_class_id = previous_class.get("id") or previous_class.get("name")
|
||||||
|
class_id = class_metadata.get("id") or class_metadata.get("name")
|
||||||
|
return (
|
||||||
|
mask_data.get("label") == seed.get("label")
|
||||||
|
and mask_data.get("color") == seed.get("color")
|
||||||
|
and previous_class_id == class_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_model_matches(mask_data: dict[str, Any], model_id: str) -> bool:
|
||||||
|
return str(mask_data.get("source") or "") == f"{model_id}_propagation"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_identity_matches(mask_data: dict[str, Any], seed_key: str, seed: dict[str, Any]) -> bool:
|
||||||
|
previous_seed_key = mask_data.get("propagation_seed_key")
|
||||||
|
if previous_seed_key == seed_key:
|
||||||
|
return True
|
||||||
|
source_instance_id = seed.get("source_instance_id")
|
||||||
|
if source_instance_id and (
|
||||||
|
mask_data.get("source_instance_id") == source_instance_id
|
||||||
|
or mask_data.get("instance_id") == source_instance_id
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
source_annotation_id = seed.get("source_annotation_id")
|
||||||
|
if source_annotation_id is not None and str(mask_data.get("source_annotation_id") or "") == str(source_annotation_id):
|
||||||
|
return True
|
||||||
|
source_mask_id = seed.get("source_mask_id")
|
||||||
|
if source_mask_id and mask_data.get("source_mask_id") == source_mask_id:
|
||||||
|
return True
|
||||||
|
has_persisted_seed_identity = bool(source_instance_id) or source_annotation_id is not None or bool(source_mask_id)
|
||||||
|
has_previous_identity = (
|
||||||
|
bool(previous_seed_key)
|
||||||
|
or mask_data.get("source_instance_id") is not None
|
||||||
|
or mask_data.get("instance_id") is not None
|
||||||
|
or mask_data.get("source_annotation_id") is not None
|
||||||
|
or bool(mask_data.get("source_mask_id"))
|
||||||
|
)
|
||||||
|
if has_persisted_seed_identity or has_previous_identity:
|
||||||
|
return False
|
||||||
|
return _legacy_seed_matches(mask_data, seed)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_identity_markers(seed: dict[str, Any]) -> set[str]:
|
||||||
|
markers = {f"seed:{_seed_key(seed)}"}
|
||||||
|
source_instance_id = seed.get("source_instance_id")
|
||||||
|
if source_instance_id:
|
||||||
|
markers.add(f"instance:{source_instance_id}")
|
||||||
|
source_annotation_id = seed.get("source_annotation_id")
|
||||||
|
if source_annotation_id is not None:
|
||||||
|
markers.add(f"annotation:{source_annotation_id}")
|
||||||
|
source_mask_id = seed.get("source_mask_id")
|
||||||
|
if source_mask_id:
|
||||||
|
markers.add(f"mask:{source_mask_id}")
|
||||||
|
return markers
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_identity_markers(mask_data: dict[str, Any]) -> set[str]:
|
||||||
|
markers: set[str] = set()
|
||||||
|
previous_seed_key = mask_data.get("propagation_seed_key")
|
||||||
|
if previous_seed_key:
|
||||||
|
markers.add(f"seed:{previous_seed_key}")
|
||||||
|
source_instance_id = mask_data.get("source_instance_id")
|
||||||
|
if source_instance_id:
|
||||||
|
markers.add(f"instance:{source_instance_id}")
|
||||||
|
instance_id = mask_data.get("instance_id")
|
||||||
|
if instance_id:
|
||||||
|
markers.add(f"instance:{instance_id}")
|
||||||
|
source_annotation_id = mask_data.get("source_annotation_id")
|
||||||
|
if source_annotation_id is not None:
|
||||||
|
markers.add(f"annotation:{source_annotation_id}")
|
||||||
|
source_mask_id = mask_data.get("source_mask_id")
|
||||||
|
if source_mask_id:
|
||||||
|
markers.add(f"mask:{source_mask_id}")
|
||||||
|
return markers
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_seed_identity_markers(payload: dict[str, Any]) -> set[str]:
|
||||||
|
markers: set[str] = set()
|
||||||
|
for step in payload.get("steps") or []:
|
||||||
|
seed = step.get("seed") or {}
|
||||||
|
markers.update(_seed_identity_markers(seed))
|
||||||
|
return markers
|
||||||
|
|
||||||
|
|
||||||
|
def _is_propagation_annotation(annotation: Annotation, seed_key: str, seed: dict[str, Any]) -> bool:
|
||||||
|
mask_data = annotation.mask_data or {}
|
||||||
|
source = str(mask_data.get("source") or "")
|
||||||
|
if not source.endswith("_propagation"):
|
||||||
|
return False
|
||||||
|
return _seed_identity_matches(mask_data, seed_key, seed)
|
||||||
|
|
||||||
|
|
||||||
|
def _direction_matches(mask_data: dict[str, Any], direction: str) -> bool:
|
||||||
|
previous_direction = mask_data.get("propagation_direction")
|
||||||
|
return previous_direction in {None, direction}
|
||||||
|
|
||||||
|
|
||||||
|
def _annotation_spatially_matches(annotation: Annotation, polygon: list[list[float]]) -> bool:
|
||||||
|
"""Use target-frame overlap as a final guard before replacing same-object propagation."""
|
||||||
|
candidate_bbox = _polygon_bbox(polygon)
|
||||||
|
for previous_polygon in (annotation.mask_data or {}).get("polygons") or []:
|
||||||
|
if len(previous_polygon) < 3:
|
||||||
|
continue
|
||||||
|
if _bbox_overlap_ratio(_polygon_bbox(previous_polygon), candidate_bbox) >= 0.15:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_replaced_frame_annotations(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
frame_id: int,
|
||||||
|
seed_key: str,
|
||||||
|
seed: dict[str, Any],
|
||||||
|
polygon: list[list[float]],
|
||||||
|
) -> int:
|
||||||
|
"""Delete old propagated masks for the same object immediately before writing a new result."""
|
||||||
|
previous_annotations = (
|
||||||
|
db.query(Annotation)
|
||||||
|
.filter(Annotation.project_id == int(payload["project_id"]))
|
||||||
|
.filter(Annotation.frame_id == frame_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
deleted_count = 0
|
||||||
|
current_seed_markers = _seed_identity_markers(seed)
|
||||||
|
task_seed_markers = _payload_seed_identity_markers(payload)
|
||||||
|
for annotation in previous_annotations:
|
||||||
|
mask_data = annotation.mask_data or {}
|
||||||
|
source = str(mask_data.get("source") or "")
|
||||||
|
if not source.endswith("_propagation"):
|
||||||
|
continue
|
||||||
|
source_instance_id = seed.get("source_instance_id")
|
||||||
|
mask_instance_ids = {
|
||||||
|
str(value)
|
||||||
|
for value in (mask_data.get("source_instance_id"), mask_data.get("instance_id"))
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
if source_instance_id and mask_instance_ids and str(source_instance_id) not in mask_instance_ids:
|
||||||
|
continue
|
||||||
|
mask_markers = _mask_identity_markers(mask_data)
|
||||||
|
# Keep sibling seeds in the same propagation task from deleting each other.
|
||||||
|
if mask_markers and mask_markers.isdisjoint(current_seed_markers) and not mask_markers.isdisjoint(task_seed_markers):
|
||||||
|
continue
|
||||||
|
same_lineage = _seed_identity_matches(mask_data, seed_key, seed)
|
||||||
|
same_manual_replacement = (
|
||||||
|
_semantic_seed_matches(mask_data, seed)
|
||||||
|
and _annotation_spatially_matches(annotation, polygon)
|
||||||
|
)
|
||||||
|
if same_lineage or same_manual_replacement:
|
||||||
|
db.delete(annotation)
|
||||||
|
deleted_count += 1
|
||||||
|
if deleted_count:
|
||||||
|
db.commit()
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_seed_propagation(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
model_id: str,
|
||||||
|
seed: dict[str, Any],
|
||||||
|
direction: str,
|
||||||
|
target_frame_ids: set[int],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
seed_key = _seed_key(seed)
|
||||||
|
seed_signature = _seed_signature(seed)
|
||||||
|
if not target_frame_ids:
|
||||||
|
return {
|
||||||
|
"skip": True,
|
||||||
|
"seed_key": seed_key,
|
||||||
|
"seed_signature": seed_signature,
|
||||||
|
"deleted_annotation_count": 0,
|
||||||
|
}
|
||||||
|
previous_annotations = (
|
||||||
|
db.query(Annotation)
|
||||||
|
.filter(Annotation.project_id == int(payload["project_id"]))
|
||||||
|
.filter(Annotation.frame_id.in_(target_frame_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
matching = [
|
||||||
|
annotation for annotation in previous_annotations
|
||||||
|
if _is_propagation_annotation(annotation, seed_key, seed)
|
||||||
|
and _direction_matches(annotation.mask_data or {}, direction)
|
||||||
|
]
|
||||||
|
covered_frame_ids = {int(annotation.frame_id) for annotation in matching}
|
||||||
|
if matching and all(
|
||||||
|
(annotation.mask_data or {}).get("propagation_seed_signature") == seed_signature
|
||||||
|
and _source_model_matches(annotation.mask_data or {}, model_id)
|
||||||
|
for annotation in matching
|
||||||
|
) and target_frame_ids.issubset(covered_frame_ids):
|
||||||
|
return {
|
||||||
|
"skip": True,
|
||||||
|
"seed_key": seed_key,
|
||||||
|
"seed_signature": seed_signature,
|
||||||
|
"deleted_annotation_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
if matching:
|
||||||
|
for annotation in matching:
|
||||||
|
db.delete(annotation)
|
||||||
|
deleted_count += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"skip": False,
|
||||||
|
"seed_key": seed_key,
|
||||||
|
"seed_signature": seed_signature,
|
||||||
|
"deleted_annotation_count": deleted_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_window(
|
||||||
|
frames: list[Frame],
|
||||||
|
source_position: int,
|
||||||
|
direction: str,
|
||||||
|
max_frames: int,
|
||||||
|
) -> tuple[list[Frame], int]:
|
||||||
|
count = max(1, min(max_frames, len(frames)))
|
||||||
|
if direction == "backward":
|
||||||
|
start = max(0, source_position - count + 1)
|
||||||
|
return frames[start:source_position + 1], source_position - start
|
||||||
|
end = min(len(frames), source_position + count)
|
||||||
|
return frames[source_position:end], 0
|
||||||
|
|
||||||
|
|
||||||
|
def _write_frame_sequence(frames: list[Frame], directory: Path) -> list[str]:
|
||||||
|
paths = []
|
||||||
|
for index, frame in enumerate(frames):
|
||||||
|
data = download_file(frame.image_url)
|
||||||
|
# SAM2VideoPredictor sorts frames by converting the filename stem to int.
|
||||||
|
path = directory / f"{index:06d}.jpg"
|
||||||
|
path.write_bytes(data)
|
||||||
|
paths.append(str(path))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _save_propagated_annotations(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
selected_frames: list[Frame],
|
||||||
|
source_frame: Frame,
|
||||||
|
propagated: list[dict[str, Any]],
|
||||||
|
seed: dict[str, Any],
|
||||||
|
) -> tuple[list[Annotation], int]:
|
||||||
|
created: list[Annotation] = []
|
||||||
|
if payload.get("save_annotations", True) is False:
|
||||||
|
return created, 0
|
||||||
|
|
||||||
|
class_metadata = seed.get("class_metadata")
|
||||||
|
template_id = seed.get("template_id")
|
||||||
|
label = seed.get("label") or "Propagated Mask"
|
||||||
|
color = seed.get("color") or "#06b6d4"
|
||||||
|
model_id = sam_registry.normalize_model_id(payload.get("model"))
|
||||||
|
include_source = bool(payload.get("include_source", False))
|
||||||
|
seed_key = _seed_key(seed)
|
||||||
|
seed_signature = _seed_signature(seed)
|
||||||
|
source_annotation_id = seed.get("source_annotation_id")
|
||||||
|
source_mask_id = seed.get("source_mask_id")
|
||||||
|
source_instance_id = seed.get("source_instance_id") or seed_key
|
||||||
|
smoothing = _normalize_smoothing_options(seed.get("smoothing"))
|
||||||
|
direction = str(payload.get("current_direction") or "")
|
||||||
|
deleted_count = 0
|
||||||
|
cleaned_frame_ids: set[int] = set()
|
||||||
|
|
||||||
|
for frame_result in propagated:
|
||||||
|
relative_index = int(frame_result.get("frame_index", -1))
|
||||||
|
if relative_index < 0 or relative_index >= len(selected_frames):
|
||||||
|
continue
|
||||||
|
frame = selected_frames[relative_index]
|
||||||
|
if not include_source and frame.id == source_frame.id:
|
||||||
|
continue
|
||||||
|
result_polygons = frame_result.get("polygons") or []
|
||||||
|
result_holes = frame_result.get("holes") or []
|
||||||
|
scores = frame_result.get("scores") or []
|
||||||
|
prepared_polygons = [
|
||||||
|
(polygon_index, _smooth_polygon(polygon, smoothing))
|
||||||
|
for polygon_index, polygon in enumerate(result_polygons)
|
||||||
|
if len(polygon) >= 3
|
||||||
|
]
|
||||||
|
cleanup_polygon = next((polygon for _polygon_index, polygon in prepared_polygons if len(polygon) >= 3), None)
|
||||||
|
if cleanup_polygon is not None and frame.id not in cleaned_frame_ids:
|
||||||
|
deleted_count += _delete_replaced_frame_annotations(
|
||||||
|
db,
|
||||||
|
payload=payload,
|
||||||
|
frame_id=int(frame.id),
|
||||||
|
seed_key=seed_key,
|
||||||
|
seed=seed,
|
||||||
|
polygon=cleanup_polygon,
|
||||||
|
)
|
||||||
|
cleaned_frame_ids.add(int(frame.id))
|
||||||
|
polygons_to_save: list[list[list[float]]] = []
|
||||||
|
holes_to_save: list[list[list[list[float]]]] = []
|
||||||
|
score_values: list[float] = []
|
||||||
|
for polygon_index, polygon in prepared_polygons:
|
||||||
|
if len(polygon) < 3:
|
||||||
|
continue
|
||||||
|
polygons_to_save.append(polygon)
|
||||||
|
hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else []
|
||||||
|
holes_to_save.append(hole_group if isinstance(hole_group, list) else [])
|
||||||
|
if polygon_index < len(scores):
|
||||||
|
try:
|
||||||
|
score_values.append(float(scores[polygon_index]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if not polygons_to_save:
|
||||||
|
continue
|
||||||
|
annotation = Annotation(
|
||||||
|
project_id=int(payload["project_id"]),
|
||||||
|
frame_id=frame.id,
|
||||||
|
template_id=template_id,
|
||||||
|
mask_data={
|
||||||
|
"polygons": polygons_to_save,
|
||||||
|
**({"holes": holes_to_save, "hasHoles": True} if any(holes_to_save) else {}),
|
||||||
|
"label": label,
|
||||||
|
"color": color,
|
||||||
|
"source": f"{model_id}_propagation",
|
||||||
|
"propagated_from_frame_id": source_frame.id,
|
||||||
|
"propagated_from_frame_index": source_frame.frame_index,
|
||||||
|
"propagation_seed_key": seed_key,
|
||||||
|
"propagation_seed_signature": seed_signature,
|
||||||
|
"propagation_direction": direction,
|
||||||
|
"instance_id": source_instance_id,
|
||||||
|
"source_instance_id": source_instance_id,
|
||||||
|
"source_annotation_id": source_annotation_id,
|
||||||
|
"source_mask_id": source_mask_id,
|
||||||
|
"score": max(score_values) if score_values else None,
|
||||||
|
**({"scores": score_values} if len(score_values) > 1 else {}),
|
||||||
|
**({"geometry_smoothing": smoothing} if smoothing else {}),
|
||||||
|
**({"class": class_metadata} if class_metadata else {}),
|
||||||
|
},
|
||||||
|
points=None,
|
||||||
|
bbox=_polygons_bbox(polygons_to_save),
|
||||||
|
)
|
||||||
|
db.add(annotation)
|
||||||
|
created.append(annotation)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
for annotation in created:
|
||||||
|
db.refresh(annotation)
|
||||||
|
return created, deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
def _run_one_step(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
frames: list[Frame],
|
||||||
|
source_frame: Frame,
|
||||||
|
source_position: int,
|
||||||
|
step: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
direction = str(step.get("direction") or "forward").lower()
|
||||||
|
if direction not in {"forward", "backward"}:
|
||||||
|
raise ValueError("direction must be forward or backward")
|
||||||
|
max_frames = max(1, min(int(step.get("max_frames") or payload.get("max_frames") or 30), 500))
|
||||||
|
seed = step.get("seed") or {}
|
||||||
|
if not (seed.get("polygons") or seed.get("bbox") or seed.get("points")):
|
||||||
|
raise ValueError("Propagation requires seed polygons, bbox, or points")
|
||||||
|
|
||||||
|
model_id = sam_registry.normalize_model_id(payload.get("model"))
|
||||||
|
selected_frames, source_relative_index = _frame_window(frames, source_position, direction, max_frames)
|
||||||
|
include_source = bool(payload.get("include_source", False))
|
||||||
|
target_frame_ids = {
|
||||||
|
int(frame.id)
|
||||||
|
for frame in selected_frames
|
||||||
|
if include_source or frame.id != source_frame.id
|
||||||
|
}
|
||||||
|
seed_state = _prepare_seed_propagation(
|
||||||
|
db,
|
||||||
|
payload=payload,
|
||||||
|
model_id=model_id,
|
||||||
|
seed=seed,
|
||||||
|
direction=direction,
|
||||||
|
target_frame_ids=target_frame_ids,
|
||||||
|
)
|
||||||
|
if seed_state["skip"]:
|
||||||
|
return {
|
||||||
|
"model": model_id,
|
||||||
|
"direction": direction,
|
||||||
|
"processed_frame_count": 0,
|
||||||
|
"created_annotation_count": 0,
|
||||||
|
"deleted_annotation_count": 0,
|
||||||
|
"skipped_seed_count": 1,
|
||||||
|
"seed_label": seed.get("label"),
|
||||||
|
"seed_key": seed_state["seed_key"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix=f"seg_propagate_{payload['project_id']}_") as tmpdir:
|
||||||
|
frame_paths = _write_frame_sequence(selected_frames, Path(tmpdir))
|
||||||
|
propagated = sam_registry.propagate_video(
|
||||||
|
model_id,
|
||||||
|
frame_paths,
|
||||||
|
source_relative_index,
|
||||||
|
seed,
|
||||||
|
direction,
|
||||||
|
len(selected_frames),
|
||||||
|
)
|
||||||
|
|
||||||
|
save_payload = {**payload, "current_direction": direction}
|
||||||
|
created, write_cleanup_count = _save_propagated_annotations(
|
||||||
|
db,
|
||||||
|
payload=save_payload,
|
||||||
|
selected_frames=selected_frames,
|
||||||
|
source_frame=source_frame,
|
||||||
|
propagated=propagated,
|
||||||
|
seed=seed,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"model": model_id,
|
||||||
|
"direction": direction,
|
||||||
|
"processed_frame_count": len(selected_frames),
|
||||||
|
"created_annotation_count": len(created),
|
||||||
|
"deleted_annotation_count": int(seed_state["deleted_annotation_count"]) + write_cleanup_count,
|
||||||
|
"skipped_seed_count": 0,
|
||||||
|
"seed_label": seed.get("label"),
|
||||||
|
"seed_key": seed_state["seed_key"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_propagate_project_task(db: Session, task_id: int) -> dict[str, Any]:
|
||||||
|
"""Run one queued SAM propagation task and update persisted progress."""
|
||||||
|
task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise ValueError(f"Task not found: {task_id}")
|
||||||
|
|
||||||
|
if task.status == TASK_STATUS_CANCELLED:
|
||||||
|
return {"task_id": task.id, "status": TASK_STATUS_CANCELLED, "message": task.message or "任务已取消"}
|
||||||
|
|
||||||
|
payload = task.payload or {}
|
||||||
|
project_id = int(payload.get("project_id") or task.project_id or 0)
|
||||||
|
source_frame_id = int(payload.get("frame_id") or 0)
|
||||||
|
try:
|
||||||
|
model_id = sam_registry.normalize_model_id(payload.get("model"))
|
||||||
|
except ValueError as exc:
|
||||||
|
_set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="自动传播失败", error=str(exc), finished=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
project = db.query(Project).filter(Project.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
_set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="项目不存在", error="Project not found", finished=True)
|
||||||
|
raise ValueError(f"Project not found: {project_id}")
|
||||||
|
|
||||||
|
source_frame = db.query(Frame).filter(Frame.id == source_frame_id, Frame.project_id == project_id).first()
|
||||||
|
if not source_frame:
|
||||||
|
_set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="参考帧不存在", error="Frame not found", finished=True)
|
||||||
|
raise ValueError(f"Frame not found: {source_frame_id}")
|
||||||
|
|
||||||
|
frames = db.query(Frame).filter(Frame.project_id == project_id).order_by(Frame.frame_index).all()
|
||||||
|
source_position = next((index for index, frame in enumerate(frames) if frame.id == source_frame.id), None)
|
||||||
|
if source_position is None:
|
||||||
|
_set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="参考帧不在项目帧序列中", error="Source frame is not in project frame sequence", finished=True)
|
||||||
|
raise ValueError("Source frame is not in project frame sequence")
|
||||||
|
|
||||||
|
steps = payload.get("steps") or []
|
||||||
|
if not steps:
|
||||||
|
_set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="传播任务缺少步骤", error="Propagation task has no steps", finished=True)
|
||||||
|
raise ValueError("Propagation task has no steps")
|
||||||
|
|
||||||
|
_ensure_not_cancelled(db, task)
|
||||||
|
_set_task_state(db, task, status=TASK_STATUS_RUNNING, progress=5, message="自动传播任务已启动", started=True)
|
||||||
|
|
||||||
|
step_results: list[dict[str, Any]] = []
|
||||||
|
created_count = 0
|
||||||
|
processed_count = 0
|
||||||
|
deleted_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
total_steps = len(steps)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for index, step in enumerate(steps, start=1):
|
||||||
|
_ensure_not_cancelled(db, task)
|
||||||
|
seed_label = (step.get("seed") or {}).get("label") or "mask"
|
||||||
|
direction_label = "向前传播" if step.get("direction") == "backward" else "向后传播"
|
||||||
|
progress_before = 5 + int(((index - 1) / total_steps) * 90)
|
||||||
|
_set_task_state(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
progress=progress_before,
|
||||||
|
message=f"{direction_label} {seed_label} ({index}/{total_steps})",
|
||||||
|
result={
|
||||||
|
"project_id": project_id,
|
||||||
|
"source_frame_id": source_frame_id,
|
||||||
|
"model": model_id,
|
||||||
|
"total_steps": total_steps,
|
||||||
|
"completed_steps": index - 1,
|
||||||
|
"processed_frame_count": processed_count,
|
||||||
|
"created_annotation_count": created_count,
|
||||||
|
"deleted_annotation_count": deleted_count,
|
||||||
|
"skipped_seed_count": skipped_count,
|
||||||
|
"steps": step_results,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _run_one_step(
|
||||||
|
db,
|
||||||
|
payload=payload,
|
||||||
|
frames=frames,
|
||||||
|
source_frame=source_frame,
|
||||||
|
source_position=source_position,
|
||||||
|
step=step,
|
||||||
|
)
|
||||||
|
step_results.append(result)
|
||||||
|
created_count += int(result["created_annotation_count"])
|
||||||
|
processed_count += int(result["processed_frame_count"])
|
||||||
|
deleted_count += int(result.get("deleted_annotation_count") or 0)
|
||||||
|
skipped_count += int(result.get("skipped_seed_count") or 0)
|
||||||
|
_set_task_state(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
progress=5 + int((index / total_steps) * 90),
|
||||||
|
message=f"{direction_label} {seed_label} 完成 ({index}/{total_steps})",
|
||||||
|
result={
|
||||||
|
"project_id": project_id,
|
||||||
|
"source_frame_id": source_frame_id,
|
||||||
|
"model": model_id,
|
||||||
|
"total_steps": total_steps,
|
||||||
|
"completed_steps": index,
|
||||||
|
"processed_frame_count": processed_count,
|
||||||
|
"created_annotation_count": created_count,
|
||||||
|
"deleted_annotation_count": deleted_count,
|
||||||
|
"skipped_seed_count": skipped_count,
|
||||||
|
"steps": step_results,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"project_id": project_id,
|
||||||
|
"source_frame_id": source_frame_id,
|
||||||
|
"model": model_id,
|
||||||
|
"total_steps": total_steps,
|
||||||
|
"completed_steps": total_steps,
|
||||||
|
"processed_frame_count": processed_count,
|
||||||
|
"created_annotation_count": created_count,
|
||||||
|
"deleted_annotation_count": deleted_count,
|
||||||
|
"skipped_seed_count": skipped_count,
|
||||||
|
"steps": step_results,
|
||||||
|
}
|
||||||
|
_set_task_state(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
status=TASK_STATUS_SUCCESS,
|
||||||
|
progress=100,
|
||||||
|
message="自动传播完成" if created_count > 0 else (
|
||||||
|
"自动传播完成,未改变的 mask 已跳过" if skipped_count > 0 else "自动传播完成,但没有生成新的 mask"
|
||||||
|
),
|
||||||
|
result=result,
|
||||||
|
finished=True,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except PropagationTaskCancelled:
|
||||||
|
task.status = TASK_STATUS_CANCELLED
|
||||||
|
task.progress = 100
|
||||||
|
task.message = task.message or "任务已取消"
|
||||||
|
task.error = task.error or "Cancelled by user"
|
||||||
|
task.finished_at = task.finished_at or _now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
publish_task_progress_event(task)
|
||||||
|
return {"task_id": task.id, "project_id": project_id, "status": TASK_STATUS_CANCELLED, "message": task.message}
|
||||||
|
except (ModelUnavailableError, NotImplementedError, ValueError) as exc:
|
||||||
|
_set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="自动传播失败", error=str(exc), finished=True)
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("Propagation task failed: task_id=%s", task.id)
|
||||||
|
_set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="自动传播失败", error=str(exc), finished=True)
|
||||||
|
raise
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -10,6 +12,67 @@ from config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_SAM2_MODEL_ID = "sam2.1_hiera_tiny"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SAM2Variant:
|
||||||
|
"""One selectable SAM 2.1 runtime variant."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
short_label: str
|
||||||
|
config: str
|
||||||
|
legacy_config: str
|
||||||
|
checkpoint_filename: str
|
||||||
|
legacy_checkpoint_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
SAM2_VARIANTS: dict[str, SAM2Variant] = {
|
||||||
|
"sam2.1_hiera_tiny": SAM2Variant(
|
||||||
|
id="sam2.1_hiera_tiny",
|
||||||
|
label="SAM 2.1 Tiny",
|
||||||
|
short_label="tiny",
|
||||||
|
config="configs/sam2.1/sam2.1_hiera_t.yaml",
|
||||||
|
legacy_config="configs/sam2/sam2_hiera_t.yaml",
|
||||||
|
checkpoint_filename="sam2.1_hiera_tiny.pt",
|
||||||
|
legacy_checkpoint_filename="sam2_hiera_tiny.pt",
|
||||||
|
),
|
||||||
|
"sam2.1_hiera_small": SAM2Variant(
|
||||||
|
id="sam2.1_hiera_small",
|
||||||
|
label="SAM 2.1 Small",
|
||||||
|
short_label="small",
|
||||||
|
config="configs/sam2.1/sam2.1_hiera_s.yaml",
|
||||||
|
legacy_config="configs/sam2/sam2_hiera_s.yaml",
|
||||||
|
checkpoint_filename="sam2.1_hiera_small.pt",
|
||||||
|
legacy_checkpoint_filename="sam2_hiera_small.pt",
|
||||||
|
),
|
||||||
|
"sam2.1_hiera_base_plus": SAM2Variant(
|
||||||
|
id="sam2.1_hiera_base_plus",
|
||||||
|
label="SAM 2.1 Base+",
|
||||||
|
short_label="base+",
|
||||||
|
config="configs/sam2.1/sam2.1_hiera_b+.yaml",
|
||||||
|
legacy_config="configs/sam2/sam2_hiera_b+.yaml",
|
||||||
|
checkpoint_filename="sam2.1_hiera_base_plus.pt",
|
||||||
|
legacy_checkpoint_filename="sam2_hiera_base_plus.pt",
|
||||||
|
),
|
||||||
|
"sam2.1_hiera_large": SAM2Variant(
|
||||||
|
id="sam2.1_hiera_large",
|
||||||
|
label="SAM 2.1 Large",
|
||||||
|
short_label="large",
|
||||||
|
config="configs/sam2.1/sam2.1_hiera_l.yaml",
|
||||||
|
legacy_config="configs/sam2/sam2_hiera_l.yaml",
|
||||||
|
checkpoint_filename="sam2.1_hiera_large.pt",
|
||||||
|
legacy_checkpoint_filename="sam2_hiera_large.pt",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
SAM2_MODEL_ALIASES = {
|
||||||
|
"sam2": DEFAULT_SAM2_MODEL_ID,
|
||||||
|
"sam2.1": DEFAULT_SAM2_MODEL_ID,
|
||||||
|
"sam2_tiny": DEFAULT_SAM2_MODEL_ID,
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Attempt to import PyTorch and SAM 2; fall back to stubs if unavailable.
|
# Attempt to import PyTorch and SAM 2; fall back to stubs if unavailable.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -38,115 +101,173 @@ class SAM2Engine:
|
|||||||
"""Lazy-loaded SAM 2 inference engine."""
|
"""Lazy-loaded SAM 2 inference engine."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._predictor: Optional[SAM2ImagePredictor] = None
|
self._predictors: dict[str, Optional[SAM2ImagePredictor]] = {}
|
||||||
self._video_predictor = None
|
self._video_predictors: dict[str, object | None] = {}
|
||||||
self._model_loaded = False
|
self._model_loaded: dict[str, bool] = {}
|
||||||
self._video_model_loaded = False
|
self._video_model_loaded: dict[str, bool] = {}
|
||||||
self._loaded_device: str | None = None
|
self._loaded_device: dict[str, str] = {}
|
||||||
self._last_error: str | None = None
|
self._last_error: dict[str, str | None] = {}
|
||||||
self._video_last_error: str | None = None
|
self._video_last_error: dict[str, str | None] = {}
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
def _load_model(self) -> None:
|
def variant_ids(self) -> list[str]:
|
||||||
|
return list(SAM2_VARIANTS.keys())
|
||||||
|
|
||||||
|
def normalize_model_id(self, model_id: str | None) -> str:
|
||||||
|
selected = (model_id or settings.sam_default_model or DEFAULT_SAM2_MODEL_ID).lower()
|
||||||
|
selected = SAM2_MODEL_ALIASES.get(selected, selected)
|
||||||
|
if selected not in SAM2_VARIANTS:
|
||||||
|
raise ValueError(f"Unsupported SAM2 model: {model_id}")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def is_sam2_model(self, model_id: str | None) -> bool:
|
||||||
|
try:
|
||||||
|
self.normalize_model_id(model_id)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _models_dir(self) -> Path:
|
||||||
|
configured_path = Path(settings.sam_model_path)
|
||||||
|
return configured_path.parent if configured_path.parent else Path("models")
|
||||||
|
|
||||||
|
def _variant(self, model_id: str | None) -> SAM2Variant:
|
||||||
|
return SAM2_VARIANTS[self.normalize_model_id(model_id)]
|
||||||
|
|
||||||
|
def _checkpoint_config(self, model_id: str | None) -> tuple[str, str]:
|
||||||
|
variant_id = self.normalize_model_id(model_id)
|
||||||
|
variant = SAM2_VARIANTS[variant_id]
|
||||||
|
models_dir = self._models_dir()
|
||||||
|
candidates: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
configured_path = Path(settings.sam_model_path)
|
||||||
|
if variant_id == DEFAULT_SAM2_MODEL_ID and configured_path.is_file():
|
||||||
|
candidates.append((settings.sam_model_config, str(configured_path)))
|
||||||
|
|
||||||
|
candidates.extend([
|
||||||
|
(variant.config, str(models_dir / variant.checkpoint_filename)),
|
||||||
|
(variant.legacy_config, str(models_dir / variant.legacy_checkpoint_filename)),
|
||||||
|
])
|
||||||
|
|
||||||
|
for config, checkpoint_path in candidates:
|
||||||
|
if os.path.isfile(checkpoint_path):
|
||||||
|
return config, checkpoint_path
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
def _load_model(self, model_id: str | None = None) -> None:
|
||||||
"""Load the SAM 2 model and predictor on first use."""
|
"""Load the SAM 2 model and predictor on first use."""
|
||||||
if self._model_loaded:
|
variant_id = self.normalize_model_id(model_id)
|
||||||
|
if self._model_loaded.get(variant_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
if not TORCH_AVAILABLE:
|
if not TORCH_AVAILABLE:
|
||||||
self._last_error = "PyTorch is not installed."
|
self._last_error[variant_id] = "PyTorch is not installed."
|
||||||
logger.warning("PyTorch not available; skipping SAM2 model load.")
|
logger.warning("PyTorch not available; skipping SAM2 model load.")
|
||||||
self._model_loaded = True
|
self._model_loaded[variant_id] = True
|
||||||
return
|
return
|
||||||
|
|
||||||
if not SAM2_AVAILABLE:
|
if not SAM2_AVAILABLE:
|
||||||
self._last_error = "sam2 package is not installed."
|
self._last_error[variant_id] = "sam2 package is not installed."
|
||||||
logger.warning("SAM2 not available; skipping model load.")
|
logger.warning("SAM2 not available; skipping model load.")
|
||||||
self._model_loaded = True
|
self._model_loaded[variant_id] = True
|
||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.isfile(settings.sam_model_path):
|
config, checkpoint_path = self._checkpoint_config(variant_id)
|
||||||
self._last_error = f"SAM2 checkpoint not found: {settings.sam_model_path}"
|
if not os.path.isfile(checkpoint_path):
|
||||||
logger.error("SAM checkpoint not found at %s", settings.sam_model_path)
|
self._last_error[variant_id] = f"SAM2 checkpoint not found: {checkpoint_path}"
|
||||||
self._model_loaded = True
|
logger.error("SAM checkpoint not found at %s", checkpoint_path)
|
||||||
|
self._model_loaded[variant_id] = True
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
device = self._best_device()
|
device = self._best_device()
|
||||||
model = build_sam2(
|
model = build_sam2(
|
||||||
settings.sam_model_config,
|
config,
|
||||||
settings.sam_model_path,
|
checkpoint_path,
|
||||||
device=device,
|
device=device,
|
||||||
)
|
)
|
||||||
self._predictor = SAM2ImagePredictor(model)
|
self._predictors[variant_id] = SAM2ImagePredictor(model)
|
||||||
self._model_loaded = True
|
self._model_loaded[variant_id] = True
|
||||||
self._loaded_device = device
|
self._loaded_device[variant_id] = device
|
||||||
self._last_error = None
|
self._last_error[variant_id] = None
|
||||||
logger.info("SAM 2 model loaded from %s on %s", settings.sam_model_path, device)
|
logger.info("SAM 2 model %s loaded from %s on %s", variant_id, checkpoint_path, device)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self._last_error = str(exc)
|
self._last_error[variant_id] = str(exc)
|
||||||
logger.error("Failed to load SAM 2 model: %s", exc)
|
logger.error("Failed to load SAM 2 model %s: %s", variant_id, exc)
|
||||||
self._model_loaded = True # Prevent repeated load attempts
|
self._model_loaded[variant_id] = True # Prevent repeated load attempts
|
||||||
|
|
||||||
def _load_video_model(self) -> None:
|
def _load_video_model(self, model_id: str | None = None) -> None:
|
||||||
"""Load the SAM 2 video predictor on first propagation use."""
|
"""Load the SAM 2 video predictor on first propagation use."""
|
||||||
if self._video_model_loaded:
|
variant_id = self.normalize_model_id(model_id)
|
||||||
|
if self._video_model_loaded.get(variant_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
if not TORCH_AVAILABLE:
|
if not TORCH_AVAILABLE:
|
||||||
self._video_last_error = "PyTorch is not installed."
|
self._video_last_error[variant_id] = "PyTorch is not installed."
|
||||||
self._video_model_loaded = True
|
self._video_model_loaded[variant_id] = True
|
||||||
return
|
return
|
||||||
if not SAM2_AVAILABLE:
|
if not SAM2_AVAILABLE:
|
||||||
self._video_last_error = "sam2 package is not installed."
|
self._video_last_error[variant_id] = "sam2 package is not installed."
|
||||||
self._video_model_loaded = True
|
self._video_model_loaded[variant_id] = True
|
||||||
return
|
return
|
||||||
if not os.path.isfile(settings.sam_model_path):
|
|
||||||
self._video_last_error = f"SAM2 checkpoint not found: {settings.sam_model_path}"
|
config, checkpoint_path = self._checkpoint_config(variant_id)
|
||||||
self._video_model_loaded = True
|
if not os.path.isfile(checkpoint_path):
|
||||||
|
self._video_last_error[variant_id] = f"SAM2 checkpoint not found: {checkpoint_path}"
|
||||||
|
self._video_model_loaded[variant_id] = True
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
device = self._best_device()
|
device = self._best_device()
|
||||||
self._video_predictor = build_sam2_video_predictor(
|
self._video_predictors[variant_id] = build_sam2_video_predictor(
|
||||||
settings.sam_model_config,
|
config,
|
||||||
settings.sam_model_path,
|
checkpoint_path,
|
||||||
device=device,
|
device=device,
|
||||||
)
|
)
|
||||||
self._video_model_loaded = True
|
self._video_model_loaded[variant_id] = True
|
||||||
self._loaded_device = device
|
self._loaded_device[variant_id] = device
|
||||||
self._video_last_error = None
|
self._video_last_error[variant_id] = None
|
||||||
logger.info("SAM 2 video predictor loaded from %s on %s", settings.sam_model_path, device)
|
logger.info("SAM 2 video predictor %s loaded from %s on %s", variant_id, checkpoint_path, device)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self._video_last_error = str(exc)
|
self._video_last_error[variant_id] = str(exc)
|
||||||
self._video_model_loaded = True
|
self._video_model_loaded[variant_id] = True
|
||||||
logger.error("Failed to load SAM 2 video predictor: %s", exc)
|
logger.error("Failed to load SAM 2 video predictor %s: %s", variant_id, exc)
|
||||||
|
|
||||||
def _best_device(self) -> str:
|
def _best_device(self) -> str:
|
||||||
if TORCH_AVAILABLE and torch is not None and torch.cuda.is_available():
|
if TORCH_AVAILABLE and torch is not None and torch.cuda.is_available():
|
||||||
return "cuda"
|
return "cuda"
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
def _ensure_ready(self) -> bool:
|
def _ensure_ready(self, model_id: str | None = None) -> bool:
|
||||||
"""Ensure the model is loaded; return whether it is usable."""
|
"""Ensure the model is loaded; return whether it is usable."""
|
||||||
self._load_model()
|
variant_id = self.normalize_model_id(model_id)
|
||||||
return SAM2_AVAILABLE and self._predictor is not None
|
self._load_model(variant_id)
|
||||||
|
return SAM2_AVAILABLE and self._predictors.get(variant_id) is not None
|
||||||
|
|
||||||
def _ensure_video_ready(self) -> bool:
|
def _ensure_video_ready(self, model_id: str | None = None) -> bool:
|
||||||
"""Ensure the video predictor is loaded; return whether it is usable."""
|
"""Ensure the video predictor is loaded; return whether it is usable."""
|
||||||
self._load_video_model()
|
variant_id = self.normalize_model_id(model_id)
|
||||||
return SAM2_AVAILABLE and self._video_predictor is not None
|
self._load_video_model(variant_id)
|
||||||
|
return SAM2_AVAILABLE and self._video_predictors.get(variant_id) is not None
|
||||||
|
|
||||||
def status(self) -> dict:
|
def status(self, model_id: str | None = None) -> dict:
|
||||||
"""Return lightweight, real runtime status without forcing model load."""
|
"""Return lightweight, real runtime status without forcing model load."""
|
||||||
checkpoint_exists = os.path.isfile(settings.sam_model_path)
|
variant_id = self.normalize_model_id(model_id)
|
||||||
device = self._loaded_device or self._best_device()
|
variant = SAM2_VARIANTS[variant_id]
|
||||||
|
_, checkpoint_path = self._checkpoint_config(variant_id)
|
||||||
|
checkpoint_exists = os.path.isfile(checkpoint_path)
|
||||||
|
using_legacy_checkpoint = Path(checkpoint_path).name == variant.legacy_checkpoint_filename
|
||||||
|
predictor = self._predictors.get(variant_id)
|
||||||
|
device = self._loaded_device.get(variant_id) or self._best_device()
|
||||||
available = bool(TORCH_AVAILABLE and SAM2_AVAILABLE and checkpoint_exists)
|
available = bool(TORCH_AVAILABLE and SAM2_AVAILABLE and checkpoint_exists)
|
||||||
if self._predictor is not None:
|
if predictor is not None:
|
||||||
message = "SAM 2 model loaded and ready."
|
message = f"{variant.label} model loaded and ready."
|
||||||
elif available:
|
elif available:
|
||||||
message = "SAM 2 dependencies and checkpoint are present; model will load on first inference."
|
message = f"{variant.label} dependencies and checkpoint are present; model will load on first inference."
|
||||||
|
if using_legacy_checkpoint:
|
||||||
|
message += " Using legacy SAM 2 checkpoint fallback."
|
||||||
else:
|
else:
|
||||||
missing = []
|
missing = []
|
||||||
if not TORCH_AVAILABLE:
|
if not TORCH_AVAILABLE:
|
||||||
@@ -155,20 +276,21 @@ class SAM2Engine:
|
|||||||
missing.append("sam2 package")
|
missing.append("sam2 package")
|
||||||
if not checkpoint_exists:
|
if not checkpoint_exists:
|
||||||
missing.append("checkpoint")
|
missing.append("checkpoint")
|
||||||
message = f"SAM 2 unavailable: missing {', '.join(missing)}."
|
message = f"{variant.label} unavailable: missing {', '.join(missing)}."
|
||||||
if self._last_error and not self._predictor:
|
last_error = self._last_error.get(variant_id)
|
||||||
message = self._last_error
|
if last_error and not predictor:
|
||||||
|
message = last_error
|
||||||
return {
|
return {
|
||||||
"id": "sam2",
|
"id": variant.id,
|
||||||
"label": "SAM 2",
|
"label": variant.label,
|
||||||
"available": available,
|
"available": available,
|
||||||
"loaded": self._predictor is not None,
|
"loaded": predictor is not None,
|
||||||
"device": device,
|
"device": device,
|
||||||
"supports": ["point", "box", "interactive", "auto", "propagate"],
|
"supports": ["point", "box", "interactive", "auto", "propagate"],
|
||||||
"message": message,
|
"message": message,
|
||||||
"package_available": SAM2_AVAILABLE,
|
"package_available": SAM2_AVAILABLE,
|
||||||
"checkpoint_exists": checkpoint_exists,
|
"checkpoint_exists": checkpoint_exists,
|
||||||
"checkpoint_path": settings.sam_model_path,
|
"checkpoint_path": checkpoint_path,
|
||||||
"python_ok": True,
|
"python_ok": True,
|
||||||
"torch_ok": TORCH_AVAILABLE,
|
"torch_ok": TORCH_AVAILABLE,
|
||||||
"cuda_required": False,
|
"cuda_required": False,
|
||||||
@@ -179,6 +301,7 @@ class SAM2Engine:
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
def predict_points(
|
def predict_points(
|
||||||
self,
|
self,
|
||||||
|
model_id: str | None,
|
||||||
image: np.ndarray,
|
image: np.ndarray,
|
||||||
points: list[list[float]],
|
points: list[list[float]],
|
||||||
labels: list[int],
|
labels: list[int],
|
||||||
@@ -193,18 +316,20 @@ class SAM2Engine:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (polygons, scores).
|
Tuple of (polygons, scores).
|
||||||
"""
|
"""
|
||||||
if not self._ensure_ready():
|
variant_id = self.normalize_model_id(model_id)
|
||||||
|
if not self._ensure_ready(variant_id):
|
||||||
logger.warning("SAM2 not ready; returning dummy masks.")
|
logger.warning("SAM2 not ready; returning dummy masks.")
|
||||||
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
predictor = self._predictors[variant_id]
|
||||||
h, w = image.shape[:2]
|
h, w = image.shape[:2]
|
||||||
pts = np.array([[p[0] * w, p[1] * h] for p in points], dtype=np.float32)
|
pts = np.array([[p[0] * w, p[1] * h] for p in points], dtype=np.float32)
|
||||||
lbls = np.array(labels, dtype=np.int32)
|
lbls = np.array(labels, dtype=np.int32)
|
||||||
|
|
||||||
with torch.inference_mode(): # type: ignore[name-defined]
|
with torch.inference_mode(): # type: ignore[name-defined]
|
||||||
self._predictor.set_image(image)
|
predictor.set_image(image)
|
||||||
masks, scores, _ = self._predictor.predict(
|
masks, scores, _ = predictor.predict(
|
||||||
point_coords=pts,
|
point_coords=pts,
|
||||||
point_labels=lbls,
|
point_labels=lbls,
|
||||||
multimask_output=False,
|
multimask_output=False,
|
||||||
@@ -223,6 +348,7 @@ class SAM2Engine:
|
|||||||
|
|
||||||
def predict_box(
|
def predict_box(
|
||||||
self,
|
self,
|
||||||
|
model_id: str | None,
|
||||||
image: np.ndarray,
|
image: np.ndarray,
|
||||||
box: list[float],
|
box: list[float],
|
||||||
) -> tuple[list[list[list[float]]], list[float]]:
|
) -> tuple[list[list[list[float]]], list[float]]:
|
||||||
@@ -235,11 +361,13 @@ class SAM2Engine:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (polygons, scores).
|
Tuple of (polygons, scores).
|
||||||
"""
|
"""
|
||||||
if not self._ensure_ready():
|
variant_id = self.normalize_model_id(model_id)
|
||||||
|
if not self._ensure_ready(variant_id):
|
||||||
logger.warning("SAM2 not ready; returning dummy masks.")
|
logger.warning("SAM2 not ready; returning dummy masks.")
|
||||||
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
predictor = self._predictors[variant_id]
|
||||||
h, w = image.shape[:2]
|
h, w = image.shape[:2]
|
||||||
bbox = np.array(
|
bbox = np.array(
|
||||||
[box[0] * w, box[1] * h, box[2] * w, box[3] * h],
|
[box[0] * w, box[1] * h, box[2] * w, box[3] * h],
|
||||||
@@ -247,8 +375,8 @@ class SAM2Engine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with torch.inference_mode(): # type: ignore[name-defined]
|
with torch.inference_mode(): # type: ignore[name-defined]
|
||||||
self._predictor.set_image(image)
|
predictor.set_image(image)
|
||||||
masks, scores, _ = self._predictor.predict(
|
masks, scores, _ = predictor.predict(
|
||||||
box=bbox[None, :],
|
box=bbox[None, :],
|
||||||
multimask_output=False,
|
multimask_output=False,
|
||||||
)
|
)
|
||||||
@@ -266,17 +394,20 @@ class SAM2Engine:
|
|||||||
|
|
||||||
def predict_interactive(
|
def predict_interactive(
|
||||||
self,
|
self,
|
||||||
|
model_id: str | None,
|
||||||
image: np.ndarray,
|
image: np.ndarray,
|
||||||
box: list[float] | None,
|
box: list[float] | None,
|
||||||
points: list[list[float]],
|
points: list[list[float]],
|
||||||
labels: list[int],
|
labels: list[int],
|
||||||
) -> tuple[list[list[list[float]]], list[float]]:
|
) -> tuple[list[list[list[float]]], list[float]]:
|
||||||
"""Run combined box and point prompt segmentation for refinement."""
|
"""Run combined box and point prompt segmentation for refinement."""
|
||||||
if not self._ensure_ready():
|
variant_id = self.normalize_model_id(model_id)
|
||||||
|
if not self._ensure_ready(variant_id):
|
||||||
logger.warning("SAM2 not ready; returning dummy masks.")
|
logger.warning("SAM2 not ready; returning dummy masks.")
|
||||||
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
predictor = self._predictors[variant_id]
|
||||||
h, w = image.shape[:2]
|
h, w = image.shape[:2]
|
||||||
bbox = None
|
bbox = None
|
||||||
if box:
|
if box:
|
||||||
@@ -291,8 +422,8 @@ class SAM2Engine:
|
|||||||
lbls = np.array(labels, dtype=np.int32)
|
lbls = np.array(labels, dtype=np.int32)
|
||||||
|
|
||||||
with torch.inference_mode(): # type: ignore[name-defined]
|
with torch.inference_mode(): # type: ignore[name-defined]
|
||||||
self._predictor.set_image(image)
|
predictor.set_image(image)
|
||||||
masks, scores, _ = self._predictor.predict(
|
masks, scores, _ = predictor.predict(
|
||||||
point_coords=pts,
|
point_coords=pts,
|
||||||
point_labels=lbls,
|
point_labels=lbls,
|
||||||
box=bbox,
|
box=bbox,
|
||||||
@@ -310,7 +441,7 @@ class SAM2Engine:
|
|||||||
logger.error("SAM2 interactive prediction failed: %s", exc)
|
logger.error("SAM2 interactive prediction failed: %s", exc)
|
||||||
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
||||||
|
|
||||||
def predict_auto(self, image: np.ndarray) -> tuple[list[list[list[float]]], list[float]]:
|
def predict_auto(self, model_id: str | None, image: np.ndarray) -> tuple[list[list[list[float]]], list[float]]:
|
||||||
"""Run automatic mask generation (grid of points).
|
"""Run automatic mask generation (grid of points).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -319,20 +450,22 @@ class SAM2Engine:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (polygons, scores).
|
Tuple of (polygons, scores).
|
||||||
"""
|
"""
|
||||||
if not self._ensure_ready():
|
variant_id = self.normalize_model_id(model_id)
|
||||||
|
if not self._ensure_ready(variant_id):
|
||||||
logger.warning("SAM2 not ready; returning dummy masks.")
|
logger.warning("SAM2 not ready; returning dummy masks.")
|
||||||
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
predictor = self._predictors[variant_id]
|
||||||
with torch.inference_mode(): # type: ignore[name-defined]
|
with torch.inference_mode(): # type: ignore[name-defined]
|
||||||
self._predictor.set_image(image)
|
predictor.set_image(image)
|
||||||
# Generate a uniform 16x16 grid of point prompts
|
# Generate a uniform 16x16 grid of point prompts
|
||||||
h, w = image.shape[:2]
|
h, w = image.shape[:2]
|
||||||
grid = np.mgrid[0:1:17j, 0:1:17j].reshape(2, -1).T
|
grid = np.mgrid[0:1:17j, 0:1:17j].reshape(2, -1).T
|
||||||
pts = grid * np.array([w, h])
|
pts = grid * np.array([w, h])
|
||||||
lbls = np.ones(pts.shape[0], dtype=np.int32)
|
lbls = np.ones(pts.shape[0], dtype=np.int32)
|
||||||
|
|
||||||
masks, scores, _ = self._predictor.predict(
|
masks, scores, _ = predictor.predict(
|
||||||
point_coords=pts,
|
point_coords=pts,
|
||||||
point_labels=lbls,
|
point_labels=lbls,
|
||||||
multimask_output=False,
|
multimask_output=False,
|
||||||
@@ -351,6 +484,7 @@ class SAM2Engine:
|
|||||||
|
|
||||||
def propagate_video(
|
def propagate_video(
|
||||||
self,
|
self,
|
||||||
|
model_id: str | None,
|
||||||
frame_paths: list[str],
|
frame_paths: list[str],
|
||||||
source_frame_index: int,
|
source_frame_index: int,
|
||||||
seed: dict,
|
seed: dict,
|
||||||
@@ -358,8 +492,10 @@ class SAM2Engine:
|
|||||||
max_frames: int | None = None,
|
max_frames: int | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Propagate one seed mask across a prepared frame directory with SAM 2 video."""
|
"""Propagate one seed mask across a prepared frame directory with SAM 2 video."""
|
||||||
if not self._ensure_video_ready():
|
variant_id = self.normalize_model_id(model_id)
|
||||||
raise RuntimeError(self._video_last_error or self.status()["message"])
|
if not self._ensure_video_ready(variant_id):
|
||||||
|
raise RuntimeError(self._video_last_error.get(variant_id) or self.status(variant_id)["message"])
|
||||||
|
video_predictor = self._video_predictors[variant_id]
|
||||||
if not frame_paths:
|
if not frame_paths:
|
||||||
return []
|
return []
|
||||||
if source_frame_index < 0 or source_frame_index >= len(frame_paths):
|
if source_frame_index < 0 or source_frame_index >= len(frame_paths):
|
||||||
@@ -371,7 +507,7 @@ class SAM2Engine:
|
|||||||
if source_image is None:
|
if source_image is None:
|
||||||
raise RuntimeError("Failed to decode source frame for SAM 2 propagation.")
|
raise RuntimeError("Failed to decode source frame for SAM 2 propagation.")
|
||||||
height, width = source_image.shape[:2]
|
height, width = source_image.shape[:2]
|
||||||
seed_mask = self._polygons_to_mask(seed.get("polygons") or [], width, height)
|
seed_mask = self._polygons_to_mask(seed.get("polygons") or [], width, height, seed.get("holes") or [])
|
||||||
if not seed_mask.any():
|
if not seed_mask.any():
|
||||||
bbox = seed.get("bbox")
|
bbox = seed.get("bbox")
|
||||||
if isinstance(bbox, list) and len(bbox) == 4:
|
if isinstance(bbox, list) and len(bbox) == 4:
|
||||||
@@ -379,12 +515,12 @@ class SAM2Engine:
|
|||||||
if not seed_mask.any():
|
if not seed_mask.any():
|
||||||
raise ValueError("SAM 2 propagation requires a non-empty seed polygon or bbox.")
|
raise ValueError("SAM 2 propagation requires a non-empty seed polygon or bbox.")
|
||||||
|
|
||||||
inference_state = self._video_predictor.init_state(
|
inference_state = video_predictor.init_state(
|
||||||
video_path=os.path.dirname(frame_paths[0]),
|
video_path=os.path.dirname(frame_paths[0]),
|
||||||
offload_video_to_cpu=True,
|
offload_video_to_cpu=True,
|
||||||
offload_state_to_cpu=True,
|
offload_state_to_cpu=True,
|
||||||
)
|
)
|
||||||
self._video_predictor.add_new_mask(
|
video_predictor.add_new_mask(
|
||||||
inference_state,
|
inference_state,
|
||||||
frame_idx=source_frame_index,
|
frame_idx=source_frame_index,
|
||||||
obj_id=1,
|
obj_id=1,
|
||||||
@@ -394,7 +530,7 @@ class SAM2Engine:
|
|||||||
results: dict[int, dict] = {}
|
results: dict[int, dict] = {}
|
||||||
|
|
||||||
def collect(reverse: bool) -> None:
|
def collect(reverse: bool) -> None:
|
||||||
for out_frame_idx, out_obj_ids, out_mask_logits in self._video_predictor.propagate_in_video(
|
for out_frame_idx, out_obj_ids, out_mask_logits in video_predictor.propagate_in_video(
|
||||||
inference_state,
|
inference_state,
|
||||||
start_frame_idx=source_frame_index,
|
start_frame_idx=source_frame_index,
|
||||||
max_frame_num_to_track=max_frames,
|
max_frame_num_to_track=max_frames,
|
||||||
@@ -407,15 +543,18 @@ class SAM2Engine:
|
|||||||
if masks.ndim == 4:
|
if masks.ndim == 4:
|
||||||
masks = masks[:, 0]
|
masks = masks[:, 0]
|
||||||
polygons = []
|
polygons = []
|
||||||
|
holes = []
|
||||||
scores = []
|
scores = []
|
||||||
for mask in masks:
|
for mask in masks:
|
||||||
polygon = self._mask_to_polygon(mask > 0)
|
mask_polygons, mask_holes = self._mask_to_polygon_data(mask > 0)
|
||||||
if polygon:
|
for polygon_index, polygon in enumerate(mask_polygons):
|
||||||
polygons.append(polygon)
|
polygons.append(polygon)
|
||||||
|
holes.append(mask_holes[polygon_index] if polygon_index < len(mask_holes) else [])
|
||||||
scores.append(1.0)
|
scores.append(1.0)
|
||||||
results[int(out_frame_idx)] = {
|
results[int(out_frame_idx)] = {
|
||||||
"frame_index": int(out_frame_idx),
|
"frame_index": int(out_frame_idx),
|
||||||
"polygons": polygons,
|
"polygons": polygons,
|
||||||
|
"holes": holes,
|
||||||
"scores": scores,
|
"scores": scores,
|
||||||
"object_ids": [int(obj_id) for obj_id in list(out_obj_ids)],
|
"object_ids": [int(obj_id) for obj_id in list(out_obj_ids)],
|
||||||
}
|
}
|
||||||
@@ -427,7 +566,7 @@ class SAM2Engine:
|
|||||||
collect(reverse=True)
|
collect(reverse=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._video_predictor.reset_state(inference_state)
|
video_predictor.reset_state(inference_state)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
return [results[index] for index in sorted(results)]
|
return [results[index] for index in sorted(results)]
|
||||||
@@ -438,19 +577,49 @@ class SAM2Engine:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _mask_to_polygon(mask: np.ndarray) -> list[list[float]]:
|
def _mask_to_polygon(mask: np.ndarray) -> list[list[float]]:
|
||||||
"""Convert a binary mask to a normalized polygon."""
|
"""Convert a binary mask to a normalized polygon."""
|
||||||
|
polygons, _holes = SAM2Engine._mask_to_polygon_data(mask)
|
||||||
|
return polygons[0] if polygons else []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mask_to_polygon_data(mask: np.ndarray) -> tuple[list[list[list[float]]], list[list[list[list[float]]]]]:
|
||||||
|
"""Convert a binary mask to normalized outer polygons and aligned hole rings."""
|
||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
if mask.dtype != np.uint8:
|
if mask.dtype != np.uint8:
|
||||||
mask = (mask > 0).astype(np.uint8)
|
mask = (mask > 0).astype(np.uint8)
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
contours, hierarchy = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
h, w = mask.shape[:2]
|
h, w = mask.shape[:2]
|
||||||
largest = []
|
if hierarchy is None:
|
||||||
for cnt in contours:
|
return [], []
|
||||||
if len(cnt) > len(largest):
|
|
||||||
largest = cnt
|
def contour_to_polygon(contour: np.ndarray) -> list[list[float]]:
|
||||||
if len(largest) < 3:
|
if len(contour) < 3:
|
||||||
return []
|
return []
|
||||||
return [[float(pt[0][0]) / w, float(pt[0][1]) / h] for pt in largest]
|
return [[float(pt[0][0]) / w, float(pt[0][1]) / h] for pt in contour]
|
||||||
|
|
||||||
|
hierarchy_rows = hierarchy[0]
|
||||||
|
outer_indices = [
|
||||||
|
index for index, row in enumerate(hierarchy_rows)
|
||||||
|
if int(row[3]) < 0 and len(contours[index]) >= 3
|
||||||
|
]
|
||||||
|
outer_indices.sort(key=lambda index: cv2.contourArea(contours[index]), reverse=True)
|
||||||
|
|
||||||
|
polygons: list[list[list[float]]] = []
|
||||||
|
holes: list[list[list[list[float]]]] = []
|
||||||
|
for outer_index in outer_indices:
|
||||||
|
outer = contour_to_polygon(contours[outer_index])
|
||||||
|
if not outer:
|
||||||
|
continue
|
||||||
|
child_index = int(hierarchy_rows[outer_index][2])
|
||||||
|
hole_group: list[list[list[float]]] = []
|
||||||
|
while child_index >= 0:
|
||||||
|
hole = contour_to_polygon(contours[child_index])
|
||||||
|
if hole:
|
||||||
|
hole_group.append(hole)
|
||||||
|
child_index = int(hierarchy_rows[child_index][0])
|
||||||
|
polygons.append(outer)
|
||||||
|
holes.append(hole_group)
|
||||||
|
return polygons, holes
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _dummy_polygons(w: int, h: int) -> list[list[list[float]]]:
|
def _dummy_polygons(w: int, h: int) -> list[list[list[float]]]:
|
||||||
@@ -465,11 +634,16 @@ class SAM2Engine:
|
|||||||
]
|
]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _polygons_to_mask(polygons: list[list[list[float]]], width: int, height: int) -> np.ndarray:
|
def _polygons_to_mask(
|
||||||
|
polygons: list[list[list[float]]],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
holes_by_polygon: list[list[list[list[float]]]] | None = None,
|
||||||
|
) -> np.ndarray:
|
||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
mask = np.zeros((height, width), dtype=np.uint8)
|
mask = np.zeros((height, width), dtype=np.uint8)
|
||||||
for polygon in polygons:
|
for polygon_index, polygon in enumerate(polygons):
|
||||||
if len(polygon) < 3:
|
if len(polygon) < 3:
|
||||||
continue
|
continue
|
||||||
pts = np.array(
|
pts = np.array(
|
||||||
@@ -483,6 +657,21 @@ class SAM2Engine:
|
|||||||
dtype=np.int32,
|
dtype=np.int32,
|
||||||
)
|
)
|
||||||
cv2.fillPoly(mask, [pts], 1)
|
cv2.fillPoly(mask, [pts], 1)
|
||||||
|
holes = holes_by_polygon[polygon_index] if holes_by_polygon and polygon_index < len(holes_by_polygon) else []
|
||||||
|
for hole in holes:
|
||||||
|
if len(hole) < 3:
|
||||||
|
continue
|
||||||
|
hole_pts = np.array(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
int(round(min(max(float(x), 0.0), 1.0) * max(width - 1, 1))),
|
||||||
|
int(round(min(max(float(y), 0.0), 1.0) * max(height - 1, 1))),
|
||||||
|
]
|
||||||
|
for x, y in hole
|
||||||
|
],
|
||||||
|
dtype=np.int32,
|
||||||
|
)
|
||||||
|
cv2.fillPoly(mask, [hole_pts], 0)
|
||||||
return mask.astype(bool)
|
return mask.astype(bool)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from services.sam2_engine import TORCH_AVAILABLE, sam_engine as sam2_engine
|
from services.sam2_engine import DEFAULT_SAM2_MODEL_ID, TORCH_AVAILABLE, sam_engine as sam2_engine
|
||||||
from services.sam3_engine import sam3_engine
|
|
||||||
|
# SAM 3 integration is intentionally disabled for the current product flow.
|
||||||
|
# The source files are kept in the repository so the integration can be
|
||||||
|
# restored later, but the active registry only exposes SAM 2.
|
||||||
|
# from services.sam3_engine import sam3_engine
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
@@ -24,20 +28,23 @@ class SAMRegistry:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._engines = {
|
self._engines = {
|
||||||
"sam2": sam2_engine,
|
"sam2": sam2_engine,
|
||||||
"sam3": sam3_engine,
|
# "sam3": sam3_engine,
|
||||||
}
|
}
|
||||||
|
|
||||||
def normalize_model_id(self, model_id: str | None) -> str:
|
def normalize_model_id(self, model_id: str | None) -> str:
|
||||||
selected = (model_id or settings.sam_default_model or "sam2").lower()
|
selected = (model_id or settings.sam_default_model or DEFAULT_SAM2_MODEL_ID).lower()
|
||||||
|
if self._engines["sam2"].is_sam2_model(selected):
|
||||||
|
return self._engines["sam2"].normalize_model_id(selected)
|
||||||
if selected not in self._engines:
|
if selected not in self._engines:
|
||||||
raise ValueError(f"Unsupported model: {model_id}")
|
raise ValueError(f"Unsupported model: {model_id}")
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
def runtime_status(self, selected_model: str | None = None) -> dict[str, Any]:
|
def runtime_status(self, selected_model: str | None = None) -> dict[str, Any]:
|
||||||
|
selected = self.normalize_model_id(selected_model)
|
||||||
return {
|
return {
|
||||||
"selected_model": self.normalize_model_id(selected_model),
|
"selected_model": selected,
|
||||||
"gpu": self.gpu_status(),
|
"gpu": self.gpu_status(),
|
||||||
"models": [engine.status() for engine in self._engines.values()],
|
"models": [sam2_engine.status(model_id) for model_id in sam2_engine.variant_ids()],
|
||||||
}
|
}
|
||||||
|
|
||||||
def gpu_status(self) -> dict[str, Any]:
|
def gpu_status(self) -> dict[str, Any]:
|
||||||
@@ -52,20 +59,26 @@ class SAMRegistry:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _engine(self, model_id: str | None) -> Any:
|
def _engine(self, model_id: str | None) -> Any:
|
||||||
return self._engines[self.normalize_model_id(model_id)]
|
normalized = self.normalize_model_id(model_id)
|
||||||
|
if self._engines["sam2"].is_sam2_model(normalized):
|
||||||
|
return self._engines["sam2"]
|
||||||
|
return self._engines[normalized]
|
||||||
|
|
||||||
def _ensure_available(self, model_id: str | None) -> Any:
|
def _ensure_available(self, model_id: str | None) -> Any:
|
||||||
|
normalized = self.normalize_model_id(model_id)
|
||||||
engine = self._engine(model_id)
|
engine = self._engine(model_id)
|
||||||
status = engine.status()
|
status = engine.status(normalized) if engine is sam2_engine else engine.status()
|
||||||
if not status["available"]:
|
if not status["available"]:
|
||||||
raise ModelUnavailableError(status["message"])
|
raise ModelUnavailableError(status["message"])
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
def predict_points(self, model_id: str | None, image: Any, points: list[list[float]], labels: list[int]):
|
def predict_points(self, model_id: str | None, image: Any, points: list[list[float]], labels: list[int]):
|
||||||
return self._ensure_available(model_id).predict_points(image, points, labels)
|
model = self.normalize_model_id(model_id)
|
||||||
|
return self._ensure_available(model).predict_points(model, image, points, labels)
|
||||||
|
|
||||||
def predict_box(self, model_id: str | None, image: Any, box: list[float]):
|
def predict_box(self, model_id: str | None, image: Any, box: list[float]):
|
||||||
return self._ensure_available(model_id).predict_box(image, box)
|
model = self.normalize_model_id(model_id)
|
||||||
|
return self._ensure_available(model).predict_box(model, image, box)
|
||||||
|
|
||||||
def predict_interactive(
|
def predict_interactive(
|
||||||
self,
|
self,
|
||||||
@@ -76,12 +89,13 @@ class SAMRegistry:
|
|||||||
labels: list[int],
|
labels: list[int],
|
||||||
):
|
):
|
||||||
model = self.normalize_model_id(model_id)
|
model = self.normalize_model_id(model_id)
|
||||||
if model != "sam2":
|
if not sam2_engine.is_sam2_model(model):
|
||||||
raise NotImplementedError("Interactive box + point refinement is currently supported by SAM 2.")
|
raise NotImplementedError("Interactive box + point refinement is currently supported by SAM 2.")
|
||||||
return self._ensure_available(model).predict_interactive(image, box, points, labels)
|
return self._ensure_available(model).predict_interactive(model, image, box, points, labels)
|
||||||
|
|
||||||
def predict_auto(self, model_id: str | None, image: Any):
|
def predict_auto(self, model_id: str | None, image: Any):
|
||||||
return self._ensure_available(model_id).predict_auto(image)
|
model = self.normalize_model_id(model_id)
|
||||||
|
return self._ensure_available(model).predict_auto(model, image)
|
||||||
|
|
||||||
def predict_semantic(
|
def predict_semantic(
|
||||||
self,
|
self,
|
||||||
@@ -90,14 +104,8 @@ class SAMRegistry:
|
|||||||
text: str,
|
text: str,
|
||||||
confidence_threshold: float | None = None,
|
confidence_threshold: float | None = None,
|
||||||
):
|
):
|
||||||
model = self.normalize_model_id(model_id)
|
self.normalize_model_id(model_id)
|
||||||
if model == "sam3":
|
raise NotImplementedError("Semantic text prompting is disabled; use SAM 2 point or box prompts.")
|
||||||
return self._ensure_available(model).predict_semantic(
|
|
||||||
image,
|
|
||||||
text,
|
|
||||||
confidence_threshold=confidence_threshold,
|
|
||||||
)
|
|
||||||
return self._ensure_available(model).predict_auto(image)
|
|
||||||
|
|
||||||
def propagate_video(
|
def propagate_video(
|
||||||
self,
|
self,
|
||||||
@@ -108,7 +116,9 @@ class SAMRegistry:
|
|||||||
direction: str,
|
direction: str,
|
||||||
max_frames: int | None,
|
max_frames: int | None,
|
||||||
):
|
):
|
||||||
return self._ensure_available(model_id).propagate_video(
|
model = self.normalize_model_id(model_id)
|
||||||
|
return self._ensure_available(model).propagate_video(
|
||||||
|
model,
|
||||||
frame_paths,
|
frame_paths,
|
||||||
source_frame_index,
|
source_frame_index,
|
||||||
seed,
|
seed,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ if str(BACKEND_DIR) not in sys.path:
|
|||||||
|
|
||||||
from database import Base, get_db # noqa: E402
|
from database import Base, get_db # noqa: E402
|
||||||
from main import websocket_progress # noqa: E402
|
from main import websocket_progress # noqa: E402
|
||||||
from routers import ai, auth, dashboard, export, media, projects, tasks, templates # noqa: E402
|
from routers import admin, ai, auth, dashboard, export, media, projects, tasks, templates # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -32,6 +32,7 @@ def db_session() -> Iterator[Session]:
|
|||||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session = TestingSessionLocal()
|
session = TestingSessionLocal()
|
||||||
|
auth.ensure_default_admin(session)
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
finally:
|
finally:
|
||||||
@@ -56,6 +57,7 @@ def app(db_session: Session) -> FastAPI:
|
|||||||
test_app.include_router(export.router)
|
test_app.include_router(export.router)
|
||||||
test_app.include_router(dashboard.router)
|
test_app.include_router(dashboard.router)
|
||||||
test_app.include_router(tasks.router)
|
test_app.include_router(tasks.router)
|
||||||
|
test_app.include_router(admin.router)
|
||||||
|
|
||||||
@test_app.get("/health")
|
@test_app.get("/health")
|
||||||
def health_check() -> dict[str, str]:
|
def health_check() -> dict[str, str]:
|
||||||
@@ -67,6 +69,10 @@ def app(db_session: Session) -> FastAPI:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client(app: FastAPI) -> Iterator[TestClient]:
|
def client(app: FastAPI, db_session: Session) -> Iterator[TestClient]:
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
|
admin = auth.ensure_default_admin(db_session)
|
||||||
|
test_client.headers.update({
|
||||||
|
"Authorization": f"Bearer {auth.create_access_token(admin)}"
|
||||||
|
})
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|||||||
268
backend/tests/test_admin.py
Normal file
268
backend/tests/test_admin.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User
|
||||||
|
from routers.auth import create_access_token, hash_password
|
||||||
|
from statuses import PROJECT_STATUS_READY
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_user_management_and_audit_logs(client, db_session):
|
||||||
|
created = client.post("/api/admin/users", json={
|
||||||
|
"username": "doctor",
|
||||||
|
"password": "secret123",
|
||||||
|
"role": "annotator",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
assert created.status_code == 201
|
||||||
|
user_id = created.json()["id"]
|
||||||
|
assert created.json()["role"] == "annotator"
|
||||||
|
|
||||||
|
updated = client.patch(f"/api/admin/users/{user_id}", json={
|
||||||
|
"password": "newsecret",
|
||||||
|
"is_active": False,
|
||||||
|
})
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["role"] == "annotator"
|
||||||
|
assert updated.json()["is_active"] == 0
|
||||||
|
|
||||||
|
users = client.get("/api/admin/users")
|
||||||
|
assert users.status_code == 200
|
||||||
|
assert any(user["username"] == "doctor" for user in users.json())
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/admin/users/{user_id}")
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
|
||||||
|
logs = client.get("/api/admin/audit-logs")
|
||||||
|
assert logs.status_code == 200
|
||||||
|
actions = [log["action"] for log in logs.json()]
|
||||||
|
assert "admin.user_created" in actions
|
||||||
|
assert "admin.user_updated" in actions
|
||||||
|
assert "admin.user_deleted" in actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_default_admin_role_is_supported(client, db_session):
|
||||||
|
extra_admin = client.post("/api/admin/users", json={
|
||||||
|
"username": "chief",
|
||||||
|
"password": "secret123",
|
||||||
|
"role": "admin",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
assert extra_admin.status_code == 400
|
||||||
|
|
||||||
|
viewer = client.post("/api/admin/users", json={
|
||||||
|
"username": "observer",
|
||||||
|
"password": "secret123",
|
||||||
|
"role": "viewer",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
assert viewer.status_code == 400
|
||||||
|
|
||||||
|
created = client.post("/api/admin/users", json={
|
||||||
|
"username": "doctor",
|
||||||
|
"password": "secret123",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
assert created.status_code == 201
|
||||||
|
user_id = created.json()["id"]
|
||||||
|
assert created.json()["role"] == "annotator"
|
||||||
|
assert client.patch(f"/api/admin/users/{user_id}", json={"role": "admin"}).status_code == 400
|
||||||
|
assert client.patch(f"/api/admin/users/{user_id}", json={"role": "viewer"}).status_code == 400
|
||||||
|
|
||||||
|
admin_id = client.get("/api/auth/me").json()["id"]
|
||||||
|
assert client.patch(f"/api/admin/users/{admin_id}", json={"role": "annotator"}).status_code == 400
|
||||||
|
assert client.patch(f"/api/admin/users/{admin_id}", json={"username": "chief"}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_routes_require_admin_role(client, db_session):
|
||||||
|
user = User(username="doctor", password_hash=hash_password("secret123"), role="annotator", is_active=1)
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(user)
|
||||||
|
original_auth = client.headers["Authorization"]
|
||||||
|
client.headers.update({"Authorization": f"Bearer {create_access_token(user)}"})
|
||||||
|
try:
|
||||||
|
response = client.get("/api/admin/users")
|
||||||
|
assert response.status_code == 403
|
||||||
|
finally:
|
||||||
|
client.headers.update({"Authorization": original_auth})
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_viewer_role_is_promoted_to_annotator(client, db_session):
|
||||||
|
project = client.post("/api/projects", json={"name": "Readonly Check"}).json()
|
||||||
|
user = User(username="readonly", password_hash=hash_password("secret123"), role="viewer", is_active=1)
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(user)
|
||||||
|
original_auth = client.headers["Authorization"]
|
||||||
|
client.headers.update({"Authorization": f"Bearer {create_access_token(user)}"})
|
||||||
|
try:
|
||||||
|
assert client.get("/api/projects").status_code == 200
|
||||||
|
assert client.post("/api/projects", json={"name": "Annotator Project"}).status_code == 201
|
||||||
|
assert client.patch(f"/api/projects/{project['id']}", json={"name": "Shared Edit"}).status_code == 200
|
||||||
|
assert client.get("/api/auth/me").json()["role"] == "annotator"
|
||||||
|
finally:
|
||||||
|
client.headers.update({"Authorization": original_auth})
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_delete_self_but_can_delete_project_author(client, db_session):
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert client.delete(f"/api/admin/users/{me['id']}").status_code == 400
|
||||||
|
|
||||||
|
user = User(username="owner", password_hash=hash_password("secret123"), role="annotator", is_active=1)
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(user)
|
||||||
|
db_session.add(Project(name="Owned", owner_user_id=user.id))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/admin/users/{user.id}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert db_session.query(Project).filter(Project.name == "Owned").count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_session, monkeypatch, tmp_path):
|
||||||
|
video_path = tmp_path / "演视LC视频序列.mp4"
|
||||||
|
video_path.write_bytes(b"demo-video")
|
||||||
|
monkeypatch.setattr("routers.admin.settings.demo_video_path", str(video_path))
|
||||||
|
dicom_dir = tmp_path / "dicom"
|
||||||
|
dicom_dir.mkdir()
|
||||||
|
for name in ["10.dcm", "2.dcm", "1.dcm"]:
|
||||||
|
(dicom_dir / name).write_bytes(name.encode())
|
||||||
|
monkeypatch.setattr("routers.admin.settings.demo_dicom_dir", str(dicom_dir))
|
||||||
|
|
||||||
|
parsed_frame_paths = []
|
||||||
|
for idx in range(3):
|
||||||
|
frame_path = tmp_path / f"frame_{idx:06d}.jpg"
|
||||||
|
frame_path.write_bytes(b"frame")
|
||||||
|
parsed_frame_paths.append(str(frame_path))
|
||||||
|
parsed_video_frame_paths = []
|
||||||
|
for idx in range(2):
|
||||||
|
frame_path = tmp_path / f"video_frame_{idx:06d}.jpg"
|
||||||
|
frame_path.write_bytes(b"video-frame")
|
||||||
|
parsed_video_frame_paths.append(str(frame_path))
|
||||||
|
|
||||||
|
uploaded = []
|
||||||
|
monkeypatch.setattr("services.demo_media.upload_file", lambda object_name, data, content_type, length: uploaded.append({
|
||||||
|
"object_name": object_name,
|
||||||
|
"data": data,
|
||||||
|
"content_type": content_type,
|
||||||
|
"length": length,
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.demo_media.parse_video",
|
||||||
|
lambda video_path_arg, output_dir, fps, target_width: (parsed_video_frame_paths, 30.0),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("services.demo_media.parse_dicom", lambda dicom_dir_arg, output_dir: parsed_frame_paths)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.demo_media.upload_frames_to_minio",
|
||||||
|
lambda frame_files, project_id: [f"projects/{project_id}/frames/frame_{idx:06d}.jpg" for idx, _ in enumerate(frame_files)],
|
||||||
|
)
|
||||||
|
|
||||||
|
extra_user = User(username="doctor", password_hash=hash_password("secret123"), role="annotator", is_active=1)
|
||||||
|
db_session.add(extra_user)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(extra_user)
|
||||||
|
old_project = Project(name="Old", owner_user_id=extra_user.id, video_path="uploads/old.mp4")
|
||||||
|
db_session.add(old_project)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(old_project)
|
||||||
|
frame = Frame(project_id=old_project.id, frame_index=0, image_url="frames/old.jpg")
|
||||||
|
db_session.add(frame)
|
||||||
|
task = ProcessingTask(task_type="parse_video", project_id=old_project.id)
|
||||||
|
private_template = Template(
|
||||||
|
name="Private",
|
||||||
|
description="private",
|
||||||
|
color="#fff",
|
||||||
|
z_index=1,
|
||||||
|
owner_user_id=extra_user.id,
|
||||||
|
)
|
||||||
|
system_template = Template(
|
||||||
|
name="头颈部CT分割",
|
||||||
|
description="头颈部CT分割",
|
||||||
|
color="#ef4444",
|
||||||
|
z_index=10,
|
||||||
|
owner_user_id=None,
|
||||||
|
mapping_rules={"classes": [{"name": "肿瘤/结节 (Tumor/Nodule)", "color": "#ff0000", "maskId": 1}], "rules": []},
|
||||||
|
)
|
||||||
|
db_session.add_all([task, private_template, system_template])
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(frame)
|
||||||
|
annotation = Annotation(project_id=old_project.id, frame_id=frame.id, mask_data={"label": "old"})
|
||||||
|
db_session.add(annotation)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(annotation)
|
||||||
|
db_session.add(Mask(annotation_id=annotation.id, mask_url="masks/old.png"))
|
||||||
|
db_session.add(AuditLog(actor_user_id=extra_user.id, action="old.audit"))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post("/api/admin/demo-factory-reset", json={"confirmation": "RESET_DEMO_FACTORY"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["message"] == "演示环境已恢复出厂设置"
|
||||||
|
assert data["admin_user"]["username"] == "admin"
|
||||||
|
assert data["project"]["name"] == "演视DICOM序列"
|
||||||
|
assert data["project"]["status"] == PROJECT_STATUS_READY
|
||||||
|
assert data["project"]["source_type"] == "dicom"
|
||||||
|
assert data["project"]["frame_count"] == 3
|
||||||
|
assert data["project"]["video_path"] == f"uploads/{data['project']['id']}/dicom"
|
||||||
|
assert [project["name"] for project in data["projects"]] == ["演视LC视频序列", "演视DICOM序列"]
|
||||||
|
assert data["projects"][0]["status"] == PROJECT_STATUS_READY
|
||||||
|
assert data["projects"][0]["source_type"] == "video"
|
||||||
|
assert data["projects"][0]["frame_count"] == 2
|
||||||
|
assert data["projects"][1]["status"] == PROJECT_STATUS_READY
|
||||||
|
assert data["projects"][1]["source_type"] == "dicom"
|
||||||
|
assert data["projects"][1]["frame_count"] == 3
|
||||||
|
assert [item["object_name"] for item in uploaded] == [
|
||||||
|
f"uploads/{data['projects'][0]['id']}/演视LC视频序列.mp4",
|
||||||
|
f"uploads/{data['project']['id']}/dicom/1.dcm",
|
||||||
|
f"uploads/{data['project']['id']}/dicom/2.dcm",
|
||||||
|
f"uploads/{data['project']['id']}/dicom/10.dcm",
|
||||||
|
]
|
||||||
|
assert [item["content_type"] for item in uploaded] == ["video/mp4", "application/dicom", "application/dicom", "application/dicom"]
|
||||||
|
|
||||||
|
assert [user.username for user in db_session.query(User).all()] == ["admin"]
|
||||||
|
assert db_session.query(Project).count() == 2
|
||||||
|
assert db_session.query(Frame).count() == 5
|
||||||
|
frames_by_project = {}
|
||||||
|
for project in db_session.query(Project).order_by(Project.id).all():
|
||||||
|
frames_by_project[project.name] = [
|
||||||
|
frame.source_frame_number
|
||||||
|
for frame in db_session.query(Frame)
|
||||||
|
.filter(Frame.project_id == project.id)
|
||||||
|
.order_by(Frame.frame_index)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
assert frames_by_project["演视LC视频序列"] == [0, 1]
|
||||||
|
assert frames_by_project["演视DICOM序列"] == [0, 1, 2]
|
||||||
|
assert db_session.query(Annotation).count() == 0
|
||||||
|
assert db_session.query(Mask).count() == 0
|
||||||
|
assert db_session.query(ProcessingTask).count() == 0
|
||||||
|
assert db_session.query(Template).filter(Template.owner_user_id.is_not(None)).count() == 0
|
||||||
|
preserved_templates = db_session.query(Template).filter(Template.owner_user_id.is_(None)).all()
|
||||||
|
templates_by_name = {template.name: template for template in preserved_templates}
|
||||||
|
assert set(templates_by_name) == {"腹腔镜胆囊切除术", "头颈部CT分割"}
|
||||||
|
head_neck_classes = templates_by_name["头颈部CT分割"].mapping_rules["classes"]
|
||||||
|
lap_classes = templates_by_name["腹腔镜胆囊切除术"].mapping_rules["classes"]
|
||||||
|
assert [item["name"] for item in head_neck_classes] == [
|
||||||
|
"肿瘤/结节",
|
||||||
|
"下颌骨",
|
||||||
|
"甲状腺",
|
||||||
|
"气管",
|
||||||
|
"颈椎",
|
||||||
|
"颈动脉",
|
||||||
|
"颈静脉",
|
||||||
|
"腮腺",
|
||||||
|
"下颌下腺",
|
||||||
|
"舌骨",
|
||||||
|
"待分类",
|
||||||
|
]
|
||||||
|
assert [item["maskId"] for item in head_neck_classes] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0]
|
||||||
|
assert len(lap_classes) == 36
|
||||||
|
assert lap_classes[-1]["name"] == "待分类"
|
||||||
|
assert lap_classes[-1]["maskId"] == 0
|
||||||
|
assert db_session.query(AuditLog).count() == 1
|
||||||
|
assert db_session.query(AuditLog).first().action == "admin.demo_factory_reset"
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_factory_reset_requires_exact_confirmation(client):
|
||||||
|
response = client.post("/api/admin/demo-factory-reset", json={"confirmation": "reset"})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,11 @@ def test_login_success(client):
|
|||||||
response = client.post("/api/auth/login", json={"username": "admin", "password": "123456"})
|
response = client.post("/api/auth/login", json={"username": "admin", "password": "123456"})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
body = response.json()
|
||||||
"token": "fake-jwt-token-for-admin",
|
assert body["token"]
|
||||||
"username": "admin",
|
assert body["token_type"] == "bearer"
|
||||||
}
|
assert body["username"] == "admin"
|
||||||
|
assert body["user"]["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
def test_login_rejects_invalid_credentials(client):
|
def test_login_rejects_invalid_credentials(client):
|
||||||
@@ -13,3 +14,53 @@ def test_login_rejects_invalid_credentials(client):
|
|||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert response.json()["detail"] == "Invalid credentials"
|
assert response.json()["detail"] == "Invalid credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_returns_current_user(client):
|
||||||
|
response = client.get("/api/auth/me")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_business_routes_require_auth(app):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
with TestClient(app) as unauthenticated:
|
||||||
|
response = unauthenticated.get("/api/projects")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_admin_seed_does_not_claim_legacy_shared_projects(db_session):
|
||||||
|
from models import Project
|
||||||
|
from routers.auth import ensure_default_admin
|
||||||
|
|
||||||
|
project = Project(name="Legacy Shared Project", owner_user_id=None)
|
||||||
|
db_session.add(project)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(project)
|
||||||
|
|
||||||
|
ensure_default_admin(db_session)
|
||||||
|
db_session.refresh(project)
|
||||||
|
|
||||||
|
assert project.owner_user_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_model_default_role_is_annotator(db_session):
|
||||||
|
from models import User
|
||||||
|
from routers.auth import hash_password
|
||||||
|
|
||||||
|
user = User(username="script-created", password_hash=hash_password("secret123"))
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(user)
|
||||||
|
|
||||||
|
assert user.role == "annotator"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_runtime_defaults_match_current_product():
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
assert settings.sam_default_model == "sam2.1_hiera_tiny"
|
||||||
|
assert settings.sam3_external_enabled is False
|
||||||
|
|||||||
@@ -69,3 +69,72 @@ def test_dashboard_overview_uses_persisted_records(client, db_session):
|
|||||||
assert any(item["kind"] == "annotation" for item in body["activity"])
|
assert any(item["kind"] == "annotation" for item in body["activity"])
|
||||||
assert any(item["kind"] == "template" for item in body["activity"])
|
assert any(item["kind"] == "template" for item in body["activity"])
|
||||||
assert all(item["name"] != "Ready Project" for item in body["tasks"])
|
assert all(item["name"] != "Ready Project" for item in body["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_overview_keeps_recent_success_tasks_in_progress_list(client, db_session):
|
||||||
|
from models import ProcessingTask
|
||||||
|
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "Completed Project",
|
||||||
|
"status": "ready",
|
||||||
|
}).json()
|
||||||
|
task = ProcessingTask(
|
||||||
|
task_type="parse_video",
|
||||||
|
status="success",
|
||||||
|
progress=100,
|
||||||
|
message="解析完成",
|
||||||
|
project_id=project["id"],
|
||||||
|
payload={"source_type": "video"},
|
||||||
|
result={"frames_extracted": 120},
|
||||||
|
)
|
||||||
|
db_session.add(task)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(task)
|
||||||
|
|
||||||
|
response = client.get("/api/dashboard/overview")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["summary"]["parsing_task_count"] == 0
|
||||||
|
assert body["tasks"] == [
|
||||||
|
{
|
||||||
|
"id": f"task-{task.id}",
|
||||||
|
"task_id": task.id,
|
||||||
|
"project_id": project["id"],
|
||||||
|
"name": "Completed Project",
|
||||||
|
"progress": 100,
|
||||||
|
"status": "解析完成",
|
||||||
|
"raw_status": "success",
|
||||||
|
"frame_count": 120,
|
||||||
|
"error": None,
|
||||||
|
"updated_at": body["tasks"][0]["updated_at"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_overview_uses_processed_frame_count_for_propagation_tasks(client, db_session):
|
||||||
|
from models import ProcessingTask
|
||||||
|
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "Propagation Project",
|
||||||
|
"status": "ready",
|
||||||
|
}).json()
|
||||||
|
task = ProcessingTask(
|
||||||
|
task_type="propagate_masks",
|
||||||
|
status="running",
|
||||||
|
progress=45,
|
||||||
|
message="向后传播 胆囊 (1/2)",
|
||||||
|
project_id=project["id"],
|
||||||
|
payload={"project_id": project["id"]},
|
||||||
|
result={"processed_frame_count": 8, "created_annotation_count": 3},
|
||||||
|
)
|
||||||
|
db_session.add(task)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(task)
|
||||||
|
|
||||||
|
response = client.get("/api/dashboard/overview")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["tasks"][0]["task_id"] == task.id
|
||||||
|
assert body["tasks"][0]["frame_count"] == 8
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import zipfile
|
import zipfile
|
||||||
import json
|
import json
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_image_bytes(width=100, height=50, color=(255, 255, 255)):
|
||||||
|
image = np.full((height, width, 3), color, dtype=np.uint8)
|
||||||
|
_, encoded = cv2.imencode(".jpg", image)
|
||||||
|
return encoded.tobytes()
|
||||||
|
|
||||||
|
|
||||||
def _seed_export_data(client):
|
def _seed_export_data(client):
|
||||||
project = client.post("/api/projects", json={"name": "Export Project"}).json()
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "Export Project",
|
||||||
|
"video_path": "uploads/1/clip.mp4",
|
||||||
|
}).json()
|
||||||
frame = client.post(f"/api/projects/{project['id']}/frames", json={
|
frame = client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
"project_id": project["id"],
|
"project_id": project["id"],
|
||||||
"frame_index": 0,
|
"frame_index": 0,
|
||||||
"image_url": "frames/0.jpg",
|
"image_url": "frames/0.jpg",
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"height": 50,
|
"height": 50,
|
||||||
|
"timestamp_ms": 1250.0,
|
||||||
|
"source_frame_number": 37,
|
||||||
}).json()
|
}).json()
|
||||||
template = client.post("/api/templates", json={
|
template = client.post("/api/templates", json={
|
||||||
"name": "Category",
|
"name": "Category",
|
||||||
@@ -113,6 +125,425 @@ def test_export_masks_uses_z_index_for_semantic_fusion(client):
|
|||||||
assert semantic[10, 10] == high_value
|
assert semantic[10, 10] == high_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_results_zip_contains_coco_original_images_and_selected_mask_outputs(client, monkeypatch):
|
||||||
|
project, _, _, annotation = _seed_export_data(client)
|
||||||
|
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes())
|
||||||
|
|
||||||
|
response = client.get(f"/api/export/{project['id']}/results?scope=all&mask_type=both")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"].startswith("application/zip")
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
frame_stem = "clip_0h00m01s250ms_frame000001"
|
||||||
|
assert "annotations_coco.json" in names
|
||||||
|
assert "maskid_GT像素值_类别映射.json" in names
|
||||||
|
assert f"原始图片/{frame_stem}.jpg" in names
|
||||||
|
assert f"分开Mask分割结果/{frame_stem}_分别导出/{frame_stem}_Category_maskid1.png" in names
|
||||||
|
assert f"GT_label图/{frame_stem}.png" in names
|
||||||
|
assert f"Pro_label彩色分割结果/{frame_stem}.png" in names
|
||||||
|
assert f"Mix_label重叠覆盖彩色分割结果/{frame_stem}.png" in names
|
||||||
|
coco = json.loads(archive.read("annotations_coco.json"))
|
||||||
|
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
|
||||||
|
label_bytes = np.frombuffer(archive.read(f"GT_label图/{frame_stem}.png"), dtype=np.uint8)
|
||||||
|
gt_label = cv2.imdecode(label_bytes, cv2.IMREAD_UNCHANGED)
|
||||||
|
pro_label = cv2.imdecode(
|
||||||
|
np.frombuffer(archive.read(f"Pro_label彩色分割结果/{frame_stem}.png"), dtype=np.uint8),
|
||||||
|
cv2.IMREAD_COLOR,
|
||||||
|
)
|
||||||
|
mix_label = cv2.imdecode(
|
||||||
|
np.frombuffer(archive.read(f"Mix_label重叠覆盖彩色分割结果/{frame_stem}.png"), dtype=np.uint8),
|
||||||
|
cv2.IMREAD_COLOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert coco["images"][0]["frame_index"] == 0
|
||||||
|
assert coco["annotations"][0]["image_id"] == annotation["frame_id"]
|
||||||
|
assert mapping["classes"] == [{
|
||||||
|
"gt_pixel_value": 1,
|
||||||
|
"maskid": 1,
|
||||||
|
"chineseName": "Category",
|
||||||
|
"className": "Category",
|
||||||
|
"categoryName": "Category",
|
||||||
|
"rgb": [6, 182, 212],
|
||||||
|
"color": "#06b6d4",
|
||||||
|
"key": f"template:{annotation['template_id']}",
|
||||||
|
"template_id": annotation["template_id"],
|
||||||
|
}]
|
||||||
|
assert gt_label.dtype == np.uint8
|
||||||
|
assert gt_label[0, 0] == 0
|
||||||
|
assert gt_label[20, 50] == 1
|
||||||
|
assert pro_label[20, 50].tolist() == [212, 182, 6]
|
||||||
|
assert pro_label[0, 0].tolist() == [0, 0, 0]
|
||||||
|
assert mix_label[20, 50].tolist() != [255, 255, 255]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_results_uses_internal_layer_order_for_gt_pro_and_mix_outputs(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "Layered Export Project",
|
||||||
|
"video_path": "uploads/2/layered.mp4",
|
||||||
|
}).json()
|
||||||
|
frame = client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_index": 0,
|
||||||
|
"image_url": "frames/layered.jpg",
|
||||||
|
"width": 20,
|
||||||
|
"height": 20,
|
||||||
|
"timestamp_ms": 0,
|
||||||
|
"source_frame_number": 0,
|
||||||
|
}).json()
|
||||||
|
client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": frame["id"],
|
||||||
|
"mask_data": {
|
||||||
|
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
|
||||||
|
"label": "Low",
|
||||||
|
"color": "#00ff00",
|
||||||
|
"class": {"id": "low", "name": "Low", "color": "#00ff00", "zIndex": 10},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": frame["id"],
|
||||||
|
"mask_data": {
|
||||||
|
"polygons": [[[0.4, 0.4], [0.9, 0.4], [0.9, 0.9], [0.4, 0.9]]],
|
||||||
|
"label": "High",
|
||||||
|
"color": "#ff0000",
|
||||||
|
"class": {"id": "high", "name": "High", "color": "#ff0000", "zIndex": 20},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/export/{project['id']}/results?scope=all&outputs=gt_label,pro_label,mix_label&mix_opacity=0.5",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
|
||||||
|
high_value = next(item["maskid"] for item in mapping["classes"] if item["key"] == "class:high")
|
||||||
|
stem = "layered_0h00m00s000ms_frame000001"
|
||||||
|
gt_label = cv2.imdecode(
|
||||||
|
np.frombuffer(archive.read(f"GT_label图/{stem}.png"), dtype=np.uint8),
|
||||||
|
cv2.IMREAD_UNCHANGED,
|
||||||
|
)
|
||||||
|
pro_label = cv2.imdecode(
|
||||||
|
np.frombuffer(archive.read(f"Pro_label彩色分割结果/{stem}.png"), dtype=np.uint8),
|
||||||
|
cv2.IMREAD_COLOR,
|
||||||
|
)
|
||||||
|
mix_label = cv2.imdecode(
|
||||||
|
np.frombuffer(archive.read(f"Mix_label重叠覆盖彩色分割结果/{stem}.png"), dtype=np.uint8),
|
||||||
|
cv2.IMREAD_COLOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert gt_label.dtype == np.uint8
|
||||||
|
assert gt_label[10, 10] == high_value
|
||||||
|
assert pro_label[10, 10].tolist() == [0, 0, 255]
|
||||||
|
assert mix_label[10, 10].tolist() == [127, 127, 255]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_results_supports_range_and_current_scope(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "Scoped Export Project",
|
||||||
|
"video_path": "uploads/9/scope.mp4",
|
||||||
|
"parse_fps": 2,
|
||||||
|
}).json()
|
||||||
|
template = client.post("/api/templates", json={
|
||||||
|
"name": "Scoped Category",
|
||||||
|
"color": "#06b6d4",
|
||||||
|
"z_index": 0,
|
||||||
|
"classes": [],
|
||||||
|
"rules": [],
|
||||||
|
}).json()
|
||||||
|
frames = []
|
||||||
|
annotations = []
|
||||||
|
for idx in range(3):
|
||||||
|
frame = client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_index": idx,
|
||||||
|
"image_url": f"frames/{idx}.jpg",
|
||||||
|
"width": 20,
|
||||||
|
"height": 20,
|
||||||
|
"timestamp_ms": idx * 500.0,
|
||||||
|
"source_frame_number": idx * 10,
|
||||||
|
}).json()
|
||||||
|
frames.append(frame)
|
||||||
|
annotations.append(client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": frame["id"],
|
||||||
|
"template_id": template["id"],
|
||||||
|
"mask_data": {"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]]},
|
||||||
|
}).json())
|
||||||
|
|
||||||
|
range_response = client.get(
|
||||||
|
f"/api/export/{project['id']}/results?scope=range&start_frame=2&end_frame=3&mask_type=gt_label",
|
||||||
|
)
|
||||||
|
current_response = client.get(
|
||||||
|
f"/api/export/{project['id']}/results?scope=current&frame_id={frames[1]['id']}&mask_type=separate",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert range_response.status_code == 200
|
||||||
|
assert "Scoped_Export_Project_seg_T_0h00m00s500ms-0h00m01s000ms_P_2-3.zip" in unquote(
|
||||||
|
range_response.headers["content-disposition"],
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(BytesIO(range_response.content)) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
coco = json.loads(archive.read("annotations_coco.json"))
|
||||||
|
assert "原始图片/scope_0h00m00s500ms_frame000002.jpg" in names
|
||||||
|
assert "原始图片/scope_0h00m01s000ms_frame000003.jpg" in names
|
||||||
|
assert "原始图片/scope_0h00m00s000ms_frame000001.jpg" not in names
|
||||||
|
assert "GT_label图/scope_0h00m00s500ms_frame000002.png" in names
|
||||||
|
assert "GT_label图/scope_0h00m01s000ms_frame000003.png" in names
|
||||||
|
assert "GT_label图/scope_0h00m00s000ms_frame000001.png" not in names
|
||||||
|
assert not any(name.startswith("分开Mask分割结果/") for name in names)
|
||||||
|
assert not any(name.startswith("Pro_label彩色分割结果/") for name in names)
|
||||||
|
assert not any(name.startswith("Mix_label重叠覆盖彩色分割结果/") for name in names)
|
||||||
|
assert [image["frame_index"] for image in coco["images"]] == [1, 2]
|
||||||
|
|
||||||
|
assert current_response.status_code == 200
|
||||||
|
with zipfile.ZipFile(BytesIO(current_response.content)) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
coco = json.loads(archive.read("annotations_coco.json"))
|
||||||
|
current_stem = "scope_0h00m00s500ms_frame000002"
|
||||||
|
assert f"原始图片/{current_stem}.jpg" in names
|
||||||
|
assert f"分开Mask分割结果/{current_stem}_分别导出/{current_stem}_Scoped_Category_maskid1.png" in names
|
||||||
|
assert f"分开Mask分割结果/scope_0h00m00s000ms_frame000001_分别导出/scope_0h00m00s000ms_frame000001_Scoped_Category_maskid1.png" not in names
|
||||||
|
assert not any(name.startswith("GT_label图/") for name in names)
|
||||||
|
assert not any(name.startswith("Pro_label彩色分割结果/") for name in names)
|
||||||
|
assert not any(name.startswith("Mix_label重叠覆盖彩色分割结果/") for name in names)
|
||||||
|
assert [image["id"] for image in coco["images"]] == [frames[1]["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_results_preserves_template_maskid_consistently_across_frames(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "MaskId Export Project",
|
||||||
|
"video_path": "uploads/8/maskid-demo.mp4",
|
||||||
|
"parse_fps": 1,
|
||||||
|
}).json()
|
||||||
|
frames = []
|
||||||
|
for idx in range(2):
|
||||||
|
frames.append(client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_index": idx,
|
||||||
|
"image_url": f"frames/{idx}.jpg",
|
||||||
|
"width": 20,
|
||||||
|
"height": 20,
|
||||||
|
"timestamp_ms": idx * 1000.0,
|
||||||
|
"source_frame_number": idx,
|
||||||
|
}).json())
|
||||||
|
client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": frames[-1]["id"],
|
||||||
|
"mask_data": {
|
||||||
|
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
|
||||||
|
"label": "Tumor",
|
||||||
|
"color": "#ff0000",
|
||||||
|
"class": {"id": "tumor", "name": "Tumor", "color": "#ff0000", "maskId": 7, "zIndex": 30},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get(f"/api/export/{project['id']}/results?scope=all&mask_type=both")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
|
||||||
|
first_stem = "maskid-demo_0h00m00s000ms_frame000001"
|
||||||
|
second_stem = "maskid-demo_0h00m01s000ms_frame000002"
|
||||||
|
assert f"分开Mask分割结果/{first_stem}_分别导出/{first_stem}_Tumor_maskid7.png" in names
|
||||||
|
assert f"分开Mask分割结果/{second_stem}_分别导出/{second_stem}_Tumor_maskid7.png" in names
|
||||||
|
first_label = cv2.imdecode(np.frombuffer(archive.read(f"GT_label图/{first_stem}.png"), dtype=np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
second_label = cv2.imdecode(np.frombuffer(archive.read(f"GT_label图/{second_stem}.png"), dtype=np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
|
||||||
|
assert mapping["classes"] == [{
|
||||||
|
"gt_pixel_value": 7,
|
||||||
|
"maskid": 7,
|
||||||
|
"chineseName": "Tumor",
|
||||||
|
"className": "Tumor",
|
||||||
|
"categoryName": "",
|
||||||
|
"rgb": [255, 0, 0],
|
||||||
|
"color": "#ff0000",
|
||||||
|
"key": "class:tumor",
|
||||||
|
"template_id": None,
|
||||||
|
}]
|
||||||
|
assert first_label.dtype == np.uint8
|
||||||
|
assert second_label.dtype == np.uint8
|
||||||
|
assert first_label[5, 5] == 7
|
||||||
|
assert second_label[5, 5] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_results_keeps_unclassified_maskid_zero_black_in_gt_and_pro(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "Unclassified Export Project",
|
||||||
|
"video_path": "uploads/8/unclassified.mp4",
|
||||||
|
}).json()
|
||||||
|
frame = client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_index": 0,
|
||||||
|
"image_url": "frames/source.jpg",
|
||||||
|
"width": 20,
|
||||||
|
"height": 20,
|
||||||
|
"timestamp_ms": 0,
|
||||||
|
}).json()
|
||||||
|
client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": frame["id"],
|
||||||
|
"mask_data": {
|
||||||
|
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
|
||||||
|
"label": "待分类",
|
||||||
|
"color": "#000000",
|
||||||
|
"class": {
|
||||||
|
"id": "reserved-unclassified",
|
||||||
|
"name": "待分类",
|
||||||
|
"color": "#000000",
|
||||||
|
"maskId": 0,
|
||||||
|
"zIndex": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get(f"/api/export/{project['id']}/results?scope=all&outputs=gt_label,pro_label")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
|
||||||
|
stem = "unclassified_0h00m00s000ms_frame000001"
|
||||||
|
gt_label = cv2.imdecode(
|
||||||
|
np.frombuffer(archive.read(f"GT_label图/{stem}.png"), dtype=np.uint8),
|
||||||
|
cv2.IMREAD_UNCHANGED,
|
||||||
|
)
|
||||||
|
pro_label = cv2.imdecode(
|
||||||
|
np.frombuffer(archive.read(f"Pro_label彩色分割结果/{stem}.png"), dtype=np.uint8),
|
||||||
|
cv2.IMREAD_COLOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mapping["classes"] == [{
|
||||||
|
"gt_pixel_value": 0,
|
||||||
|
"maskid": 0,
|
||||||
|
"chineseName": "待分类",
|
||||||
|
"className": "待分类",
|
||||||
|
"categoryName": "",
|
||||||
|
"rgb": [0, 0, 0],
|
||||||
|
"color": "#000000",
|
||||||
|
"key": "class:reserved-unclassified",
|
||||||
|
"template_id": None,
|
||||||
|
}]
|
||||||
|
assert gt_label.dtype == np.uint8
|
||||||
|
assert gt_label[5, 5] == 0
|
||||||
|
assert pro_label[5, 5].tolist() == [0, 0, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_exported_gtlabel_round_trips_through_gt_mask_import_with_template_maskid(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "GT Roundtrip Project",
|
||||||
|
"video_path": "uploads/8/roundtrip.mp4",
|
||||||
|
}).json()
|
||||||
|
template = client.post("/api/templates", json={
|
||||||
|
"name": "Roundtrip Template",
|
||||||
|
"color": "#06b6d4",
|
||||||
|
"z_index": 0,
|
||||||
|
"classes": [
|
||||||
|
{"id": "tumor", "name": "Tumor", "color": "#ff0000", "zIndex": 30, "maskId": 7},
|
||||||
|
],
|
||||||
|
"rules": [],
|
||||||
|
}).json()
|
||||||
|
source_frame = client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_index": 0,
|
||||||
|
"image_url": "frames/source.jpg",
|
||||||
|
"width": 20,
|
||||||
|
"height": 20,
|
||||||
|
"timestamp_ms": 0,
|
||||||
|
}).json()
|
||||||
|
target_frame = client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_index": 1,
|
||||||
|
"image_url": "frames/target.jpg",
|
||||||
|
"width": 20,
|
||||||
|
"height": 20,
|
||||||
|
"timestamp_ms": 1000,
|
||||||
|
}).json()
|
||||||
|
client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": source_frame["id"],
|
||||||
|
"template_id": template["id"],
|
||||||
|
"mask_data": {
|
||||||
|
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
|
||||||
|
"label": "Tumor",
|
||||||
|
"color": "#ff0000",
|
||||||
|
"class": {"id": "tumor", "name": "Tumor", "color": "#ff0000", "maskId": 7, "zIndex": 30},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export_response = client.get(
|
||||||
|
f"/api/export/{project['id']}/results?scope=current&frame_id={source_frame['id']}&outputs=gt_label",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert export_response.status_code == 200
|
||||||
|
with zipfile.ZipFile(BytesIO(export_response.content)) as archive:
|
||||||
|
stem = "roundtrip_0h00m00s000ms_frame000001"
|
||||||
|
exported_gt_label = archive.read(f"GT_label图/{stem}.png")
|
||||||
|
gt_label = cv2.imdecode(np.frombuffer(exported_gt_label, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
|
||||||
|
|
||||||
|
assert gt_label.dtype == np.uint8
|
||||||
|
assert gt_label[5, 5] == 7
|
||||||
|
assert mapping["classes"][0]["maskid"] == 7
|
||||||
|
|
||||||
|
import_response = client.post(
|
||||||
|
"/api/ai/import-gt-mask",
|
||||||
|
data={
|
||||||
|
"project_id": str(project["id"]),
|
||||||
|
"frame_id": str(target_frame["id"]),
|
||||||
|
"template_id": str(template["id"]),
|
||||||
|
"unknown_color_policy": "discard",
|
||||||
|
},
|
||||||
|
files={"file": ("exported_gt_label.png", exported_gt_label, "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert import_response.status_code == 201
|
||||||
|
imported = import_response.json()
|
||||||
|
assert len(imported) == 1
|
||||||
|
assert imported[0]["frame_id"] == target_frame["id"]
|
||||||
|
assert imported[0]["mask_data"]["gt_label_value"] == 7
|
||||||
|
assert imported[0]["mask_data"]["label"] == "Tumor"
|
||||||
|
assert imported[0]["mask_data"]["class"]["maskId"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_results_rejects_gtlabel_maskid_outside_uint8_range(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "Large MaskId Project",
|
||||||
|
"video_path": "uploads/8/large-maskid.mp4",
|
||||||
|
}).json()
|
||||||
|
frame = client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_index": 0,
|
||||||
|
"image_url": "frames/source.jpg",
|
||||||
|
"width": 20,
|
||||||
|
"height": 20,
|
||||||
|
}).json()
|
||||||
|
client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": frame["id"],
|
||||||
|
"mask_data": {
|
||||||
|
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
|
||||||
|
"label": "TooLarge",
|
||||||
|
"color": "#ff0000",
|
||||||
|
"class": {"id": "too-large", "name": "TooLarge", "color": "#ff0000", "maskId": 300, "zIndex": 30},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get(f"/api/export/{project['id']}/results?scope=all&outputs=gt_label")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "8-bit maskid" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_export_missing_project_returns_404(client):
|
def test_export_missing_project_returns_404(client):
|
||||||
assert client.get("/api/export/999/coco").status_code == 404
|
assert client.get("/api/export/999/coco").status_code == 404
|
||||||
assert client.get("/api/export/999/masks").status_code == 404
|
assert client.get("/api/export/999/masks").status_code == 404
|
||||||
|
assert client.get("/api/export/999/results").status_code == 404
|
||||||
|
|||||||
@@ -48,15 +48,37 @@ def test_upload_dicom_batch_filters_files_and_creates_project(client, monkeypatc
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/media/upload/dicom",
|
"/api/media/upload/dicom",
|
||||||
files=[
|
files=[
|
||||||
("files", ("a.dcm", b"dcm", "application/dicom")),
|
("files", ("10.dcm", b"dcm10", "application/dicom")),
|
||||||
("files", ("skip.txt", b"text", "text/plain")),
|
("files", ("skip.txt", b"text", "text/plain")),
|
||||||
|
("files", ("2.dcm", b"dcm2", "application/dicom")),
|
||||||
|
("files", ("1.dcm", b"dcm1", "application/dicom")),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["uploaded_count"] == 1
|
assert data["uploaded_count"] == 3
|
||||||
assert uploaded == [f"uploads/{data['project_id']}/dicom/a.dcm"]
|
assert uploaded == [
|
||||||
|
f"uploads/{data['project_id']}/dicom/1.dcm",
|
||||||
|
f"uploads/{data['project_id']}/dicom/2.dcm",
|
||||||
|
f"uploads/{data['project_id']}/dicom/10.dcm",
|
||||||
|
]
|
||||||
|
project_detail = client.get(f"/api/projects/{data['project_id']}").json()
|
||||||
|
assert project_detail["name"] == "1.dcm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_dicom_batch_rejects_when_no_valid_dicom(client, monkeypatch):
|
||||||
|
monkeypatch.setattr("routers.media.upload_file", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/media/upload/dicom",
|
||||||
|
files=[
|
||||||
|
("files", ("notes.txt", b"text", "text/plain")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "No valid DICOM files uploaded"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_media_queues_background_task(client, monkeypatch):
|
def test_parse_media_queues_background_task(client, monkeypatch):
|
||||||
@@ -194,6 +216,155 @@ def test_parse_task_runner_registers_frames(client, db_session, monkeypatch, tmp
|
|||||||
assert frames[0]["source_frame_number"] == 0
|
assert frames[0]["source_frame_number"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_task_runner_replaces_existing_frames_and_annotations(client, db_session, monkeypatch, tmp_path):
|
||||||
|
from models import Annotation, Frame, Mask, ProcessingTask
|
||||||
|
from services.media_task_runner import run_parse_media_task
|
||||||
|
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "Reparse Me",
|
||||||
|
"video_path": "uploads/1/clip.mp4",
|
||||||
|
"source_type": "video",
|
||||||
|
"status": "ready",
|
||||||
|
"parse_fps": 30,
|
||||||
|
"thumbnail_url": "projects/old/thumbnail.jpg",
|
||||||
|
}).json()
|
||||||
|
old_frame = Frame(project_id=project["id"], frame_index=0, image_url="projects/old/frame.jpg")
|
||||||
|
db_session.add(old_frame)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(old_frame)
|
||||||
|
old_annotation = Annotation(project_id=project["id"], frame_id=old_frame.id, mask_data={"label": "old"})
|
||||||
|
db_session.add(old_annotation)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(old_annotation)
|
||||||
|
db_session.add(Mask(annotation_id=old_annotation.id, mask_url="masks/old.png"))
|
||||||
|
task = ProcessingTask(
|
||||||
|
task_type="parse_video",
|
||||||
|
status="queued",
|
||||||
|
progress=0,
|
||||||
|
project_id=project["id"],
|
||||||
|
payload={"source_type": "video", "parse_fps": 10},
|
||||||
|
)
|
||||||
|
db_session.add(task)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(task)
|
||||||
|
|
||||||
|
frame_file = tmp_path / "frame_000000.jpg"
|
||||||
|
frame_file.write_bytes(b"new frame")
|
||||||
|
monkeypatch.setattr("services.media_task_runner.download_file", lambda object_name: b"video")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.media_task_runner.parse_video",
|
||||||
|
lambda local_path, output_dir, fps, max_frames=None, target_width=640: ([str(frame_file)], 25.0),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("services.media_task_runner.extract_thumbnail", lambda local_path, thumbnail_path: open(thumbnail_path, "wb").write(b"thumb"))
|
||||||
|
monkeypatch.setattr("services.media_task_runner.upload_file", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr("services.media_task_runner.upload_frames_to_minio", lambda frame_files, project_id: [f"projects/{project_id}/frames/frame_000000.jpg"])
|
||||||
|
monkeypatch.setattr("services.media_task_runner.publish_task_progress_event", lambda task: None)
|
||||||
|
|
||||||
|
result = run_parse_media_task(db_session, task.id)
|
||||||
|
|
||||||
|
assert result["frames_extracted"] == 1
|
||||||
|
frames = db_session.query(Frame).filter(Frame.project_id == project["id"]).all()
|
||||||
|
assert len(frames) == 1
|
||||||
|
assert frames[0].image_url == f"projects/{project['id']}/frames/frame_000000.jpg"
|
||||||
|
assert db_session.query(Annotation).filter(Annotation.project_id == project["id"]).count() == 0
|
||||||
|
assert db_session.query(Mask).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dicom_reads_files_in_natural_filename_order(monkeypatch, tmp_path):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from services.frame_parser import parse_dicom
|
||||||
|
|
||||||
|
dcm_dir = tmp_path / "dcm"
|
||||||
|
output_dir = tmp_path / "frames"
|
||||||
|
dcm_dir.mkdir()
|
||||||
|
for name in ["10.dcm", "2.dcm", "1.dcm"]:
|
||||||
|
(dcm_dir / name).write_bytes(b"dcm")
|
||||||
|
|
||||||
|
read_order = []
|
||||||
|
|
||||||
|
class FakeDicom:
|
||||||
|
pixel_array = np.ones((2, 2), dtype=np.uint8)
|
||||||
|
|
||||||
|
def fake_dcmread(path):
|
||||||
|
read_order.append(Path(path).name)
|
||||||
|
return FakeDicom()
|
||||||
|
|
||||||
|
def fake_imwrite(path, image, params=None):
|
||||||
|
Path(path).write_bytes(image.tobytes())
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("services.frame_parser.dcmread", fake_dcmread)
|
||||||
|
monkeypatch.setattr("services.frame_parser.cv2.imwrite", fake_imwrite)
|
||||||
|
|
||||||
|
frame_files = parse_dicom(str(dcm_dir), str(output_dir))
|
||||||
|
|
||||||
|
assert read_order == ["1.dcm", "2.dcm", "10.dcm"]
|
||||||
|
assert [Path(path).name for path in frame_files] == ["frame_000000.jpg", "frame_000001.jpg", "frame_000002.jpg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_task_runner_downloads_dicom_objects_in_natural_filename_order(client, db_session, monkeypatch, tmp_path):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from models import ProcessingTask
|
||||||
|
from services.media_task_runner import run_parse_media_task
|
||||||
|
|
||||||
|
project = client.post("/api/projects", json={
|
||||||
|
"name": "DICOM",
|
||||||
|
"video_path": "uploads/1/dicom",
|
||||||
|
"source_type": "dicom",
|
||||||
|
"parse_fps": 30,
|
||||||
|
}).json()
|
||||||
|
task = ProcessingTask(
|
||||||
|
task_type="parse_dicom",
|
||||||
|
status="queued",
|
||||||
|
progress=0,
|
||||||
|
project_id=project["id"],
|
||||||
|
payload={"source_type": "dicom"},
|
||||||
|
)
|
||||||
|
db_session.add(task)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(task)
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def list_objects(self, bucket, prefix, recursive=True):
|
||||||
|
return [
|
||||||
|
SimpleNamespace(object_name=f"{prefix}/10.dcm"),
|
||||||
|
SimpleNamespace(object_name=f"{prefix}/2.dcm"),
|
||||||
|
SimpleNamespace(object_name=f"{prefix}/1.dcm"),
|
||||||
|
]
|
||||||
|
|
||||||
|
downloaded = []
|
||||||
|
frame_files = []
|
||||||
|
for idx in range(3):
|
||||||
|
frame_file = tmp_path / f"frame_{idx:06d}.jpg"
|
||||||
|
frame_file.write_bytes(b"fake image")
|
||||||
|
frame_files.append(str(frame_file))
|
||||||
|
|
||||||
|
monkeypatch.setattr("services.media_task_runner.get_minio_client", lambda: FakeClient())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.media_task_runner.download_file",
|
||||||
|
lambda object_name: downloaded.append(object_name) or b"dcm",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("services.media_task_runner.parse_dicom", lambda *args, **kwargs: frame_files)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.media_task_runner.upload_frames_to_minio",
|
||||||
|
lambda frames, project_id: [f"projects/{project_id}/frames/{idx}.jpg" for idx, _ in enumerate(frames)],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("services.media_task_runner.publish_task_progress_event", lambda task: None)
|
||||||
|
|
||||||
|
result = run_parse_media_task(db_session, task.id)
|
||||||
|
|
||||||
|
assert result["frames_extracted"] == 3
|
||||||
|
assert downloaded == [
|
||||||
|
"uploads/1/dicom/1.dcm",
|
||||||
|
"uploads/1/dicom/2.dcm",
|
||||||
|
"uploads/1/dicom/10.dcm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_parse_task_runner_skips_already_cancelled_task(db_session):
|
def test_parse_task_runner_skips_already_cancelled_task(db_session):
|
||||||
from models import ProcessingTask
|
from models import ProcessingTask
|
||||||
from services.media_task_runner import run_parse_media_task
|
from services.media_task_runner import run_parse_media_task
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
from models import Annotation, Frame, Mask, ProcessingTask, Project, User
|
||||||
|
from routers.auth import create_access_token, hash_password
|
||||||
|
|
||||||
|
|
||||||
def test_project_crud_and_frames(client, monkeypatch):
|
def test_project_crud_and_frames(client, monkeypatch):
|
||||||
monkeypatch.setattr("routers.projects.get_presigned_url", lambda key, expires=3600: f"http://storage/{key}")
|
monkeypatch.setattr("routers.projects.get_presigned_url", lambda key, expires=3600: f"http://storage/{key}")
|
||||||
|
|
||||||
@@ -38,15 +42,164 @@ def test_project_crud_and_frames(client, monkeypatch):
|
|||||||
assert updated.json()["name"] == "Renamed"
|
assert updated.json()["name"] == "Renamed"
|
||||||
assert updated.json()["status"] == "ready"
|
assert updated.json()["status"] == "ready"
|
||||||
|
|
||||||
|
empty_name = client.patch(f"/api/projects/{project_id}", json={"name": " "})
|
||||||
|
assert empty_name.status_code == 400
|
||||||
|
|
||||||
deleted = client.delete(f"/api/projects/{project_id}")
|
deleted = client.delete(f"/api/projects/{project_id}")
|
||||||
assert deleted.status_code == 204
|
assert deleted.status_code == 204
|
||||||
assert client.get(f"/api/projects/{project_id}").status_code == 404
|
assert client.get(f"/api/projects/{project_id}").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_project_frames_returns_more_than_default_timeline_window(client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setattr("routers.projects.get_presigned_url", lambda key, expires=3600: f"http://storage/{key}")
|
||||||
|
created = client.post("/api/projects", json={"name": "Long Sequence", "status": "ready"})
|
||||||
|
assert created.status_code == 201
|
||||||
|
project_id = created.json()["id"]
|
||||||
|
|
||||||
|
db_session.add_all([
|
||||||
|
Frame(project_id=project_id, frame_index=index, image_url=f"frames/{index}.jpg")
|
||||||
|
for index in range(1001)
|
||||||
|
])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
frames = client.get(f"/api/projects/{project_id}/frames")
|
||||||
|
assert frames.status_code == 200
|
||||||
|
body = frames.json()
|
||||||
|
assert len(body) == 1001
|
||||||
|
assert body[-1]["frame_index"] == 1000
|
||||||
|
|
||||||
|
limited = client.get(f"/api/projects/{project_id}/frames?limit=2")
|
||||||
|
assert limited.status_code == 200
|
||||||
|
assert [frame["frame_index"] for frame in limited.json()] == [0, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_project_cascades_related_records(client, db_session):
|
||||||
|
created = client.post("/api/projects", json={"name": "Cascade Delete"})
|
||||||
|
assert created.status_code == 201
|
||||||
|
project_id = created.json()["id"]
|
||||||
|
|
||||||
|
frame = client.post(f"/api/projects/{project_id}/frames", json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"frame_index": 0,
|
||||||
|
"image_url": "frames/0.jpg",
|
||||||
|
"width": 640,
|
||||||
|
"height": 360,
|
||||||
|
})
|
||||||
|
assert frame.status_code == 201
|
||||||
|
frame_id = frame.json()["id"]
|
||||||
|
|
||||||
|
annotation = client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"frame_id": frame_id,
|
||||||
|
"mask_data": {"polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]]},
|
||||||
|
})
|
||||||
|
assert annotation.status_code == 201
|
||||||
|
annotation_id = annotation.json()["id"]
|
||||||
|
|
||||||
|
db_session.add(Mask(annotation_id=annotation_id, mask_url="masks/1.png"))
|
||||||
|
db_session.add(ProcessingTask(task_type="parse_video", project_id=project_id, status="queued"))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/projects/{project_id}")
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
assert db_session.query(Project).filter(Project.id == project_id).count() == 0
|
||||||
|
assert db_session.query(Frame).filter(Frame.project_id == project_id).count() == 0
|
||||||
|
assert db_session.query(Annotation).filter(Annotation.project_id == project_id).count() == 0
|
||||||
|
assert db_session.query(Mask).count() == 0
|
||||||
|
assert db_session.query(ProcessingTask).filter(ProcessingTask.project_id == project_id).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_project_reset_copies_frame_sequence_without_annotations(client, db_session):
|
||||||
|
created = client.post("/api/projects", json={
|
||||||
|
"name": "Reset Source",
|
||||||
|
"description": "desc",
|
||||||
|
"video_path": "uploads/source.mp4",
|
||||||
|
"thumbnail_url": "thumb.jpg",
|
||||||
|
"status": "ready",
|
||||||
|
"parse_fps": 12,
|
||||||
|
})
|
||||||
|
assert created.status_code == 201
|
||||||
|
project_id = created.json()["id"]
|
||||||
|
frame = client.post(f"/api/projects/{project_id}/frames", json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"frame_index": 0,
|
||||||
|
"image_url": "frames/source/frame_000000.jpg",
|
||||||
|
"width": 640,
|
||||||
|
"height": 360,
|
||||||
|
"timestamp_ms": 0,
|
||||||
|
"source_frame_number": 0,
|
||||||
|
})
|
||||||
|
assert frame.status_code == 201
|
||||||
|
annotation = client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"frame_id": frame.json()["id"],
|
||||||
|
"mask_data": {"label": "Tumor", "polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]]},
|
||||||
|
})
|
||||||
|
assert annotation.status_code == 201
|
||||||
|
|
||||||
|
copied = client.post(f"/api/projects/{project_id}/copy", json={"mode": "reset"})
|
||||||
|
assert copied.status_code == 201
|
||||||
|
copied_body = copied.json()
|
||||||
|
assert copied_body["name"] == "Reset Source 副本"
|
||||||
|
assert copied_body["frame_count"] == 1
|
||||||
|
assert copied_body["video_path"] == "uploads/source.mp4"
|
||||||
|
assert copied_body["parse_fps"] == 12
|
||||||
|
|
||||||
|
copied_frames = db_session.query(Frame).filter(Frame.project_id == copied_body["id"]).all()
|
||||||
|
assert len(copied_frames) == 1
|
||||||
|
assert copied_frames[0].image_url == "frames/source/frame_000000.jpg"
|
||||||
|
assert db_session.query(Annotation).filter(Annotation.project_id == copied_body["id"]).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_project_full_copies_annotations_and_mask_metadata(client, db_session):
|
||||||
|
created = client.post("/api/projects", json={
|
||||||
|
"name": "Full Source",
|
||||||
|
"status": "ready",
|
||||||
|
})
|
||||||
|
assert created.status_code == 201
|
||||||
|
project_id = created.json()["id"]
|
||||||
|
frame = client.post(f"/api/projects/{project_id}/frames", json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"frame_index": 0,
|
||||||
|
"image_url": "frames/source/frame_000000.jpg",
|
||||||
|
"width": 640,
|
||||||
|
"height": 360,
|
||||||
|
})
|
||||||
|
assert frame.status_code == 201
|
||||||
|
frame_id = frame.json()["id"]
|
||||||
|
annotation = client.post("/api/ai/annotate", json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"frame_id": frame_id,
|
||||||
|
"mask_data": {"label": "Tumor", "polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]]},
|
||||||
|
"points": [[0.1, 0.1]],
|
||||||
|
"bbox": [0.1, 0.1, 0.1, 0.1],
|
||||||
|
})
|
||||||
|
assert annotation.status_code == 201
|
||||||
|
annotation_id = annotation.json()["id"]
|
||||||
|
db_session.add(Mask(annotation_id=annotation_id, mask_url="masks/source.png", format="png"))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
copied = client.post(f"/api/projects/{project_id}/copy", json={"mode": "full"})
|
||||||
|
assert copied.status_code == 201
|
||||||
|
copied_body = copied.json()
|
||||||
|
copied_frames = db_session.query(Frame).filter(Frame.project_id == copied_body["id"]).all()
|
||||||
|
copied_annotations = db_session.query(Annotation).filter(Annotation.project_id == copied_body["id"]).all()
|
||||||
|
|
||||||
|
assert copied_body["name"] == "Full Source 副本"
|
||||||
|
assert len(copied_frames) == 1
|
||||||
|
assert len(copied_annotations) == 1
|
||||||
|
assert copied_annotations[0].id != annotation_id
|
||||||
|
assert copied_annotations[0].frame_id == copied_frames[0].id
|
||||||
|
assert copied_annotations[0].mask_data["label"] == "Tumor"
|
||||||
|
assert copied_annotations[0].bbox == [0.1, 0.1, 0.1, 0.1]
|
||||||
|
assert copied_annotations[0].masks[0].mask_url == "masks/source.png"
|
||||||
|
|
||||||
|
|
||||||
def test_project_and_frame_404s(client):
|
def test_project_and_frame_404s(client):
|
||||||
assert client.get("/api/projects/999").status_code == 404
|
assert client.get("/api/projects/999").status_code == 404
|
||||||
assert client.patch("/api/projects/999", json={"name": "x"}).status_code == 404
|
assert client.patch("/api/projects/999", json={"name": "x"}).status_code == 404
|
||||||
assert client.delete("/api/projects/999").status_code == 404
|
assert client.delete("/api/projects/999").status_code == 404
|
||||||
|
assert client.post("/api/projects/999/copy", json={"mode": "reset"}).status_code == 404
|
||||||
assert client.post("/api/projects/999/frames", json={
|
assert client.post("/api/projects/999/frames", json={
|
||||||
"project_id": 999,
|
"project_id": 999,
|
||||||
"frame_index": 0,
|
"frame_index": 0,
|
||||||
@@ -54,3 +207,38 @@ def test_project_and_frame_404s(client):
|
|||||||
}).status_code == 404
|
}).status_code == 404
|
||||||
assert client.get("/api/projects/999/frames").status_code == 404
|
assert client.get("/api/projects/999/frames").status_code == 404
|
||||||
assert client.get("/api/projects/999/frames/1").status_code == 404
|
assert client.get("/api/projects/999/frames/1").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_are_shared_between_authenticated_users(client, db_session):
|
||||||
|
owner_project = client.post("/api/projects", json={"name": "Owner Project"}).json()
|
||||||
|
other_user = User(
|
||||||
|
username="other",
|
||||||
|
password_hash=hash_password("pass"),
|
||||||
|
role="annotator",
|
||||||
|
is_active=1,
|
||||||
|
)
|
||||||
|
db_session.add(other_user)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(other_user)
|
||||||
|
other_project = Project(name="Other Project", owner_user_id=other_user.id)
|
||||||
|
db_session.add(other_project)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(other_project)
|
||||||
|
|
||||||
|
listing = client.get("/api/projects")
|
||||||
|
assert {project["id"] for project in listing.json()} == {owner_project["id"], other_project.id}
|
||||||
|
assert client.get(f"/api/projects/{other_project.id}").status_code == 200
|
||||||
|
|
||||||
|
original_auth = client.headers["Authorization"]
|
||||||
|
client.headers.update({"Authorization": f"Bearer {create_access_token(other_user)}"})
|
||||||
|
try:
|
||||||
|
other_listing = client.get("/api/projects")
|
||||||
|
assert {project["id"] for project in other_listing.json()} == {owner_project["id"], other_project.id}
|
||||||
|
assert client.get(f"/api/projects/{owner_project['id']}").status_code == 200
|
||||||
|
renamed = client.patch(f"/api/projects/{owner_project['id']}", json={"name": "Edited By Other"})
|
||||||
|
assert renamed.status_code == 200
|
||||||
|
assert renamed.json()["name"] == "Edited By Other"
|
||||||
|
finally:
|
||||||
|
client.headers.update({"Authorization": original_auth})
|
||||||
|
|
||||||
|
assert client.get(f"/api/projects/{owner_project['id']}").json()["name"] == "Edited By Other"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from services.sam2_engine import SAM2Engine
|
from services.sam2_engine import DEFAULT_SAM2_MODEL_ID, SAM2Engine
|
||||||
|
|
||||||
|
|
||||||
class _FakePredictor:
|
class _FakePredictor:
|
||||||
@@ -26,8 +26,8 @@ def _mask(offset=0):
|
|||||||
def _ready_engine(monkeypatch, predictor):
|
def _ready_engine(monkeypatch, predictor):
|
||||||
monkeypatch.setattr("services.sam2_engine.SAM2_AVAILABLE", True)
|
monkeypatch.setattr("services.sam2_engine.SAM2_AVAILABLE", True)
|
||||||
engine = SAM2Engine()
|
engine = SAM2Engine()
|
||||||
engine._model_loaded = True
|
engine._model_loaded[DEFAULT_SAM2_MODEL_ID] = True
|
||||||
engine._predictor = predictor
|
engine._predictors[DEFAULT_SAM2_MODEL_ID] = predictor
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ def test_sam2_point_prediction_requests_single_best_mask(monkeypatch):
|
|||||||
engine = _ready_engine(monkeypatch, predictor)
|
engine = _ready_engine(monkeypatch, predictor)
|
||||||
|
|
||||||
polygons, scores = engine.predict_points(
|
polygons, scores = engine.predict_points(
|
||||||
|
DEFAULT_SAM2_MODEL_ID,
|
||||||
np.zeros((32, 32, 3), dtype=np.uint8),
|
np.zeros((32, 32, 3), dtype=np.uint8),
|
||||||
[[0.5, 0.5]],
|
[[0.5, 0.5]],
|
||||||
[1],
|
[1],
|
||||||
@@ -56,8 +57,48 @@ def test_sam2_auto_prediction_keeps_single_best_mask(monkeypatch):
|
|||||||
)
|
)
|
||||||
engine = _ready_engine(monkeypatch, predictor)
|
engine = _ready_engine(monkeypatch, predictor)
|
||||||
|
|
||||||
polygons, scores = engine.predict_auto(np.zeros((32, 32, 3), dtype=np.uint8))
|
polygons, scores = engine.predict_auto(DEFAULT_SAM2_MODEL_ID, np.zeros((32, 32, 3), dtype=np.uint8))
|
||||||
|
|
||||||
assert predictor.calls[0]["multimask_output"] is False
|
assert predictor.calls[0]["multimask_output"] is False
|
||||||
assert len(polygons) == 1
|
assert len(polygons) == 1
|
||||||
assert scores == [0.800000011920929]
|
assert scores == [0.800000011920929]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sam2_status_exposes_selectable_variants(monkeypatch, tmp_path):
|
||||||
|
checkpoint = tmp_path / "sam2.1_hiera_small.pt"
|
||||||
|
checkpoint.write_bytes(b"model")
|
||||||
|
monkeypatch.setattr("services.sam2_engine.settings.sam_model_path", str(tmp_path / "sam2.1_hiera_tiny.pt"))
|
||||||
|
engine = SAM2Engine()
|
||||||
|
|
||||||
|
status = engine.status("sam2.1_hiera_small")
|
||||||
|
|
||||||
|
assert engine.normalize_model_id("sam2") == DEFAULT_SAM2_MODEL_ID
|
||||||
|
assert "sam2.1_hiera_small" in engine.variant_ids()
|
||||||
|
assert status["id"] == "sam2.1_hiera_small"
|
||||||
|
assert status["label"] == "SAM 2.1 Small"
|
||||||
|
assert status["checkpoint_exists"] is True
|
||||||
|
assert status["checkpoint_path"].endswith("sam2.1_hiera_small.pt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sam2_seed_mask_subtracts_holes():
|
||||||
|
mask = SAM2Engine._polygons_to_mask(
|
||||||
|
polygons=[[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
|
||||||
|
width=100,
|
||||||
|
height=100,
|
||||||
|
holes_by_polygon=[[[[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]]]],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert bool(mask[20, 20]) is True
|
||||||
|
assert bool(mask[50, 50]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sam2_mask_to_polygon_data_preserves_holes():
|
||||||
|
mask = np.zeros((100, 100), dtype=np.uint8)
|
||||||
|
mask[10:90, 10:90] = 1
|
||||||
|
mask[40:60, 40:60] = 0
|
||||||
|
|
||||||
|
polygons, holes = SAM2Engine._mask_to_polygon_data(mask)
|
||||||
|
|
||||||
|
assert len(polygons) == 1
|
||||||
|
assert len(holes) == 1
|
||||||
|
assert len(holes[0]) == 1
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.skip(
|
||||||
|
"SAM 3 integration is disabled in the current SAM2-only product flow.",
|
||||||
|
allow_module_level=True,
|
||||||
|
)
|
||||||
|
|
||||||
from services.sam3_engine import SAM3Engine
|
from services.sam3_engine import SAM3Engine
|
||||||
from services.sam3_external_worker import _prediction_to_response, _to_numpy
|
from services.sam3_external_worker import _prediction_to_response, _to_numpy
|
||||||
|
|||||||
@@ -84,6 +84,42 @@ def test_retry_task_creates_fresh_parse_task(client, db_session, monkeypatch):
|
|||||||
assert client.get(f"/api/projects/{project['id']}").json()["status"] == "parsing"
|
assert client.get(f"/api/projects/{project['id']}").json()["status"] == "parsing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_task_dispatches_propagation_worker_without_media_requirement(client, db_session, monkeypatch):
|
||||||
|
project = client.post("/api/projects", json={"name": "Retry Propagation"}).json()
|
||||||
|
task = ProcessingTask(
|
||||||
|
task_type="propagate_masks",
|
||||||
|
status="failed",
|
||||||
|
progress=100,
|
||||||
|
message="自动传播失败",
|
||||||
|
error="model unavailable",
|
||||||
|
project_id=project["id"],
|
||||||
|
payload={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": 1,
|
||||||
|
"steps": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db_session.add(task)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(task)
|
||||||
|
|
||||||
|
class FakeAsyncResult:
|
||||||
|
id = "celery-propagation-retry"
|
||||||
|
|
||||||
|
queued = []
|
||||||
|
monkeypatch.setattr("routers.tasks.propagate_project_masks.delay", lambda task_id: queued.append(task_id) or FakeAsyncResult())
|
||||||
|
monkeypatch.setattr("routers.tasks.publish_task_progress_event", lambda event_task: None)
|
||||||
|
|
||||||
|
response = client.post(f"/api/tasks/{task.id}/retry")
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
body = response.json()
|
||||||
|
assert body["task_type"] == "propagate_masks"
|
||||||
|
assert body["celery_task_id"] == "celery-propagation-retry"
|
||||||
|
assert queued == [body["id"]]
|
||||||
|
assert client.get(f"/api/projects/{project['id']}").json()["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
def test_task_actions_reject_invalid_states(client, db_session):
|
def test_task_actions_reject_invalid_states(client, db_session):
|
||||||
project = client.post("/api/projects", json={
|
project = client.post("/api/projects", json={
|
||||||
"name": "Done",
|
"name": "Done",
|
||||||
|
|||||||
@@ -37,3 +37,84 @@ def test_template_404s(client):
|
|||||||
assert client.get("/api/templates/999").status_code == 404
|
assert client.get("/api/templates/999").status_code == 404
|
||||||
assert client.patch("/api/templates/999", json={"name": "x"}).status_code == 404
|
assert client.patch("/api/templates/999", json={"name": "x"}).status_code == 404
|
||||||
assert client.delete("/api/templates/999").status_code == 404
|
assert client.delete("/api/templates/999").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_head_neck_ct_template_is_seeded_and_visible(client, db_session):
|
||||||
|
from main import ensure_default_templates
|
||||||
|
from models import Template
|
||||||
|
|
||||||
|
ensure_default_templates(db_session)
|
||||||
|
ensure_default_templates(db_session)
|
||||||
|
|
||||||
|
templates = db_session.query(Template).filter(Template.owner_user_id.is_(None)).all()
|
||||||
|
names = [template.name for template in templates]
|
||||||
|
assert names.count("头颈部CT分割") == 1
|
||||||
|
|
||||||
|
listing = client.get("/api/templates")
|
||||||
|
assert listing.status_code == 200
|
||||||
|
head_neck = next(template for template in listing.json() if template["name"] == "头颈部CT分割")
|
||||||
|
assert head_neck["description"] == "头颈部CT分割"
|
||||||
|
expected_names = [
|
||||||
|
"肿瘤/结节",
|
||||||
|
"下颌骨",
|
||||||
|
"甲状腺",
|
||||||
|
"气管",
|
||||||
|
"颈椎",
|
||||||
|
"颈动脉",
|
||||||
|
"颈静脉",
|
||||||
|
"腮腺",
|
||||||
|
"下颌下腺",
|
||||||
|
"舌骨",
|
||||||
|
"待分类",
|
||||||
|
]
|
||||||
|
expected_colors = [
|
||||||
|
"#ff0000",
|
||||||
|
"#00ff00",
|
||||||
|
"#0000ff",
|
||||||
|
"#ffff00",
|
||||||
|
"#ff00ff",
|
||||||
|
"#00ffff",
|
||||||
|
"#ff8000",
|
||||||
|
"#800080",
|
||||||
|
"#008080",
|
||||||
|
"#808000",
|
||||||
|
"#000000",
|
||||||
|
]
|
||||||
|
actual_names = [item["name"] for item in head_neck["classes"]]
|
||||||
|
actual_colors = [item["color"] for item in head_neck["classes"]]
|
||||||
|
actual_mask_ids = [item["maskId"] for item in head_neck["classes"]]
|
||||||
|
if actual_names != expected_names:
|
||||||
|
raise AssertionError(f"Unexpected head-neck classes: {actual_names}")
|
||||||
|
if actual_colors != expected_colors:
|
||||||
|
raise AssertionError(f"Unexpected head-neck colors: {actual_colors}")
|
||||||
|
if actual_mask_ids != [*list(range(1, 11)), 0]:
|
||||||
|
raise AssertionError(f"Unexpected head-neck mask IDs: {actual_mask_ids}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_head_neck_ct_template_migrates_legacy_english_labels(db_session):
|
||||||
|
from main import ensure_default_templates
|
||||||
|
from models import Template
|
||||||
|
|
||||||
|
db_session.add(Template(
|
||||||
|
name="头颈部CT分割",
|
||||||
|
description="legacy",
|
||||||
|
color="#ef4444",
|
||||||
|
z_index=10,
|
||||||
|
owner_user_id=None,
|
||||||
|
mapping_rules={
|
||||||
|
"classes": [
|
||||||
|
{"id": "old-1", "name": "肿瘤/结节 (Tumor/Nodule)", "color": "#ff0000", "zIndex": 10, "maskId": 1},
|
||||||
|
{"id": "reserved-unclassified", "name": "待分类", "color": "#000000", "zIndex": 0, "maskId": 0},
|
||||||
|
],
|
||||||
|
"rules": [],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
ensure_default_templates(db_session)
|
||||||
|
|
||||||
|
templates = db_session.query(Template).filter(Template.name == "头颈部CT分割").all()
|
||||||
|
assert len(templates) == 1
|
||||||
|
classes = templates[0].mapping_rules["classes"]
|
||||||
|
assert classes[0]["name"] == "肿瘤/结节"
|
||||||
|
assert all("(" not in item["name"] for item in classes)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
from celery_app import celery_app
|
from celery_app import celery_app
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
from services.media_task_runner import run_parse_media_task
|
from services.media_task_runner import run_parse_media_task
|
||||||
|
from services.propagation_task_runner import run_propagate_project_task
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,3 +21,16 @@ def parse_project_media(task_id: int) -> dict:
|
|||||||
raise exc
|
raise exc
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="ai.propagate_project")
|
||||||
|
def propagate_project_masks(task_id: int) -> dict:
|
||||||
|
"""Run SAM video propagation for one queued task."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
return run_propagate_project_task(db, task_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("Propagation task failed: task_id=%s", task_id)
|
||||||
|
raise exc
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Word 方案描述的理想系统包含:
|
|||||||
- FastAPI 后端,使用 WebSocket 处理实时交互与任务进度。
|
- FastAPI 后端,使用 WebSocket 处理实时交互与任务进度。
|
||||||
- Celery + Redis 处理视频拆帧等长任务。
|
- Celery + Redis 处理视频拆帧等长任务。
|
||||||
- FFmpeg/OpenCV 解析视频,pydicom 解析医学影像。
|
- FFmpeg/OpenCV 解析视频,pydicom 解析医学影像。
|
||||||
- 本地 CUDA 上的 SAM 3 推理。
|
- 本地 CUDA 上的 SAM 推理;当前产品实现启用可选 SAM 2.1 tiny/small/base+/large,SAM 3 因没有文本提示入口而暂时禁用。
|
||||||
- GT mask 导入后通过距离变换、骨架提取、聚类等算法降维为点区域。
|
- GT mask 导入后通过距离变换、骨架提取、聚类等算法降维为点区域。
|
||||||
- 模板库管理分类、颜色和 z-index,用于语义分割遮罩重叠裁决。
|
- 模板库管理分类、颜色和 z-index,用于语义分割遮罩重叠裁决。
|
||||||
- PostgreSQL 存储项目、帧、模板和点区域数据。
|
- PostgreSQL 存储项目、帧、模板和点区域数据。
|
||||||
@@ -36,23 +36,22 @@ Word 方案描述的理想系统包含:
|
|||||||
| MinIO 对象存储 | 已落地 | `backend/minio_client.py` |
|
| MinIO 对象存储 | 已落地 | `backend/minio_client.py` |
|
||||||
| Redis 连接 | 已落地 | 用于 Celery broker/result backend,并通过 `seg:progress` pub/sub 转发任务进度 |
|
| Redis 连接 | 已落地 | 用于 Celery broker/result backend,并通过 `seg:progress` pub/sub 转发任务进度 |
|
||||||
| 视频拆帧 | 已落地 | `backend/services/frame_parser.py`、`backend/routers/media.py` |
|
| 视频拆帧 | 已落地 | `backend/services/frame_parser.py`、`backend/routers/media.py` |
|
||||||
| DICOM 批量导入 | 部分落地 | 上传和解析存在,项目级体验还需完善 |
|
| DICOM 批量导入 | 已落地 | 上传、文件名自然排序、解析任务创建和项目库解析进度回显均已接入 |
|
||||||
| WebSocket 进度 | 已落地 | 拆帧进度写入任务表后发布到 Redis `seg:progress`,FastAPI 广播到 `/ws/progress` |
|
| WebSocket 进度 | 已落地 | 拆帧进度写入任务表后发布到 Redis `seg:progress`,FastAPI 广播到 `/ws/progress` |
|
||||||
| SAM 推理 | 部分落地 | 后端已有 SAM 2 / SAM 3 选择和真实模型状态接口;SAM 2 已接 video predictor 片段传播;SAM 3 通过独立 Python 3.12 环境桥接,支持文本/框提示和 official video tracker 入口,状态会检查 Python/CUDA/包/本地 checkpoint |
|
| SAM 推理 | 部分落地 | 当前产品入口启用 SAM 2.1 tiny/small/base+/large 和真实 GPU/SAM2.1 状态接口;SAM 2.1 已接 point/box/interactive 和 video predictor 片段传播。SAM 3 桥接源码保留,但前端入口和后端 registry 已禁用 |
|
||||||
| 模板库 | 部分落地 | 分类、颜色、z-index 能存储和编辑;PNG mask 导出时会按 zIndex 做语义融合裁决,前端预览裁决尚未落地 |
|
| 模板库 | 部分落地 | 分类、颜色、maskid、JSON 批量导入预览和拖拽排序能存储和编辑;右侧语义分类树也可拖拽调整内部覆盖顺序;PNG mask 导出时会按内部优先级做语义融合裁决,前端预览裁决尚未落地 |
|
||||||
| 标注持久化 | 部分落地 | 后端有 `Annotation` 表,前端已接入新增、回显、分类更新、当前帧删除、手工绘制、GT mask 导入、seed point 编辑、polygon 顶点拖动/删除、边中点插点和多 polygon 子区域编辑;复杂洞结构编辑未落地 |
|
| 标注持久化 | 部分落地 | 后端有 `Annotation` 表,前端已接入新增、回显、分类更新、传播链前后帧同目标同步换类、当前帧删除、手工绘制、GT mask 导入、polygon 顶点拖动/删除、边中点插点和多 polygon 子区域编辑;复杂洞结构编辑未落地 |
|
||||||
| COCO / Mask 导出 | 已落地基础能力 | `backend/routers/export.py`;COCO JSON 和 PNG mask ZIP 前端按钮均已接入,ZIP 包含单标注 mask、语义融合 mask 和类别映射 |
|
| COCO / Mask 导出 | 已落地基础能力 | `backend/routers/export.py`;COCO JSON、兼容 PNG mask ZIP 和统一分割结果 ZIP 均已接入;统一 ZIP 包含 maskid/GT 像素值映射、原始图片、按帧/类别合并的分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;GT_label 固定为 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 |
|
||||||
|
|
||||||
## 当前代码尚未落地的目标
|
## 当前代码尚未落地的目标
|
||||||
|
|
||||||
- SAM 3:当前已提供 `sam3_engine.py` 外部环境桥接、`sam3_external_worker.py` 和 `setup_sam3_env.sh`;本机 `sam3` 环境已满足 Python 3.12、PyTorch 2.10/cu128、CUDA/GPU、官方包导入和本地 `sam3权重/sam3.pt` checkpoint 状态检查。官方没有 SAM 3 tiny/small 权重,当前可选最小真实 SAM 权重仍是 SAM 2 tiny;video tracker 入口已接入,真实效果取决于本地 checkpoint 是否兼容 video model。
|
- SAM 3:`sam3_engine.py`、`sam3_external_worker.py` 和 `setup_sam3_env.sh` 作为历史实现保留;由于当前系统不给文本提示,前端不再展示 SAM 3,后端 registry 也不暴露 `sam3`。官方没有 SAM 3 tiny/small 权重,当前可选最小真实 SAM 权重仍是 SAM 2.1 tiny。
|
||||||
- Celery 异步任务队列:已注册 Celery app 和拆帧 worker task,`/api/media/parse` 会创建任务表记录并入队。
|
- GT mask 导入:当前仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图导入,后端会按 maskid 拆分区域,生成高精度 polygon 标注;超出现有类别的 maskid 可舍弃或导入为黑色 `maskid:0` 的“待分类”;16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不一致会自动最近邻拉伸到当前帧;骨架提取、HDBSCAN 和更复杂的模板自动映射尚未实现。
|
||||||
- GT mask 导入:当前已支持二值/多类别 mask 导入,后端会按非零像素值拆分区域,生成 polygon 标注和距离变换 seed point;骨架提取、HDBSCAN 和模板自动映射尚未实现。
|
- Mask 到点区域的拓扑降维:后端保留 distance transform seed point 数据兼容;前端不再显示黄色 seed point,也不提供 seed point 拖拽编辑;骨架提取、HDBSCAN 等增强尚未实现。
|
||||||
- Mask 到点区域的拓扑降维:当前完成 distance transform seed point 和前端 seed point 拖拽编辑;骨架提取、HDBSCAN 等增强尚未实现。
|
- 类别优先级融合:PNG mask 导出时已按内部优先级生成语义融合 mask;前端裁决预览尚未实现。
|
||||||
- 类别优先级融合:PNG mask 导出时已按 zIndex 生成语义融合 mask;前端裁决预览尚未实现。
|
|
||||||
- 撤销/重做:当前已有全局 mask 历史栈。
|
- 撤销/重做:当前已有全局 mask 历史栈。
|
||||||
- 结构化归档保存:工作区按钮已调用 `POST /api/ai/annotate` 保存当前未归档 mask,并通过 `PATCH /api/ai/annotations/{id}` 更新 dirty mask。
|
- 保存状态按钮:工作区按钮按待保存数量显示“保存 X 个改动”或“已全部保存”,并调用 `POST /api/ai/annotate` 保存当前未归档 mask,通过 `PATCH /api/ai/annotations/{id}` 更新 dirty mask。
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
当前项目已经从 UI 原型推进到“可上传、可异步拆帧、可取消/重试任务、可查看失败详情、可实时查看任务进度、可浏览项目帧、可维护模板、可手工绘制、可逐点编辑 polygon、可边中点插点、可多 polygon 子区域编辑、可区域合并/去除、可点/框 AI 推理、可对点/框 prompt 做裁剪推理和背景过滤、可用 SAM 2 / SAM 3 进行视频片段传播、可导入多类别 GT mask、可编辑 seed point、可保存标注、可导出 COCO/语义 mask ZIP、可查看 Dashboard 后端概览”的全栈雏形。下一阶段最重要的是继续补齐 SAM 3 真实视频 tracker smoke test、复杂洞结构编辑和 GT mask 骨架/聚类增强。
|
当前项目已经从 UI 原型推进到“可上传、可异步拆帧、可取消/重试任务、可查看失败详情、可实时查看任务进度、可浏览项目帧、可维护模板、可手工绘制、可逐点编辑 polygon、可边中点插点、可多 polygon 子区域编辑、可区域合并/去除、可用可选 SAM 2.1 做点/框 AI 推理、可对点/框 prompt 做裁剪推理和背景过滤、可用 SAM 2.1 后台任务进行视频片段传播、可导入多类别 GT mask、可保存标注、可导出 COCO/语义 mask ZIP、可查看 Dashboard 后端概览”的全栈雏形。下一阶段最重要的是继续补齐复杂洞结构编辑、GT mask 骨架/聚类增强和前端语义融合预览。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
### 后端入口
|
### 后端入口
|
||||||
|
|
||||||
@@ -24,11 +24,12 @@
|
|||||||
- 检查 MinIO bucket。
|
- 检查 MinIO bucket。
|
||||||
- 测试 Redis。
|
- 测试 Redis。
|
||||||
- Seed 默认模板。
|
- Seed 默认模板。
|
||||||
- 如果存在 `Data_MyVideo_1.mp4`,创建默认项目并拆前 100 帧。
|
- 如果存在 `demo/演视LC视频序列.mp4` 和 `demo/演视DICOM序列/`,创建名为“演视LC视频序列”的默认演示视频项目和名为“演视DICOM序列”的演示 DICOM 项目,视频和 DICOM 都会生成帧,DICOM 按文件名自然顺序读取;启动时会把旧显示名 `Data_MyVideo_1` / `演示DICOM序列` 迁移为新显示名。
|
||||||
|
|
||||||
## 前端模块切换
|
## 前端模块切换
|
||||||
|
|
||||||
`App.tsx` 使用 Zustand 中的 `activeModule` 做模块切换,没有使用路由库。
|
`App.tsx` 使用 Zustand 中的 `activeModule` 做模块切换,没有使用路由库。
|
||||||
|
`useStore` 默认 `activeModule` 为 `dashboard`,因此用户登录后默认进入“总体概况”页。
|
||||||
|
|
||||||
| activeModule | 组件 | 页面 |
|
| activeModule | 组件 | 页面 |
|
||||||
|--------------|------|------|
|
|--------------|------|------|
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
| `workspace` | `VideoWorkspace` | 分割工作区 |
|
| `workspace` | `VideoWorkspace` | 分割工作区 |
|
||||||
| `ai` | `AISegmentation` | AI 智能分割页 |
|
| `ai` | `AISegmentation` | AI 智能分割页 |
|
||||||
| `templates` | `TemplateRegistry` | 模板库 |
|
| `templates` | `TemplateRegistry` | 模板库 |
|
||||||
|
| `admin` | `UserAdmin` | 管理员用户后台,仅 `role=admin` 可见 |
|
||||||
|
|
||||||
未登录时,`App.tsx` 直接渲染 `Login`。
|
未登录时,`App.tsx` 直接渲染 `Login`。
|
||||||
|
|
||||||
@@ -44,14 +46,14 @@
|
|||||||
|
|
||||||
全局状态在 `src/store/useStore.ts` 中,主要包括:
|
全局状态在 `src/store/useStore.ts` 中,主要包括:
|
||||||
|
|
||||||
- 登录状态:`isAuthenticated`、`token`
|
- 登录状态:`isAuthenticated`、`token`、`currentUser`
|
||||||
- 项目:`projects`、`currentProject`
|
- 项目:`projects`、`currentProject`
|
||||||
- 工作区:`activeModule`、`activeTool`、`frames`、`currentFrameIndex`
|
- 工作区:`activeModule`、`activeTool`、`frames`、`currentFrameIndex`
|
||||||
- 标注与 mask:`annotations`、`masks`
|
- 标注与 mask:`annotations`、`masks`
|
||||||
- 模板:`templates`、`activeTemplateId`
|
- 模板:`templates`、`activeTemplateId`
|
||||||
- UI:`isLoading`、`error`
|
- UI:`isLoading`、`error`
|
||||||
|
|
||||||
当前状态管理是前端内存状态,没有持久化到 localStorage,除了登录 token。
|
当前状态管理主要是前端内存状态;登录 token 会持久化到 `localStorage`,刷新后再通过 `/api/auth/me` 恢复当前用户。
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|
||||||
@@ -59,18 +61,28 @@
|
|||||||
|
|
||||||
1. `Login.tsx` 调用 `login()`。
|
1. `Login.tsx` 调用 `login()`。
|
||||||
2. `src/lib/api.ts` 请求 `POST /api/auth/login`。
|
2. `src/lib/api.ts` 请求 `POST /api/auth/login`。
|
||||||
3. FastAPI `backend/routers/auth.py` 校验 `admin / 123456`。
|
3. FastAPI `backend/routers/auth.py` 查询 `users` 表并校验密码哈希。
|
||||||
4. 前端把返回 token 写入 localStorage。
|
4. 前端把返回 JWT 写入 localStorage,并把用户资料写入 store。
|
||||||
|
5. 后续业务请求带 `Authorization: Bearer <token>`,后端按当前用户过滤项目资源。
|
||||||
|
6. 系统只支持唯一默认 `admin` 和 `annotator`;`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`。
|
||||||
|
|
||||||
|
### 管理员用户管理
|
||||||
|
|
||||||
|
1. `Sidebar.tsx` 仅对 `currentUser.role === 'admin'` 显示“用户管理”。
|
||||||
|
2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成标注员新增、停用/启用、改密码和删除用户;不提供观察员或第二个管理员入口。
|
||||||
|
3. `UserAdmin.tsx` 调用 `GET /api/admin/audit-logs` 展示登录成功/失败以及用户管理操作审计。
|
||||||
|
4. `UserAdmin.tsx` 危险区“恢复演示出厂设置”需要浏览器确认和输入 `RESET_DEMO_FACTORY`,随后调用 `POST /api/admin/demo-factory-reset`。
|
||||||
|
5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用、改名或降级自己;项目库已共享,因此删除标注员不会删除或迁移项目;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,直接从 `demo/` 重新创建名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已自然排序演示 DICOM 项目。
|
||||||
|
|
||||||
### 项目与拆帧
|
### 项目与拆帧
|
||||||
|
|
||||||
1. `ProjectLibrary.tsx` 调用 `getProjects()` 获取项目。
|
1. `ProjectLibrary.tsx` 调用 `getProjects()` 获取项目。
|
||||||
2. 上传视频时先 `createProject()`,再 `uploadMedia()`;导入视频不自动调用 `parseMedia()`。
|
2. 上传视频时先 `createProject()`,再 `uploadMedia()`;导入视频不自动调用 `parseMedia()`。
|
||||||
3. 后端 `media.py` 把原始文件上传到 MinIO。
|
3. 后端 `media.py` 把原始文件上传到 MinIO。
|
||||||
4. 用户在项目库点击“生成帧”并选择 FPS 后,`parseMedia()` 创建 `processing_tasks` 记录并投递 Celery worker。
|
4. 用户在项目库点击“生成帧”或“重新生成帧”并选择 FPS 后,`parseMedia()` 创建 `processing_tasks` 记录并投递 Celery worker;已有帧的视频重新生成时,worker 会先删除旧帧、旧标注和旧 mask,再写入新的帧序列。
|
||||||
5. Celery worker 下载 MinIO 文件,调用 `frame_parser.py` 拆帧。
|
5. Celery worker 下载 MinIO 文件,调用 `frame_parser.py` 拆帧。
|
||||||
6. worker 把拆出的帧重新上传 MinIO,写入 `frames` 表,并更新任务状态。
|
6. worker 把拆出的帧重新上传 MinIO,写入 `frames` 表,并更新任务状态。
|
||||||
7. 工作区只通过 `GET /api/projects/{id}/frames` 获取预签名图片 URL;若项目有源视频但无帧,会提示先回项目库生成帧。
|
7. 工作区只通过 `GET /api/projects/{id}/frames` 获取完整预签名图片 URL 列表;若项目有源视频但无帧,会提示先回项目库生成帧。
|
||||||
8. Dashboard 可通过 `POST /api/tasks/{id}/cancel` 取消 queued/running 任务,通过 `POST /api/tasks/{id}/retry` 重试 failed/cancelled 任务,并用 `GET /api/tasks/{id}` 查看失败详情。
|
8. Dashboard 可通过 `POST /api/tasks/{id}/cancel` 取消 queued/running 任务,通过 `POST /api/tasks/{id}/retry` 重试 failed/cancelled 任务,并用 `GET /api/tasks/{id}` 查看失败详情。
|
||||||
|
|
||||||
### 工作区浏览
|
### 工作区浏览
|
||||||
@@ -99,7 +111,7 @@
|
|||||||
## 当前主要风险点
|
## 当前主要风险点
|
||||||
|
|
||||||
- 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。
|
- 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。
|
||||||
- AI 语义文本提示在选择 SAM 3 且运行环境满足官方依赖、并具备 Hugging Face gated 权重访问时走 SAM 3;当前状态接口会分别暴露外部 Python 环境、CUDA、包导入和 checkpoint access 是否满足。
|
- AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。
|
||||||
- 工作区顶部“导出 JSON 标注集”“导出 PNG Mask ZIP”“导入 GT Mask”和“结构化归档保存”已接入导出、GT 多类别导入、seed point 回显/编辑、标注新增和 dirty 标注更新;清空当前帧遮罩会删除对应后端标注。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构。
|
- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为黑色 `maskid:0` 的“待分类”,16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧;GT 连通域会生成高精度 polygon,导入后和普通 mask 一样不显示黄色 seed point,并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围;Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹,GT_label 固定输出 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认,选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;多边形、矩形、圆和画笔创建遵循“有选中 mask 则并入选中 mask、无选中 mask 才新建”的规则,即使新几何和旧区域不重叠也会组成同一个多 polygon mask;无选中分类的新建多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”,画笔无选中 mask 时仍要求右侧语义分类树有 active class;`Esc` 只取消选区和临时绘制状态,不删除已有 mask。
|
||||||
- Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`,worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。
|
- Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`,worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。
|
||||||
- 后端路由大多未做真实鉴权。
|
- 后端已接入 Bearer JWT 鉴权、共享项目库和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求默认 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。
|
||||||
|
|||||||
@@ -12,25 +12,38 @@
|
|||||||
| 元素 | 位置 | 状态 | 说明 |
|
| 元素 | 位置 | 状态 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| 登录拦截 | `App.tsx` | 真实可用 | 未登录显示 `Login`,登录后显示主界面 |
|
| 登录拦截 | `App.tsx` | 真实可用 | 未登录显示 `Login`,登录后显示主界面 |
|
||||||
| 模块切换 | `Sidebar.tsx` + `App.tsx` | 真实可用 | 切换 `dashboard/projects/workspace/ai/templates` |
|
| 模块切换 | `Sidebar.tsx` + `App.tsx` | 真实可用 | 切换 `dashboard/projects/workspace/ai/templates`;“AI智能分割”入口使用 Bot + Sparkles 组合图标,强化 AI 语义 |
|
||||||
| Logo | `Sidebar.tsx` | 真实可用 | 使用 `/logo.png`,文件存在于 `public/logo.png` |
|
| Logo | `Login.tsx` / `Sidebar.tsx` | 真实可用 | 登录页、侧边栏和 favicon 都使用 `public/logo.png`,运行时访问路径为 `/logo.png` |
|
||||||
| GPU 状态圆标 | `Sidebar.tsx` | 真实可用 | 通过 `GET /api/ai/models/status` 显示 GPU/CPU 和当前模型可用性 |
|
| GPU 状态圆标 | `Sidebar.tsx` | 真实可用 | 通过 `GET /api/ai/models/status` 显示 GPU/CPU 和当前模型可用性 |
|
||||||
|
|
||||||
## 登录页
|
## 登录页
|
||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 用户名/密码输入 | 真实可用 | 默认填入 `admin / 123456` |
|
| 用户名/密码输入 | 真实可用 | 默认填入 `admin / 123456`,用户名使用 `autocomplete=username`,密码使用 `autocomplete=current-password` |
|
||||||
| 安全登录按钮 | 真实可用 | 调用 `POST /api/auth/login` |
|
| 安全登录按钮 | 真实可用 | 调用 `POST /api/auth/login`,后端校验 `users` 表密码哈希并返回签名 JWT |
|
||||||
| 错误提示 | 真实可用 | 捕获后端错误并显示 |
|
| 错误提示 | 真实可用 | 捕获后端错误并显示 |
|
||||||
| 安全审计说明文字 | Mock / UI-only | UI 文案,没有真实审计功能 |
|
| 登录态恢复 / 退出 | 真实可用 | 页面刷新后用 `/api/auth/me` 恢复当前用户;侧栏底部使用退出图标显示当前用户名并可退出登录,退出提示不接收鼠标事件,避免悬浮到工作区按钮时误弹出 |
|
||||||
|
| 安全审计说明文字 | 部分可用 | 登录和用户管理操作已有 `audit_logs` 记录;登录页“端到端加密”等安全文案仍是展示性说明,不代表已接入完整企业级安全审计 |
|
||||||
|
|
||||||
|
## 管理员用户后台
|
||||||
|
|
||||||
|
| 元素 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 侧栏“用户管理”入口 | 真实可用 | 仅当前用户 `role=admin` 时显示;非管理员无法看到入口,后端 `/api/admin/*` 也会返回 403 |
|
||||||
|
| 用户列表 | 真实可用 | 调用 `GET /api/admin/users`,展示用户 id、用户名、角色、启停用状态和创建时间 |
|
||||||
|
| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名和初始密码,新用户固定为标注员;后端校验用户名唯一、密码长度,并拒绝第二个管理员或观察员角色 |
|
||||||
|
| 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级、改名或停用,避免锁死后台 |
|
||||||
|
| 删除用户 | 真实可用 | 调用 `DELETE /api/admin/users/{id}`;后端禁止删除自己,且用户名下仍有项目时返回 409,避免悬空项目数据 |
|
||||||
|
| 审计日志 | 真实可用 | 调用 `GET /api/admin/audit-logs`,展示登录成功/失败、用户新增、修改和删除等管理操作 |
|
||||||
|
| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端直接从 `demo/` 读取“演视LC视频序列”和“演视DICOM序列”,只保留默认 admin、已生成帧的演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据;“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |
|
||||||
|
|
||||||
## Dashboard 系统概况
|
## Dashboard 系统概况
|
||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| WebSocket 连接状态 | 真实可用 | 前端通过 `src/lib/config.ts` 推导或读取 `VITE_WS_PROGRESS_URL`,后端有 `/ws/progress` |
|
| WebSocket 连接状态 | 真实可用 | 前端通过 `src/lib/config.ts` 推导或读取 `VITE_WS_PROGRESS_URL`,后端有 `/ws/progress`;Dashboard 卸载或切页导致的主动断开不会触发自动重连,也不会继续输出“Connection closed”噪音 |
|
||||||
| 解析队列任务 | 真实可用 | 初始数据来自 `GET /api/dashboard/overview`,按 `processing_tasks` queued/running/failed/cancelled 任务生成 |
|
| 任务进度 | 真实可用 | 初始数据来自 `GET /api/dashboard/overview`,按 `processing_tasks` queued/running/success/failed/cancelled 任务生成;统计卡片中的处理中任务数只计算 queued/running |
|
||||||
| 任务取消 | 真实可用 | queued/running 任务显示取消按钮,调用 `POST /api/tasks/{task_id}/cancel` |
|
| 任务取消 | 真实可用 | queued/running 任务显示取消按钮,调用 `POST /api/tasks/{task_id}/cancel` |
|
||||||
| 任务重试 | 真实可用 | failed/cancelled 任务显示重试按钮,调用 `POST /api/tasks/{task_id}/retry` 创建新任务 |
|
| 任务重试 | 真实可用 | failed/cancelled 任务显示重试按钮,调用 `POST /api/tasks/{task_id}/retry` 创建新任务 |
|
||||||
| 失败详情 | 真实可用 | 任务详情按钮调用 `GET /api/tasks/{task_id}`,弹窗展示 error、payload、result、Celery ID 和时间 |
|
| 失败详情 | 真实可用 | 任务详情按钮调用 `GET /api/tasks/{task_id}`,弹窗展示 error、payload、result、Celery ID 和时间 |
|
||||||
@@ -45,70 +58,80 @@
|
|||||||
| 项目列表 | 真实可用 | 调用 `GET /api/projects` |
|
| 项目列表 | 真实可用 | 调用 `GET /api/projects` |
|
||||||
| 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 |
|
| 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 |
|
||||||
| 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` |
|
| 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` |
|
||||||
| 新建项目 | 真实可用 | 调用 `POST /api/projects` |
|
| 新建项目 | 已移除入口 | 项目库不再展示独立“新建项目”按钮;导入视频/DICOM 时自动创建项目,后端 `POST /api/projects` 保留给导入流程和兼容调用 |
|
||||||
| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧 |
|
| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧;上传期间显示项目库导入进度条、百分比和已上传字节 |
|
||||||
| 生成帧按钮 | 真实可用 | 仅对已导入源视频且尚无帧、非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })` |
|
| 生成帧/重新生成帧按钮 | 真实可用 | 对已导入源视频且非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })`;已有帧时显示“重新生成帧”,后端会先清空旧帧、标注和 mask;任务入队后项目库继续轮询 `GET /api/tasks/{task_id}`,解析成功后立即重新拉取项目列表,使后端新写入的 `thumbnail_url` 自动刷新到项目封面 |
|
||||||
| 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS |
|
| 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS |
|
||||||
| 导入 DICOM 序列 | 部分可用 | 可上传 `.dcm` 并触发解析;体验和错误反馈较粗 |
|
| 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 |
|
||||||
|
| 导入 DICOM 序列 | 真实可用 | 可上传 `.dcm` 并触发解析;上传前按文件名自然顺序排序,后端解析也保持同一顺序;上传期间显示导入进度条、有效 DICOM 文件数量和已上传字节,上传完成后继续显示解析任务进度直到完成、失败或取消 |
|
||||||
| 项目状态徽标 | 真实可用 | 项目状态统一为 `pending/parsing/ready/error`,前端兼容归一化旧状态值 |
|
| 项目状态徽标 | 真实可用 | 项目状态统一为 `pending/parsing/ready/error`,前端兼容归一化旧状态值 |
|
||||||
| 更多按钮 | Mock / UI-only | 有图标,没有菜单或事件 |
|
| 删除项目按钮 | 真实可用 | 点击垃圾桶按钮会确认删除,调用 `DELETE /api/projects/{id}`,成功后从项目库移除;若删除的是当前项目,会清空工作区当前项目、帧、mask 和选区 |
|
||||||
| alert 成功/失败提示 | 真实可用但粗糙 | 使用浏览器 `alert` |
|
| 操作成功/失败提示 | 真实可用 | 使用非阻塞 `TransientNotice` 浮层,自动消失,不会拦截后续按钮、输入框或画布操作 |
|
||||||
|
|
||||||
## 工作区 VideoWorkspace
|
## 工作区 VideoWorkspace
|
||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 当前项目名 | 真实可用 | 读取 `currentProject.name` |
|
| 当前项目名 | 真实可用 | 读取 `currentProject.name` |
|
||||||
|
| 顶栏操作提示 | 真实可用 | 保存、导出、传播范围选择等短反馈会自动消失;保存/导出/传播进行中和无帧项目提示会保留到状态变化 |
|
||||||
| 自动加载项目帧 | 真实可用 | 调用 `GET /api/projects/{id}/frames` |
|
| 自动加载项目帧 | 真实可用 | 调用 `GET /api/projects/{id}/frames` |
|
||||||
| 无帧项目提示 | 真实可用 | 如果 `video_path` 存在但无帧,只提示回到项目库生成帧,不自动创建拆帧任务 |
|
| 无帧项目提示 | 真实可用 | 如果 `video_path` 存在但无帧,只提示回到项目库生成帧,不自动创建拆帧任务 |
|
||||||
| SAM 模型状态徽标 | 真实可用 | 调用 `GET /api/ai/models/status`,显示当前选择的 SAM 2/SAM 3 是否可用 |
|
| SAM 模型状态徽标 | 真实可用 | 左侧 Sidebar 底部保留紧凑 GPU/CPU 状态徽标;工作区顶栏不再重复显示该徽标,传播权重下拉和自动传播范围摘要只在进入自动传播后显示 |
|
||||||
| 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask |
|
| 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask;回显时保留当前项目帧里尚未保存的 AI/手工 draft mask,避免从 AI 页推送的候选被覆盖 |
|
||||||
| “导出 JSON 标注集”按钮 | 真实可用 | 导出前会保存未归档 mask,然后调用 `exportCoco()` 下载 JSON |
|
| “分割结果导出”按钮 | 真实可用 | 原“导出 JSON 标注集”和“导出 PNG Mask ZIP”已合并为一个入口;按钮使用 `FileDown` 图标和绿色强调背景,区别于普通灰色操作按钮;点击后可选择整体视频、特定范围帧或当前图片,默认导出范围为当前图片,并勾选导出分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;选择“特定范围帧”后会进入时间轴范围选择模式,可在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧,也可直接修改起止帧输入框;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览;提交前会保存未归档 mask,然后调用 `GET /api/export/{project_id}/results` 下载 ZIP;浏览器下载名和后端 `Content-Disposition` 均使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;时间戳格式为 `0h00m00s000ms`,帧序号来自项目抽帧后的 1-based 顺序,不使用原视频帧号;包内固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`;选择分开 mask 时包含按帧子目录组织且同类合并的 `分开Mask分割结果/`,选择 GT_label/Pro_label/Mix_label 时分别包含 `GT_label图/`、`Pro_label彩色分割结果/`、`Mix_label重叠覆盖彩色分割结果/`。GT_label 图固定为 8-bit uint8 PNG,背景为 0,语义类别值使用类别真实 maskid,`maskid: 0` 的“待分类”与背景同为 0,Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 |
|
||||||
| “导出 PNG Mask ZIP”按钮 | 真实可用 | 导出前会保存未归档 mask,然后调用 `GET /api/export/{project_id}/masks` 下载 ZIP;后端同时包含单标注 mask、每帧语义融合 mask 和 `semantic_classes.json` |
|
| “导入 GT Mask”按钮 | 真实可用 | 入口已从工作区顶栏移动到左侧工具栏“重叠区域去除”之后,使用紫色图标底色;选择图片后先弹出导入结果预览和未知 maskid 策略选择,可舍弃未知类别或导入为黑色 `maskid:0` 的“待分类”;随后调用 `POST /api/ai/import-gt-mask`,后端仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,不符合 8-bit 灰度/maskid 图要求时返回错误,16-bit/uint16 GT_label 会被拒绝;尺寸不同会自动最近邻拉伸到当前帧,再按类别/连通域生成高精度 polygon 标注,最后回显到工作区;导入 mask 与普通 mask 一样不显示黄色 seed point,并共用拓扑锚点统计、边缘平滑、编辑、分类和保存链路 |
|
||||||
| “导入 GT Mask”按钮 | 真实可用 | 选择图片后调用 `POST /api/ai/import-gt-mask`,后端按非零像素值和连通域生成 polygon 标注与距离变换 seed point,再回显到工作区 |
|
| 参考帧/起止帧/传播权重/AI自动推理 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed;左侧工具栏橡皮擦下方有彩色 AI 大脑图标“AI自动推理”入口,点击后进入时间轴范围选择模式,顶栏才显示独立“传播权重”下拉,可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;工作区会读取 `GET /api/ai/models/status`,当所有 SAM 2.1 变体不可用时禁用“AI自动推理”,当所选传播权重不可用时禁用“开始传播”,传播权重下拉中不可用变体也不可选择,避免提交后没有任何推理结果;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 实例 id、未编辑传播结果的原始 seed 签名和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id,再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictor;同一参考帧多个同类别 seed 会优先按 `source_instance_id/instance_id` 分开传播,语义 `maskid` 只用于类别/导出;worker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon,因此传播以新几何参与签名;中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏蓝色进度面板显示任务进度、已处理帧次、删除旧区域数和已保存区域数,同一任务 message 不再同时显示在左侧灰色状态文字里;前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask |
|
||||||
| “传播片段”按钮 | 真实可用 | 使用当前选中 mask 或当前帧第一个 mask 作为 seed,调用 `POST /api/ai/propagate`;SAM 2 走 video predictor,SAM 3 走 external video tracker,完成后刷新已保存标注 |
|
| 清空片段遮罩 | 已移除 | 顶栏不再提供重复的“清空片段遮罩”;当前帧清空和 DEL 删除只从左侧工具栏或键盘触发,存在传播链时在同一弹窗提供取消/只清当前帧/按帧范围选择/清空所有传播帧 |
|
||||||
| “结构化归档保存”按钮 | 真实可用 | 未保存 mask 写入 `POST /api/ai/annotate`;dirty mask 写入 `PATCH /api/ai/annotations/{id}` |
|
| 保存状态按钮 | 真实可用 | 顶栏按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;未保存 mask 写入 `POST /api/ai/annotate`,dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask,避免仍显示未保存 |
|
||||||
|
|
||||||
## CanvasArea 画布
|
## CanvasArea 画布
|
||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 当前帧底图显示 | 真实可用 | `useImage(frameUrl)` 加载当前帧 URL |
|
| 当前帧底图显示 | 真实可用 | `useImage(frameUrl)` 加载当前帧 URL;切换帧或容器尺寸变化时会按 86% 适配比例居中放大显示,默认留出画布边距,不铺满整个画布 |
|
||||||
| 滚轮缩放 | 真实可用 | 改变 Konva Stage scale |
|
| 滚轮缩放 | 真实可用 | 改变 Konva Stage scale |
|
||||||
| 拖拽平移 | 真实可用 | activeTool 为 `move` 时 Stage draggable |
|
| 拖拽平移 | 真实可用 | activeTool 为 `move` 时 Stage draggable,拖拽结束会回写 React position state,避免 Konva 节点位置和前端状态脱节 |
|
||||||
| 光标坐标显示 | 真实可用 | 根据 pointer position 计算 |
|
| 光标坐标显示 | 真实可用 | 根据 pointer position 计算 |
|
||||||
| 正向/反向选点 | 真实可用 | UI 能加点,并按当前帧 `frame.id` 调用 `/api/ai/predict`;结果需点击归档保存才持久化 |
|
| 正向/反向选点 | 真实可用 | UI 能加点,并按当前帧 `frame.id` 调用 `/api/ai/predict`;结果需点击归档保存才持久化 |
|
||||||
| 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 |
|
| 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 |
|
||||||
| AI 推理中提示 | 真实可用 | 请求期间会显示 |
|
| AI 推理中提示 | 真实可用 | 请求期间会显示 |
|
||||||
| 手工多边形/矩形/圆/点/线 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆/线拖拽生成 polygon;点工具生成小区域;绘制工具可在已有 mask 上继续落点;均写入 `Mask.segmentation`,可归档保存 |
|
| 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆拖拽生成 polygon;切换到多边形/矩形/圆会保留当前 mask 选区,有选中 mask 时新创建的多边形/矩形/圆会通过 polygon union 并入该 mask,即使两块区域不重叠也合并为同一个多 polygon mask;没有选中 mask 时才创建新 mask,未选语义分类时自动归入黑色 `maskid:0` 的“待分类”;画笔按当前语义分类或当前选中 mask 生成连续圆形笔触,松开后有选中 mask 则并入选中 mask,没有选中 mask 才创建新的当前类别 mask;画笔闭合形成中空区域时保留内洞 ring,使用 even-odd 渲染并显示内外圈顶点;按 `Esc` 或点击左侧“取消选中”按钮可清空选区和临时绘制状态;橡皮擦从选中 mask 中扣除笔触区域;均写入 `Mask.segmentation`,可归档保存 |
|
||||||
| Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染 |
|
| 画布上下文提示 | 真实可用 | 切换到多边形、矩形、圆、画笔、橡皮擦、区域合并/去除、调整多边形等隐性操作工具时,画布左上角显示当前工具的完成/取消/选择顺序提示;提示会在数秒后自动隐藏,避免长期遮挡待编辑图像,工具或操作状态变化时会重新出现 |
|
||||||
| Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;拖动顶点会重算 `pathData/segmentation/bbox/area`,已保存 mask 标为 dirty;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask,已保存 mask 会同步调用后端删除 |
|
| Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染;普通 mask 和导入 mask 都不显示黄色 seed point;未选中特定 mask 时,当前帧 mask 会按右侧“语义分类树”拖拽得到的内部覆盖优先级从低到高渲染,使高优先级类别显示在上层;有选中 mask 时保留编辑态置顶行为,方便操作 |
|
||||||
| GT seed point 回显/编辑 | 真实可用 | 已保存标注的 `points` 会显示为黄色 seed 点;拖动后标记为 dirty,归档保存会更新后端 |
|
| Mask 透明度 | 真实可用 | 右侧语义分类树上方的“遮罩透明度”滑杆写入全局 `maskPreviewOpacity`,工作区 Canvas 和 AI 智能分割页都会使用该值调整 mask 预览透明度,选中 mask 会在该基础上略微加亮 |
|
||||||
| 应用分类 | 真实可用 | Canvas 右下角按钮可将当前选择的模板分类应用到本帧 mask;右侧语义分类树点击分类时会优先改当前已选 mask;已保存 mask 会标为 dirty,归档保存时更新后端 |
|
| 传播链跨帧选区跟随 | 真实可用 | 用户选中某个 mask 后切到同一自动传播结果覆盖的其他帧时,`CanvasArea` 会根据 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 查找目标帧对应传播 mask 并自动选中;找不到同链结果时才清空选区 |
|
||||||
| 清空遮罩 | 真实可用 | 工作区中会删除当前帧已保存标注并清空当前帧本地 mask |
|
| Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;多 polygon 或分离区域组成的同一个 mask 会显示所有子区域顶点,不再只显示主区域;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`,不需要先单击选中顶点,已保存 mask 标为 dirty;顶点拖拽结束不会触发 Stage 平移,Canvas 当前缩放和位置保持不变;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask,左侧 DEL 按钮复用同一链路;已保存 mask 删除前会预检当前后端 annotation id 并只删除仍存在的 id,避免陈旧本地 id 产生 DELETE 404;若删除对象是传播 seed 或传播结果,前端会按 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 同步删除同链自动传播 mask,但不删除其他帧独立 AI 推理/人工 mask |
|
||||||
|
| 应用分类 | 真实可用 | Canvas 右下角不再提供“应用分类”快捷按钮,避免没选区时误改整帧;右侧语义分类树点击分类时,无选中 mask 只设置后续新建 mask 的 active class,不修改已有 mask;有选中 mask 时才改当前已选 mask,并通过 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 同步更新同一传播链上的前后传播 mask,同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty,归档保存时更新后端 |
|
||||||
|
| 清空遮罩 | 真实可用 | 工作区只通过左侧工具栏触发清空;当前帧有选中 mask 时清选中 mask,没有选中时清当前帧全部 mask;无传播链结果时直接执行,存在传播链结果时弹窗选择取消、只清当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用时间轴范围选择和最终确认;按范围清空或清空所有传播帧时若目标范围包含人工/AI 标注帧,会二次确认是否删除,选择否会整帧保留 |
|
||||||
| 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 |
|
| 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 |
|
||||||
| 当前图层树文字 | Mock / UI-only | 固定显示 `OBJECT_VEHICLE_01` |
|
| 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id;未保存 mask 显示“未保存”,未选中时显示“未选择” |
|
||||||
|
|
||||||
## ToolsPalette 工具栏
|
## ToolsPalette 工具栏
|
||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| 工具分组分隔线 | 真实可用 | 拖拽/选择到创建圆为绘制/基础编辑组,画笔/橡皮擦/AI自动推理为局部与追踪组,区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask 和 AI 智能分割为外部动作组;组间使用浅灰横线分隔,`data-testid="tool-group-separator"` 位于清空遮罩下方的外部动作组分隔线 |
|
||||||
| 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 |
|
| 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 |
|
||||||
| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持拖动顶点、点击边中点插点、双击边界按位置插点 |
|
| 取消选中 | 真实可用 | 位于拖拽/选择按钮下方,实体按钮等同 `Esc`:清空当前 mask 选区、临时绘制点/笔触和顶点选择,不删除 mask、不清空 active class |
|
||||||
| 多边形/矩形/圆/点/线 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成可保存的 polygon mask |
|
| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持按住顶点直接拖动、点击边中点插点、双击边界按位置插点 |
|
||||||
| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask,右下角显示已选数量和操作按钮;合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;使用 `polygon-clipping` 做 union / difference;合并会保留主 mask 并移除被合并 mask,去除会从主 mask 扣除后续选中 mask;内含扣除会保留 hole ring 并用 even-odd 规则渲染 |
|
| 多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成或编辑可保存的 polygon mask;画笔/橡皮擦在工具栏显示尺寸滑杆 |
|
||||||
| 正向选点/反向选点/框选 | 部分可用 | 会影响 Canvas 交互,并能触发已对齐的 AI 推理接口 |
|
| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask,右下角显示已选数量和操作按钮;合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;布尔选择态中第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,避免主区域和扣除区域看起来像随机阴影差异;使用 `polygon-clipping` 做 union / difference;若选中的主区域和参与区域存在传播帧对应 mask,会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择会进入和传播一致的时间轴范围选择,点击顶栏确认后再弹最终确认,只处理范围内存在对应传播链的帧;合并会保留主 mask 并移除被合并 mask,且移除次级 mask 时会同步删除其同链自动传播结果;去除会从主 mask 扣除后续选中 mask;内含扣除会保留 hole ring 并用 even-odd 规则渲染 |
|
||||||
| 魔法棒 SAM 触发 | 部分可用 | 切到 AI 页面;不是直接执行推理 |
|
| 导入 GT Mask | 真实可用 | 位于“重叠区域去除”之后,点击后打开文件选择器,并在上传前选择未知类别处理策略;该入口不切换 activeTool |
|
||||||
| 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`,支持工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y |
|
| AI 智能分割跳转入口 | 真实可用 | 切到 AI 智能分割页;不是直接执行推理 |
|
||||||
|
| AI 正向选点/反向选点/框选 | 不在工作区工具栏显示 | 这些是 AI 智能分割页功能,工作区左侧工具栏不再提供正向选点、反向选点和边界框选按钮 |
|
||||||
|
| AI 智能分割入口 | 真实可用 | 位于工作区工具栏底部,使用和侧栏一致的 Bot + Sparkles 组合图标;点击后切到 AI 智能分割页 |
|
||||||
|
| 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`,工作区只保留顶栏按钮和快捷键 `Ctrl/Cmd+Z`、`Ctrl/Cmd+Shift+Z`、`Ctrl/Cmd+Y`,AI 页保留自己的按钮;左侧工具栏不再重复放置撤销/重做;输入框聚焦时不拦截快捷键;工作区顶栏撤销图标使用琥珀色、重做图标使用蓝紫色,提高深色顶栏里的识别度 |
|
||||||
|
| 紧凑/滚动布局 | 真实可用 | 工具按钮使用较紧凑的垂直间距;左侧高度不足时工具栏自身出现纵向滚动,不挤压画布;外层工具栏扩展到 56px,按钮列仍固定 48px,滚动条占用右侧外扩空间,不挤占图标位置;滚动条使用 `seg-scrollbar`,默认低对比融入深色工具区,hover/focus 时才增强为青色提示 |
|
||||||
|
|
||||||
## FrameTimeline 时间轴
|
## FrameTimeline 时间轴
|
||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 帧缩略图 | 真实可用 | 使用 `frames[].url` |
|
| 帧缩略图 | 真实可用 | 使用 `frames[].url` |
|
||||||
| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)` |
|
| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 |
|
||||||
| 顶部 range 拖动 | 真实可用 | 改变当前帧 |
|
| 顶部 range 拖动 | 真实可用 | 改变当前帧 |
|
||||||
| 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` |
|
| 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` |
|
||||||
|
| 播放进度条 / 视频处理进度条 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;当前帧位置用一条白色竖线贯穿两条进度条,避免和青色播放进度、红/蓝处理状态混淆;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `mask_data.source`、`propagated_from_frame_id`、`source_annotation_id`、`source_mask_id` 或 `propagation_seed_key` 识别自动传播生成的帧并显示蓝色区段,人工绘制或 AI 智能分割生成的帧显示红色竖线,红/蓝标识也可点击跳转到对应帧;每次自动传播成功处理帧后,工作区会在当前会话记录最近传播范围,并在视频处理进度条上叠加同一蓝色系的纯色片段,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,辅助识别第一次、第二次、第 N 次传播;传播历史片段会按当前仍存在的自动传播 mask 自动裁剪或拆分,单独删除传播 mask 后,无任何 mask 的帧不会继续显示红/蓝颜色;未处理背景使用中性灰以和红/蓝/传播历史标记区分;工作区进入自动传播或布尔操作的范围选择模式时,两条进度条显示 amber 选区,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表示待处理起止帧,颜色避开附近的青色、红色、蓝色和 amber 元素 |
|
||||||
| 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps |
|
| 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps |
|
||||||
| 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 |
|
| 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 |
|
||||||
|
|
||||||
@@ -116,29 +139,36 @@
|
|||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 模板选择 | 部分可用 | 读取全局 templates,可切换 activeTemplateId |
|
| 模板选择 | 真实可用 | 读取全局 templates,可切换 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息 |
|
||||||
| 分类树展示 / 换标签 | 真实可用 | 显示模板 classes 和本地 customClasses;点击分类会设为后续新 mask 的 activeClass,如果 Canvas 已选 mask,则同步更新已选 mask 的标签、颜色和 class 元数据 |
|
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`,默认低对比融入深色侧栏,hover/focus 时才增强显示 |
|
||||||
| 添加自定义分类 | 部分可用 | 只存在组件本地状态,不保存到后端 |
|
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
|
||||||
| 置信度条 | Mock / UI-only | 固定 `0.9412` |
|
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes;点击分类会设为后续新 mask 的 activeClass;如果 Canvas 无选中 mask,则不会改变已有 mask;如果 Canvas 已选 mask,则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class,并滚动/聚焦到对应分类按钮 |
|
||||||
| 拓扑锚点数量 | Mock / UI-only | 固定 `12 节点` |
|
| 添加自定义分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store |
|
||||||
| 重新提取骨架按钮 | Mock / UI-only | 无事件 |
|
| 目标实例属性标题 | 真实可用 | “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不再跟随全局 active class,避免点过其他分类后标题固定成旧分类 |
|
||||||
|
| 当前选中区域计数 | 已移除 | 当前交互以单选 mask 为主,计数长期为 1,属于低价值信息,已从实例属性面板删除 |
|
||||||
|
| 后端拓扑锚点数量 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,后端按 polygon 的真实顶点数量返回 `topology_anchor_count`;`topology_anchors` 列表只保留最多 64 个抽样点用于调试展示,避免把真实数量误压成十几个;前端会忽略被浏览器中止或已过期的分析请求,避免切换 mask、拖动平滑预览或卸载组件时出现误报 |
|
||||||
|
| 边缘平滑强度 / 应用边缘平滑 | 真实可用 | 选中 mask 后调整 0-100 平滑强度会先即时更新滑杆数值,再在用户停止拖动约 220ms 后调用 `POST /api/ai/smooth-mask` 生成预览 polygon,避免拖动时连续请求导致卡顿;预览会临时替换当前 mask 显示但不标 dirty;点“应用边缘平滑”后会把平滑 polygon 作为新的实际 mask 几何写入当前 mask 和同传播链前/后对应 mask,整次应用进入同一个撤销/重做历史步骤,并把相关 mask 标记为 dirty/draft;传播链上的 mask 保存时会保留原传播 lineage metadata,不会因为平滑几何同步而在时间轴上变成人工/AI 红色标注帧;应用后平滑强度重置为 0,后续可继续用“调整多边形”编辑新的 polygon;后端平滑使用缓入强度曲线,低强度只做温和切角和轻量去噪,高强度才逐步增加 Chaikin 迭代、切角比例和简化阈值,避免 20% 前后已经过度平滑 |
|
||||||
|
|
||||||
## AISegmentation 独立 AI 页
|
## AISegmentation 独立 AI 页
|
||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 模型选择 SAM2/SAM3 | 真实可用 | 选择写入 Zustand,`predictMask()` 会把 `model` 传给后端 SAM registry |
|
| SAM 2.1 变体选择 / 模型状态 | 真实可用 | AI 页可选 tiny/small/base+/large,调用 `GET /api/ai/models/status?selected_model=<variant>` 展示所选变体和 GPU 状态;只有本地存在 checkpoint 且 PyTorch/SAM2 依赖可用的变体显示可用,不可用变体不能点击,执行按钮显示“当前模型不可用”并阻止推理请求 |
|
||||||
| 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口;SAM 2 框选后会携带原始框和累计正/反点细化同一个候选 mask;SAM 3 选择后会提示点交互需切回 SAM 2 |
|
| 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口;AI 页中点击已有候选 mask 时也会继续添加当前正/反向提示点,点击已有提示点会删除该点;SAM 2.1 框选后会携带原始框和累计正/反点细化同一个候选 mask |
|
||||||
| SAM 3 框选 | 真实可用 | 工作区选择 SAM 3 后可使用框选工具;后端通过官方 `add_geometric_prompt()` 正框执行 SAM 3 几何提示推理 |
|
| 边界框选 | 真实可用 | AI 页选择工具后可在画布拖拽蓝色虚线框;执行分割时会随 `/api/ai/predict` 发送 `box`,框选后继续添加正/反点会发送 interactive prompt |
|
||||||
| 语义文本输入 | 部分可用 | 纯文本会以 `semantic` prompt 调用后端;选择 SAM 3 且独立 Python 3.12 环境、CUDA、官方包和本地 checkpoint 均满足时走 SAM 3 文本语义推理,否则状态接口会标明不可用;空文本、失败和 0 mask 返回会显示前端反馈 |
|
| AI 画布上下文提示 | 真实可用 | 选择正向点、反向点、边界框选或视口控制时,画布左上角提示点击/拖拽、删除提示点和执行推理的操作方式 |
|
||||||
| 参数开关 | 真实可用 | `cropMode` 会随 `/api/ai/predict` 发送 `crop_to_prompt`,后端对点/框 prompt 裁剪推理区域并回映射 polygon;`autoDeleteBg` 会发送 `auto_filter_background` 和 `min_score`,后端过滤低分结果和覆盖负向点的结果 |
|
| SAM 3 入口 | 当前禁用 | 因当前系统不提供文本提示,前端不再显示 SAM 3 模型选择、文本输入或 SAM 3 框选入口;后端 `model=sam3` 返回不支持 |
|
||||||
| 执行高精度语义分割 | 真实可用 | 使用当前项目帧调用 `/api/ai/predict`;SAM 2 需要点提示且只采用最高分候选,SAM 3 需要文本语义提示;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 |
|
| 语义文本输入 | 当前禁用 | AI 页不再提供文本语义输入;后端收到 `semantic` prompt 会返回 400 |
|
||||||
| 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的 mask,便于继续调轮廓和归档 |
|
| 参数开关 | 真实可用 | UI 展示为“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”,只是为了让用户更容易理解,不重命名内部字段;`cropMode` 会随 `/api/ai/predict` 发送 `crop_to_prompt`,后端对点/框 prompt 裁剪推理区域并回映射 polygon;`autoDeleteBg` 会发送 `auto_filter_background` 和 `min_score`,后端过滤低分结果和覆盖负向点的结果 |
|
||||||
| 上传替换底图 | Mock / UI-only | 按钮无事件 |
|
| AI 遮罩透明度 | 真实可用 | 调节共享的 `maskPreviewOpacity`,AI 页候选 mask 和右侧“遮罩透明度”滑杆联动,只影响预览显示,不改变 mask 几何、分类或保存数据 |
|
||||||
|
| 执行高精度语义分割 | 真实可用 | 使用当前项目帧和所选 SAM 2.1 变体调用 `/api/ai/predict`;SAM 2.1 需要点/框提示且只采用最高分候选;AI 页只渲染本页最新候选,不显示工作区已有 mask,重复执行会替换上一次 AI 页候选而不是叠加;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 |
|
||||||
|
| 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的未保存 mask 和当前帧视角;推送前会校验当前 AI 候选 mask 必须已有 `classId` 或 `className`,未选择语义分类时会用右上角 error toast 提示用户先点右侧语义分类树,不允许进入工作区;如果用户直接离开 AI 页,未分类 AI 候选会被清理,避免无语义 mask 进入工作区;工作区回显后端标注时不会覆盖这类 draft mask,也不会强制跳回第一帧 |
|
||||||
| 撤销/重做 | 真实可用 | 绑定全局 mask 历史栈 |
|
| 撤销/重做 | 真实可用 | 绑定全局 mask 历史栈 |
|
||||||
| 清空全体锚点 | 部分可用 | 清空前端 points 和 masks |
|
| 删除最近锚点 | 真实可用 | 删除 AI 页最近一次放置的正/反向提示点,不影响已生成候选 mask 或工作区 mask |
|
||||||
| 退档推送至工作区重组 | 部分可用 | 只切回工作区,共用 masks store,但没有保存/确认流程 |
|
| 删除选中候选 | 真实可用 | 删除 AI 页当前选中的本页候选 mask;不会删除工作区已有 mask,Delete/Backspace 也遵循同一范围 |
|
||||||
| 背景图 | 部分可用 | 优先显示当前项目帧;没有项目帧时仍回退到 Unsplash 演示图 |
|
| 清空全体锚点 | 真实可用 | 清空 AI 页提示点和本页生成的候选 mask,不删除工作区已有 mask |
|
||||||
|
| 背景图 / 空状态 | 真实可用 | 优先显示当前项目帧;没有项目帧时显示空状态提示,不再回退到外部演示图片 |
|
||||||
|
| AI 画布初始视图 | 真实可用 | 当前帧在 AI 画布中默认居中,并按 86% 适配比例尽量放大但保留边距 |
|
||||||
|
|
||||||
## TemplateRegistry 模板库
|
## TemplateRegistry 模板库
|
||||||
|
|
||||||
@@ -149,13 +179,12 @@
|
|||||||
| 编辑模板 | 真实可用 | 调用 `PATCH /api/templates/{id}` |
|
| 编辑模板 | 真实可用 | 调用 `PATCH /api/templates/{id}` |
|
||||||
| 删除模板 | 真实可用 | 调用 `DELETE /api/templates/{id}` |
|
| 删除模板 | 真实可用 | 调用 `DELETE /api/templates/{id}` |
|
||||||
| 添加/删除分类 | 真实可用 | 保存在模板 `mapping_rules.classes` |
|
| 添加/删除分类 | 真实可用 | 保存在模板 `mapping_rules.classes` |
|
||||||
| 拖拽排序 | 真实可用 | 重算 zIndex,保存时写后端 |
|
| 拖拽排序 | 真实可用 | 模板库详情页、模板编辑弹窗和工作区右侧语义分类树都可拖拽调整内部覆盖优先级,保存时写后端;模板库详情页拖拽会刷新当前详情并同步当前工作区同类 mask 的 `classZIndex`,工作区拖拽也会同步当前同类 mask 的 `classZIndex` 并标记待保存;界面只显示类别稳定 maskid,maskid 不作为排序规范;黑色 `maskid: 0` 的“待分类”保留类固定在最后,不可删除或拖拽上移 |
|
||||||
| JSON 批量导入 | 部分可用 | 前端解析 JSON 并加入编辑态,保存后才落库 |
|
| JSON 批量导入 | 真实可用 | 前端解析 `[[colors], [names]]` 和 `{colors, names}` 两种格式,并兼容带前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容;显示导入数量、maskid 起点和缺失颜色提示;导入后加入编辑态,保存模板时落库 |
|
||||||
| 载入腹腔镜 35 分类 | 真实可用 | 前端内置数据;后端也 seed 默认模板 |
|
| mapping rules | 部分可用 | 可存 `rules`,但当前没有运行时映射执行引擎;适合后续用于导入外部标签、别名归一化或跨数据集类别映射 |
|
||||||
| mapping rules | 部分可用 | 可存 `rules`,但无实际映射执行引擎 |
|
|
||||||
|
|
||||||
## 总体结论
|
## 总体结论
|
||||||
|
|
||||||
当前前端真实可用的主链路是:登录、Dashboard 后端概览、项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、COCO 导出、PNG mask ZIP 导出、模板 CRUD。
|
当前前端真实可用的主链路是:JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、上传视频/DICOM、显式生成帧/重新生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。
|
||||||
|
|
||||||
当前最主要的 Mock 或未打通链路是:polygon 插点/边编辑增强、真正的文本语义分割、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单和若干检查面板指标。
|
当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看。
|
||||||
|
|||||||
@@ -15,39 +15,43 @@ timeout: 30000
|
|||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
当前后端多数接口没有鉴权依赖,所以这个 header 主要是前端侧行为。
|
当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401;项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,所有登录用户可读取,`admin/annotator` 可写入。
|
||||||
|
|
||||||
## 前端封装的 API
|
## 前端封装的 API
|
||||||
|
|
||||||
| 函数 | 方法与路径 | 状态 | 说明 |
|
| 函数 | 方法与路径 | 状态 | 说明 |
|
||||||
|------|------------|------|------|
|
|------|------------|------|------|
|
||||||
| `login(username, password)` | `POST /api/auth/login` | 对齐 | 后端返回 `{ token, username }`,前端只使用 token |
|
| `login(username, password)` | `POST /api/auth/login` | 对齐 | 后端返回 `{ token, token_type, username, user }`,前端保存 token 和当前用户 |
|
||||||
|
| `getCurrentUser()` | `GET /api/auth/me` | 对齐 | 用已有 Bearer token 恢复当前登录用户 |
|
||||||
| `getProjects()` | `GET /api/projects` | 对齐 | 前端映射 `frame_count`、`thumbnail_url` 等字段 |
|
| `getProjects()` | `GET /api/projects` | 对齐 | 前端映射 `frame_count`、`thumbnail_url` 等字段 |
|
||||||
| `createProject(payload)` | `POST /api/projects` | 对齐 | 支持 `name`、`description`、`parse_fps` |
|
| `createProject(payload)` | `POST /api/projects` | 对齐 | 支持 `name`、`description`、`parse_fps` |
|
||||||
| `updateProject(id, payload)` | `PATCH /api/projects/{id}` | 对齐 | 后端是 `PATCH /api/projects/{id}` |
|
| `updateProject(id, payload)` | `PATCH /api/projects/{id}` | 对齐 | 后端是 `PATCH /api/projects/{id}` |
|
||||||
| `deleteProject(id)` | `DELETE /api/projects/{id}` | 对齐 | 当前 UI 未明显接入 |
|
| `deleteProject(id)` | `DELETE /api/projects/{id}` | 对齐 | 项目卡片删除按钮已接入,删除前使用站内确认弹窗 |
|
||||||
| `getTemplates()` | `GET /api/templates` | 对齐 | 前端从 `mapping_rules` 取 classes/rules |
|
| `getTemplates()` | `GET /api/templates` | 对齐 | 前端从 `mapping_rules` 取 classes/rules |
|
||||||
| `createTemplate(payload)` | `POST /api/templates` | 对齐 | 后端会打包 classes/rules 到 mapping_rules |
|
| `createTemplate(payload)` | `POST /api/templates` | 对齐 | 后端会打包 classes/rules 到 mapping_rules |
|
||||||
| `updateTemplate(id, payload)` | `PATCH /api/templates/{id}` | 对齐 | 模板编辑页使用 |
|
| `updateTemplate(id, payload)` | `PATCH /api/templates/{id}` | 对齐 | 模板编辑页使用 |
|
||||||
| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 |
|
| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 |
|
||||||
| `uploadMedia(file, projectId)` | `POST /api/media/upload` | 对齐 | multipart form-data |
|
| `uploadMedia(file, projectId, options?)` | `POST /api/media/upload` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度 |
|
||||||
| `uploadDicomBatch(files, projectId)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data |
|
| `uploadDicomBatch(files, projectId, options?)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度,上传完成后项目库轮询解析任务进度 |
|
||||||
| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task;由项目库“生成帧”显式调用,支持 `parse_fps`、`max_frames`、`target_width` |
|
| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task;由项目库“生成帧/重新生成帧”显式调用,已有帧时 worker 会先清空旧帧、标注和 mask;支持 `parse_fps`、`max_frames`、`target_width` |
|
||||||
| `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 |
|
| `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 |
|
||||||
| `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery |
|
| `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery |
|
||||||
| `retryTask(taskId)` | `POST /api/tasks/{task_id}/retry` | 对齐 | 对 failed/cancelled 任务创建新的 queued 重试任务 |
|
| `retryTask(taskId)` | `POST /api/tasks/{task_id}/retry` | 对齐 | 对 failed/cancelled 任务创建新的 queued 重试任务 |
|
||||||
| `getProjectFrames(projectId)` | `GET /api/projects/{id}/frames` | 对齐 | 后端返回预签名 image_url,以及 `timestamp_ms`、`source_frame_number` |
|
| `getProjectFrames(projectId)` | `GET /api/projects/{id}/frames` | 对齐 | 默认返回完整帧列表和预签名 image_url,以及 `timestamp_ms`、`source_frame_number`;可选 `skip/limit` 仅用于显式分页 |
|
||||||
| `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 同步传播接口,供后端兼容和测试使用 |
|
||||||
| `getAiModelStatus(selectedModel?)` | `GET /api/ai/models/status` | 对齐 | 返回 GPU、SAM 2、SAM 3 的真实运行状态 |
|
| `queuePropagationTask(payload)` | `POST /api/ai/propagate/task` | 对齐 | 工作区“AI自动推理”入口;创建 Celery 后台任务并由任务表/进度流追踪 |
|
||||||
|
| `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;保存链路会先预检后端标注 id,已知缺失则直接用同一几何和 metadata 调用 `saveAnnotation()` 重新创建;预检后仍遇到 404 时也会重新创建并回显替换本地旧 id |
|
||||||
| `deleteAnnotation(annotationId)` | `DELETE /api/ai/annotations/{annotation_id}` | 对齐 | 工作区清空当前帧已保存标注 |
|
| `deleteAnnotation(annotationId)` | `DELETE /api/ai/annotations/{annotation_id}` | 对齐 | 工作区清空当前帧、关联传播帧、DEL/键盘删除和切换激活模板时删除已保存标注;批量删除前会先读取当前项目 annotation 列表,跳过本地陈旧 id,避免重复 DELETE 产生 404 |
|
||||||
| `importGtMask(file, projectId, frameId, templateId?)` | `POST /api/ai/import-gt-mask` | 对齐 | multipart 上传 GT mask,后端按非零像素值/连通域生成 polygon 标注和 seed point |
|
| `importGtMask(file, projectId, frameId, templateId?, options?)` | `POST /api/ai/import-gt-mask` | 对齐 | multipart 上传 GT mask;支持 `unknown_color_policy=discard/undefined`;后端仅接受 8-bit 灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,0 为背景、X 为 1-255 的 maskid;16-bit/uint16 GT_label、全背景 0 图和普通彩色类别图会被拒绝,全背景错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”;按模板 `maskId` 匹配类别,未知 maskid 可舍弃或导入为黑色 `maskid:0` 的“待分类”;尺寸不同会最近邻拉伸到当前帧,连通域会生成高精度 polygon 标注;导入标注可直接用于 `/api/ai/analyze-mask` 和 `/api/ai/smooth-mask`,前端不显示或拖动 seed point |
|
||||||
| `getDashboardOverview()` | `GET /api/dashboard/overview` | 对齐 | Dashboard 初始统计、队列和活动日志 |
|
| `getDashboardOverview()` | `GET /api/dashboard/overview` | 对齐 | Dashboard 初始统计、队列和活动日志 |
|
||||||
| `exportCoco(projectId)` | `GET /api/export/{projectId}/coco` | 对齐 | 后端实际是 `GET /api/export/{project_id}/coco` |
|
| `exportCoco(projectId)` | `GET /api/export/{projectId}/coco` | 对齐 | 后端实际是 `GET /api/export/{project_id}/coco` |
|
||||||
| `exportMasks(projectId)` | `GET /api/export/{projectId}/masks` | 对齐 | 下载单标注 mask、语义融合 mask 和类别映射 ZIP |
|
| `exportMasks(projectId)` | `GET /api/export/{projectId}/masks` | 对齐 | 下载单标注 mask、语义融合 mask 和类别映射 ZIP |
|
||||||
|
| `exportSegmentationResults(projectId, options)` | `GET /api/export/{projectId}/results` | 对齐 | 新的统一导出入口;支持 `scope=all/range/current`、`outputs=separate,gt_label,pro_label,mix_label`、`mix_opacity`、`start_frame/end_frame` 和 `frame_id` 参数,返回包含 COCO JSON、maskid/GT 像素值映射、原始帧图片和所选 mask PNG 的 ZIP;`mask_type=separate/gt_label/pro_label/mix_label/both` 仍兼容 |
|
||||||
|
|
||||||
## 后端 FastAPI 接口
|
## 后端 FastAPI 接口
|
||||||
|
|
||||||
@@ -56,6 +60,10 @@ Authorization: Bearer <token>
|
|||||||
| 方法 | 路径 | 用途 |
|
| 方法 | 路径 | 用途 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| POST | `/api/auth/login` | 登录 |
|
| POST | `/api/auth/login` | 登录 |
|
||||||
|
| GET | `/api/auth/me` | 当前用户 |
|
||||||
|
| GET/POST/PATCH/DELETE | `/api/admin/users` | 管理员用户管理 |
|
||||||
|
| GET | `/api/admin/audit-logs` | 管理员审计日志 |
|
||||||
|
| POST | `/api/admin/demo-factory-reset` | 演示部署恢复出厂设置;请求体需 `confirmation=RESET_DEMO_FACTORY`;重置后保留默认 admin、从 `demo/演视LC视频序列.mp4` 创建的已生成帧演示视频项目和从 `demo/演视DICOM序列/` 创建的已按文件名自然顺序生成帧的演示 DICOM 项目;同时按内置权威定义重建缺失的“腹腔镜胆囊切除术”“头颈部CT分割”系统模板,并覆盖恢复被修改或删减的默认语义分类树;响应包含兼容单个 `project` 和完整 `projects` 列表 |
|
||||||
| POST | `/api/projects` | 创建项目 |
|
| POST | `/api/projects` | 创建项目 |
|
||||||
| GET | `/api/projects` | 项目列表 |
|
| GET | `/api/projects` | 项目列表 |
|
||||||
| GET | `/api/projects/{project_id}` | 项目详情 |
|
| GET | `/api/projects/{project_id}` | 项目详情 |
|
||||||
@@ -76,8 +84,10 @@ Authorization: Bearer <token>
|
|||||||
| GET | `/api/tasks/{task_id}` | 查询单个后台任务 |
|
| GET | `/api/tasks/{task_id}` | 查询单个后台任务 |
|
||||||
| POST | `/api/tasks/{task_id}/cancel` | 取消后台任务 |
|
| POST | `/api/tasks/{task_id}/cancel` | 取消后台任务 |
|
||||||
| POST | `/api/tasks/{task_id}/retry` | 重试失败或取消的后台任务 |
|
| POST | `/api/tasks/{task_id}/retry` | 重试失败或取消的后台任务 |
|
||||||
| POST | `/api/ai/predict` | SAM 2 / SAM 3 可选推理 |
|
| POST | `/api/ai/predict` | 当前启用 SAM 2 点/框/interactive 推理 |
|
||||||
| POST | `/api/ai/propagate` | SAM 2 / SAM 3 视频片段传播并保存标注 |
|
| POST | `/api/ai/propagate` | 当前启用 SAM 2 单 seed 同步视频片段传播并保存标注 |
|
||||||
|
| POST | `/api/ai/propagate/task` | 创建 SAM 2 自动传播后台任务;payload 可包含多个 seed/direction step |
|
||||||
|
| 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 标注 |
|
||||||
@@ -88,6 +98,7 @@ Authorization: Bearer <token>
|
|||||||
| GET | `/api/dashboard/overview` | Dashboard 聚合快照 |
|
| GET | `/api/dashboard/overview` | Dashboard 聚合快照 |
|
||||||
| GET | `/api/export/{project_id}/coco` | 导出 COCO JSON |
|
| GET | `/api/export/{project_id}/coco` | 导出 COCO JSON |
|
||||||
| GET | `/api/export/{project_id}/masks` | 导出 PNG mask ZIP |
|
| GET | `/api/export/{project_id}/masks` | 导出 PNG mask ZIP |
|
||||||
|
| GET | `/api/export/{project_id}/results` | 统一导出分割结果 ZIP,包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json`、`原始图片/` 和按参数选择的 `分开Mask分割结果/`、`GT_label图/`、`Pro_label彩色分割结果/`、`Mix_label重叠覆盖彩色分割结果/`;GT_label 固定输出 8-bit uint8 PNG,背景为 0,类别值使用模板中的真实 maskid,`maskid:0` 待分类和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数;正整数 maskid 超出 1-255 时拒绝导出 |
|
||||||
| GET | `/health` | 健康检查 |
|
| GET | `/health` | 健康检查 |
|
||||||
| WS | `/ws/progress` | WebSocket 进度通道,未出现在 OpenAPI paths 中 |
|
| WS | `/ws/progress` | WebSocket 进度通道,未出现在 OpenAPI paths 中 |
|
||||||
|
|
||||||
@@ -159,6 +170,7 @@ POST /api/media/parse?project_id=1&parse_fps=15&max_frames=120&target_width=960
|
|||||||
"name": "胆囊",
|
"name": "胆囊",
|
||||||
"color": "#ffae00",
|
"color": "#ffae00",
|
||||||
"zIndex": 280,
|
"zIndex": 280,
|
||||||
|
"maskId": 1,
|
||||||
"category": "腹腔镜胆囊切除术"
|
"category": "腹腔镜胆囊切除术"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -173,7 +185,7 @@ POST /api/media/parse?project_id=1&parse_fps=15&max_frames=120&target_width=960
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"image_id": 123,
|
"image_id": 123,
|
||||||
"model": "sam2",
|
"model": "sam2.1_hiera_tiny",
|
||||||
"prompt_type": "point",
|
"prompt_type": "point",
|
||||||
"prompt_data": {
|
"prompt_data": {
|
||||||
"points": [[0.5, 0.5]],
|
"points": [[0.5, 0.5]],
|
||||||
@@ -187,19 +199,19 @@ POST /api/media/parse?project_id=1&parse_fps=15&max_frames=120&target_width=960
|
|||||||
- `point`
|
- `point`
|
||||||
- `box`
|
- `box`
|
||||||
- `interactive`,用于 SAM 2 交互式细化,`prompt_data` 同时携带 `box`、累计 `points` 和 `labels`。
|
- `interactive`,用于 SAM 2 交互式细化,`prompt_data` 同时携带 `box`、累计 `points` 和 `labels`。
|
||||||
- `semantic`,选择 `sam3` 时进入 SAM 3 文本语义推理。前端 AI 页面不会再用 SAM 2 发送纯文本 semantic;SAM 2 的交互入口应使用点/框提示。SAM 3 真实可用性由 `/api/ai/models/status` 中的外部环境和本地 checkpoint 状态决定。
|
- `semantic` 当前被禁用;由于产品不提供文本提示,前端不会显示语义文本入口,后端收到 semantic 会返回 400。
|
||||||
|
|
||||||
SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同一提示下多个备选 mask 被前端叠加显示。
|
SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同一提示下多个备选 mask 被前端叠加显示。
|
||||||
|
|
||||||
工作区 SAM 2 请求包含反向点时,`CanvasArea` 会发送 `options.auto_filter_background=true` 和 `options.min_score=0.05`;如果负向点过滤后没有可用 polygon,前端会移除当前旧候选 mask 并要求重新框选或添加正向点。
|
工作区 SAM 2 请求包含反向点时,`CanvasArea` 会发送 `options.auto_filter_background=true` 和 `options.min_score=0.05`;如果负向点过滤后没有可用 polygon,前端会移除当前旧候选 mask 并要求重新框选或添加正向点。
|
||||||
|
|
||||||
选择 `sam3` 且发送 `box` 时,前端仍传 normalized `[x1, y1, x2, y2]`,后端适配层会转换成官方几何 prompt 的 `[center_x, center_y, width, height]` 正框;当前 SAM 3 不接正/反点修正。
|
当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;发送 `model=sam3` 会返回 400 Unsupported model。SAM 3 源码文件保留在仓库中,但没有接入当前运行时模型列表。
|
||||||
|
|
||||||
可选 `options` 字段:
|
可选 `options` 字段:
|
||||||
|
|
||||||
- `crop_to_prompt`:对 point/box/interactive prompt 按锚点或框附近区域裁剪后推理,再把 polygon 回映射到原图坐标。
|
- `crop_to_prompt`:对 point/box/interactive prompt 按锚点或框附近区域裁剪后推理,再把 polygon 回映射到原图坐标。
|
||||||
- `auto_filter_background`:过滤低分结果,并移除包含负向点的 polygon。
|
- `auto_filter_background`:过滤低分结果,并移除包含负向点的 polygon。
|
||||||
- `min_score`:配合 `auto_filter_background` 使用的最低置信度阈值;对 SAM 3 semantic 请求也会作为 external worker 的 `confidence_threshold` 传入,避免本地 checkpoint 在默认高阈值下返回 0 个 mask。
|
- `min_score`:配合 `auto_filter_background` 使用的最低置信度阈值。
|
||||||
|
|
||||||
后端响应:
|
后端响应:
|
||||||
|
|
||||||
@@ -228,13 +240,15 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同
|
|||||||
|
|
||||||
### 视频片段传播请求体
|
### 视频片段传播请求体
|
||||||
|
|
||||||
工作区“传播片段”调用:
|
`POST /api/ai/propagate` 仍是单 seed 同步接口。工作区实际使用 `POST /api/ai/propagate/task`:当前打开帧作为参考帧,该帧全部 mask 作为 seed;用户设置传播起始帧和传播结束帧后,前端会在本地把多个 seed 或前后双向范围拆成 `steps`,一次提交为 `propagate_masks` 后台任务,避免长 HTTP 请求和多个视频 tracker 并发抢占 GPU。
|
||||||
|
|
||||||
|
单次调用示例:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"project_id": 1,
|
"project_id": 1,
|
||||||
"frame_id": 123,
|
"frame_id": 123,
|
||||||
"model": "sam2",
|
"model": "sam2.1_hiera_tiny",
|
||||||
"direction": "forward",
|
"direction": "forward",
|
||||||
"max_frames": 30,
|
"max_frames": 30,
|
||||||
"include_source": false,
|
"include_source": false,
|
||||||
@@ -244,13 +258,39 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同
|
|||||||
"bbox": [0.1, 0.1, 0.2, 0.2],
|
"bbox": [0.1, 0.1, 0.2, 0.2],
|
||||||
"label": "胆囊",
|
"label": "胆囊",
|
||||||
"color": "#ff0000",
|
"color": "#ff0000",
|
||||||
"class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20},
|
"class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20, "maskId": 1},
|
||||||
"template_id": 2
|
"template_id": 2,
|
||||||
|
"source_instance_id": "instance-123"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`model=sam2` 使用 SAM 2 video predictor 的 mask seed 传播;`model=sam3` 使用独立 Python 3.12 helper 中的 SAM 3 video tracker,并以 seed bbox 作为初始提示。响应会返回已创建的 `annotations`,保存的 `mask_data.source` 为 `sam2_propagation` 或 `sam3_propagation`。
|
后台任务调用示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project_id": 1,
|
||||||
|
"frame_id": 123,
|
||||||
|
"model": "sam2.1_hiera_tiny",
|
||||||
|
"include_source": false,
|
||||||
|
"save_annotations": true,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"direction": "forward",
|
||||||
|
"max_frames": 30,
|
||||||
|
"seed": {
|
||||||
|
"polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||||||
|
"label": "胆囊",
|
||||||
|
"color": "#ff0000",
|
||||||
|
"source_instance_id": "instance-123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny,`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source` 为 `<model_id>_propagation`,前端回显时会把该字段保留到 `Mask.metadata`,用于在视频处理进度条上把自动传播帧显示为蓝色区段。
|
||||||
|
后台任务入队接口会先规范化/校验 `model` 字段中的 SAM 2.1 权重 id,再把规范化后的权重 id 写入 `processing_tasks.payload.model`;前端提交传播前会先保存当前项目中的 draft/dirty mask,使 seed 尽量携带稳定的 `source_instance_id`、`source_annotation_id` 和 `source_mask_id`。如果参考 mask 本身来自自动传播且未被编辑,前端会继承其 `source_instance_id` 和 `propagation_seed_signature`,让后端识别它仍是原始 seed 的同一条传播链;如果该 mask 被编辑,保存时只保留 lineage,不继承旧签名,从而触发旧结果清理和重传。worker 保存传播结果时会写入 `instance_id`、`source_instance_id`、`propagation_seed_key`、`propagation_seed_signature` 和 `propagation_direction`。同一目标帧段内,同一 seed、同一权重、同一方向再次传播时,如果所有目标帧已有同签名结果,worker 会跳过该 seed;如果签名变化、目标帧段只部分覆盖或本次改用其他 SAM 2.1 权重,worker 会先删除本次目标帧段内的旧自动传播标注再保存新结果。同一参考帧多个同类别 seed 会优先按 `source_instance_id/instance_id` 区分实例,再兼容 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key`,避免 label/color/class/maskid 相同的不同 mask 互相清理;旧版本缺少稳定来源 id 的传播结果才走 label/color/class 兼容清理,避免保存后的稳定 id 无法替换旧结果。任务运行中/完成后会写入 `processing_tasks.result.model`、`completed_steps`、`processed_frame_count`、`created_annotation_count`、`deleted_annotation_count`、`skipped_seed_count` 和每个 step 的权重/方向/数量结果;前端通过 `GET /api/tasks/{task_id}` 轮询,Dashboard 同时可通过 Redis/WebSocket 进度流显示该任务。
|
||||||
|
|
||||||
## 已完成的接口对齐
|
## 已完成的接口对齐
|
||||||
|
|
||||||
@@ -262,20 +302,22 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同
|
|||||||
- `saveAnnotation()` 已接入 `POST /api/ai/annotate`。
|
- `saveAnnotation()` 已接入 `POST /api/ai/annotate`。
|
||||||
- `getProjectAnnotations()` 已接入 `GET /api/ai/annotations`。
|
- `getProjectAnnotations()` 已接入 `GET /api/ai/annotations`。
|
||||||
- `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}`。
|
- `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}`。
|
||||||
- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`。
|
- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`;工作区批量删除前会先用 `GET /api/ai/annotations` 预检存在的 id,跳过本地陈旧 id。
|
||||||
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的 polygon 标注、原始 `gt_label_value` 和 seed point。
|
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype,仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图,并按模板 `maskId` 匹配类别;16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误,全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为黑色 `maskid:0` 的“待分类”,并保留 `gt_unknown_class` 和原始 `gt_label_value`。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,中空导入结果通过 `mask_data.holes` 和 `metadata.polygonRingCounts` 回显为可编辑内洞,前端不显示黄色 seed point。
|
||||||
- `exportMasks()` 已接入 `GET /api/export/{projectId}/masks`。
|
- `exportMasks()` 已接入 `GET /api/export/{projectId}/masks`。
|
||||||
- `parseMedia()` 已改为创建 Celery 后台任务,并返回 `ProcessingTask`。
|
- `parseMedia()` 已改为创建 Celery 后台任务,并返回 `ProcessingTask`。
|
||||||
|
- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求;传播 seed 可携带与 `polygons` 对齐的 `holes` 和 `source_instance_id`,后端 seed 签名、SAM 2 seed mask 栅格化和传播结果保存都会保留内洞,并用实例 id 区分同语义多 mask。
|
||||||
- `getTask()` 已接入 `GET /api/tasks/{taskId}`。
|
- `getTask()` 已接入 `GET /api/tasks/{taskId}`。
|
||||||
- `cancelTask()` 已接入 `POST /api/tasks/{taskId}/cancel`。
|
- `cancelTask()` 已接入 `POST /api/tasks/{taskId}/cancel`。
|
||||||
- `retryTask()` 已接入 `POST /api/tasks/{taskId}/retry`。
|
- `retryTask()` 已接入 `POST /api/tasks/{taskId}/retry`。
|
||||||
- `getDashboardOverview()` 已从 `processing_tasks` 聚合解析队列。
|
- `getDashboardOverview()` 已从 `processing_tasks` 聚合解析队列。
|
||||||
- Dashboard 任务列表已展示 queued/running/failed/cancelled 任务,并可通过 `getTask()` 查看失败详情。
|
- Dashboard 任务列表已展示 queued/running/success/failed/cancelled 任务,并可通过 `getTask()` 查看失败详情;`summary.parsing_task_count` 仍只统计 queued/running。
|
||||||
- 工作区导出按钮已调用 `exportCoco()` / `exportMasks()`,并会先保存未归档 mask。
|
- 工作区“分割结果导出”已调用 `exportSegmentationResults()`,并会先保存未归档 mask;旧的 `exportCoco()` / `exportMasks()` 仍保留为兼容接口。
|
||||||
- PNG mask ZIP 已包含每帧 `semantic_frame_*.png` 和 `semantic_classes.json`,重叠区域按 zIndex 裁决。
|
- PNG mask ZIP 已包含每帧 `semantic_frame_*.png` 和 `semantic_classes.json`,重叠区域按 zIndex 裁决。
|
||||||
|
- 统一导出 ZIP 下载文件名为 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自 `Project.name` 并会替换文件系统不安全字符,时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`,不使用原视频 `source_frame_number`。ZIP 内包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`。原始图片按 `视频名称_时间戳_项目帧序号` 命名;选择分开 mask 时写入 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`,同一帧同一类别会合并为一张二值 mask;选择 GT_label 图时写入 `GT_label图/{视频名称_时间戳_项目帧序号}.png`,固定为 8-bit uint8 PNG;选择 Pro_label 彩色图时写入 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`;选择 Mix_label 叠加图时写入 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`,透明度由 `mix_opacity` 控制,默认 0.3。导出时 maskid 与 GT_label 像素值相同;有模板 maskid 的类别保留真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注补下一个可用正整数并写入映射 JSON,跨图一致;正整数 maskid 必须在 1-255 内,超出时拒绝导出;maskid 不参与覆盖排序,覆盖顺序仍使用内部拖拽排序字段。
|
||||||
|
|
||||||
## 仍需处理的接口问题
|
## 仍需处理的接口问题
|
||||||
|
|
||||||
- WebSocket 地址已从 `VITE_WS_PROGRESS_URL` 读取,未配置时从 `API_BASE_URL` 推导;部署时仍要确认浏览器能访问该地址。
|
- WebSocket 地址已从 `VITE_WS_PROGRESS_URL` 读取,未配置时从 `API_BASE_URL` 推导;部署时仍要确认浏览器能访问该地址。
|
||||||
- Celery worker 进度会写 PostgreSQL 任务表,同时发布到 Redis `seg:progress`;FastAPI 订阅后广播到 `/ws/progress`。
|
- Celery worker 进度会写 PostgreSQL 任务表,同时发布到 Redis `seg:progress`;FastAPI 订阅后广播到 `/ws/progress`。
|
||||||
- 已保存标注目前支持分类级更新、polygon 顶点拖动、顶点删除、边中点插入、多 polygon 子区域选择编辑后的 PATCH 更新和整帧清空删除;复杂洞结构的专业编辑仍未实现。
|
- 已保存标注目前支持分类级更新、polygon 顶点拖动、顶点删除、边中点插入、多 polygon 子区域选择、中空 mask 内洞 ring 编辑后的 PATCH 更新和整帧清空删除;`mask_data.polygons` 保存外圈,`mask_data.holes` 保存与外圈对齐的内洞,`metadata.polygonRingCounts` 支撑前端把外圈/内洞重新组合成可编辑结构。
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
剩余边界:
|
剩余边界:
|
||||||
|
|
||||||
1. SAM 3 已完成独立 Python 3.12 环境安装脚本、外部 worker 桥接、本地 `sam3权重/sam3.pt` checkpoint 状态检查、本地 checkpoint 加载参数接入、单帧文本/框提示和 video tracker API 入口;下一步需要基于真实业务帧验证语义召回质量和视频 tracker 稳定性。
|
1. SAM 3 相关源码和安装脚本保留,但当前产品入口已禁用:前端不展示 SAM 3,后端 registry 不暴露 `sam3`,`model=sam3` 请求返回不支持。若后续重新需要文本语义提示,再恢复前端入口、registry、状态接口和对应测试。
|
||||||
2. 标注删除/更新接口已打通基础能力;逐点几何编辑器已支持顶点拖动/删除、边中点插入和多 polygon 子区域选择编辑,复杂洞结构仍待增强。
|
2. 标注删除/更新接口已打通基础能力;逐点几何编辑器已支持顶点拖动/删除、边中点插入和多 polygon 子区域选择编辑,复杂洞结构仍待增强。
|
||||||
|
|
||||||
## 阶段 2:打通标注保存(已完成基础闭环)
|
## 阶段 2:打通标注保存(已完成基础闭环)
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
已完成:
|
已完成:
|
||||||
|
|
||||||
1. 前端根据 `Mask.segmentation` 构造后端需要的 normalized `mask_data.polygons`。
|
1. 前端根据 `Mask.segmentation` 构造后端需要的 normalized `mask_data.polygons`。
|
||||||
2. 用户点击“结构化归档保存”后,未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 调用 `PATCH /api/ai/annotations/{annotation_id}`。
|
2. 用户点击顶栏保存状态按钮后,未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 调用 `PATCH /api/ai/annotations/{annotation_id}`;按钮文案会按待保存数量显示“保存 X 个改动”或“已全部保存”。
|
||||||
3. 后端保存或更新 `project_id`、`frame_id`、`template_id`、`mask_data`、`bbox`;具体分类写入 `mask_data.class`。
|
3. 后端保存或更新 `project_id`、`frame_id`、`template_id`、`mask_data`、`bbox`;具体分类写入 `mask_data.class`。
|
||||||
4. 工作区加载帧后调用 `GET /api/ai/annotations` 回显已保存标注。
|
4. 工作区加载帧后调用 `GET /api/ai/annotations` 回显已保存标注。
|
||||||
5. 工作区“清空遮罩”调用 `DELETE /api/ai/annotations/{annotation_id}` 删除当前帧已保存标注。
|
5. 工作区“清空遮罩”调用 `DELETE /api/ai/annotations/{annotation_id}` 删除当前帧已保存标注。
|
||||||
@@ -37,15 +37,16 @@
|
|||||||
2. 逐点几何编辑器已支持拖动/删除顶点、边中点插入新点和多 polygon 子区域编辑;后续增强为复杂洞结构编辑。
|
2. 逐点几何编辑器已支持拖动/删除顶点、边中点插入新点和多 polygon 子区域编辑;后续增强为复杂洞结构编辑。
|
||||||
3. 区域合并/去除已支持基础 union/difference;后续增强为更明确的多选列表、操作预览和冲突确认。
|
3. 区域合并/去除已支持基础 union/difference;后续增强为更明确的多选列表、操作预览和冲突确认。
|
||||||
|
|
||||||
## 阶段 3:接入导出按钮(已完成 COCO JSON 和 PNG Mask ZIP)
|
## 阶段 3:接入导出按钮(已完成统一分割结果导出)
|
||||||
|
|
||||||
当前工作区“导出 JSON 标注集”和“导出 PNG Mask ZIP”都会先保存未归档 mask,再调用后端导出接口。
|
当前工作区“分割结果导出”会先保存未归档 mask,再调用后端统一结果导出接口。旧 COCO JSON 和 PNG Mask ZIP 接口保留为兼容路径。
|
||||||
|
|
||||||
已完成:
|
已完成:
|
||||||
|
|
||||||
1. COCO JSON 调用 `/api/export/{projectId}/coco`。
|
1. COCO JSON 调用 `/api/export/{projectId}/coco`。
|
||||||
2. PNG Mask ZIP 调用 `/api/export/{projectId}/masks`。
|
2. PNG Mask ZIP 调用 `/api/export/{projectId}/masks`。
|
||||||
3. ZIP 内保留单标注二值 `mask_*.png`,同时输出 `semantic_frame_*.png` 和 `semantic_classes.json`。
|
3. 兼容 PNG Mask ZIP 仍保留单标注二值 `mask_*.png`,同时输出 `semantic_frame_*.png` 和 `semantic_classes.json`。
|
||||||
|
4. 统一导出调用 `/api/export/{projectId}/results`,支持整体视频、特定范围帧、当前图片三种范围,以及分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;ZIP 固定包含 maskid/GT 像素值映射 JSON 和原始图片文件夹,各输出文件夹按客户指定的 `视频名称_0h00m00s000ms_项目帧序号` 规则命名;GT_label 图固定为 8-bit uint8 PNG,背景为 0,类别值优先使用模板中的真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出。
|
||||||
|
|
||||||
剩余建议:
|
剩余建议:
|
||||||
|
|
||||||
@@ -94,17 +95,17 @@ Dashboard 的解析队列现在已经从“项目状态派生”升级为任务
|
|||||||
|
|
||||||
## 阶段 6:GT 导入与点区域(已完成基础增强版)
|
## 阶段 6:GT 导入与点区域(已完成基础增强版)
|
||||||
|
|
||||||
Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前已经完成基础增强版:导入二值/标签 mask 图片后,后端按非零像素值拆分类别,再按连通域生成 polygon 标注,并用距离变换提取一个正向 seed point。
|
Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前已经完成基础增强版:导入二值/标签 mask 图片后,后端按非零像素值拆分类别,再按连通域生成高精度 polygon 标注;导入结果与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路,前端不显示或拖动 seed point。
|
||||||
|
|
||||||
已完成:
|
已完成:
|
||||||
|
|
||||||
1. 工作区提供“导入 GT Mask”入口。
|
1. 工作区左侧工具栏提供“导入 GT Mask”入口,位置在“重叠区域去除”之后。
|
||||||
2. 前端调用 `POST /api/ai/import-gt-mask` multipart 接口。
|
2. 前端调用 `POST /api/ai/import-gt-mask` multipart 接口。
|
||||||
3. 后端按非零像素值拆分多类别 mask。
|
3. 后端按非零像素值拆分多类别 mask。
|
||||||
4. 后端使用 OpenCV contour 提取每个类别下的连通域。
|
4. 后端使用 OpenCV 高精度 contour 提取每个类别下的连通域,尽量保留边界细节,并用点数上限保护前端性能。
|
||||||
5. 后端使用 distance transform 生成 `points` seed。
|
5. 后端保留 distance transform `points` seed 供数据兼容。
|
||||||
6. 导入结果写入 `annotations` 表并回显为工作区 mask。
|
6. 导入结果写入 `annotations` 表并回显为工作区 mask。
|
||||||
7. 前端把 seed point 转为像素坐标显示在 Canvas 上,拖动后会标记标注为 dirty 并可归档保存。
|
7. 前端不显示 seed point,也不提供 seed point 拖动;导入 mask 与普通 mask 保持一致的可选中、顶点编辑、拓扑统计、边缘平滑和保存体验。
|
||||||
|
|
||||||
剩余建议:
|
剩余建议:
|
||||||
|
|
||||||
@@ -117,10 +118,10 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
|
|||||||
|
|
||||||
已完成:
|
已完成:
|
||||||
|
|
||||||
1. 标注保存时记录 template class id / name / zIndex。
|
1. 标注保存时记录 template class id / name / maskid,并保留内部覆盖优先级。
|
||||||
2. 导出 mask 时按 zIndex 从低到高覆盖。
|
2. 导出 mask 时按内部优先级从低到高覆盖。
|
||||||
3. 同类语义值在融合图中共享同一个 class value。
|
3. 同类语义值在融合图中共享同一个 class value。
|
||||||
4. 跨类重叠由高 zIndex 覆盖低 zIndex。
|
4. 跨类重叠由更高内部优先级覆盖更低内部优先级;maskid 不作为排序规范。
|
||||||
|
|
||||||
剩余建议:
|
剩余建议:
|
||||||
|
|
||||||
@@ -129,21 +130,22 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
|
|||||||
|
|
||||||
## 阶段 7.5:视频片段传播(已完成基础闭环)
|
## 阶段 7.5:视频片段传播(已完成基础闭环)
|
||||||
|
|
||||||
当前工作区“传播片段”会使用当前选中 mask 或当前帧第一个 mask 作为 seed,默认向后传播 30 帧并把结果写入后端标注表。
|
当前工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed,按用户设置的传播起始帧和传播结束帧向前、向后或双向传播,并把结果写入后端标注表。前端只保留一个“自动传播”按钮,减少传播对象选择带来的歧义。
|
||||||
|
|
||||||
已完成:
|
已完成:
|
||||||
|
|
||||||
1. 前端 `propagateMasks()` 已接入 `POST /api/ai/propagate`。
|
1. 前端 `propagateMasks()` 已接入 `POST /api/ai/propagate`。
|
||||||
2. 工作区按钮会把 seed mask 的 normalized polygon、bbox、label、color 和 class 元数据传给后端。
|
2. 工作区会把 seed mask 的 normalized polygon、bbox、label、color 和 class 元数据传给后端。
|
||||||
3. SAM 2 路径使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。
|
3. SAM 2 路径使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。
|
||||||
4. SAM 3 路径通过 `sam3_external_worker.py` 调用独立 Python 3.12 环境中的官方 `build_sam3_video_predictor()`。
|
4. SAM 3 video tracker 路径已从当前产品入口禁用,相关 helper 仅保留作后续恢复参考。
|
||||||
5. 后端会跳过源帧,把传播结果保存到后续帧 `annotations`,并在完成后由前端刷新回显。
|
5. 后端会跳过源帧,把传播结果保存到后续帧 `annotations`,并在完成后由前端刷新回显。
|
||||||
|
6. 前端已经支持参考帧、起止帧范围和单按钮自动传播;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。
|
||||||
|
|
||||||
剩余建议:
|
剩余建议:
|
||||||
|
|
||||||
1. 把传播任务改为异步任务,接入 Dashboard 和 WebSocket 进度。
|
1. 把传播任务改为异步任务,接入 Dashboard 和 WebSocket 进度。
|
||||||
2. 前端增加传播方向、帧数和覆盖已有标注策略设置。
|
2. 增加覆盖已有标注策略设置,例如跳过已有、覆盖同类、全部覆盖。
|
||||||
3. 用真实长视频分别做 SAM 2 / SAM 3 tracker smoke test 和质量评估。
|
3. 用真实长视频做 SAM 2 tracker smoke test 和质量评估;如果未来恢复 SAM 3,再单独补充 SAM 3 tracker 评估。
|
||||||
|
|
||||||
## 阶段 8:清理 UI 文案与 Mock
|
## 阶段 8:清理 UI 文案与 Mock
|
||||||
|
|
||||||
@@ -152,4 +154,4 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
|
|||||||
- SAM/GPU 状态已改为 `GET /api/ai/models/status` 驱动。
|
- SAM/GPU 状态已改为 `GET /api/ai/models/status` 驱动。
|
||||||
- 撤销/重做按钮已接全局 mask 历史栈。
|
- 撤销/重做按钮已接全局 mask 历史栈。
|
||||||
- “重新提取内侧中轴树骨架”接真实接口,否则标为未实现。
|
- “重新提取内侧中轴树骨架”接真实接口,否则标为未实现。
|
||||||
- AI 独立页不要固定 Unsplash 图,应从当前项目帧或上传文件进入。
|
- AI 独立页已移除固定 Unsplash 演示图;没有当前项目帧时显示空状态。后续如果要支持独立图片分析,应接正式上传入口和项目/帧关联。
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ FastAPI 会根据代码里的路由和 Pydantic schema 自动生成 OpenAPI 描
|
|||||||
- Projects:项目 CRUD、项目帧 CRUD
|
- Projects:项目 CRUD、项目帧 CRUD
|
||||||
- Templates:模板 CRUD
|
- Templates:模板 CRUD
|
||||||
- Media:上传视频/DICOM、触发拆帧
|
- Media:上传视频/DICOM、触发拆帧
|
||||||
- AI:SAM 2 / SAM 3 可选推理、模型状态、自动分割、保存标注
|
- AI:当前启用 SAM 2 推理、模型状态、自动分割、保存标注;SAM 3 源码保留但产品入口禁用
|
||||||
- Export:导出 COCO JSON、导出 PNG masks
|
- Export:导出 COCO JSON、导出 PNG masks
|
||||||
- Health:健康检查
|
- Health:健康检查
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,31 @@
|
|||||||
## R1 登录与会话
|
## R1 登录与会话
|
||||||
|
|
||||||
- 系统提供登录页。
|
- 系统提供登录页。
|
||||||
- 默认开发凭证为 `admin / 123456`。
|
- 默认开发管理员为启动时种子化的 `admin / 123456`,密码以哈希形式存入 `users` 表。
|
||||||
- 登录成功后前端保存 token,并进入主应用。
|
- 登录成功后前端保存签名 JWT,并进入主应用。
|
||||||
|
- 页面刷新后前端会用已有 token 调用 `/api/auth/me` 恢复当前用户。
|
||||||
- 登录失败时显示错误信息。
|
- 登录失败时显示错误信息。
|
||||||
- 当前 token 是开发用固定 token,不做真实 JWT 校验。
|
- 业务接口必须校验 Bearer token;缺失或无效 token 返回 401。
|
||||||
|
- 项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库;所有登录用户读取同一项目库,`admin/annotator` 可创建、导入、解析、标注、AI 推理、导出、复制、重命名和删除项目。
|
||||||
|
- 角色只包括唯一默认 `admin` 和 `annotator`;历史 `viewer` 或额外管理员会归一为标注员;用户管理、审计日志和演示环境出厂设置后台仅默认 `admin` 可用。
|
||||||
|
- 管理员侧栏显示“用户管理”入口;管理员可以新增标注员、停用/启用、修改密码、删除用户。
|
||||||
|
- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。
|
||||||
|
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、从 `demo/演视LC视频序列.mp4` 创建的已生成帧演示视频项目和从 `demo/演视DICOM序列/` 创建的已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
|
||||||
|
- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”;头颈部 CT 默认分类名必须使用纯中文,不带括号英文翻译;恢复演示出厂设置不得删除系统默认模板,并必须重建缺失的默认模板、覆盖恢复被修改或删减的默认语义分类树。
|
||||||
|
|
||||||
## R2 项目管理
|
## R2 项目管理
|
||||||
|
|
||||||
- 前端展示项目库,并从 `GET /api/projects` 获取项目列表。
|
- 前端展示项目库,并从 `GET /api/projects` 获取项目列表。
|
||||||
- 用户可以新建项目,前端调用 `POST /api/projects`。
|
- 项目库不提供独立“新建项目”按钮;导入视频或 DICOM 时由前端/后端自动创建项目,后端仍保留 `POST /api/projects` 供导入流程和兼容接口使用。
|
||||||
- 用户可以选择项目,进入工作区。
|
- 用户可以选择项目,进入工作区。
|
||||||
- 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。
|
- 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。
|
||||||
- 用户可以对已导入且尚未生成帧的视频项目点击“生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务。
|
- 用户可以对已导入源视频的视频项目点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;如果项目已有帧,重新生成前必须清空该项目现有帧、标注和 mask,避免重复帧序列;项目名称编辑状态下不能显示/触发生成帧入口,DICOM 项目不能显示生成帧入口。
|
||||||
- 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。
|
- 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。
|
||||||
- 后端支持项目创建、列表、详情、局部更新和删除。
|
- 用户可以在项目库项目卡片上修改项目名称,名称不能为空。
|
||||||
|
- 用户可以在项目卡片删除按钮旁复制项目;复制时可选择“新项目重置”或“全内容复制”。新项目重置必须复制项目媒体字段和已生成帧序列,但不复制标注或 mask 元数据;全内容复制必须额外复制标注和关联 mask 元数据,并将复制标注重新指向新项目中的对应帧。任务运行历史不复制。
|
||||||
|
- 用户可以在项目卡片上删除项目;前端调用 `DELETE /api/projects/{id}`,删除成功后从项目库移除,若删除当前项目则清空工作区当前项目、帧、mask 和选区。
|
||||||
|
- 后端支持项目创建、列表、详情、局部更新、复制和删除。
|
||||||
|
- 后端删除项目时通过 ORM 级联删除项目帧、标注、导出 mask 元数据和后台任务记录。
|
||||||
- 后端支持项目帧创建、列表和单帧查询。
|
- 后端支持项目帧创建、列表和单帧查询。
|
||||||
|
|
||||||
## R3 媒体上传与拆帧
|
## R3 媒体上传与拆帧
|
||||||
@@ -28,9 +39,11 @@
|
|||||||
- 后端允许上传视频、图片、DICOM 文件,其他扩展名返回 400。
|
- 后端允许上传视频、图片、DICOM 文件,其他扩展名返回 400。
|
||||||
- 未提供项目 ID 上传时,后端自动创建项目。
|
- 未提供项目 ID 上传时,后端自动创建项目。
|
||||||
- 提供项目 ID 上传时,后端把上传对象关联到该项目。
|
- 提供项目 ID 上传时,后端把上传对象关联到该项目。
|
||||||
|
- 项目库导入视频和导入 DICOM 序列时,前端必须显示导入进度条;浏览器提供上传总字节数时显示百分比和已上传/总字节数,未提供总字节数时显示已上传字节的非确定进度。DICOM 导入还必须显示本次有效 `.dcm` 文件数量,并在上传完成后持续显示解析任务进度,直到成功、失败或取消。
|
||||||
- 拆帧接口根据项目 `source_type` 处理视频或 DICOM。
|
- 拆帧接口根据项目 `source_type` 处理视频或 DICOM。
|
||||||
- 拆帧接口支持 `parse_fps`、`max_frames` 和 `target_width` 参数,用于生成可被 SAM 2 / SAM 3 视频处理复用的标准帧序列。
|
- 拆帧接口支持 `parse_fps`、`max_frames` 和 `target_width` 参数,用于生成可被 SAM 2 视频处理复用的标准帧序列。
|
||||||
- 视频帧使用连续 `frame_%06d.jpg` 命名,默认从 `frame_000000.jpg` 开始,并按 `target_width` 缩放。
|
- DICOM 批量导入和解析必须按文件名自然顺序处理 `.dcm` 文件,避免数字文件名被字符串排序打乱。
|
||||||
|
- 视频/DICOM 解析后都使用连续 `frame_%06d.jpg` 命名,默认从 `frame_000000.jpg` 开始;视频帧按 `target_width` 缩放。
|
||||||
- 拆帧完成后写入 `frames` 记录,并把项目状态设为 `ready`。
|
- 拆帧完成后写入 `frames` 记录,并把项目状态设为 `ready`。
|
||||||
- 每条帧记录包含 `frame_index`、`image_url`、`width`、`height`、`timestamp_ms` 和 `source_frame_number`。
|
- 每条帧记录包含 `frame_index`、`image_url`、`width`、`height`、`timestamp_ms` 和 `source_frame_number`。
|
||||||
- 任务完成结果包含 `frame_sequence` 元数据:`original_fps`、`parse_fps`、`frame_count`、`duration_ms`、`target_width`、帧宽高和对象存储前缀。
|
- 任务完成结果包含 `frame_sequence` 元数据:`original_fps`、`parse_fps`、`frame_count`、`duration_ms`、`target_width`、帧宽高和对象存储前缀。
|
||||||
@@ -44,56 +57,91 @@
|
|||||||
|
|
||||||
- 工作区根据当前项目加载帧列表。
|
- 工作区根据当前项目加载帧列表。
|
||||||
- 若项目有媒体但无帧,工作区只提示需要先在项目库生成帧,不再自动触发拆帧。
|
- 若项目有媒体但无帧,工作区只提示需要先在项目库生成帧,不再自动触发拆帧。
|
||||||
|
- 工作区加载帧时必须获取项目完整帧序列,不能被接口默认分页截断到 1000 帧。
|
||||||
- Canvas 显示当前帧图片。
|
- Canvas 显示当前帧图片。
|
||||||
- Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。
|
- Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。
|
||||||
- 时间轴支持缩略图点击切帧、range 拖动切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。
|
- Canvas 未选中特定 mask 时,mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。
|
||||||
|
- 时间轴支持缩略图点击切帧、range 拖动切帧、视频处理进度条点击切帧、人工/AI 标注帧和自动传播帧标识点击切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。
|
||||||
|
- 顶栏旧“清空片段遮罩”入口已移除;当前清空/DEL 只在目标 mask 存在传播链结果时进入范围选择。用户选择按帧范围清空后,必须复用时间轴范围选择并最终确认;范围内只清空同一传播链自动传播结果,不能清空无关人工绘制或独立 AI 智能分割 mask。按范围清空或清空所有传播帧时,如果目标帧范围内包含人工绘制或独立 AI 智能分割 mask,必须二次询问是否删除人工/AI 标注帧;用户选择是时删除这些人工/AI 标注帧中的全部 mask,用户选择否时这些帧整帧保留,只清空其它自动传播帧。用户取消确认时不能删除本地 mask、后端标注或传播历史条。
|
||||||
|
- 用户在某帧选中 mask 后,如果切换到同一自动传播结果覆盖的其他帧,工作区应自动识别并选中目标帧中对应的传播 mask;匹配依据为传播结果回显到 mask metadata 的 seed 来源和传播链字段,而不是仅凭标签或颜色。
|
||||||
- 播放帧率使用项目 `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`。
|
||||||
|
- 时间轴顶部播放进度条只表达当前播放位置;其下方的视频处理进度条表达处理状态:当前帧位置用白色竖线贯穿播放进度条和视频处理进度条;人工绘制或 AI 智能分割生成的帧显示红色竖线,自动传播生成的帧显示蓝色区段,最近自动传播处理过的片段叠加同一蓝色系纯色条,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,帮助识别第一次、第二次、第 N 次传播;清空片段遮罩后,与清空范围重叠的最近传播历史条必须同步移除或裁剪,不应继续显示已经清空的传播范围;未处理背景使用中性灰以和标记保持明显区分。进入自动传播或清空遮罩范围选择时,起始帧和结束帧必须额外显示两条贯穿两条进度条的高对比边界线,颜色避开青色播放进度、红色标注、蓝色传播、amber 选区和深色背景。底部帧可视化栏中,人工/AI 标注帧缩略图边框为红色,自动传播/推理帧缩略图边框为蓝色,当前帧仍用青色外框高亮优先;如果同一帧既有人工/AI 标注又有自动传播结果,红色人工/AI 标注框优先保留,自动传播状态只作为蓝色内描边或次级提示;如果当前帧同时是人工/AI 标注帧,则显示青色外框加红色内描边,外层选中框和内层标注框顺序不能交换。
|
||||||
|
- 自动传播提交前支持独立选择传播权重,范围限定为 SAM 2.1 tiny/small/base+/large 四个权重变体;该选择只影响传播任务,不提供 SAM2/SAM3 家族切换,也不改变 AI 智能分割页的单帧推理权重。
|
||||||
|
|
||||||
## R5 工具栏
|
## R5 工具栏
|
||||||
|
|
||||||
- 工具栏可以切换当前 active tool。
|
- 工具栏可以切换当前 active tool。
|
||||||
- 正向点、反向点、框选工具会影响 Canvas 交互。
|
- 工作区左侧工具栏不展示正向点、反向点、框选工具;这些入口只属于 AI 智能分割页。
|
||||||
- 魔法棒按钮切换到 AI 页面。
|
- 侧栏“AI智能分割”和工作区工具栏 AI 跳转入口必须使用带明确 AI 语义的图标,而不是普通魔法棒等泛化工具图标。
|
||||||
- 多边形、矩形、圆、点、线工具会在 Canvas 上生成可保存的 polygon mask。
|
- 工作区 AI 智能分割入口切换到 AI 页面。
|
||||||
- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆、线通过拖拽生成;点工具生成小点区域。
|
- 多边形、矩形、圆、画笔、橡皮擦工具会在 Canvas 上生成或编辑可保存的 polygon mask;左侧工具栏不再提供创建点和创建线段入口。
|
||||||
- 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉。
|
- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆通过拖拽生成;点击创建多边形、创建矩形或创建圆工具时必须保留当前 mask 选区;如果当前有选中 mask,新建多边形/矩形/圆必须并入该选中 mask,即使两块区域不重叠也作为同一个多 polygon mask;如果当前没有选中 mask,才创建新 mask 并自动选中,在仍处于创建工具时显示该 mask 边界顶点作为只读选中提示;画笔和橡皮擦支持调整大小。
|
||||||
- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;拖动顶点会更新 mask 几何并把已保存 mask 标记为 dirty。
|
- 画笔工具在语义分类树有选中类别或当前已有选中 mask 时可用,按住拖动时以圆形笔触采样,鼠标松开后一次性 union;如果当前有选中 mask,笔触必须并入该 mask,不论是否重叠;如果当前没有选中 mask,才创建新的当前类别 mask;笔触只在当前图像范围内采样,最终几何也必须裁剪到当前帧边界内;如果画笔闭合形成中空区域,必须保留外圈与内洞 ring 分组,并按中空 mask 规则渲染、编辑和保存。
|
||||||
|
- 橡皮擦工具只在当前帧已选中 mask 时可用,按住拖动时以圆形笔触采样,鼠标松开后从选中 mask 中 difference 扣除;扣空时删除该 mask,已保存 mask 仍需同步后端删除;进入画笔或橡皮擦模式后,当前选中 mask 的顶点提示仍保持可见,但这些顶点在笔触模式下只读不可拖动。
|
||||||
|
- 创建多边形、创建矩形、区域合并/去除、调整多边形等 Canvas 左上角上下文提示只作为短提示,切换工具或操作状态变化时显示,数秒后自动隐藏,避免长期遮挡待编辑图像;再次切换工具或操作状态变化会重新显示。
|
||||||
|
- 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉;按 `Esc` 或点击左侧工具栏“取消选中”实体按钮,必须清空当前 mask 选区和正在绘制的临时点/笔触,使用户可以重新选择语义分类并用画笔创建一个新 mask。
|
||||||
|
- 所有 polygon mask 都不显示黄色 seed point,也不提供 seed point 拖动;普通手工/AI/GT mask 在画布上应保持一致的区域渲染、选择、顶点编辑、拓扑统计、边缘平滑和保存体验。
|
||||||
|
- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;按住顶点即可直接拖动并实时更新 mask 几何,不需要先单击选中顶点,已保存 mask 会标记为 dirty;顶点拖拽不能冒泡成画布拖拽,编辑结束后 Canvas 当前缩放和平移视口必须保持不变。
|
||||||
|
- 工具栏按浅灰分隔线分组:拖拽/选择、取消选中到创建圆为基础绘制组,画笔/橡皮擦为局部笔触组,区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask/AI 智能分割为外部动作组;`data-testid="tool-group-separator"` 位于清空遮罩下方并分隔外部动作组。工作区在拖拽/选择下方提供“取消选中”按钮,语义等同 `Esc`;在“清空遮罩”上方提供 `DEL` 按钮,语义等同键盘 Delete/Backspace;“导入 GT Mask”入口使用区别于普通编辑工具的紫色底色,不切换 activeTool。
|
||||||
- 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。
|
- 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。
|
||||||
- “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。
|
- “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。
|
||||||
- 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。
|
- 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。
|
||||||
- 选中整个 mask 且未选中具体顶点时,Delete/Backspace 删除该 mask;已保存 mask 同步调用后端删除接口。
|
- 中空 mask 必须保留外圈与内洞 ring 分组;进入“调整多边形”后,外圈和内洞都应显示可拖动顶点与边中点插入手柄,内洞顶点拖动、插点和保存后的回显都不能把 mask 变成实心。
|
||||||
- 撤销、重做绑定全局 `maskHistory/maskFuture`,支持工具栏按钮、AI 页按钮和 Canvas 快捷键。
|
- 多 polygon 或分离区域组成的同一个 mask 进入“调整多边形”后,所有分离 polygon 都应显示可拖动顶点与边中点插入手柄,不能只显示第一个主区域。
|
||||||
- 区域合并工具支持多选当前帧 mask,并使用 polygon union 生成合并后的主 mask。
|
- 选中整个 mask 且未选中具体顶点时,Delete/Backspace 删除该 mask;左侧 `DEL` 按钮复用同一删除链路。删除已保存 mask 前,前端必须用当前后端标注列表预检 `annotationId`,只对仍存在的 id 发送 `DELETE /api/ai/annotations/{id}`,避免本地陈旧 id 导致浏览器控制台出现 404 红字;如果删除对象属于自动传播链或是传播 seed,应同步删除同一传播链上的自动传播 mask,但不能删除其他帧独立 AI 推理或人工标注 mask。
|
||||||
- 区域去除工具支持多选当前帧 mask,并从第一个选中的主 mask 中扣除后续选中 mask。
|
- 撤销、重做绑定全局 `maskHistory/maskFuture`,工作区支持顶栏按钮和全局快捷键 `Ctrl/Cmd+Z`、`Ctrl/Cmd+Shift+Z`、`Ctrl/Cmd+Y`;快捷键监听应在 capture 阶段处理,并在 `event.key` 不可靠时兼容 `event.code=KeyZ/KeyY`,但输入框、文本域、下拉框和可编辑文本聚焦时不能拦截;AI 页支持自己的按钮;左侧工具栏不重复放置撤销/重做入口。
|
||||||
- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击。
|
- 区域合并工具支持多选当前帧 mask,并使用 polygon union 生成合并后的主 mask;若主区域和参与区域存在同一传播链上的对应 mask,合并前必须弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择进入和自动传播/清空一致的时间轴范围选择,点击确认后再弹出最终确认。选择所有传播帧或范围帧时,同一次合并必须同步应用到对应传播帧中的主区域和参与区域,只删除每个已同步帧里的参与合并 mask,不能把未参与本次同步或范围外的同链对象整链误删。
|
||||||
- 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染。
|
- 区域去除工具支持多选当前帧 mask,并从第一个选中的主 mask 中扣除后续选中 mask;若主区域和参与区域存在同一传播链上的对应 mask,去除前必须弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择进入和自动传播/清空一致的时间轴范围选择,点击确认后再弹出最终确认。选择所有传播帧或范围帧时,同一次去除必须同步应用到对应传播帧中的主区域和参与区域,参与扣除的 mask 本身保留。
|
||||||
|
- 区域合并/去除同步到传播帧时必须保留传播 mask 原有 `source`、`source_annotation_id`、`source_mask_id`、`propagation_seed_key`、`propagation_seed_signature` 等 lineage metadata;这些帧可以进入 dirty 待保存状态,但不能因为几何同步在时间轴上从自动传播帧变成人工/AI 标注帧。
|
||||||
|
- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击;第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓。
|
||||||
|
- 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染,保存时把外圈写入 `mask_data.polygons`、把每个外圈对应内洞写入 `mask_data.holes`,并用 `metadata.polygonRingCounts` 支撑前端 ring 回显。
|
||||||
|
|
||||||
## R6 AI 推理
|
## R6 AI 推理
|
||||||
|
|
||||||
- 前端可以在 AI 页面选择 `sam2` 或 `sam3`,选择结果存放在全局 store。
|
- 当前 AI 页面支持选择 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`;SAM 3 选择、文本输入和相关状态展示已隐藏。
|
||||||
- 前端和工作区通过 `GET /api/ai/models/status` 展示 GPU、SAM 2 和 SAM 3 的真实运行状态。
|
- 前端和工作区通过 `GET /api/ai/models/status` 展示 GPU 和四个 SAM 2.1 变体的真实运行状态;`selected_model=sam3` 会被后端拒绝。
|
||||||
- 前端 `predictMask()` 调用 `POST /api/ai/predict`。
|
- 前端 `predictMask()` 调用 `POST /api/ai/predict`。
|
||||||
- 前端发送后端契约:`image_id`、`prompt_type`、`prompt_data`、`model`。
|
- 前端发送后端契约:`image_id`、`prompt_type`、`prompt_data`、`model`。
|
||||||
- 点提示传 `{ points, labels }`,正向点 label 为 1,反向点 label 为 0。
|
- 点提示传 `{ points, labels }`,正向点 label 为 1,反向点 label 为 0。
|
||||||
|
- AI 页面在已有候选 mask 上点击正向/反向选点时,应继续添加提示点,不应被 mask 选择事件拦截。
|
||||||
|
- AI 页面点击已有提示点应删除对应点;“删除最近锚点”只移除最近放置的提示点,不删除候选 mask 或工作区 mask。
|
||||||
|
- AI 页面“删除选中候选”和 Delete/Backspace 只删除本页生成且已选中的 AI 候选 mask,不删除工作区已有 mask。
|
||||||
|
- 工作区点击已有 SAM 提示点应优先删除该点并按剩余提示重新推理;该事件不得冒泡成新增提示点、mask 选择或其它画布点击行为。
|
||||||
- 框选提示传归一化 `[x1, y1, x2, y2]`。
|
- 框选提示传归一化 `[x1, y1, x2, y2]`。
|
||||||
- 工作区 SAM 2 框选会建立一个候选 mask;后续正向点/反向点会携带原始框和累计点,以 `interactive` prompt 细化并替换同一个候选 mask。
|
- AI 页面边界框选应支持画布拖拽预览框;执行分割时只框选不加点发送 `box` prompt,框选后继续加点发送 `interactive` prompt。
|
||||||
- 工作区 SAM 2 一旦包含反向点,会随请求启用 `auto_filter_background` 和 `min_score=0.05`;若后端判定反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask,避免继续显示已被否定的区域。
|
- 工作区 SAM 2.1 框选会建立一个候选 mask;后续正向点/反向点会携带原始框和累计点,以 `interactive` prompt 细化并替换同一个候选 mask。
|
||||||
- SAM 2 不支持文本语义提示;AI 页面在 SAM 2 下输入纯文本时会提示用户改用点提示或切换 SAM 3,不再回退到自动分割。
|
- 工作区 SAM 2.1 一旦包含反向点,会随请求启用 `auto_filter_background` 和 `min_score=0.05`;若后端判定反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask,避免继续显示已被否定的区域。
|
||||||
- SAM 2 点提示和 auto fallback 默认只采用一个最高分候选 mask,避免多个候选 mask 作为同一结果重叠显示。
|
- SAM 2.1 不支持文本语义提示;当前 AI 页面不提供文本语义输入,必须使用点/框提示。
|
||||||
- AI 页面生成的 SAM 2/SAM 3 mask 会写入全局 `masks`,自动同步到当前项目帧,并写入全局 `selectedMaskIds`;右侧语义分类树可以直接给新生成 mask 换标签。
|
- SAM 2.1 点提示和 auto fallback 默认只采用一个最高分候选 mask,避免多个候选 mask 作为同一结果重叠显示。
|
||||||
- AI 页面“推送至工作区编辑”会切回工作区并把工具切到“调整多边形”,保留当前选中的 AI mask 以便继续编辑轮廓和归档保存。
|
- AI 页面只渲染本页最新生成的候选 mask;重复执行高精度分割会替换上一次 AI 页候选,工作区已有手工、保存、传播或 GT 导入 mask 不会自动进入 AI 画布,也不会被替换。
|
||||||
- 语义文本提示传 `semantic`;选择 `sam3` 且独立 Python 3.12 环境、CUDA、官方包和本地 checkpoint 均满足时走 SAM 3 文本语义推理。
|
- AI 页面提供“AI 遮罩透明度”滑杆,并与右侧“遮罩透明度”共享 `maskPreviewOpacity`;调节任一入口都会改变 AI 候选 mask 预览透明度,不改变 mask 几何、分类或保存数据。
|
||||||
- SAM 3 支持工作区框选提示;后端把 normalized `[x1, y1, x2, y2]` 转成官方 `add_geometric_prompt()` 需要的 `[center_x, center_y, width, height]` 正框。
|
- AI 页面参数开关展示文案使用“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”;这是 UI 可读性文案,不改变 `cropMode`、`autoDeleteBg` 或后端 `options` 字段。
|
||||||
- 当前 SAM 3 前端路径不支持正/反点修正;在工作区用 SAM 3 进行点交互时,前端会提示切回 SAM 2。
|
- AI 页面生成的 SAM 2.1 mask 会写入全局 `masks`,自动同步到当前项目帧,并写入全局 `selectedMaskIds`;右侧语义分类树可以直接给新生成 mask 换标签。
|
||||||
- 工作区“传播片段”会把当前选中区域或当前帧第一个区域作为 seed,调用 `POST /api/ai/propagate`,默认从当前帧向后传播 30 帧并保存结果标注。
|
- AI 页“清空全体锚点”只清空本页提示点和本页生成的候选 mask,不删除工作区已有 mask。
|
||||||
- `POST /api/ai/propagate` 支持 `model=sam2` 或 `model=sam3`;SAM 2 使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`,SAM 3 通过独立 Python 3.12 helper 调用官方 `build_sam3_video_predictor()` video tracker。
|
- AI 页面“推送至工作区编辑”必须先校验待推送 AI 候选 mask 已有语义分类;没有 `classId` 或 `className` 时用右上角 error toast 明确提示并停留在 AI 页,不允许进入工作区,确保工作区内 mask 都有语义。
|
||||||
- 传播结果会写入后续帧 `annotations`,`mask_data.source` 分别标记为 `sam2_propagation` 或 `sam3_propagation`,并保留 label、color 和 class 元数据。
|
- 如果用户不通过推送按钮而是直接离开 AI 页面,仍未选择语义分类的 AI 候选 mask 必须从全局 `masks` 和 `selectedMaskIds` 中清理,避免无语义候选通过侧栏切换进入工作区。
|
||||||
- AI 页面会对 SAM 3 空文本、推理失败和返回 0 个 mask 的情况显示明确反馈。
|
- AI 页面“推送至工作区编辑”在语义校验通过后会切回工作区并把工具切到“调整多边形”,保留当前选中的 AI mask 和当前帧视角,以便继续编辑轮廓和归档保存;如果 AI 操作发生在非第一帧,回到工作区后不得强制跳回第一帧。
|
||||||
- AI 参数支持 `crop_to_prompt`、`auto_filter_background` 和 `min_score`;点/框 prompt 可以裁剪局部区域推理并回映射结果,背景过滤会移除低分结果和包含负向点的 polygon;SAM 3 semantic 会用 `min_score` 控制 external worker 的置信度阈值。
|
- 工作区加载后端已保存标注时,必须保留当前项目帧里尚未保存的 AI/手工 draft mask,避免 AI 页推送到工作区的候选 mask 被异步回显流程覆盖。
|
||||||
|
- 语义文本提示 `semantic` 当前被后端禁用并返回 400。
|
||||||
|
- SAM 3 源码和历史测试保留,但不属于当前产品可用功能;前端不再展示 SAM 3 入口,后端 registry 不暴露 `sam3`。
|
||||||
|
- 工作区传播功能以当前打开帧作为参考帧,并使用该帧全部 mask 作为 seed;用户不再选择“选中区域/当前帧全部”传播对象。
|
||||||
|
- 工作区传播功能允许设置传播起始帧和传播结束帧;前端以当前参考帧为 seed,只向起止范围内位于参考帧之前和之后的帧传播,源帧不重复保存。
|
||||||
|
- 工作区只保留一个“自动传播”按钮,点击后在指定范围内按前向/后向自动生成 mask。
|
||||||
|
- 工作区进入自动传播范围选择时,顶栏必须显示本次传播权重以及按当前参考帧计算的向前/向后传播帧数,避免用户只看到起止帧而不清楚实际传播方向。
|
||||||
|
- 当前参考帧没有 mask 时,点击“开始传播”必须提示“当前参考帧无遮罩”,且不得提交传播任务或保存其它帧标注。
|
||||||
|
- 自动传播提交前,前端必须只保存当前参考帧中的 draft/dirty mask;参考帧 seed 优先使用后端 `annotation_id` 作为稳定来源,避免第一次用前端临时 id 传播、后续保存后无法替换旧传播结果;其它帧的脏标注不能在传播准备阶段触发无关后端更新。
|
||||||
|
- 前端会把多个 seed 或双向范围拆成 `steps`,通过 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务,避免长 HTTP 请求卡在浏览器侧,同时避免并发抢占 GPU。
|
||||||
|
- `POST /api/ai/propagate` 作为单 seed 同步兼容接口保留;`POST /api/ai/propagate/task` 是工作区自动传播使用的任务接口。两者当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。
|
||||||
|
- 自动传播任务写入 `processing_tasks`,前端轮询 `GET /api/tasks/{task_id}` 显示进度并刷新标注;Dashboard 也能看到该任务,任务可取消和重试。
|
||||||
|
- 传播 seed 若包含中空结构,前端必须把内洞按外圈对齐传入 `holes`;后端栅格化 SAM 2 seed mask 时先填充外圈再扣除内洞,不能以实心 mask 注入 video predictor;seed 签名也必须包含 `holes`,避免中空编辑后被误判为未变化。
|
||||||
|
- 传播结果会写入后续帧 `annotations`,`mask_data.source` 标记为 `<model_id>_propagation`,并保留 label、color、class 元数据、seed 来源 id、seed 签名和传播方向;后端从传播二值 mask 提取轮廓时必须保留内洞,保存为与 `polygons` 对齐的 `mask_data.holes`,前端回显后仍能编辑内洞;如果历史或外部 seed 带 `geometry_smoothing` 平滑参数,worker 保存前仍必须对传播返回的 polygon 实际应用同一平滑几何,不能只更新拓扑锚点或 metadata。当前工作区平滑按钮应用后会直接改写实际 polygon 并清除平滑参数,后续传播以新几何本身参与签名。
|
||||||
|
- 自动传播任务必须避免重复叠加:同一目标帧段内,同一参考 seed、同一权重、同一方向且所有目标帧已有未变化结果时,worker 直接跳过;同一参考 seed 已变化、目标帧段只部分覆盖或用户改用其他 SAM 2.1 权重时,worker 先删除本次目标帧段内对应旧自动传播标注,再保存新传播结果;对早期只记录前端临时 `source_mask_id` 的旧传播结果,worker 会按传播方向和语义信息做兼容清理。用户在自动传播链中间帧人工新增或修改同一物体 mask 后重新向前/向后传播时,即使新 seed 缺少旧传播链 source id,也要按语义信息和目标帧空间重叠清理旧传播结果后再写入新结果;写入前清理不受旧结果 `propagation_direction` 限制,因此当前帧向前传播时也会替换原先由更早帧向后传播出来的旧 mask,避免同一物体新旧 mask 堆叠。未编辑的自动传播结果再次作为参考 seed 时,会继承原始 `propagation_seed_signature` 以避免重复传播;被编辑后的传播结果只保留 lineage,不继承旧签名,以便触发删除旧结果并重新传播。历史带 `geometry_smoothing` 的 seed 在 forward/backward 两个方向都会用同一参数平滑保存结果。
|
||||||
|
- AI 页面会对未放置点提示、后端错误和返回 0 个 mask 的情况显示明确反馈。
|
||||||
|
- AI 参数支持 `crop_to_prompt`、`auto_filter_background` 和 `min_score`;点/框 prompt 可以裁剪局部区域推理并回映射结果,背景过滤会移除低分结果和包含负向点的 polygon。
|
||||||
- 后端返回 `polygons` 和 `scores`。
|
- 后端返回 `polygons` 和 `scores`。
|
||||||
- 前端把后端 `polygons` 转成 Konva `pathData`、`segmentation`、`bbox`、`area`。
|
- 前端把后端 `polygons` 转成 Konva `pathData`、`segmentation`、`bbox`、`area`。
|
||||||
- AI 推理结果先存放在前端 store 的 `masks` 中,点击“结构化归档保存”后持久化到后端标注表。
|
- AI 推理结果先存放在前端 store 的 `masks` 中,顶栏保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;点击保存后持久化到后端标注表。
|
||||||
|
|
||||||
## R7 标注保存
|
## R7 标注保存
|
||||||
|
|
||||||
@@ -102,36 +150,48 @@
|
|||||||
- 后端提供 `GET /api/ai/annotations` 查询项目标注,可选按 `frame_id` 过滤。
|
- 后端提供 `GET /api/ai/annotations` 查询项目标注,可选按 `frame_id` 过滤。
|
||||||
- 后端提供 `PATCH /api/ai/annotations/{annotation_id}` 更新已保存标注的 `mask_data`、`points`、`bbox` 和 `template_id`。
|
- 后端提供 `PATCH /api/ai/annotations/{annotation_id}` 更新已保存标注的 `mask_data`、`points`、`bbox` 和 `template_id`。
|
||||||
- 后端提供 `DELETE /api/ai/annotations/{annotation_id}` 删除已保存标注。
|
- 后端提供 `DELETE /api/ai/annotations/{annotation_id}` 删除已保存标注。
|
||||||
- 当前前端“结构化归档保存”会保存当前项目未保存 mask,并会更新已标记为 dirty 的已保存 mask。
|
- 当前前端保存状态按钮会保存当前项目未保存 mask,并会更新已标记为 dirty 的已保存 mask。
|
||||||
- 工作区“清空遮罩”会删除当前帧已保存标注,并清空当前帧未保存 mask。
|
- 如果 dirty mask 携带的本地旧 `annotationId` 在后端已经不存在,前端保存链路必须先用当前后端标注列表做存在性预检,已知缺失的 id 直接用同一几何和 metadata 重新 `POST` 创建标注;如果预检后发生并发删除导致 `PATCH` 返回 404,也必须降级为重新创建,并重新拉取后端标注替换本地旧 id;点击“开始传播”前的参考帧保存也必须复用该容错逻辑,不能因陈旧 id 中断传播。
|
||||||
|
- 保存成功后,前端会重新拉取后端标注,并用后端 saved annotation 替换本次提交的 draft mask;未提交的其他 draft mask 仍保留。
|
||||||
|
- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask,不弹确认;若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask,选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。
|
||||||
- 工作区加载项目帧后会查询已保存标注并回显。
|
- 工作区加载项目帧后会查询已保存标注并回显。
|
||||||
- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`。
|
- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`。
|
||||||
- 后端导入 GT mask 时按非零像素值拆分多类别区域,再按连通域生成 polygon 标注,并通过距离变换写入 seed point。
|
- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为黑色 `maskid:0` 的“待分类”,并保留原始 `gt_label_value` 等待后续重新命名。
|
||||||
- 前端会回显导入标注的 seed point;拖动 seed point 后,已保存标注会变为 dirty,归档保存时会更新后端 `points`。
|
- 后端导入 GT mask 时必须仅支持 8-bit 二值/灰度 `GT_label图`,以及 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图;0 是背景,X 是 1-255 的 maskid。灰度/RGB 等通道图按当前模板 `maskId` 匹配类别,超出现有类别时按用户选择的策略处理;16-bit/uint16 GT_label 和普通彩色 RGB 类别图不再视为合法 GT mask,必须返回图片不符合要求的明确错误。
|
||||||
|
- 后端导入 GT mask 时必须把全背景 0 图视为非法 GT mask,返回“GT Mask 图片中没有非背景 maskid 区域。”,前端导入预览也必须保留同一提示并禁止继续导入。
|
||||||
|
- 导入 GT mask 前端必须提供导入结果预览,显示检测到的 maskid、未知 maskid 和尺寸适配提示;如果 mask 图片尺寸与当前帧不同,后端导入前必须按当前帧长宽用最近邻插值拉伸,使 mask 可适配当前图片。
|
||||||
|
- 后端导入 GT mask 时按非背景像素值或颜色拆分多类别区域,再按连通域生成高精度 polygon 标注;轮廓提取应尽量保留边界细节,同时对单轮廓点数设置上限避免严重影响前端渲染和编辑性能;可通过距离变换写入内部 `points` seed 供数据兼容。
|
||||||
|
- 前端不回显导入标注的 seed point,也不提供 seed point 拖动;导入 mask 必须与普通 mask 共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存能力。
|
||||||
|
|
||||||
## R8 模板库
|
## R8 模板库
|
||||||
|
|
||||||
- 前端展示模板列表,调用 `GET /api/templates`。
|
- 前端展示模板列表,调用 `GET /api/templates`。
|
||||||
- 用户可以新建、编辑、删除模板。
|
- 用户可以新建、编辑、删除模板,也可以在“生效中模板架构清单”中用鼠标复制现有模板为当前用户私有副本。
|
||||||
- 模板分类存放在 `mapping_rules.classes`,规则存放在 `mapping_rules.rules`。
|
- 模板分类存放在 `mapping_rules.classes`,规则存放在 `mapping_rules.rules`。
|
||||||
- 前端支持添加/删除分类、拖拽排序后重算 `zIndex`、JSON 批量导入、加载腹腔镜默认分类。
|
- 所有新建、复制、批量导入和后端返回的模板必须包含 `maskid: 0`、颜色 `[0,0,0]`/`#000000`、名称为“待分类”的保留分类;该分类固定显示在语义分类树最后,不能删除,也不能通过拖拽上移。
|
||||||
|
- 前端支持添加/删除分类、拖拽排序后更新内部覆盖优先级和 JSON 批量导入。JSON 批量导入必须支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式,并兼容常见粘贴内容中的前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号。模板详情页分类区标题必须显示为“语义分类树(拖拽调层级)”,右上角按钮必须显示为带编辑图标的“编辑模板”;分类行右侧不得显示“未分类/批量导入/模板名”等描述标签,必须显示垃圾桶图标并可点击删除该 label。编辑模板弹窗点击分类后只允许编辑分类名称,不得显示或编辑旧 `category` 来源元信息。复制模板必须保留分类名称、颜色、`maskid`、内部层级顺序和规则,但要重建类别内部 id。界面不展示内部优先级数值,只展示每个类别稳定的 `maskid`。
|
||||||
- 后端支持模板创建、列表、详情、局部更新和删除。
|
- 后端支持模板创建、列表、详情、局部更新和删除。
|
||||||
|
|
||||||
## R9 本体检查面板
|
## R9 本体检查面板
|
||||||
|
|
||||||
- 工作区右侧可以选择模板。
|
- 工作区右侧可以选择模板。
|
||||||
- 面板显示模板分类和组件本地自定义分类。
|
- 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes`。
|
||||||
|
- 右侧面板修改激活模板时,如果当前项目已有任意 mask,必须弹窗提示用户确认;确认后清空当前项目所有本地 mask 和已保存后端标注,再切换激活模板。当前项目没有任何 mask 时切换激活模板不需要提示。
|
||||||
- 用户可以选择具体分类;新 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 元数据改为该分类;如果 Canvas 当前没有选中任何 mask,点击语义分类树只设置后续新建 mask 使用的 active class,不能修改已有 mask;如果这些 mask 属于自动传播链,还必须通过 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 同步更新同一传播链前后帧的对应 mask;已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。
|
||||||
- 添加自定义分类只存在组件本地状态,不保存到后端。
|
- 打开工作区回显项目标注时,如果已保存 mask 的 class 不再存在于其所属模板中,前端必须把该 mask 转为 `maskid: 0` 的“待分类”mask,保留几何,标记为 dirty,等待用户重新分类并保存。
|
||||||
- 置信度、拓扑锚点和重新提取骨架按钮当前为展示/占位。
|
- 添加自定义分类需要先选择模板,保存时调用 `PATCH /api/templates/{id}` 并同步全局模板 store。
|
||||||
|
- “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不显示全局 active class 的旧值。
|
||||||
|
- 当前实例属性面板不展示“当前选中区域”计数;当前 mask 交互以单选为主,计数长期为 1,不作为有效业务信息展示。
|
||||||
|
- 选中 mask 后,拓扑锚点调用 `POST /api/ai/analyze-mask` 自动读取,不再显示固定占位值;后端 `topology_anchor_count` 必须表示 polygon 的真实顶点数量,不能用抽样后的展示点数代替;前端必须静默忽略 abort/cancel 或过期的分析请求,避免快速切换 mask、拖动平滑预览或卸载组件时误显示“后端属性读取失败”;前端不再展示“后端模型置信度”条目,也不再提供“重新提取拓扑锚点”调试按钮。
|
||||||
|
- 选中 mask 后,右侧实例属性面板提供“边缘平滑强度”和“应用边缘平滑”;调整滑杆时必须立即更新数值,但后端预览请求需要做短防抖,用户停止拖动约 220ms 后再调用 `POST /api/ai/smooth-mask` 并用返回 polygon 临时预览当前 mask 边缘,避免连续拖动时请求过密造成卡顿;预览阶段不标 dirty;点击“应用边缘平滑”后确认当前预览结果,前端必须把平滑 polygon 作为新的实际 mask 几何写入当前 mask,并同步写入同一传播链前后对应 mask;整次平滑应用必须作为一个撤销/重做历史步骤,撤销/重做要同时作用于当前 mask 和传播链对应 mask;应用后相关 mask 标记为 dirty/draft,平滑强度重置为 0,用户仍可继续用 polygon 编辑工具编辑平滑后的新多边形,并通过顶栏保存状态按钮落库;保存 dirty 传播链 mask 时必须保留传播来源 metadata,不能让原本自动传播帧变为人工/AI 标注帧。后端平滑必须对 AI/SAM 密集轮廓执行去噪简化、Chaikin 平滑和二次简化,使结果 polygon 的密集边缘点实际减少;强度映射必须低段温和、高段继续递进,避免 20% 左右已经过度平滑且后续档位无明显变化。
|
||||||
|
|
||||||
## R10 Dashboard 与 WebSocket
|
## R10 Dashboard 与 WebSocket
|
||||||
|
|
||||||
- Dashboard 显示基础统计、解析队列和活动日志。
|
- Dashboard 显示基础统计、任务进度和活动日志。
|
||||||
- Dashboard 初始数据来自 `GET /api/dashboard/overview`。
|
- Dashboard 初始数据来自 `GET /api/dashboard/overview`。
|
||||||
- 后端聚合项目数、处理中任务数、标注数、帧数、模板数和主机 load average。
|
- 后端聚合项目数、处理中任务数、标注数、帧数、模板数和主机 load average。
|
||||||
- 解析队列由 `processing_tasks` 中的 queued/running/failed/cancelled 任务生成;活动日志由最近任务、项目、标注和模板记录生成。
|
- 任务进度由 `processing_tasks` 中的 queued/running/success/failed/cancelled 任务生成,避免刚完成任务从进度区立即消失;处理中任务数统计只计算 queued/running;活动日志由最近任务、项目、标注和模板记录生成。
|
||||||
- Dashboard 对 queued/running 任务提供取消按钮,对 failed/cancelled 任务提供重试按钮。
|
- Dashboard 对 queued/running 任务提供取消按钮,对 failed/cancelled 任务提供重试按钮。
|
||||||
- Dashboard 任务详情会读取 `GET /api/tasks/{task_id}` 并展示失败 error、payload、result、Celery ID 和时间信息。
|
- Dashboard 任务详情会读取 `GET /api/tasks/{task_id}` 并展示失败 error、payload、result、Celery ID 和时间信息。
|
||||||
- Dashboard 会连接 `/ws/progress`。
|
- Dashboard 会连接 `/ws/progress`。
|
||||||
@@ -144,11 +204,21 @@
|
|||||||
|
|
||||||
- 后端支持 `GET /api/export/{project_id}/coco` 导出 COCO JSON。
|
- 后端支持 `GET /api/export/{project_id}/coco` 导出 COCO JSON。
|
||||||
- 后端支持 `GET /api/export/{project_id}/masks` 导出 PNG mask ZIP。
|
- 后端支持 `GET /api/export/{project_id}/masks` 导出 PNG mask ZIP。
|
||||||
|
- 后端支持 `GET /api/export/{project_id}/results` 统一导出分割结果 ZIP,参数支持整体视频、特定范围帧和当前图片三种范围,并支持分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;Mix_label 透明度默认 0.3。
|
||||||
|
- 统一导出 ZIP 必须固定包含 `maskid_GT像素值_类别映射.json`,记录当前导出中每个类别的 `maskid`、GT_label 像素值、中文名、类别名、RGB 值、颜色和类别 key;GT_label 必须固定输出 8-bit uint8 PNG,背景值固定为 0,语义类别值使用类别真实 maskid,缺失 maskid 的旧标注才补下一个可用正整数,且同一类别跨图片保持一致;`maskid: 0` 的“待分类”必须在映射中保留为 0,GT_label 内与背景同为 0;正整数 maskid 超出 1-255 时必须拒绝导出。
|
||||||
|
- 统一导出 ZIP 必须固定包含 `原始图片/` 文件夹,导出范围内每帧的原始图片命名为 `视频名称_时间戳_项目帧序号` 加原图片扩展名;视频名称来自项目视频文件名,时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`,不使用原视频帧号。
|
||||||
|
- 选择“分开 Mask”时,统一导出 ZIP 必须包含 `分开Mask分割结果/`;每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹,同一帧同一类别的所有 annotation 合并为一张二值 PNG,文件名包含 `视频名称_时间戳_项目帧序号_{类别名称}_maskid{maskid}`。
|
||||||
|
- 选择“GT_label 黑白图”时,统一导出 ZIP 必须包含 `GT_label图/`;每帧输出一张融合后的 GT_label PNG,文件名为 `视频名称_时间戳_项目帧序号`,重叠区域仍按内部拖拽排序从低到高覆盖;maskid 不构成排序规范。
|
||||||
|
- 选择“Pro_label 彩色图”时,统一导出 ZIP 必须包含 `Pro_label彩色分割结果/`;每帧输出一张按类别 RGB 上色的 PNG,背景为 `[0,0,0]`,`maskid: 0` 的“待分类”也必须输出为黑色 `[0,0,0]`。
|
||||||
|
- 选择“Mix_label 叠加图”时,统一导出 ZIP 必须包含 `Mix_label重叠覆盖彩色分割结果/`;每帧输出一张彩色 label 叠加原始图片的 PNG,透明度可选且默认为 0.3。
|
||||||
|
- GT_label、Pro_label 和 Mix_label 的重叠区域覆盖顺序必须和右侧“语义分类树”的内部覆盖优先级一致,低优先级先写入,高优先级后写入。
|
||||||
|
- 分割结果导出 ZIP 文件名必须使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自项目库中的 `Project.name`,时间戳来自导出范围首尾帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`。
|
||||||
- 当前前端 `exportCoco()` API 封装已对齐后端路径。
|
- 当前前端 `exportCoco()` API 封装已对齐后端路径。
|
||||||
- 当前前端 `exportMasks()` API 封装已对齐后端路径。
|
- 当前前端 `exportMasks()` API 封装已对齐后端路径。
|
||||||
- 工作区“导出 JSON 标注集”按钮已绑定下载事件;导出前会先保存当前未归档 mask。
|
- 当前前端 `exportSegmentationResults()` API 封装已对齐统一导出路径。
|
||||||
- 工作区“导出 PNG Mask ZIP”按钮已绑定下载事件;导出前会先保存当前未归档 mask。
|
- 工作区“分割结果导出”按钮已替代原 JSON/PNG 两个按钮;点击后在下拉栏内选择导出范围、勾选导出内容,并在选择 Mix_label 时调节遮罩透明度和查看当前/待导出第一帧预览;导出范围默认选中“当前图片”,导出前会先保存当前未归档 mask。选择“特定范围帧”时,用户既可以直接修改起止帧输入框,也可以像自动传播、清空遮罩一样在播放进度条或视频处理进度条上点击/拖拽选择导出范围。
|
||||||
- PNG mask ZIP 包含单标注二值 mask、按 zIndex 融合后的每帧语义 mask 和 `semantic_classes.json`。
|
- PNG mask ZIP 包含单标注二值 mask、按 zIndex 融合后的每帧语义 mask 和 `semantic_classes.json`。
|
||||||
|
- 统一导出的 GT_label 图必须是 8-bit uint8 PNG,背景值固定为 0,所有语义类别值优先保留模板类别真实 maskid,缺失 maskid 的旧标注才按下一个可用正整数补值;有效类别值范围为 0-255,其中 0 仅用于背景和系统保留的“待分类”。
|
||||||
|
|
||||||
## R12 配置
|
## R12 配置
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
|
|
||||||
- React + TypeScript 前端 SPA。
|
- React + TypeScript 前端 SPA。
|
||||||
- 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。
|
||||||
|
|
||||||
## 前端模块
|
## 前端模块
|
||||||
|
|
||||||
@@ -23,17 +23,19 @@
|
|||||||
| API 封装 | `src/lib/api.ts` | Axios 客户端、字段映射、AI 响应转换 |
|
| API 封装 | `src/lib/api.ts` | Axios 客户端、字段映射、AI 响应转换 |
|
||||||
| 配置 | `src/lib/config.ts` | 推导 API 和 WebSocket 地址 |
|
| 配置 | `src/lib/config.ts` | 推导 API 和 WebSocket 地址 |
|
||||||
| WebSocket | `src/lib/websocket.ts` | 进度流连接、订阅、连接状态通知、心跳和重连 |
|
| WebSocket | `src/lib/websocket.ts` | 进度流连接、订阅、连接状态通知、心跳和重连 |
|
||||||
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态 |
|
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;左侧 Sidebar 底部使用 compact 形态显示 GPU/CPU 状态,工作区顶栏不再重复显示,具体传播权重只在进入自动传播后由顶栏下拉负责 |
|
||||||
| 登录页 | `src/components/Login.tsx` | 调用登录 API,写入 store |
|
| 登录页 | `src/components/Login.tsx` | 使用 `public/logo.png` 和系统标题文案,调用登录 API,写入 store |
|
||||||
| Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 |
|
| Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 |
|
||||||
| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、导入视频/DICOM、显式生成帧 |
|
| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、重命名、删除、复制、导入视频/DICOM、显式生成帧/重新生成帧 |
|
||||||
| 工作区 | `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` | 切换工作区编辑工具、提供等同 `Esc` 的“取消选中”实体按钮、在“重叠区域去除”后触发当前帧/传播链清空、GT Mask 导入和 AI 页面跳转;AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择/取消选中到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、清空遮罩/导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px,按钮列固定 48px,滚动条使用右侧外扩空间和低对比 `seg-scrollbar` |
|
||||||
| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、左右方向键切帧、播放和当前/总时长显示 |
|
| 工作区顶栏 | `src/components/VideoWorkspace.tsx` | 保存状态按钮(“保存 X 个改动”/“已全部保存”)、导出/传播/按起止帧批量清空遮罩、显式撤销/重做按钮和工作区快捷键 |
|
||||||
| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、分类树、本地自定义分类 |
|
| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、播放进度、视频处理进度条、自动传播历史片段、自动传播/布尔操作/导出范围选择、左右方向键切帧、播放和当前/总时长显示 |
|
||||||
|
| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、工作区 mask 透明度、分类树、后端自定义分类、mask 后端属性分析;内容过长时自身滚动,滚动条使用低对比 `seg-scrollbar` |
|
||||||
| AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 |
|
| AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 |
|
||||||
| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、排序 |
|
| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、详情页和编辑弹窗拖拽排序 |
|
||||||
|
| 短提示浮层 | `src/components/TransientNotice.tsx` | 项目库和模板库的非阻塞成功/失败提示,自动消失 |
|
||||||
|
|
||||||
## 后端模块
|
## 后端模块
|
||||||
|
|
||||||
@@ -42,17 +44,19 @@
|
|||||||
| 应用入口 | `backend/main.py` | FastAPI app、CORS、路由注册、健康检查、WebSocket |
|
| 应用入口 | `backend/main.py` | FastAPI app、CORS、路由注册、健康检查、WebSocket |
|
||||||
| 配置 | `backend/config.py` | Pydantic settings |
|
| 配置 | `backend/config.py` | Pydantic settings |
|
||||||
| 数据库 | `backend/database.py` | SQLAlchemy engine、session、Base |
|
| 数据库 | `backend/database.py` | SQLAlchemy engine、session、Base |
|
||||||
| 模型 | `backend/models.py` | Project、Frame、Template、Annotation、Mask、ProcessingTask |
|
| 模型 | `backend/models.py` | User、Project、Frame、Template、Annotation、Mask、AuditLog、ProcessingTask |
|
||||||
| Schema | `backend/schemas.py` | Pydantic 请求/响应模型 |
|
| Schema | `backend/schemas.py` | Pydantic 请求/响应模型 |
|
||||||
| Auth | `backend/routers/auth.py` | 开发登录 |
|
| Auth | `backend/routers/auth.py` | 用户表、密码哈希、JWT 登录和 `/api/auth/me` |
|
||||||
|
| Admin | `backend/routers/admin.py` | 管理员用户 CRUD、角色/密码/启停用和审计日志 |
|
||||||
| Projects | `backend/routers/projects.py` | 项目与帧 CRUD |
|
| Projects | `backend/routers/projects.py` | 项目与帧 CRUD |
|
||||||
| Templates | `backend/routers/templates.py` | 模板 CRUD 和 mapping_rules 打包/解包 |
|
| Templates | `backend/routers/templates.py` | 模板 CRUD 和 mapping_rules 打包/解包 |
|
||||||
| Media | `backend/routers/media.py` | 上传媒体和拆帧 |
|
| Media | `backend/routers/media.py` | 上传媒体和拆帧 |
|
||||||
| AI | `backend/routers/ai.py` | SAM 2 / SAM 3 可选推理、视频传播、模型状态和标注保存 |
|
| AI | `backend/routers/ai.py` | 当前启用 SAM 2 推理、视频传播、模型状态和标注保存 |
|
||||||
|
| 传播任务 | `backend/services/propagation_task_runner.py` | Celery 中执行自动传播 steps,写任务进度并保存传播标注 |
|
||||||
| Export | `backend/routers/export.py` | COCO 和 PNG mask 导出 |
|
| Export | `backend/routers/export.py` | COCO 和 PNG mask 导出 |
|
||||||
| SAM 2 | `backend/services/sam2_engine.py` | SAM 2 懒加载、状态检测、点/框/自动推理和视频 mask 传播 |
|
| SAM 2 | `backend/services/sam2_engine.py` | SAM 2 懒加载、状态检测、点/框/自动推理和视频 mask 传播 |
|
||||||
| SAM 3 | `backend/services/sam3_engine.py`, `backend/services/sam3_external_worker.py`, `backend/setup_sam3_env.sh` | SAM 3 状态检测、独立 Python 3.12 环境桥接、本地 checkpoint 加载、文本语义推理、正框几何提示和 video tracker 适配 |
|
| SAM 3 | `backend/services/sam3_engine.py`, `backend/services/sam3_external_worker.py`, `backend/setup_sam3_env.sh` | 历史保留的 SAM 3 桥接源码和脚本;当前未接入 registry |
|
||||||
| SAM Registry | `backend/services/sam_registry.py` | 模型选择、GPU 状态和推理分发 |
|
| SAM Registry | `backend/services/sam_registry.py` | 当前暴露 SAM 2.1 四个变体、GPU 状态和推理分发 |
|
||||||
|
|
||||||
## 状态模型
|
## 状态模型
|
||||||
|
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
- `maskHistory` / `maskFuture`:mask 编辑历史栈,用于撤销和重做。
|
- `maskHistory` / `maskFuture`:mask 编辑历史栈,用于撤销和重做。
|
||||||
- `activeModule`:当前页面。
|
- `activeModule`:当前页面。
|
||||||
- `activeTool`:当前工具。
|
- `activeTool`:当前工具。
|
||||||
- `aiModel`:当前选择的 AI 模型,取值为 `sam2` 或 `sam3`。
|
- `aiModel`:当前启用的 AI 模型,取值为 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus` 或 `sam2.1_hiera_large`,默认 `sam2.1_hiera_tiny`。
|
||||||
|
|
||||||
## 关键数据流
|
## 关键数据流
|
||||||
|
|
||||||
@@ -74,22 +78,36 @@
|
|||||||
|
|
||||||
1. `Login` 收集用户名和密码。
|
1. `Login` 收集用户名和密码。
|
||||||
2. `login()` 调用 `POST /api/auth/login`。
|
2. `login()` 调用 `POST /api/auth/login`。
|
||||||
3. 成功后 store 写入 token,App 渲染主界面。
|
3. 后端用 `users` 表中的密码哈希校验用户,成功后返回签名 JWT 和用户资料。
|
||||||
|
4. 前端把 token 写入 `localStorage` 和 Zustand;刷新页面时 `useStore` 会从 `localStorage` 恢复 token。
|
||||||
|
5. `App` 在已登录状态调用 `/api/auth/me` 恢复当前用户,再拉取当前用户项目列表。
|
||||||
|
|
||||||
|
### 用户隔离
|
||||||
|
|
||||||
|
1. `Project.owner_user_id` 指向 `users.id` 作为创建者/历史元数据保留;项目库访问不再按该字段隔离,所有登录用户共享同一项目库。
|
||||||
|
2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。
|
||||||
|
3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。
|
||||||
|
4. 角色只分为唯一默认 `admin` 和 `annotator`:`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`,用于用户管理、审计日志和演示环境出厂设置。
|
||||||
|
5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成标注员新增、停用/启用、密码修改和删除用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`。
|
||||||
|
6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态,删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计,重新创建 `demo/演视LC视频序列.mp4` 指向且显示名为“演视LC视频序列”的已生成帧演示视频项目,以及 `demo/演视DICOM序列/` 指向且显示名为“演视DICOM序列”的演示 DICOM 项目;视频和 DICOM 都会上传源媒体并生成帧,DICOM 会按文件名自然顺序读取;系统模板保留以保证重置后仍可标注。
|
||||||
|
7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401,权限不足返回 403,其他用户项目资源对当前用户表现为 404。
|
||||||
|
|
||||||
### 项目导入与生成帧
|
### 项目导入与生成帧
|
||||||
|
|
||||||
1. `ProjectLibrary` 创建项目。
|
1. `ProjectLibrary` 创建项目。
|
||||||
2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`。
|
2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`。上传期间项目库显示导入进度条、百分比和已上传字节,完成后短暂显示“视频导入完成”。
|
||||||
3. 用户在项目卡片点击“生成帧”,在弹窗中选择目标 FPS。
|
3. 用户在视频项目卡片点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS。已有帧的视频重新生成时会提示该操作会清空现有帧序列、标注和 mask;后端任务开始时删除旧帧和旧标注,再写入新的标准帧序列。任务入队后项目库会继续轮询任务进度,解析成功后自动重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面,无需刷新页面或重新进入项目库。
|
||||||
4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps`、`max_frames` 和 `target_width` 指定标准帧序列参数。
|
4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps`、`max_frames` 和 `target_width` 指定标准帧序列参数。
|
||||||
5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧,视频帧按 `frame_%06d.jpg` 从 `frame_000000.jpg` 连续命名,并按目标宽度缩放。
|
5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧;DICOM 在前端选择、后端上传、worker 下载和 pydicom 读取时都按文件名自然顺序排序;视频/DICOM 解析结果都按 `frame_%06d.jpg` 从 `frame_000000.jpg` 连续命名,视频帧按目标宽度缩放。
|
||||||
6. worker 写入 `frames.timestamp_ms` 和 `frames.source_frame_number`,并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。
|
6. worker 写入 `frames.timestamp_ms` 和 `frames.source_frame_number`,并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。
|
||||||
7. worker 持续更新 `processing_tasks`,并发布 Redis `seg:progress`。
|
7. worker 持续更新 `processing_tasks`,并发布 Redis `seg:progress`。
|
||||||
8. 刷新项目列表。
|
8. 刷新项目列表;项目卡片右上角 FPS 徽标显示生成关键帧序列时选择的 `parse_fps`,原始视频 FPS 仅作为底部“原 xx fps”辅助信息显示。
|
||||||
|
9. 导入视频、生成帧、上传 DICOM 和失败反馈使用 `TransientNotice`,不再使用浏览器 `alert()` 阻塞操作;提示默认数秒后自动消失。视频和 DICOM 上传阶段额外显示项目库内的导入进度面板,DICOM 面板显示有效文件数量,并在上传完成后切换为解析任务进度;视频生成帧也会显示解析进度并轮询 `GET /api/tasks/{task_id}` 直到成功、失败或取消。
|
||||||
|
10. DICOM 和视频帧序列写入同一 `frames` 表并共用工作区、时间轴、AI 传播、标注保存、GT 导入和导出链路,差异只存在于项目库导入入口和后端解析器。
|
||||||
|
|
||||||
### 任务控制
|
### 任务控制
|
||||||
|
|
||||||
1. Dashboard 从 `GET /api/dashboard/overview` 读取 queued/running/failed/cancelled 任务。
|
1. Dashboard 从 `GET /api/dashboard/overview` 读取 queued/running/success/failed/cancelled 任务;queued/running 代表当前进度,success/failed/cancelled 代表最近任务状态。
|
||||||
2. 用户取消任务时,前端调用 `POST /api/tasks/{task_id}/cancel`;后端写入 `cancelled`、设置 `finished_at`,并尝试 `celery_app.control.revoke(..., terminate=True)`。
|
2. 用户取消任务时,前端调用 `POST /api/tasks/{task_id}/cancel`;后端写入 `cancelled`、设置 `finished_at`,并尝试 `celery_app.control.revoke(..., terminate=True)`。
|
||||||
3. worker 在下载、解析、上传、写帧等关键阶段刷新任务状态;如果发现 `cancelled`,停止后续写入并发布 cancelled 事件。
|
3. worker 在下载、解析、上传、写帧等关键阶段刷新任务状态;如果发现 `cancelled`,停止后续写入并发布 cancelled 事件。
|
||||||
4. 用户重试任务时,前端调用 `POST /api/tasks/{task_id}/retry`;后端基于原任务 `payload` 创建新任务,记录 `retry_of` 并重新投递 Celery。
|
4. 用户重试任务时,前端调用 `POST /api/tasks/{task_id}/retry`;后端基于原任务 `payload` 创建新任务,记录 `retry_of` 并重新投递 Celery。
|
||||||
@@ -99,76 +117,110 @@
|
|||||||
### 工作区加载
|
### 工作区加载
|
||||||
|
|
||||||
1. `VideoWorkspace` 根据 `currentProject.id` 调用 `getProjectFrames()`。
|
1. `VideoWorkspace` 根据 `currentProject.id` 调用 `getProjectFrames()`。
|
||||||
2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`。
|
2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`;帧列表接口默认返回完整帧序列,工作区右下角总帧数以实际项目帧数为准。
|
||||||
3. 帧数据映射为 store `Frame[]`,包含 `timestampMs` 和 `sourceFrameNumber`,供时间轴和后续视频传播使用。
|
3. 帧数据映射为 store `Frame[]`,包含 `timestampMs` 和 `sourceFrameNumber`,供时间轴和后续视频传播使用。
|
||||||
4. 当前帧传入 `CanvasArea`。
|
4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask,但保留没有 `annotationId` 的未保存 draft mask;这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。
|
||||||
|
5. `VideoWorkspace` 加载项目帧时会优先按当前选中 mask 的 `frameId` 和当前打开帧 id 恢复 `currentFrameIndex`;只有没有可恢复帧时才回到第一帧,避免 AI 页在非第一帧推送回工作区时视角被重置。
|
||||||
|
6. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态;如果切换到另一帧时原 id 不存在,但目标帧存在同一自动传播链的结果,前端会用 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 匹配对应传播 mask 并自动选中。
|
||||||
|
7. `CanvasArea` 根据容器和帧尺寸按 86% 适配比例计算初始 scale/position,使底图默认居中且尽量大,但保留画布边距;滚轮缩放和拖拽平移仍由用户后续控制。
|
||||||
|
8. `CanvasArea` 未选中特定 mask 时,会按 `classZIndex` 从低到高渲染当前帧 mask;该值来自右侧“语义分类树”的拖拽排序,因此高优先级类别会后渲染并覆盖低优先级类别。有选中 mask 时,编辑态可保留选中区域置顶,方便拖点、换类和布尔操作。
|
||||||
|
9. `FrameTimeline` 顶部播放进度条显示当前播放位置;其下方视频处理进度条根据 `Mask.metadata.source` / `propagated_from_frame_id` 计算自动传播帧并显示蓝色区段,对人工绘制或 AI 智能分割等非传播 mask 帧显示红色竖线。当前帧另用白色竖线贯穿播放进度条和视频处理进度条,和青色播放进度、红色标注、蓝色传播状态区分。普通状态下,视频处理进度条可点击跳转到对应帧,红色人工/AI 标注帧和蓝色自动传播帧标识本身也可点击跳转。处理条未处理背景使用中性灰,和红色/蓝色标记保持明显区分。`VideoWorkspace` 会记录当前会话最近 8 次成功处理过的自动传播范围,并通过 `propagationHistory` 传给 `FrameTimeline`;时间轴会把这些片段叠加为同一蓝色系的纯色条,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,不再在单个片段内部使用渐变。传播历史条只显示当前仍有自动传播 mask 的帧,`VideoWorkspace` 会在 mask 变化时按剩余传播 mask 裁剪本地传播历史;`FrameTimeline` 渲染时也会按当前传播 mask 再次拆分/过滤,避免单独删除传播 mask 后空帧仍显示红/蓝颜色。底部缩略图导航轴对非当前帧使用红色边框标识人工/AI 标注帧,使用蓝色边框标识自动传播/推理帧;如果同一帧同时存在人工/AI 标注和自动传播结果,红色人工/AI 标注边框优先保留,自动传播状态只作为蓝色内描边。当前帧使用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则以青色外框加红色内描边同时表达两个状态,外层当前帧框和内层人工/AI 框的顺序固定。工作区进入自动传播、布尔操作或特定范围帧导出选择模式时,播放进度条和视频处理进度条显示 amber 覆盖层,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表达待处理或待导出范围边界,可点击/拖拽设置起止帧。
|
||||||
|
10. 当前帧传入 `CanvasArea`。
|
||||||
|
11. 工作区顶栏短状态文本会在空闲状态下自动消失;保存、导出、导入 GT 和传播任务运行中仍保留进度状态,无帧项目提示也会保留。
|
||||||
|
12. 左侧工具栏和右侧本体/语义分类面板使用 `seg-scrollbar` 定制纵向滚动条;默认滚动条 thumb 低透明度融入深色背景,hover/focus 时增强为青色提示,避免系统默认滚动条在工具区中过于突兀。左侧工具栏额外保留右侧滚动条槽位,按钮列仍按原 48px 布局,避免滚动条和图标抢空间。
|
||||||
|
12. 右侧面板不再显示“本体论与属性分类管理树”固定说明栏,直接展示实际可操作内容。
|
||||||
|
13. 右侧“遮罩透明度”滑杆写入 Zustand `maskPreviewOpacity`,`CanvasArea` 和 `AISegmentation` 都用该值计算 mask group opacity;选中 mask 在基础透明度上加亮或按基础透明度显示,方便保留选中反馈。
|
||||||
|
14. Canvas 点击 mask 后,全局 `selectedMaskIds` 会同步到 `OntologyInspector`;本体面板按选中 mask 的 `classId`、`className/label` 和颜色匹配模板分类,自动设置 active class,并把分类按钮滚动/聚焦到可见区域。
|
||||||
|
15. 工作区顶栏只在进入自动传播、传播链布尔操作或传播链清空时显示对应范围控制;自动传播由左侧工具栏按钮进入范围选择,传播权重下拉旁显示当前权重和相对参考帧的向前/向后帧数,点击“开始传播”后提交后台任务。布尔操作范围选择时,顶栏按钮变为“确认区域合并”或“确认重叠区域去除”;清空范围选择时顶栏按钮变为“确认清空”;点击后均弹出最终确认,再只对范围内存在对应传播链的帧执行。顶栏不再提供重复的“清空片段遮罩”。
|
||||||
|
|
||||||
### AI 点/框推理
|
### AI 点/框推理
|
||||||
|
|
||||||
1. 用户在 Canvas 选择正向点、反向点或框选。
|
1. 用户在 Canvas 选择正向点、反向点或框选。
|
||||||
2. `CanvasArea` 读取当前帧 ID 和宽高。
|
2. `CanvasArea` 读取当前帧 ID 和宽高。
|
||||||
3. SAM 2 框选会创建一个候选 mask,并记录原始框;后续正向点/反向点会累计到同一候选上。
|
3. SAM 2.1 框选会创建一个候选 mask,并记录原始框;后续正向点/反向点会累计到同一候选上。
|
||||||
4. `predictMask()` 归一化坐标并携带当前 `model` 调用 `/api/ai/predict`;同时有框和点时发送 `interactive` prompt。
|
4. `predictMask()` 归一化坐标并携带当前 `model` 调用 `/api/ai/predict`;同时有框和点时发送 `interactive` prompt。
|
||||||
5. SAM 2 请求中只要存在反向点,`CanvasArea` 会额外发送 `options.auto_filter_background=true` 和 `options.min_score=0.05`,让后端移除低分结果和包含负向点的 polygon。
|
5. SAM 2.1 请求中只要存在反向点,`CanvasArea` 会额外发送 `options.auto_filter_background=true` 和 `options.min_score=0.05`,让后端移除低分结果和包含负向点的 polygon。
|
||||||
6. 后端加载帧图片并通过 SAM registry 分发到 SAM 2 或 SAM 3。
|
6. 后端加载帧图片并通过 SAM registry 分发到所选 SAM 2.1 变体;`model=sam2` 会兼容归一化为 tiny,`model=sam3` 会被拒绝。
|
||||||
7. 前端把 `polygons` 转为 mask;交互式细化会替换同一个候选 mask,而不是新增多个 mask。
|
7. 前端把 `polygons` 转为 mask;交互式细化会替换同一个候选 mask,而不是新增多个 mask。
|
||||||
8. 若带反向点的 SAM 2 细化返回空结果,前端会删除当前旧候选 mask 并提示反向点已排除该区域。
|
8. 若带反向点的 SAM 2.1 细化返回空结果,前端会删除当前旧候选 mask 并提示反向点已排除该区域。
|
||||||
9. Canvas 按当前帧过滤并渲染 mask。
|
9. AI 页面只按本页最新生成的候选 id 渲染 mask,不把工作区已有 mask 带入 AI 画布;每次 `runInference()` 都先过滤掉旧 `aiMaskIds` 对应候选,再写入本次最高分候选。
|
||||||
10. 新 mask 会带上当前选择的模板分类元数据,包括 `classId`、`className`、`classZIndex` 和保存状态 `draft`。
|
10. AI 页面候选 mask 的 Path 点击事件会先判断当前工具;正向/反向选点工具下点击 mask 会继续追加提示点,其他工具下才选中 mask。
|
||||||
11. 用户点击“结构化归档保存”后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 调用 `PATCH /api/ai/annotations/{annotation_id}`。
|
11. 工作区 SAM 提示点由 `CanvasArea` 本地 `points` 状态维护;点击已渲染提示点会先 `cancelBubble`,再删除对应点并按剩余提示重新调用 `runInference()`,避免同一次点击继续触发 Stage 加点或 Path 选择。
|
||||||
12. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。
|
12. AI 页面边界框选由 `promptBox/boxStart/boxCurrent` 维护;拖拽时渲染蓝色虚线框,鼠标释放后固化 `promptBox` 并清空旧提示点,避免旧点误绑定到新框。
|
||||||
13. 工作区“清空遮罩”删除当前帧已保存标注,并清除当前帧本地 mask。
|
13. AI 页面执行分割时,如果只有 `promptBox` 则发送 `box` prompt;如果 `promptBox` 和 `points` 同时存在,`predictMask()` 会发送 interactive prompt。
|
||||||
|
14. AI 页面提示点由本地 `points` 状态维护;点击已渲染提示点会按 index 删除对应点,“删除最近锚点”会删除数组最后一个点,不改动候选 mask 列表。
|
||||||
|
15. AI 页面候选 mask 删除只接受当前 `aiMaskIds` 范围内的已选 id;“删除选中候选”和 Delete/Backspace 都复用该范围过滤,避免删除工作区已有 mask。
|
||||||
|
16. AI 页面参数开关文案只做展示增强:“局部专注模式(自动裁剪无锚区域)”仍控制 `cropMode/crop_to_prompt`,“严格除杂模式(自动清理干涉点)”仍控制 `autoDeleteBg/auto_filter_background/min_score`。
|
||||||
|
17. AI 页面“AI 遮罩透明度”滑杆复用 Zustand `maskPreviewOpacity`,和右侧“遮罩透明度”联动,只调节候选 mask 的 Konva preview opacity,不写入 `Mask.segmentation`、分类元数据或后端 payload。
|
||||||
|
18. AI 画布左上角根据正向点、反向点、边界框选和视口控制显示上下文提示,说明点击/拖拽、删除提示点和执行推理的操作方式。
|
||||||
|
19. AI 画布根据容器和当前帧尺寸按 86% 适配比例计算初始 scale/position,使底图默认居中且尽量大,但保留画布边距。
|
||||||
|
20. Canvas 按当前帧过滤并渲染 mask。
|
||||||
|
21. 新 mask 会带上当前选择的模板分类元数据,包括 `classId`、`className`、`classZIndex`、`metadata.source=ai_segmentation` 和保存状态 `draft`。
|
||||||
|
20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;用户点击保存后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 会先读取当前后端标注 id 列表,已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`,已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建;如果预检后发生并发删除导致 `PATCH` 返回 404,也会降级为重新创建,并在随后回显时排除本地旧 mask id;保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。
|
||||||
|
21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。
|
||||||
|
22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask,则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask,不弹确认;如果存在传播链结果,`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧;按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时,如果目标帧范围包含人工/AI 标注帧,会二次询问是否删除;其中清空所有传播帧会用传播链 seed 与传播结果跨越的完整帧段检查人工/AI 帧,避免从传播结果帧触发时漏掉中间独立 AI/人工帧;选择是会删除这些人工/AI 标注帧中的全部 mask,选择否时这些帧整帧保留,只清其它自动传播帧。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认,但 DEL/键盘删除在人工/AI 帧确认选择“是”时只删除本次选中或同传播链对应 mask,不会清掉同帧其它 mask;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id,只对存在的 id 发送 `DELETE`。
|
||||||
|
|
||||||
### 视频片段传播
|
### 视频片段传播
|
||||||
|
|
||||||
1. 用户在工作区选中一个当前帧 mask;如果未显式选中,前端使用当前帧第一个 mask。
|
1. 用户在工作区打开一帧作为参考帧;该帧全部 mask 都会作为传播 seed,不再提供传播对象下拉。
|
||||||
2. `VideoWorkspace` 用 `buildAnnotationPayload()` 把 seed mask 转成 normalized polygon、bbox、label、color 和 class 元数据。
|
2. 用户点击左侧工具栏橡皮擦下方的彩色 AI 大脑图标“AI自动推理”后,可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重;该入口不提供 SAM2/SAM3 家族切换,默认跟随全局 AI 权重,用户手动选择后不再被 AI 页权重切换覆盖;未进入自动传播时顶栏不显示传播权重。
|
||||||
3. 前端调用 `POST /api/ai/propagate`,默认 `direction=forward`、`max_frames=30`、`include_source=false`。
|
3. `VideoWorkspace` 以当前参考帧为 seed,将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。
|
||||||
4. 后端按项目帧序列截取片段,下载对应帧到临时 `frame_%06d.jpg` 目录,保持当前帧在片段中的相对索引。
|
4. `VideoWorkspace` 在提交传播前会先调用现有归档保存链路保存当前项目中的 draft/dirty mask,并重新读取 store 中的回显结果;参考帧 seed 因此优先携带稳定的后端 `source_annotation_id`,避免用前端临时 mask id 生成传播结果后,二次传播无法找到旧结果。
|
||||||
5. `model=sam2` 时,`sam2_engine` 使用 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播。
|
5. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`instance_id`、`source_mask_id` 和可用时的 `source_annotation_id/source_instance_id`;中空 mask 会按 `metadata.polygonRingCounts` 将外圈写入 `mask_data.polygons`,把与外圈对齐的内洞写入 `mask_data.holes`,传播 seed 同步携带 `holes`;如果 seed mask 是未编辑的自动传播结果,会沿用其原始 `source_instance_id/source_annotation_id/source_mask_id/propagation_seed_signature`,让后端把它识别为原传播链的同一个 seed;如果该传播结果被编辑并保存,更新 payload 只保留 lineage,不保留旧签名,使后端按“已修改”路径清理旧结果并重传。`maskid` 仍是语义类别和导出像素值,不用于区分同类别实例。对历史或外部写入的 `geometry_smoothing` metadata,payload 仍可透传给后端兼容处理;当前前端平滑应用会直接改写 polygon 几何并移除该参数。
|
||||||
6. `model=sam3` 时,`sam3_engine` 将请求交给 `sam3_external_worker.py`,由独立 Python 3.12 环境调用官方 `build_sam3_video_predictor()`,以 seed bbox 走 video tracker。
|
6. 前端把传播权重 id、每个 seed、每个方向组装成 `steps`,一次调用 `POST /api/ai/propagate/task`,`include_source=false`、`save_annotations=true`;接口先规范化/校验 `model` 字段中的权重 id,再创建 `processing_tasks.task_type=propagate_masks` 并投递 Celery,避免长 HTTP 请求阻塞前端等待。
|
||||||
7. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧,`mask_data.source` 记录模型传播来源。
|
7. `VideoWorkspace` 记录返回的 `task_id`,轮询 `GET /api/tasks/{task_id}` 显示任务 message、步骤进度、已处理帧次和已保存区域数;传播进度存在时,顶栏只在蓝色进度面板内显示任务 message,隐藏左侧灰色状态文字,避免同一提示重复出现;任务运行期间提供取消传播按钮,调用通用 `POST /api/tasks/{task_id}/cancel`。
|
||||||
8. 前端传播完成后重新调用 `GET /api/ai/annotations` 并回显新标注。
|
8. Celery worker 逐 step 顺序执行传播,避免多个视频 tracker 并发抢占 GPU;每个 step 开始/完成都会写入 `processing_tasks.progress/result/message` 并发布 Redis `seg:progress`,Dashboard 可同步显示。每个 step 开始前,worker 会在本次目标帧段内用 seed 来源 id、传播方向和 seed 签名查找旧传播标注:同权重、签名相同且目标帧都已有结果时跳过该 seed;签名不同、目标帧只部分覆盖或本次使用了其他 SAM 2.1 权重则先删除本次目标帧段内对应方向的旧自动传播标注,再执行新的 video predictor 传播;若历史 seed 签名中包含 `geometry_smoothing`,仍按完整签名参与兼容去重。对同一参考帧多个同类别 seed,worker 优先以 `source_instance_id/instance_id` 区分实例,再兼容 `source_annotation_id/source_mask_id/propagation_seed_key`,避免 label/color/class/maskid 相同的不同实例互相清理;旧版本缺少稳定来源 id 的传播标注才使用 label/color/class 兼容匹配,写入新结果前仍用目标帧 bbox 重叠做二次确认和清理,但已有稳定实例 id 且与当前 seed 不同的结果不会被这层空间兜底清理误删。写入前这层清理不限制旧结果方向,确保 backward 传播可覆盖早先 forward 传播留下的同物体旧 mask。
|
||||||
|
9. 后端按项目帧序列截取片段,下载对应帧到临时目录,并写成 `000000.jpg` 这类纯数字文件名;这是 `SAM2VideoPredictor` 对视频帧排序的要求,和项目库中持久化的 `frame_%06d.jpg` 对象名无关。
|
||||||
|
10. `model` 为任一 SAM 2.1 权重变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播;注入 seed 前会把外圈 polygon 栅格化为前景,再按 `holes` 扣除内洞,避免中空参考 mask 以实心形式传播;`model=sam2` 会在入队时规范化为 tiny,任务 payload/result 会保留规范化后的权重 id;单个 SAM2 video predictor 调用内部暂不提供逐帧流式进度。
|
||||||
|
11. `model=sam3` 当前不支持;SAM 3 video tracker 代码保留但没有接入产品路径。
|
||||||
|
12. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧;同一个 seed 在同一目标帧得到的多个不连通外轮廓会保存在同一个 annotation 的 `mask_data.polygons` 中,前端回显为一个含多个分离区域的 mask;传播 mask 轮廓提取使用层级信息保留内洞,外圈写入 `mask_data.polygons`,内洞按外圈对齐写入 `mask_data.holes`,并设置 `metadata.hasHoles` 供前端按中空 mask 回显和编辑;如果历史或外部 seed 带 `geometry_smoothing`,保存前仍会用同一平滑参数处理 forward/backward 两个方向的结果:强度先经过缓入曲线映射,低强度使用较小 Chaikin 切角比例和简化阈值,高强度再逐步增加迭代、切角和简化力度;随后按强度对 SAM 密集轮廓做 `approxPolyDP` 去噪简化,再做 Chaikin 平滑,最后二次简化并以平滑后的多 polygon 组合 bbox 后落库。当前工作区“应用边缘平滑”会在前端把同传播链对应 mask 直接改写为新的 polygon 并移除 `geometry_smoothing` 参数,因此后续传播通常按新几何本身参与 seed 签名。`mask_data.source` 记录权重传播来源,同时写入 `instance_id`、`source_instance_id`、`propagation_seed_key`、`propagation_seed_signature`、`propagation_direction`、`source_annotation_id` 和 `source_mask_id` 供后续幂等传播判断;历史 `geometry_smoothing` 仅在存在时保留用于兼容判断。
|
||||||
|
13. 前端轮询到已创建区域后刷新 `GET /api/ai/annotations` 并回显新标注;任务结束后如果后端返回 0 个新区域,工作区会明确提示没有生成新的 mask,若是未改变 seed 被跳过则提示未改变 mask 已跳过。处理过帧次大于 0 的成功任务会追加一条本地传播历史片段,用于视频处理进度条显示最近传播范围;`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 取消和点击首节点闭合。`Esc` 和左侧工具栏“取消选中”按钮只取消当前 mask 选区、临时多边形点、矩形/圆拖拽状态、画笔/橡皮擦临时笔触和顶点选择,不删除已有 mask,也不清空右侧语义分类树的当前类别。提示会在工具或操作状态变化时出现,并在数秒后自动隐藏,避免长期遮挡底图。
|
||||||
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. 画笔/橡皮擦尺寸保存在 Zustand 中;拖动期间只保留采样后的圆形笔触预览,鼠标松开后再用 `polygon-clipping` 计算一次几何结果,避免拖动中反复重算复杂 polygon。画笔有选中 mask 时会把本次采样笔触 union 进选中 mask,即使笔触和旧区域不重叠也形成同一个多 polygon mask;没有选中 mask 时才按当前语义分类创建新的独立 mask;如果画笔笔触闭合形成中空区域,`segmentation` 保留外圈和内洞 ring,`metadata.hasHoles/polygonRingCounts` 记录 ring 分组,并使用 even-odd 渲染;橡皮擦则对当前选中 mask 执行 difference 扣除。
|
||||||
7. 工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y 调用 `undoMasks()` / `redoMasks()`。
|
7. 多边形、矩形、圆和画笔完成时,如果当前帧有选中 mask,会把新几何 union 进该 mask,保留原 mask 的语义分类并将已保存 mask 标为 dirty;如果当前没有选中 mask,才创建新 mask,写入 `pathData`、像素 `segmentation`、`bbox`、`area` 和当前模板分类元数据,并自动写入 `selectedMaskIds` 成为当前选中 mask。若右侧没有选中具体分类,新建 mask 默认使用 `maskid: 0` 的“待分类”。创建工具仍处于激活状态时,刚创建/被并入的选中 mask 会显示只读边界顶点;切换到 `move` 或“调整多边形”后这些顶点可拖动编辑。
|
||||||
|
8. `addMask()`、`setMasks()`、`updateMask()`、`clearMasks()` 会维护 `maskHistory/maskFuture`。
|
||||||
|
9. 工作区撤销/重做只保留顶栏按钮和快捷键入口,AI 页保留自己的撤销/重做按钮;工作区由 `VideoWorkspace` 在 window capture 阶段统一处理 `Ctrl/Cmd+Z`、`Ctrl/Cmd+Shift+Z` 和 `Ctrl/Cmd+Y`,快捷键判断由 `src/lib/keyboardShortcuts.ts` 同时兼容 `event.key` 与物理键码 `event.code=KeyZ/KeyY`;输入框、下拉框和可编辑文本聚焦时跳过快捷键,避免影响帧范围输入。
|
||||||
|
|
||||||
### Polygon 逐点编辑
|
### Polygon 逐点编辑
|
||||||
|
|
||||||
1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path,`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 第一条 polygon 的顶点控制点和边中点插入手柄。
|
1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path,`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 所有可编辑 polygon 的顶点控制点和边中点插入手柄;多 polygon 或分离区域组成的同一个 mask 不再只显示第一条 polygon。
|
||||||
2. 拖动顶点后,前端重算 `pathData`、像素 `segmentation`、`bbox`、`area`。
|
2. 顶点 `mousedown/dragstart` 会立即设置当前顶点选择;拖动过程中通过 `dragMove` 实时重算 `pathData`、像素 `segmentation`、`bbox`、`area`,不需要先单击顶点再拖动。
|
||||||
3. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。
|
3. Stage 的 `onDragEnd` 只处理 Stage 自身拖拽;polygon 顶点等子节点拖拽结束事件会被忽略,避免子节点坐标误写入 Canvas `position` 导致视口跳动。
|
||||||
4. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty` 且 `saved=false`。
|
4. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。
|
||||||
5. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端。
|
5. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty` 且 `saved=false`。
|
||||||
6. 选中顶点后 Delete/Backspace 可删除顶点;前端保持 polygon 至少三点。
|
6. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端。
|
||||||
7. 未选中具体顶点但选中了 mask 时,Delete/Backspace 从前端 store 删除该 mask;如果包含 `annotationId`,通过工作区回调调用后端删除接口。
|
7. 选中顶点后 Delete/Backspace 可删除顶点;前端保持 polygon 至少三点。
|
||||||
|
8. 未选中具体顶点但选中了 mask 时,Delete/Backspace 从前端 store 删除该 mask;左侧工具栏 `DEL` 按钮调用同一删除逻辑。如果包含 `annotationId`,通过工作区回调先预检后端 annotation id 再调用删除接口;删除对象属于传播链或传播 seed 时,删除范围会扩展到同链自动传播 mask,但不移除其他帧独立 AI 推理/人工 mask。
|
||||||
|
9. 普通 mask 和导入 mask 都不显示黄色 seed point,也不提供 seed point 拖动;保存 payload 仍可保留已有 `points` 数据兼容,但画布体验统一为区域选择和 polygon 顶点编辑。
|
||||||
|
|
||||||
### 区域合并与去除
|
### 区域合并与去除
|
||||||
|
|
||||||
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 已保存,会调用工作区传入的删除回调删除后端标注。执行前会按 `source_instance_id/instance_id`、`source_annotation_id`、`source_mask_id` 和可靠的 `propagation_seed_key` 计算可同步的传播帧;若存在其它传播帧,先弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择。布尔同步使用严格实例匹配:优先可靠 lineage,旧传播结果缺少可靠 id 时只为每个已选 mask 选取空间最近的一个同语义传播结果,不使用宽泛同类别 legacy 分组批量合并,避免同类其它实例被一起卷入。按帧范围选择会把本次布尔操作交给 `VideoWorkspace`,复用底部时间轴范围选择和最终确认弹窗;确认后只在范围内且具备对应关系的帧上执行同一次 union,只删除该帧参与合并的次级 mask,避免把同链但未参与同步或范围外的区域整链误删。用户在顶栏范围确认前重新点击“合并选中”开始新的布尔选择时,旧的范围请求必须立即取消。
|
||||||
|
7. `area_remove` 使用 difference,从第一个选中的主 mask 中扣除后续选中 mask,扣除对象本身保留;同样会在执行前计算可同步的传播帧并弹出当前帧/所有传播帧/按帧范围选择。按帧范围选择确认后,会在范围内其它传播帧中找到对应主区域和扣除区域并执行 difference,扣除区域本身继续保留;如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring,`metadata.polygonRingCounts` 记录每个 polygon 的 ring 数,渲染时使用 even-odd fill。
|
||||||
|
8. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;同步到传播帧时保留传播来源和 lineage metadata,避免自动传播帧在时间轴上变成人工/AI 标注帧;带洞结果的面积按外圈减内洞计算;进入调整多边形时,外圈和内洞 ring 都会显示顶点和边中点插入手柄,内洞拖动、插点、保存与回显继续保持中空结构。
|
||||||
|
|
||||||
### GT Mask 导入
|
### GT Mask 导入
|
||||||
|
|
||||||
1. 工作区“导入 GT Mask”选择图片文件。
|
1. 工作区左侧工具栏“导入 GT Mask”选择图片文件;入口位于“重叠区域去除”之后。
|
||||||
2. 前端 `importGtMask()` 以 multipart form-data 调用 `POST /api/ai/import-gt-mask`,携带 `project_id` 和 `frame_id`。
|
2. 前端 `importGtMask()` 以 multipart form-data 调用 `POST /api/ai/import-gt-mask`,携带 `project_id` 和 `frame_id`。
|
||||||
3. 后端验证项目、帧、模板后使用 OpenCV 读取灰度 mask。
|
3. 后端验证项目、帧、模板后使用 OpenCV 读取灰度 mask。
|
||||||
4. 后端按非零像素值拆分多类别标签。
|
4. 后端按非零像素值拆分多类别标签。
|
||||||
5. 后端对每个类别的前景做 contour 提取,每个连通域保存为一个 `Annotation`。
|
5. 后端对每个类别的前景做高精度 contour 提取,每个连通域保存为一个 `Annotation`;轮廓使用未压缩链提取并以较小 `approxPolyDP` epsilon 保留细节,超过点数上限时才逐步增加简化强度或抽样。
|
||||||
6. `points` 字段保存距离变换中心 seed point,`mask_data.polygons` 保存 normalized polygon,`mask_data.gt_label_value` 保存原始像素类别值。
|
6. `points` 字段可保存距离变换中心 seed point 供数据兼容,`mask_data.polygons` 保存 normalized polygon,`mask_data.holes` 保存与外圈对齐的内洞,`mask_data.gt_label_value` 保存原始像素类别值;导入后的 polygon 与普通 mask 走同一套拓扑锚点统计、边缘平滑、编辑和保存链路。
|
||||||
7. 前端重新读取项目标注并回显。
|
7. 前端重新读取项目标注并回显。
|
||||||
8. `annotationToMask()` 会把 normalized seed point 转成像素坐标,Canvas 以可拖拽点显示;拖动后 `buildAnnotationPayload()` 会把点再归一化写回后端。
|
8. `annotationToMask()` 仍可把后端 `points` 转成像素坐标保存在 mask 数据中,但 Canvas 不显示 seed point,也不提供拖动;普通 polygon 若没有后端 seed point,保存逻辑可按 polygon 自动计算内部代表点写入,以保持数据兼容。
|
||||||
|
|
||||||
### 模板管理
|
### 模板管理
|
||||||
|
|
||||||
@@ -177,12 +229,19 @@
|
|||||||
3. 保存时调用 `createTemplate()` 或 `updateTemplate()`。
|
3. 保存时调用 `createTemplate()` 或 `updateTemplate()`。
|
||||||
4. 后端把 `classes`、`rules` 打包进 `mapping_rules`。
|
4. 后端把 `classes`、`rules` 打包进 `mapping_rules`。
|
||||||
5. 返回时再解包给前端。
|
5. 返回时再解包给前端。
|
||||||
6. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`;切换工具、切换帧或卸载 Canvas 时会清空选择。
|
6. 模板详情页和编辑弹窗都支持拖拽调整语义类别层级顺序;拖拽后重算 `zIndex`,保存到后端模板并刷新当前详情页,`maskId` 保持不变。所有模板都会归一化包含黑色 `maskId: 0` 的“待分类”保留类,该类固定在语义分类树最后,不参与删除和拖拽上移。编辑弹窗点击分类后只编辑分类名称,不展示或编辑旧 `category` 来源元信息。编辑弹窗中的 JSON 批量导入支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式,并兼容带前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容;导入前会先显示分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示,确认导入后进入编辑态,保存模板时落库。
|
||||||
7. `AISegmentation` 生成 mask 后会写入全局 `masks` 并把生成的 mask id 写入 `selectedMaskIds`;点击 AI 页预览 mask 也会更新 `selectedMaskIds`。
|
7. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`;切换到多边形、矩形、圆、画笔、橡皮擦、移动、调整多边形、区域合并或重叠区域去除时保留当前选区,创建工具会把新几何并入当前选中 mask;只有 `Esc`、左侧“取消选中”、删除 mask、AI prompt 等显式离开选区的动作会清空旧 mask 选区。切换帧时会优先沿传播链跟随同一 mask,找不到对应结果时才清空;卸载 Canvas 时清空选择。
|
||||||
8. AI 页“推送至工作区编辑”会切换到工作区并把 `activeTool` 设为 `edit_polygon`;`CanvasArea` 初始读取全局 `selectedMaskIds`,让 AI 页选中的 mask 在工作区继续保持选中。
|
8. `AISegmentation` 生成 mask 后会写入全局 `masks` 并把生成的 mask id 写入 `selectedMaskIds`;点击 AI 页预览 mask 也会更新 `selectedMaskIds`。
|
||||||
9. `OntologyInspector` 可以选择具体分类;选择结果进入全局 store,供 `CanvasArea` 和 `AISegmentation` 新建/更新 mask 时使用。
|
9. AI 页“推送至工作区编辑”会先检查待推送 AI 候选 mask 是否具备 `classId` 或 `className`;缺少语义分类时清空普通推理反馈,并通过 `TransientNotice` 右上角 error toast 提示用户先点右侧语义分类树,不切换模块、不修改工具状态。
|
||||||
10. 如果 `selectedMaskIds` 中存在当前 store 的 mask,点击分类时会立即更新这些 mask 的 `templateId`、`classId`、`className`、`classZIndex`、`label` 和 `color`。
|
10. `AISegmentation` 卸载时会清理仍缺少 `classId/className` 的本页 AI 候选,并同步移除对应 `selectedMaskIds`,避免用户绕过推送按钮从侧栏切到工作区时带入无语义 mask。
|
||||||
11. 已保存 mask 被重新分类后进入 `dirty` 且 `saved=false`,继续复用工作区归档保存的 PATCH 链路。
|
11. AI 页语义校验通过后会切换到工作区并把 `activeTool` 设为 `edit_polygon`;`CanvasArea` 初始读取全局 `selectedMaskIds`,让 AI 页选中的 mask 在工作区继续保持选中。
|
||||||
|
12. 工作区帧/标注异步加载完成后,`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask,不会用后端回显结果直接覆盖整个 `masks` store。
|
||||||
|
13. `OntologyInspector` 可以选择激活模板和具体分类;项目已有任意 mask 时,用户修改激活模板会先弹出确认框,确认后调用删除标注接口清空当前项目所有已保存标注并清空本地 mask,再切换模板;项目没有任何 mask 时直接切换。具体分类选择结果进入全局 store,供 `CanvasArea` 和 `AISegmentation` 新建/更新 mask 时使用。
|
||||||
|
14. 如果 `selectedMaskIds` 中存在当前 store 的 mask,点击分类时会立即更新这些 mask 的 `templateId`、`classId`、`className`、`classZIndex`、`label` 和 `color`;如果当前没有选中任何 mask,点击分类只更新后续新建 mask 使用的 active class,不会改动已有 mask。
|
||||||
|
15. 对属于自动传播链的 mask,分类更新会复用 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 查找同一目标实例在前后帧中的传播结果,并同步更新这些传播 mask 的分类元数据,避免同一物体跨帧语义不一致。
|
||||||
|
16. 同一次点击会把这些已选 mask 移动到前端 `masks` 数组末尾;`CanvasArea` 按数组顺序渲染,后渲染的 Path 显示在最上层,方便用户继续编辑刚换标签的区域。该显示置顶不改变模板 `zIndex` 或后端导出语义覆盖规则。
|
||||||
|
17. 已保存 mask 被重新分类后进入 `dirty` 且 `saved=false`,同传播链被同步更新的已保存 mask 也进入 `dirty`,继续复用工作区归档保存的 PATCH 链路。
|
||||||
|
18. 模板保存、删除和 JSON 导入失败使用 `TransientNotice` 非阻塞提示,默认数秒后自动消失。
|
||||||
|
|
||||||
### 导出
|
### 导出
|
||||||
|
|
||||||
@@ -190,7 +249,9 @@
|
|||||||
2. PNG mask 导出会把 normalized polygon 渲染为单标注二值 mask。
|
2. PNG mask 导出会把 normalized polygon 渲染为单标注二值 mask。
|
||||||
3. PNG mask 导出还会按 `mask_data.class.zIndex` 或模板 `z_index` 从低到高覆盖,生成每帧语义融合 mask。
|
3. PNG mask 导出还会按 `mask_data.class.zIndex` 或模板 `z_index` 从低到高覆盖,生成每帧语义融合 mask。
|
||||||
4. ZIP 内写入 `semantic_classes.json`,记录语义值到类别、颜色和 zIndex 的映射。
|
4. ZIP 内写入 `semantic_classes.json`,记录语义值到类别、颜色和 zIndex 的映射。
|
||||||
5. 前端“导出 JSON 标注集”和“导出 PNG Mask ZIP”按钮都会在导出前保存待归档标注,然后下载对应文件。
|
5. 前端使用“分割结果导出”统一入口替代原 JSON/PNG 两个按钮;点击后在下拉栏选择整体视频、特定范围帧或当前图片,默认选中当前图片,并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择“特定范围帧”时,导出起止帧输入框和 `FrameTimeline` 的范围拖拽选择共用同一组导出范围状态;选择 Mix_label 时显示透明度滑杆,默认 0.3,并用当前/待导出第一帧做遮罩预览。提交前会保存待归档标注,然后下载统一 ZIP。下载文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自 `currentProject.name`,起止帧按当前导出范围取首尾帧,时间戳格式为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 顺序,项目名中的文件系统不安全字符会替换为 `_`。
|
||||||
|
6. 统一导出 ZIP 固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`;原始图片文件名使用 `视频名称_时间戳_项目帧序号`。导出会保留类别真实 maskid,GT_label 固定为 8-bit uint8 PNG,像素值与 maskid 相同并跨图一致;`maskId: 0` 的“待分类”保持 0,和背景同为黑色,Pro_label 中也输出为 `[0,0,0]`;缺失 maskid 的旧标注才补下一个可用正整数并写入映射 JSON;正整数 maskid 超出 1-255 会拒绝导出。选择分开 mask 时包含 `分开Mask分割结果/`,每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹,并按“同一帧同一类别合并一张图”的方式输出 `{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`。选择 GT_label 图时包含 `GT_label图/{视频名称_时间戳_项目帧序号}.png`;选择 Pro_label 图时包含 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`;选择 Mix_label 图时包含 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。GT_label、Pro_label 和 Mix_label 的重叠区域按内部拖拽排序从低到高覆盖,和未选中状态下的画布显示顺序一致;maskid 不参与排序。后端直接下载接口的 `Content-Disposition` 使用同一 ZIP 命名规则,并用 `filename*` 支持中文项目名。
|
||||||
|
7. 右侧 `OntologyInspector` 的语义分类树支持拖拽调整内部覆盖顺序;拖拽后保存到模板并同步当前工作区同类 mask 的 `classZIndex`,但保留类别 maskid 不变。
|
||||||
|
|
||||||
## 接口契约
|
## 接口契约
|
||||||
|
|
||||||
@@ -199,26 +260,26 @@
|
|||||||
- `updateProject()` 使用 `PATCH /api/projects/{id}`。
|
- `updateProject()` 使用 `PATCH /api/projects/{id}`。
|
||||||
- `exportCoco()` 使用 `GET /api/export/{projectId}/coco`。
|
- `exportCoco()` 使用 `GET /api/export/{projectId}/coco`。
|
||||||
- `exportMasks()` 使用 `GET /api/export/{projectId}/masks`。
|
- `exportMasks()` 使用 `GET /api/export/{projectId}/masks`。
|
||||||
|
- `exportSegmentationResults()` 使用 `GET /api/export/{projectId}/results`,通过 query 参数选择范围和 mask 类型。
|
||||||
- `cancelTask()` 使用 `POST /api/tasks/{taskId}/cancel`。
|
- `cancelTask()` 使用 `POST /api/tasks/{taskId}/cancel`。
|
||||||
- `retryTask()` 使用 `POST /api/tasks/{taskId}/retry`。
|
- `retryTask()` 使用 `POST /api/tasks/{taskId}/retry`。
|
||||||
- `predictMask()` 使用 `POST /api/ai/predict`,请求体为 `image_id`、`prompt_type`、`prompt_data`、`model`。
|
- `predictMask()` 使用 `POST /api/ai/predict`,请求体为 `image_id`、`prompt_type`、`prompt_data`、`model`。
|
||||||
- `propagateMasks()` 使用 `POST /api/ai/propagate`,请求体为 `project_id`、`frame_id`、`model`、`seed`、`direction`、`max_frames`。
|
- `propagateMasks()` 使用 `POST /api/ai/propagate`,请求体为 `project_id`、`frame_id`、`model`、`seed`、`direction`、`max_frames`,作为单 seed 同步兼容接口保留。
|
||||||
|
- `queuePropagationTask()` 使用 `POST /api/ai/propagate/task`,请求体为 `project_id`、`frame_id`、`model`、`steps`、`include_source`、`save_annotations`,返回 `ProcessingTask`。
|
||||||
- `saveAnnotation()` 使用 `POST /api/ai/annotate`。
|
- `saveAnnotation()` 使用 `POST /api/ai/annotate`。
|
||||||
- `importGtMask()` 使用 `POST /api/ai/import-gt-mask` multipart form-data。
|
- `importGtMask()` 使用 `POST /api/ai/import-gt-mask` multipart form-data,并传入 `unknown_color_policy=discard|undefined`。前端上传前弹出导入结果预览和未知 maskid 策略选择;后端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype。合法 GT mask 限定为 8-bit 灰度图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,0 为背景、X 为 1-255 的 maskid;灰度/RGB 等通道图按模板 `maskId` 匹配类别,16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图不再按颜色匹配并会返回格式错误;全背景图提示为“GT Mask 图片中没有非背景 maskid 区域。”;未知类别按策略舍弃或保存为黑色 `maskid:0` 的“待分类”,并保留 `gt_unknown_class` 和原始 `gt_label_value`。若 GT mask 尺寸和当前帧不同,后端用最近邻插值拉伸到当前帧尺寸后再生成高精度 polygon。
|
||||||
- `getProjectAnnotations()` 使用 `GET /api/ai/annotations`。
|
- `getProjectAnnotations()` 使用 `GET /api/ai/annotations`。
|
||||||
- `updateAnnotation()` 使用 `PATCH /api/ai/annotations/{annotationId}`。
|
- `updateAnnotation()` 使用 `PATCH /api/ai/annotations/{annotationId}`。
|
||||||
- `deleteAnnotation()` 使用 `DELETE /api/ai/annotations/{annotationId}`。
|
- `deleteAnnotation()` 使用 `DELETE /api/ai/annotations/{annotationId}`;工作区批量删除前会先用 `getProjectAnnotations()` 预检当前项目存在的 id,跳过本地陈旧 id,避免已被撤销/清空流程删除过的 annotation 再次发起 DELETE 产生 404。
|
||||||
- `parseMedia()` 使用 `POST /api/media/parse?project_id=...`,可选 `parse_fps`、`max_frames`、`target_width`,用于生成标准帧序列。
|
- `parseMedia()` 使用 `POST /api/media/parse?project_id=...`,可选 `parse_fps`、`max_frames`、`target_width`,用于生成标准帧序列。
|
||||||
- `getProjectFrames()` 返回帧图像 URL、宽高、`timestamp_ms` 和 `source_frame_number`。
|
- `getProjectFrames()` 返回帧图像 URL、宽高、`timestamp_ms` 和 `source_frame_number`。
|
||||||
- 后端 `/api/ai/predict` 支持 point、box、interactive、semantic 四种 prompt_type,并通过 `model` 选择 SAM 2 或 SAM 3。
|
- 后端 `/api/ai/predict` 当前支持 SAM 2.1 的 point、box、interactive;`semantic` 文本提示禁用并返回 400。
|
||||||
- SAM 2 是点/框交互式分割模型,不做文本语义分割;AI 页面在 SAM 2 + 纯文本时直接提示用户改用点提示或切换 SAM 3。
|
- SAM 2.1 是点/框交互式分割模型,不做文本语义分割;AI 页面已经移除纯文本输入。
|
||||||
- SAM 2 点提示和 auto fallback 只返回一个最高分候选,避免同一提示产生多个重叠候选 mask。
|
- SAM 2.1 点提示和 auto fallback 只返回一个最高分候选,避免同一提示产生多个重叠候选 mask。
|
||||||
- 当前 SAM 3 暴露 semantic 文本语义推理和 box 几何提示;工作区 Canvas 的点交互会在选择 SAM 3 时显示提示,不再静默失败。
|
- SAM 3 前端入口、后端 registry 入口和状态展示均已禁用;`model=sam3` 会返回不支持。
|
||||||
- SAM 3 box prompt 复用后端 `/api/ai/predict` 的 `box` prompt_type,输入仍是 normalized `[x1, y1, x2, y2]`,引擎适配层会转换为官方 `add_geometric_prompt()` 使用的 `[center_x, center_y, width, height]` 正框。
|
- 后端 `/api/ai/predict` 支持可选 `options`:`crop_to_prompt` 会对 point/box/interactive prompt 做局部裁剪推理并回映射 polygon,`auto_filter_background` 会按 `min_score` 和负向点过滤结果。
|
||||||
- AI 页面选择 SAM 3 时优先发送文本 semantic prompt,不会把正/反点误发送为 SAM 3 point prompt;空文本、后端错误和空结果都会显示反馈消息。
|
- 后端 `/api/ai/propagate/task` 当前支持所选 SAM 2.1 mask seed 视频传播后台任务;同步 `/api/ai/propagate` 仍保留为单 seed 兼容接口。
|
||||||
- 后端 `/api/ai/predict` 支持可选 `options`:`crop_to_prompt` 会对 point/box/interactive prompt 做局部裁剪推理并回映射 polygon,`auto_filter_background` 会按 `min_score` 和负向点过滤结果;SAM 3 semantic 会把正数 `min_score` 传给 external worker 作为 `confidence_threshold`。
|
- 后端 `/api/ai/models/status` 返回 GPU 和四个 SAM 2.1 变体的真实运行状态。
|
||||||
- 后端 `/api/ai/propagate` 支持 SAM 2 mask seed 视频传播和 SAM 3 external video tracker;当前前端默认向后传播 30 帧并保存结果标注。
|
|
||||||
- 后端 `/api/ai/models/status` 返回 GPU、SAM 2、SAM 3 的真实运行状态;SAM 3 状态包含外部 Python 环境与 checkpoint access 的可用性。
|
|
||||||
- point prompt 支持旧数组形式和 `{ points, labels }` 对象形式。
|
- point prompt 支持旧数组形式和 `{ points, labels }` 对象形式。
|
||||||
|
|
||||||
## 外部依赖边界
|
## 外部依赖边界
|
||||||
@@ -235,8 +296,9 @@
|
|||||||
|
|
||||||
以下能力属于当前冻结版本的占位或半可用功能:
|
以下能力属于当前冻结版本的占位或半可用功能:
|
||||||
|
|
||||||
- Dashboard 初始快照来自 `GET /api/dashboard/overview`;解析队列由 `processing_tasks` queued/running/failed/cancelled 任务生成。
|
- Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。
|
||||||
- 已保存标注支持通过“应用分类”、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑和区域合并/去除进入 dirty 状态并归档更新;选中整块 mask 可用 Delete/Backspace 删除并同步后端;复杂洞结构编辑尚未实现。
|
- 已保存标注支持通过右侧语义分类树换标签、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新;多 polygon/分离区域选中后所有子区域都显示编辑手柄,同帧同传播链的分散 mask 会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;旧传播结果缺少稳定 lineage 时,会用传播来源、来源帧、方向、分类/标签/颜色构造兼容分组,保证同一传播 mask 拆出的不连通片段仍一起高亮;区域合并/去除同步传播帧时不复用这类宽泛高亮分组,而是优先可靠 lineage,缺少可靠 lineage 时为每个已选 mask 在同来源帧且同语义/颜色的候选传播结果中选取空间最近的单个对应实例,避免把同类别其它实例一起合并或扣除;区域合并支持跨语义链路,当前帧把 A mask 合并进 B mask 时,传播帧中的 A 对应结果会并入 B 对应结果;若某个传播帧没有 B 对应结果但有 A 对应结果,则把该 A 结果转换为 B 语义并标记为 dirty;Canvas 右下角不再提供旧的“应用分类”按钮,避免没选区时误改整帧;区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择,范围选择复用时间轴和确认弹窗,并保留传播帧来源 metadata;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,同步后端前会预检 id,同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留。
|
||||||
- SAM 3 文本语义分割取决于官方依赖、GPU 运行环境和本地 checkpoint;状态接口会暴露真实可用性,运行时缺失时 `available=false`。
|
- SAM 3 文本语义分割已从当前产品路径中禁用;相关源码保留,恢复时需要重新接入前端入口、registry、状态接口和测试。
|
||||||
- 自定义分类只存在本地组件状态。
|
- 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`。
|
||||||
- GT mask 导入已完成多类别像素值拆分、contour、distance transform seed point 和前端 seed point 拖拽编辑;骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。
|
- 选中 mask 后,本体面板的“特定目标实例属性追踪”标题值来自当前 mask 的 `className/label`,不使用全局 active class;面板不再展示长期为 1 的“当前选中区域”计数;面板调用 `POST /api/ai/analyze-mask` 自动显示拓扑锚点数量等属性,`topology_anchor_count` 是真实 polygon 顶点数量,`topology_anchors` 只保留最多 64 个抽样点用于调试展示;`OntologyInspector` 会为分析请求维护递增序号,旧请求返回时不再回写状态,并静默忽略 Axios abort/cancel 错误,避免快速切换、平滑预览或组件卸载时把正常中止误报成失败;不再提供“重新提取拓扑锚点”调试按钮;“边缘平滑强度”滑杆会即时更新数值,但 `POST /api/ai/smooth-mask` 预览请求经过约 220ms 防抖后才发送,返回 polygon 作为临时预览写入当前 mask 显示,预览不改变保存状态;点击“应用边缘平滑”后,前端把平滑 polygon 作为新的实际几何写入当前 mask,并按传播 lineage 同步写入传播链前后对应 mask,相关 mask 标记为 dirty/draft,整次操作通过一次 `setMasks()` 进入撤销/重做历史;应用后不保留 `geometry_smoothing` 参数,平滑强度重置为 0。前端不再展示“后端模型置信度”。
|
||||||
|
- GT mask 导入已完成多类别像素值拆分、contour 和 distance transform seed point 数据兼容;前端不显示或拖动 seed point,导入 mask 与普通 mask 共享拓扑统计、边缘平滑、顶点编辑、分类和保存体验;骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。
|
||||||
|
|||||||
@@ -14,54 +14,70 @@
|
|||||||
|
|
||||||
| 需求 | 测试文件 | 覆盖点 |
|
| 需求 | 测试文件 | 覆盖点 |
|
||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| R1 登录与会话 | `src/components/Login.test.tsx`, `backend/tests/test_auth.py` | 成功登录、失败提示、后端 401 |
|
| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 登录页 logo 和系统标题文案、成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口用户图标、底部退出图标和非交互 tooltip、用户 CRUD、唯一 admin/标注员角色权限、审计日志、旧 viewer 归一为标注员、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
|
||||||
| R2 项目管理 | `src/lib/api.test.ts`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、后端 CRUD、帧列表 |
|
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目库不展示独立新建项目按钮、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、完整帧列表不默认截断到 1000、项目按当前 JWT 用户隔离 |
|
||||||
| 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`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧/重新生成帧 FPS 选择、重新生成前清空旧帧旧标注旧 mask、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
|
||||||
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、缩略图/range/左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
|
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
|
||||||
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 工具切换、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、polygon 顶点拖动/删除、边中点插点、双击边界按位置插点、整块 mask 删除、区域合并/去除、内含去除 hole 渲染、合并模式隐藏编辑手柄、撤销/重做历史栈 |
|
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/lib/keyboardShortcuts.test.ts`, `src/store/useStore.test.ts` | 工具切换、切换到多边形/矩形/圆会保留旧 mask 选区、有选中 mask 时多边形/矩形/圆/画笔新几何会并入选中 mask 且不要求重叠、无选中 mask 时手工新建 mask 后自动选中新 mask 并显示创建后边界点、Esc 和左侧“取消选中”按钮清空当前 mask 选区和临时绘制状态、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI自动推理入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、AI自动推理后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔无选中时新建当前类别 mask、画笔/橡皮擦模式下保留当前选中 mask 顶点提示且只读、画笔从图外落笔不创建 mask、靠边画笔生成几何裁剪到当前帧边界内、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、范围确认前重新开始当前帧布尔操作会取消旧顶栏范围请求、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、旧传播缺可靠 lineage 时布尔同步只选每个已选 mask 的空间最近对应实例而不批量处理同类其它实例、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染和 ring 分组保存、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y、物理键码 fallback 和输入框快捷键跳过、撤销/重做历史栈 |
|
||||||
| 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`, `backend/tests/test_sam3_engine.py` | 点/框/interactive/semantic 契约、SAM 2 纯文本提示拦截、SAM 2 最高分候选去重、SAM 2 框选后正负点细化同一候选 mask、SAM 2 反向点启用背景过滤且空结果移除旧候选、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页推送到工作区编辑保留选择、SAM 2 视频传播、SAM 3 语义文本前端执行路径、SAM 3 semantic 请求级阈值、SAM 3 worker 单 2D mask 转 polygon、SAM 3 工作区框选、SAM 3 video tracker 外部桥接、SAM 3 点交互不支持提示、空文本/空结果反馈、模型选择、GPU/SAM 状态、SAM 3 外部 worker 桥接、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 变体选择、模型不可用时 AI 页禁用不可用变体和执行按钮、工作区所有变体不可用时禁用 AI自动推理、点/框/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 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
|
||||||
| R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存标注、加载回显、更新 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` | 保存状态按钮“保存 X 个改动/已全部保存”、保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、dirty 本地旧 annotationId 预检缺失时直接重新 POST 创建、预检后 PATCH 404 时重新 POST 创建并回显替换、中空 mask 保存为 `polygons` + `holes` 并可回显为 ring 分组、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 |
|
||||||
| 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/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、删除模板站内确认、鼠标复制模板为私有副本、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页“语义分类树(拖拽调层级)”标题、详情页“编辑模板”按钮和编辑图标、详情页垃圾桶删除 label 且不显示来源标签、编辑弹窗分类编辑不显示旧 category 来源元信息、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、`[[colors],[names]]` 数组格式、`{colors,names}` 对象格式、带前缀/宽松 keys/中文标点粘贴格式、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/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、已有 mask 时切换激活模板需确认并清空所有 mask/标注、无 mask 时直接切换、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、无选中 mask 时点击分类只设置后续新建类别且不改已有 mask、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、分类变更同步同一传播链前后帧对应 mask、自定义分类 PATCH 后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、选中 mask 后端拓扑属性分析、拓扑锚点数量按真实 polygon 顶点数显示、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览不标 dirty、应用边缘平滑后将 mask 标记为 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑保存时保留传播 lineage 而不把传播帧变成人工/AI 标注帧、平滑撤销/重做、平滑应用后强度归零 |
|
||||||
| 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` | 统一分割结果导出按钮使用导出图标和绿色强调背景、统一分割结果导出下拉、导出前自动保存、整体/范围/当前帧范围参数、特定范围帧可通过播放进度条/视频处理进度条拖拽选择、下载 ZIP 按项目名/`0h00m00s000ms` 起止时间戳/起止项目帧序号命名、导出内容 outputs 参数、Mix_label 透明度参数和预览、兼容 COCO/PNG 路径、JSON 结构、maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/按类别合并的分开 Mask 文件夹、GT_label 黑白图文件夹、Pro_label 彩色图文件夹、Mix_label 原图叠加图文件夹、GT/Pro/Mix 按内部优先级覆盖且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类在 GT_label/Pro_label 中与背景同为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出 GT_label 再导入保持类别一致 |
|
||||||
| R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 |
|
| R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 |
|
||||||
| R13 文档与测试 | `doc/09-test-plan.md` | 测试覆盖矩阵 |
|
| R13 文档与测试 | `doc/09-test-plan.md`, `doc/11-frontend-interaction-state-machines.md` | 测试覆盖矩阵、前端交互状态机、键盘规则和确认弹窗流 |
|
||||||
|
|
||||||
## 逐功能点追踪
|
## 逐功能点追踪
|
||||||
|
|
||||||
| 需求 | 功能点 | 对应测试 | 当前状态 |
|
| 需求 | 功能点 | 对应测试 | 当前状态 |
|
||||||
|------|--------|----------|----------|
|
|------|--------|----------|----------|
|
||||||
| R1 | 登录页、默认开发凭证、token 写入、失败提示、后端 401 | `Login.test.tsx`, `test_auth.py` | 已覆盖 |
|
| R1 | 登录页 logo 和系统标题文案、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理入口图标、底部退出入口图标和 tooltip 命中范围、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已生成帧自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
|
||||||
| R2 | 项目列表/创建/选择、视频导入、DICOM 导入、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
|
| R2 | 项目列表/无独立新建项目按钮/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、完整帧列表不默认截断到 1000、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、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 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧/重新生成帧、重新生成清理旧帧旧标注旧 mask、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
|
||||||
| R4 | 工作区加载帧、无帧项目不自动解析、Canvas 底图、缩略图/range/左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
|
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
|
||||||
| R5 | 工具切换、调整多边形入口、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
|
| R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、清空遮罩唯一左侧入口、Canvas 右下角旧清空/应用分类按钮移除、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
|
||||||
| R5 | 顶点编辑、边中点插点、双击边界按位置插点、顶点删除、整块删除、撤销/重做、区域合并、区域去除、hole even-odd 渲染 | `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 |
|
| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y 和 KeyZ/KeyY fallback、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `keyboardShortcuts.test.ts`, `useStore.test.ts` | 已覆盖 |
|
||||||
| R6 | SAM 2 点/框/interactive、SAM 2 纯文本提示拦截、SAM 2 最高分候选去重、AI 页生成 mask 自动选中并可换标签、AI 页推送到工作区编辑保留选择、SAM 2 视频传播、SAM 3 semantic、SAM 3 semantic 请求级阈值、SAM 3 worker 单 2D mask 转 polygon、SAM 3 box、SAM 3 video tracker、SAM 3 不支持点交互时的前端反馈、模型选择、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`, `test_sam3_engine.py` | 已覆盖 |
|
| R6 | SAM 2.1 变体选择、模型不可用时 AI 页禁用不可用变体和执行按钮、工作区所有变体不可用时禁用 AI自动推理、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空 seed holes 栅格化扣除和传播结果 holes 提取、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 |
|
||||||
| R7 | 保存、查询、更新、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 回写 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 |
|
| R7 | 保存状态按钮“保存 X 个改动/已全部保存”、保存、保存后替换已提交 draft、查询、更新、dirty 本地旧 annotationId 的预检缺失直接重新创建和 PATCH 404 重新创建、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 数据兼容、导入 mask 不显示黄色 seed point、高精度 GT contour、导入 mask 拓扑统计和边缘平滑、8-bit 低数值 GT_label 图导入、16-bit/uint16 GT_label 图拒绝、全背景 0 GT_label 图拒绝并保留“没有非背景 maskid 区域”提示、RGB 等通道 maskid 图导入、导入预览、未知 maskid 导入策略、非法彩色 GT mask 拒绝、尺寸不一致自动最近邻拉伸 | `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 | 模板加载、新建、编辑、删除、删除模板站内确认、鼠标复制模板为私有副本并保留 maskid/颜色/层级/规则、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页标题/编辑模板按钮/垃圾桶删 label、编辑弹窗分类编辑不显示旧 category 来源元信息、默认模板“腹腔镜胆囊切除术”和“头颈部CT分割”幂等 seed、头颈部 CT 默认分类名纯中文且不带括号英文翻译、恢复出厂设置保留并权威恢复系统模板、默认模板缺失后重建、默认语义分类树被修改/删减后覆盖恢复、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、数组/对象/常见粘贴格式导入、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py`, `test_admin.py` | 已覆盖 |
|
||||||
| R9 | 模板选择、分类展示、分类选择、已选 mask 换标签、自定义本地分类、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 |
|
| R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、分类树拖拽调整内部覆盖顺序且不改变 maskid、拖拽后同步同类 mask 层级并标记待保存、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、分类变更同步同一传播链前后帧对应 mask、自定义分类写入后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、后端拓扑属性分析、拓扑锚点真实顶点计数、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览、边缘平滑应用后确认 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑撤销/重做、平滑应用后强度归零、占位状态 | `OntologyInspector.test.tsx`, `VideoWorkspace.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 | 统一“分割结果导出”下拉、整体视频/特定范围帧/当前图片导出、特定范围帧时间轴拖拽选择、ZIP 文件名 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`、时间戳 `0h00m00s000ms` 格式、项目帧序号使用抽帧后 1-based 顺序、分开 Mask/GT_label/Pro_label/Mix_label outputs、Mix_label 透明度、导出前保存、兼容 COCO/PNG ZIP 路径、JSON/ZIP 结构、maskid/GT 像素值映射、原始图片导出、分开 Mask 按帧子目录与同类合并命名、GT_label/Pro_label/Mix_label 命名、GT/Pro/Mix 内部优先级融合且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类导出为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出的 GT_label 可按同一模板导回 | `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` | 已覆盖 |
|
||||||
| R13 | 文档测试矩阵与功能点追踪 | `doc/09-test-plan.md` | 已覆盖 |
|
| R13 | 文档测试矩阵、前端交互状态机、键盘规则、工具/范围/确认弹窗流与对应测试追踪 | `doc/09-test-plan.md`, `doc/11-frontend-interaction-state-machines.md` | 已覆盖 |
|
||||||
|
|
||||||
## 本轮补齐记录
|
## 本轮补齐记录
|
||||||
|
|
||||||
- R5:补充 `CanvasArea.test.tsx` 中圆形和线段手工绘制测试,明确验证 metadata、segmentation、bbox/area 和草稿状态。
|
- R5:补充 `CanvasArea.test.tsx` 中圆形、画笔新建、画笔有选中 mask 时并入选中 mask、无选中时新建和橡皮擦扣除测试,明确验证 metadata、segmentation、bbox/area、选中状态和草稿状态;补充 `ToolsPalette.test.tsx` 中画笔/橡皮擦尺寸控制测试,并验证创建点、创建线段入口不再显示。
|
||||||
- R6:补充 `AISegmentation.test.tsx` 中 SAM 3 semantic 文本推理测试,验证前端传参和返回 mask 绑定当前语义类别。
|
- R6:补充 `AISegmentation.test.tsx` 中 SAM 2.1 变体选择测试,验证前端不展示 SAM 3 入口、选择 small 后请求携带对应模型,且未放置点提示时不发起推理。
|
||||||
- R6:补充 SAM 2 纯文本提示拦截、SAM 2 多候选只保留最高分、SAM 2 engine 单候选请求测试,避免多个重叠候选 mask 被同时叠加。
|
- R6:补充 SAM 2 纯文本提示拦截、SAM 2 多候选只保留最高分、SAM 2 engine 单候选请求测试,避免多个重叠候选 mask 被同时叠加。
|
||||||
- R6:补充 Canvas 工作区 SAM 2 反向点背景过滤测试,覆盖请求 options 和过滤为空时清除旧候选 mask。
|
- R6:补充 Canvas 工作区 SAM 2 反向点背景过滤测试,覆盖请求 options 和过滤为空时清除旧候选 mask。
|
||||||
- R6:补充 SAM 3 空文本、空结果和工作区点交互不支持提示测试,避免前端静默失败。
|
- R6:补充 `ModelStatusBadge.test.tsx` 中 SAM 3 不展示测试,避免禁用入口重新出现在前端。
|
||||||
- R6:补充 SAM 3 工作区 box prompt 测试和外部 worker box prompt 测试,验证官方 `add_geometric_prompt()` 正框链路。
|
- 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()` 同步兼容接口和 `queuePropagationTask()` 任务接口测试,验证当前参考帧全部 mask 会按范围组装为后台传播 steps。
|
||||||
- R6:补充 SAM 3 external video tracker 请求测试,验证主后端会把帧目录、源帧索引、seed bbox 和方向传给独立 Python helper。
|
- R6:补充 `VideoWorkspace` 自动传播进度测试,验证传播任务运行中显示进度,后端返回 0 个新区域时给出明确反馈。
|
||||||
|
- R4/R6:补充时间轴传播范围选择测试,验证点击“AI自动推理”后可在播放进度条或视频处理进度条上拖拽回填起止帧,再提交后台传播任务。
|
||||||
|
- R4/R6:补充视频处理进度条传播历史测试,验证多次自动传播后会按同一蓝色系显示最近处理范围,最新最亮、旧记录逐次变暗且第 5 次后统一阈值色,单个片段不使用渐变。
|
||||||
|
- R6/R10:补充 `queuePropagationTask()`、`POST /api/ai/propagate/task`、传播 Celery runner 和传播任务重试测试,验证工作区自动传播不再依赖长 HTTP 请求,并验证传给 `SAM2VideoPredictor` 的临时帧文件名是纯数字序列。
|
||||||
|
- R6:补充传播去重回归测试,验证前端传播前会先保存 draft seed mask 并用稳定 `source_annotation_id` 入队;后端在 seed 来源由前端临时 id 迁移到后端 annotation id、用户换用其他 SAM 2.1 权重、未编辑传播结果再次作为 seed、已编辑传播结果重新作为 seed、中间帧人工新增替代 seed 时,会分别跳过或清理旧传播标注再保存新结果。
|
||||||
|
- R6/R7:补充传播实例 id 回归测试,验证保存标注会写入/保留 `instance_id`,自动传播 seed 携带 `source_instance_id`,同类别多个 mask 在传播、重传、布尔合并/去除和选择高亮时按实例链路同步,不因相同 label/color/maskid 互相合并或删除。
|
||||||
|
- R5/R6/R7:补充中空 mask 回归测试,验证保存时拆分 `polygons`/`holes` 并回显为 ring 分组,调整多边形时内洞显示可编辑顶点,以及 SAM 2 seed mask 会扣除 holes、传播结果轮廓提取会保留 holes。
|
||||||
|
- R7:补充 dirty 本地旧 annotationId 回归测试,验证后端标注 id 预检已缺失时会跳过失败 PATCH、直接 `POST /api/ai/annotate` 重新创建;同时验证预检后 `PATCH /api/ai/annotations/{id}` 返回 404 时,保存链路也会改用 `POST` 重新创建并用回显标注替换本地旧 mask。
|
||||||
|
- R4/R5/R8/R9:补充模板切换、工具栏清空入口和传播链布尔操作回归测试,验证已有 mask 切换模板需确认清空,模板详情按钮改为“编辑模板”,当前帧清空会在传播链存在时同一行提供取消/只清当前帧/按帧范围选择/清空所有传播帧,且按范围/全部清空遇到人工/AI 标注帧时可选择保留人工帧,区域合并/去除会在存在传播帧时同一行选择取消/按帧范围选择/当前帧/所有传播帧并保留传播 metadata。
|
||||||
|
- 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:补充 `ProjectLibrary.test.tsx` 和 `api.test.ts` 中上传进度测试,验证视频/DICOM 上传通过 Axios `onUploadProgress` 回调更新项目库导入进度条,并显示 DICOM 文件数量和解析任务轮询进度。
|
||||||
- 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 状态。
|
||||||
|
- R9:补充边缘平滑滑杆防抖测试,验证连续拖动只触发最后一次后端预览请求,降低拖动卡顿。
|
||||||
|
- R9:补充边缘平滑应用到传播链并可撤销/重做的测试,验证平滑后成为新的实际 polygon、强度归零且不再只保存平滑参数。
|
||||||
|
- R5/R13:补充 `CanvasArea.test.tsx` 中 `Esc` 交互测试,验证 `Esc` 只取消当前 mask 选中和临时多边形点,不删除已有 mask、不清空 `activeClass`;新增 `doc/11-frontend-interaction-state-machines.md` 记录工作区工具、语义分类树、范围选择、AI 页、模板确认和导入导出状态机。
|
||||||
|
- R5/R13:完成文档一致性回查,修正 `doc/02-current-implementation-map.md` 和 `doc/08-current-design-freeze.md` 中手工绘制、画笔有选中时并入/无选中时新建、Esc、工具切换保留选区和无选区点击语义分类树的旧描述,使实现映射、设计冻结、状态机文档和测试计划保持一致。
|
||||||
|
- R5/R13:补充左侧工具栏“取消选中”实体按钮测试和 Canvas `clearSelectionSignal` 测试,验证实体按钮与 `Esc` 共享取消选区/临时绘制状态语义。
|
||||||
|
- R5:补充创建后边界点和中空画笔回归测试,验证多边形/矩形/圆创建完成后即使仍在创建工具下也显示已选 mask 边界点,并验证画笔闭合成中空区域时保留 `hasHoles/polygonRingCounts`、使用 even-odd 渲染且内外圈顶点可显示。
|
||||||
|
|
||||||
## 运行命令
|
## 运行命令
|
||||||
|
|
||||||
|
|||||||
639
doc/10-installation.md
Normal file
639
doc/10-installation.md
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
# Installation / 部署安装指南
|
||||||
|
|
||||||
|
本文件记录当前仓库的真实安装和部署方式。它面向一台新的 Linux 机器,目标是跑起完整系统:
|
||||||
|
|
||||||
|
- React 前端:默认 `http://localhost:3000`
|
||||||
|
- FastAPI 后端:默认 `http://localhost:8000`
|
||||||
|
- PostgreSQL:项目、帧、模板、标注、任务元数据
|
||||||
|
- Redis:Celery broker/result backend 与进度 pub/sub
|
||||||
|
- MinIO:视频、DICOM、拆帧图片等对象存储
|
||||||
|
- Celery worker:执行视频/DICOM 拆帧等后台任务
|
||||||
|
- SAM 2.1:当前产品启用 tiny/small/base+/large;SAM 3 源码保留但产品入口禁用,正常部署不需要安装 SAM 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 前置条件
|
||||||
|
|
||||||
|
推荐环境:
|
||||||
|
|
||||||
|
| 项 | 建议 |
|
||||||
|
|----|------|
|
||||||
|
| OS | Ubuntu 22.04 LTS 或相近 Linux |
|
||||||
|
| Python | 3.11 |
|
||||||
|
| Node.js | 22.x |
|
||||||
|
| 数据库 | PostgreSQL 14+ |
|
||||||
|
| 缓存/队列 | Redis 6+ |
|
||||||
|
| 对象存储 | MinIO |
|
||||||
|
| 视频处理 | FFmpeg |
|
||||||
|
| GPU | NVIDIA GPU + CUDA,用于 SAM 2.1 推理;无 GPU 时可 CPU 运行但会很慢 |
|
||||||
|
|
||||||
|
Docker GPU 部署还需要宿主机安装 NVIDIA Container Toolkit,并确保以下命令可正常输出 GPU:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
如果这里报 `failed to discover GPU vendor from CDI`,说明 Docker 还没有拿到 GPU,即使宿主机 `nvidia-smi` 正常,容器内仍会显示 CPU。
|
||||||
|
|
||||||
|
安装系统依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y \
|
||||||
|
postgresql postgresql-contrib \
|
||||||
|
redis-server \
|
||||||
|
ffmpeg \
|
||||||
|
libpq-dev build-essential curl ca-certificates gnupg wget
|
||||||
|
```
|
||||||
|
|
||||||
|
安装 MinIO:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp
|
||||||
|
wget https://dl.min.io/server/minio/release/linux-amd64/minio
|
||||||
|
chmod +x minio
|
||||||
|
sudo mv minio /usr/local/bin/
|
||||||
|
mkdir -p ~/minio_data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 获取代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Desktop
|
||||||
|
git clone <your-gitea-or-git-url> Seg_Server
|
||||||
|
cd Seg_Server
|
||||||
|
```
|
||||||
|
|
||||||
|
如果已经有仓库,进入项目根目录即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server
|
||||||
|
```
|
||||||
|
|
||||||
|
后续命令默认在项目根目录执行,除非特别说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.1 Docker 最小部署
|
||||||
|
|
||||||
|
当前仓库旁的 `/home/wkmgc/Desktop/Seg_Server_Docker` 是最小 Docker 部署目录,包含前端、FastAPI 后端、Celery worker、PostgreSQL、Redis、MinIO、演示视频/DICOM 数据和部署文档。
|
||||||
|
|
||||||
|
编辑 `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server_Docker
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
关键配置:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
PUBLIC_HOST=192.168.3.11
|
||||||
|
CORS_ORIGINS=["http://192.168.3.11:3000","http://localhost:3000","http://127.0.0.1:3000"]
|
||||||
|
SAM_MODELS_DIR=/home/wkmgc/Desktop/Seg_Server/models
|
||||||
|
```
|
||||||
|
|
||||||
|
`SAM_MODELS_DIR` 会挂载到容器内 `/app/models`。当前后端镜像安装 PyTorch/SAM2 后,只有这里存在 checkpoint 的 SAM 2.1 变体会显示可用;如果没有 NVIDIA Container Toolkit,模型可在 CPU 上可用,但 GPU 状态仍是 CPU。
|
||||||
|
|
||||||
|
启动普通容器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
启动 GPU 容器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 配置 PostgreSQL
|
||||||
|
|
||||||
|
默认后端配置来自 `backend/config.py`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
postgresql://seguser:segpass123@localhost:5432/segserver
|
||||||
|
```
|
||||||
|
|
||||||
|
创建数据库和用户:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE segserver;"
|
||||||
|
sudo -u postgres psql -c "CREATE USER seguser WITH PASSWORD 'segpass123';"
|
||||||
|
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE segserver TO seguser;"
|
||||||
|
sudo -u postgres psql -d segserver -c "GRANT ALL ON SCHEMA public TO seguser;"
|
||||||
|
sudo -u postgres psql -c "ALTER DATABASE segserver OWNER TO seguser;"
|
||||||
|
```
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_isready
|
||||||
|
psql "postgresql://seguser:segpass123@localhost:5432/segserver" -c "select 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 启动 Redis 和 MinIO
|
||||||
|
|
||||||
|
Redis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start redis-server
|
||||||
|
redis-cli ping
|
||||||
|
```
|
||||||
|
|
||||||
|
MinIO:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nohup minio server ~/minio_data --console-address :9001 > /tmp/minio.log 2>&1 &
|
||||||
|
curl http://localhost:9000/minio/health/live
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 MinIO 账号密码是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
minioadmin / minioadmin
|
||||||
|
```
|
||||||
|
|
||||||
|
后端启动时会检查并创建 bucket:
|
||||||
|
|
||||||
|
```text
|
||||||
|
seg-media
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 安装后端 Python 环境
|
||||||
|
|
||||||
|
推荐使用 Conda:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda create -n seg_server python=3.11 -y
|
||||||
|
conda activate seg_server
|
||||||
|
```
|
||||||
|
|
||||||
|
安装 PyTorch。根据机器 CUDA 版本选择合适 wheel。示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CUDA 12.4 示例
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124
|
||||||
|
|
||||||
|
# 无 GPU / CPU 示例
|
||||||
|
# pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后端依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 使用本机 GPU
|
||||||
|
|
||||||
|
Docker 显示 GPU 的前提不是前端开关,而是宿主机、Docker runtime 和容器依赖都可用:
|
||||||
|
|
||||||
|
1. 宿主机 `nvidia-smi` 必须能正常看到 NVIDIA GPU。
|
||||||
|
2. 安装并配置 NVIDIA Container Toolkit。
|
||||||
|
3. Docker compose 需要给 `backend` 和 `worker` 透传 GPU,例如在部署包中使用 `docker-compose.gpu.yml` 覆盖文件。
|
||||||
|
4. 后端镜像内还必须安装 CUDA 版 PyTorch、`sam2` Python 包,并挂载对应 `models/sam2.1_*.pt` 权重;最小部署镜像为了体积默认不安装这些 AI 依赖,因此只加 GPU 透传仍会显示 CPU/模型不可用。
|
||||||
|
|
||||||
|
示例启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build
|
||||||
|
docker compose exec backend python - <<'PY'
|
||||||
|
import torch
|
||||||
|
print(torch.cuda.is_available())
|
||||||
|
PY
|
||||||
|
curl http://localhost:8000/api/ai/models/status
|
||||||
|
```
|
||||||
|
|
||||||
|
确认关键包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
import fastapi, sqlalchemy, redis, celery, minio, torch
|
||||||
|
print("torch:", torch.__version__, "cuda:", torch.cuda.is_available())
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 配置后端环境变量
|
||||||
|
|
||||||
|
后端从 `backend/.env` 读取配置;该文件被 `.gitignore` 忽略,不要提交真实密码或本机路径。
|
||||||
|
|
||||||
|
创建 `backend/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > backend/.env <<'EOF'
|
||||||
|
db_url=postgresql://seguser:segpass123@localhost:5432/segserver
|
||||||
|
redis_url=redis://localhost:6379/0
|
||||||
|
|
||||||
|
minio_endpoint=localhost:9000
|
||||||
|
minio_access_key=minioadmin
|
||||||
|
minio_secret_key=minioadmin
|
||||||
|
minio_secure=false
|
||||||
|
|
||||||
|
sam_default_model=sam2.1_hiera_tiny
|
||||||
|
sam_model_path=/home/wkmgc/Desktop/Seg_Server/models/sam2.1_hiera_tiny.pt
|
||||||
|
sam_model_config=configs/sam2.1/sam2.1_hiera_t.yaml
|
||||||
|
sam3_external_enabled=false
|
||||||
|
|
||||||
|
app_env=development
|
||||||
|
cors_origins=["http://localhost:3000","http://127.0.0.1:3000"]
|
||||||
|
jwt_secret_key=change-this-to-a-long-random-production-secret
|
||||||
|
access_token_expire_minutes=1440
|
||||||
|
default_admin_username=admin
|
||||||
|
default_admin_password=123456
|
||||||
|
demo_video_path=/home/wkmgc/Desktop/Seg_Server/demo/演视LC视频序列.mp4
|
||||||
|
demo_dicom_dir=/home/wkmgc/Desktop/Seg_Server/demo/演视DICOM序列
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
演示视频和 DICOM 测试影像统一放在项目根目录 `demo/` 下;系统 seed 和“恢复演示出厂设置”会直接读取 `demo/演视LC视频序列.mp4` 和 `demo/演视DICOM序列/`。
|
||||||
|
|
||||||
|
如果前端通过局域网 IP 访问,例如 `http://192.168.3.11:3000`,需要把该地址加入 `cors_origins`,同时前端也要配置 API 地址。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 准备 SAM 2.1 权重
|
||||||
|
|
||||||
|
当前产品入口只暴露 SAM 2.1 变体:
|
||||||
|
|
||||||
|
- `sam2.1_hiera_tiny`
|
||||||
|
- `sam2.1_hiera_small`
|
||||||
|
- `sam2.1_hiera_base_plus`
|
||||||
|
- `sam2.1_hiera_large`
|
||||||
|
|
||||||
|
下载脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python download_sam2.py
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本默认下载到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/wkmgc/Desktop/Seg_Server/models/
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐文件名:
|
||||||
|
|
||||||
|
```text
|
||||||
|
models/sam2.1_hiera_tiny.pt
|
||||||
|
models/sam2.1_hiera_small.pt
|
||||||
|
models/sam2.1_hiera_base_plus.pt
|
||||||
|
models/sam2.1_hiera_large.pt
|
||||||
|
```
|
||||||
|
|
||||||
|
可以只部署 tiny;前端会显示四个选项,但只有本地存在 checkpoint 的模型会显示可用。
|
||||||
|
|
||||||
|
注意:SAM 3 相关脚本和源码是历史保留。当前前端入口隐藏 SAM 3,后端 registry 不暴露 `sam3`,正常部署不需要下载 SAM 3 权重,也不要把 Hugging Face token 写进项目文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 安装前端依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
如需指定前端访问的后端地址,在项目根目录创建 `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .env <<'EOF'
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
VITE_WS_PROGRESS_URL=ws://localhost:8000/ws/progress
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
如果不设置,前端会按当前浏览器 hostname 推导:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://<browser-host>:8000
|
||||||
|
ws://<browser-host>:8000/ws/progress
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 手动启动所有服务
|
||||||
|
|
||||||
|
开 4 个终端分别启动。
|
||||||
|
|
||||||
|
终端 A:FastAPI 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate seg_server
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server/backend
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
终端 B:Celery worker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate seg_server
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server/backend
|
||||||
|
celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1
|
||||||
|
```
|
||||||
|
|
||||||
|
终端 C:前端开发服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
终端 D:确认基础设施
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_isready
|
||||||
|
redis-cli ping
|
||||||
|
curl http://localhost:9000/minio/health/live
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:
|
||||||
|
|
||||||
|
| 服务 | 地址 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | `http://localhost:3000` |
|
||||||
|
| FastAPI Docs | `http://localhost:8000/docs` |
|
||||||
|
| Health | `http://localhost:8000/health` |
|
||||||
|
| MinIO Console | `http://localhost:9001` |
|
||||||
|
|
||||||
|
默认开发登录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin / 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT,业务接口会校验 `Authorization: Bearer <token>`。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。
|
||||||
|
|
||||||
|
默认管理员登录后会看到“用户管理”后台,可新增标注员、停用/启用用户、重置密码、删除用户并查看登录与用户管理审计日志。系统只支持唯一默认 `admin` 和 `annotator` 两类角色:标注员不能新增用户、查看审计日志或恢复演示出厂设置,但可以和管理员共享同一项目库并执行项目管理、标注、AI 推理、任务和导出等业务操作。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`,DICOM 序列来自 `demo_dicom_dir`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 一键启动脚本
|
||||||
|
|
||||||
|
项目根目录有 `start_services.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x start_services.sh
|
||||||
|
./start_services.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会检查/启动:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PostgreSQL -> Redis -> MinIO -> FastAPI -> Celery worker -> 前端
|
||||||
|
```
|
||||||
|
|
||||||
|
使用前必须检查脚本里的本机路径和 sudo 逻辑:
|
||||||
|
|
||||||
|
- `PROJECT_DIR="/home/wkmgc/Desktop/Seg_Server"`
|
||||||
|
- `CONDA_ENV="seg_server"`
|
||||||
|
- MinIO 数据目录 `/home/wkmgc/minio_data`
|
||||||
|
- 脚本里包含本机 sudo 密码写法,迁移机器时应移除或改成安全的 systemd/service 管理方式
|
||||||
|
|
||||||
|
### 10.1 开发重启速查
|
||||||
|
|
||||||
|
本地开发时不要靠猜。不同服务的热更新行为如下:
|
||||||
|
|
||||||
|
| 改动类型 | 是否需要重启 | 原因 |
|
||||||
|
|----------|--------------|------|
|
||||||
|
| 前端 `src/`、`server.ts` | 通常不需要 | `npm run dev` 使用 Vite/tsx,前端会热更新 |
|
||||||
|
| 前端依赖、`.env`、`vite.config.ts` | 需要重启前端 | 依赖和环境变量只在进程启动时读取 |
|
||||||
|
| FastAPI 路由/普通后端代码 | 需要重启后端 | 开发重启脚本用独立后台进程运行后端;显式重启可以保证接口和运行态一致 |
|
||||||
|
| `backend/.env`、模型路径、依赖安装 | 需要重启后端 | 配置和依赖在进程启动时生效 |
|
||||||
|
| Celery 任务、拆帧、自动传播、SAM runner | 必须重启 Celery worker | worker 不是 `uvicorn --reload` 的子进程,不会自动加载代码改动 |
|
||||||
|
|
||||||
|
推荐使用项目根目录的开发重启脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server
|
||||||
|
./restart_dev_services.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本会:
|
||||||
|
|
||||||
|
```text
|
||||||
|
检查 PostgreSQL/Redis/MinIO -> 停止旧 FastAPI/Celery/前端 -> 用独立后台进程启动 FastAPI/Celery/前端 -> 检查 3000/8000
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本通过 `setsid` 启动应用层服务,脚本退出后服务会继续运行;pid 文件默认位于 `/tmp/seg_server_*.pid`,日志默认位于 `/tmp/seg_server_*.log`。
|
||||||
|
|
||||||
|
默认日志:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/tmp/seg_server_fastapi.log
|
||||||
|
/tmp/seg_server_celery.log
|
||||||
|
/tmp/seg_server_frontend.log
|
||||||
|
/tmp/seg_server_minio.log
|
||||||
|
```
|
||||||
|
|
||||||
|
如果只想手动重启应用层服务,可以使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server
|
||||||
|
|
||||||
|
# 停止旧进程
|
||||||
|
pkill -f "uvicorn main:app" || true
|
||||||
|
pkill -f "celery -A celery_app:celery_app worker" || true
|
||||||
|
pkill -f "/home/wkmgc/Desktop/Seg_Server/node_modules/.bin/tsx server.ts" || true
|
||||||
|
pkill -f "npm run dev" || true
|
||||||
|
|
||||||
|
# 启动后端和 worker
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server/backend
|
||||||
|
setsid ~/miniconda3/bin/conda run -n seg_server uvicorn main:app --host 0.0.0.0 --port 8000 \
|
||||||
|
> /tmp/seg_server_fastapi.log 2>&1 < /dev/null &
|
||||||
|
setsid ~/miniconda3/bin/conda run -n seg_server celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 \
|
||||||
|
> /tmp/seg_server_celery.log 2>&1 < /dev/null &
|
||||||
|
|
||||||
|
# 启动前端
|
||||||
|
cd /home/wkmgc/Desktop/Seg_Server
|
||||||
|
setsid npm run dev > /tmp/seg_server_frontend.log 2>&1 < /dev/null &
|
||||||
|
```
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
curl -I http://localhost:3000
|
||||||
|
ps -ef | grep -E "(uvicorn main:app|celery -A celery_app:celery_app worker|tsx server.ts)" | grep -v grep
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 生产构建方式
|
||||||
|
|
||||||
|
前端构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
生产模式启动前端静态服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NODE_ENV=production npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
后端生产启动示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Celery worker 仍需要单独启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1
|
||||||
|
```
|
||||||
|
|
||||||
|
实际生产建议用 systemd、supervisor 或容器编排托管 FastAPI、Celery、前端静态服务、MinIO、Redis、PostgreSQL。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 部署验收 Checklist
|
||||||
|
|
||||||
|
基础服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_isready
|
||||||
|
redis-cli ping
|
||||||
|
curl http://localhost:9000/minio/health/live
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
后端模型状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/ai/models/status
|
||||||
|
```
|
||||||
|
|
||||||
|
前端质量检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run test:run
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
后端测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate seg_server
|
||||||
|
python -m pytest backend/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
手工业务验收:
|
||||||
|
|
||||||
|
1. 打开 `http://localhost:3000`。
|
||||||
|
2. 使用 `admin / 123456` 登录。
|
||||||
|
3. 创建项目或上传视频。
|
||||||
|
4. 在项目库点击“生成帧”,选择 FPS。
|
||||||
|
5. Dashboard 中应看到任务进度;Celery 日志应显示拆帧任务。
|
||||||
|
6. 进入分割工作区,能看到帧、时间轴和画布。
|
||||||
|
7. 手工画一个多边形 mask,确认顶栏保存状态按钮显示“保存 1 个改动”,点击保存。
|
||||||
|
8. 刷新工作区后,已保存标注应回显。
|
||||||
|
9. AI 智能分割中选择可用 SAM 2.1 模型,放置点或框,执行分割。
|
||||||
|
10. 导出 JSON 或 PNG Mask ZIP。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 常见问题
|
||||||
|
|
||||||
|
### 前端打不开或请求后端失败
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
cat .env
|
||||||
|
```
|
||||||
|
|
||||||
|
如果通过局域网 IP 访问前端,确保:
|
||||||
|
|
||||||
|
- `.env` 中 `VITE_API_BASE_URL` 是浏览器可访问的后端地址。
|
||||||
|
- `backend/.env` 中 `cors_origins` 包含前端地址。
|
||||||
|
|
||||||
|
### Dashboard WebSocket 经常断开
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli ping
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
同时确认前端 `VITE_WS_PROGRESS_URL` 指向真实可访问的:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://<host>:8000/ws/progress
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成帧没有进度
|
||||||
|
|
||||||
|
检查 Celery worker 是否启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ps aux | grep celery
|
||||||
|
tail -f /tmp/celery.log
|
||||||
|
```
|
||||||
|
|
||||||
|
检查 Redis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli ping
|
||||||
|
```
|
||||||
|
|
||||||
|
### MinIO 上传失败
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9000/minio/health/live
|
||||||
|
tail -f /tmp/minio.log
|
||||||
|
```
|
||||||
|
|
||||||
|
如果磁盘空间不足,MinIO 可能拒绝写入。清理 `~/minio_data`、旧日志、旧模型权重或迁移数据目录。
|
||||||
|
|
||||||
|
### SAM 2 模型不可用
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lh models/
|
||||||
|
curl http://localhost:8000/api/ai/models/status
|
||||||
|
```
|
||||||
|
|
||||||
|
常见原因:
|
||||||
|
|
||||||
|
- checkpoint 文件不存在。
|
||||||
|
- `backend/.env` 中 `sam_model_path` 指向旧文件名。
|
||||||
|
- `sam2` Python 包未正确安装。
|
||||||
|
- PyTorch/CUDA 不匹配。
|
||||||
|
|
||||||
|
### 不需要 SAM 3
|
||||||
|
|
||||||
|
当前版本不用 SAM 3。不要为了正常部署执行 `backend/setup_sam3_env.sh`,也不要在项目里保存 Hugging Face token。
|
||||||
110
doc/11-frontend-interaction-state-machines.md
Normal file
110
doc/11-frontend-interaction-state-machines.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 前端交互与状态机
|
||||||
|
|
||||||
|
本文档记录当前前端真实交互规则,重点覆盖那些不会直接体现在接口契约里的 UI 细节。测试以本文件、`doc/07-current-requirements-freeze.md` 和 `doc/09-test-plan.md` 为准。
|
||||||
|
|
||||||
|
## 全局状态
|
||||||
|
|
||||||
|
| 状态字段 | 所在文件 | 含义 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| `activeModule` | `src/store/useStore.ts` | 当前页面模块;登录后默认 `dashboard`。 |
|
||||||
|
| `currentProject` / `frames` / `currentFrameIndex` | `src/store/useStore.ts` | 当前工作项目、帧序列和当前帧。 |
|
||||||
|
| `activeTool` | `src/store/useStore.ts` | 工作区当前工具。 |
|
||||||
|
| `selectedMaskIds` | `src/store/useStore.ts` | 当前选中的 mask id 列表;Canvas、本体面板和 AI 页共享。 |
|
||||||
|
| `activeTemplateId` / `activeClass` | `src/store/useStore.ts` | 当前模板和后续新建 mask 使用的语义类别。 |
|
||||||
|
| `maskHistory` / `maskFuture` | `src/store/useStore.ts` | 撤销/重做栈。 |
|
||||||
|
|
||||||
|
## 工作区工具自动机
|
||||||
|
|
||||||
|
| 状态 | 进入事件 | 可用动作 | 退出事件 | 测试 |
|
||||||
|
|------|----------|----------|----------|------|
|
||||||
|
| `idle/no-selection` | 初始、`Esc`、左侧“取消选中”、删除 mask、切帧无对应传播结果 | 右侧语义树只设置后续新建类别;清空遮罩作用于当前帧全部 mask | 点击 mask、AI 推送、创建新 mask | `CanvasArea.test.tsx`、`OntologyInspector.test.tsx` |
|
||||||
|
| `mask-selected` | `move/edit_polygon` 下点击 mask、新建 mask 完成、AI 候选选中 | 右侧语义树给已选 mask 换类;Delete/Backspace/DEL 删除;橡皮擦可扣除;顶点可编辑;创建工具的新几何会并入当前 mask | `Esc`、左侧“取消选中”、删除 mask、切帧无对应传播结果 | `CanvasArea.test.tsx` |
|
||||||
|
| `polygon-drawing` | `create_polygon` 下点击画布 | 继续加点;三点后 Enter 或点击首点闭合 | Enter/首点在有选中 mask 时并入选中 mask,无选中时创建新 mask 并显示边界点;`Esc` 放弃临时点并清选区 | `CanvasArea.test.tsx` |
|
||||||
|
| `shape-dragging` | `create_rectangle/create_circle` 下按下鼠标 | 拖拽预览形状 | 鼠标释放时有选中 mask 则并入选中 mask,无选中时创建新 mask 并显示边界点;切工具取消临时状态 | `CanvasArea.test.tsx` |
|
||||||
|
| `brush-stroking` | `brush` 且已有 `activeClass` 或当前选中 mask 时按下鼠标 | 采样图像范围内圆形笔触 | 鼠标释放时有选中 mask 则并入选中 mask,无选中时创建新的当前类别 mask;闭合成中空区域时保留内洞 ring;图外落笔不创建;`Esc` 取消笔触和选区 | `CanvasArea.test.tsx` |
|
||||||
|
| `eraser-stroking` | `eraser` 且已有选中 mask 时按下鼠标 | 采样图像范围内圆形笔触 | 鼠标释放从选中 mask 扣除;扣空则删除该 mask;`Esc` 取消笔触和选区 | `CanvasArea.test.tsx` |
|
||||||
|
| `boolean-selecting` | `area_merge/area_remove` | 选择多个 mask;主区域黄色实线,参与区域红色虚线 | 当前帧执行、所有传播帧、按帧范围、取消、切换工具 | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` |
|
||||||
|
|
||||||
|
### 细节规则
|
||||||
|
|
||||||
|
- `Esc` 是取消当前交互状态,不是删除:清空 `selectedMaskIds`、临时多边形点、矩形/圆拖拽状态、画笔/橡皮擦笔触和顶点选择;保留已有 mask、当前 `activeClass` 和当前工具。
|
||||||
|
- 切换到 `create_polygon`、`create_rectangle`、`create_circle` 会保留旧 mask 选区;用户若想新建独立 mask,需要先按 `Esc` 或点击“取消选中”。
|
||||||
|
- 多边形、矩形、圆和画笔创建完成后,有选中 mask 时会并入选中 mask,无选中 mask 时会自动选中新创建的 mask。
|
||||||
|
- 画笔和形状创建遵循同一规则:有选中 mask 时并入选中 mask,没有选中 mask 时才新建独立 mask。
|
||||||
|
- 橡皮擦只作用于当前选中 mask,不会在无选区时启动。
|
||||||
|
- 绘制类工具点击已有 mask 时继续绘制,不触发 mask 选择。
|
||||||
|
|
||||||
|
## 右侧语义分类树自动机
|
||||||
|
|
||||||
|
| 状态 | 点击分类结果 | 后续效果 | 测试 |
|
||||||
|
|------|--------------|----------|------|
|
||||||
|
| 无选中 mask | 仅更新 `activeClass` | 后续新建 mask 使用该类别;已有 mask 不变 | `OntologyInspector.test.tsx` |
|
||||||
|
| 有选中 mask | 更新已选 mask 的 class/label/color;同传播链对应 mask 同步更新 | 已保存 mask 标记为 dirty;已选 mask 移到前端渲染数组末尾 | `OntologyInspector.test.tsx` |
|
||||||
|
| 当前 mask 的类别被删除 | 工作区回显时降级为 `maskid:0` “待分类” | 保留几何并等待用户重新分类保存 | `VideoWorkspace.test.tsx` |
|
||||||
|
|
||||||
|
## 键盘交互
|
||||||
|
|
||||||
|
| 按键 | 前置状态 | 行为 | 测试 |
|
||||||
|
|------|----------|------|------|
|
||||||
|
| `Esc` / 左侧“取消选中” | 任意 Canvas 工具 | 取消选中 mask 和临时绘制状态,不删除 mask,不清 active class | `CanvasArea.test.tsx`、`ToolsPalette.test.tsx` |
|
||||||
|
| `Enter` | 多边形已有至少 3 点 | 闭合并创建新 mask | `CanvasArea.test.tsx` |
|
||||||
|
| `Delete/Backspace` | 选中顶点 | 删除该顶点,保持 polygon 至少 3 点 | `CanvasArea.test.tsx` |
|
||||||
|
| `Delete/Backspace` | 选中整块 mask | 删除 mask;传播链 mask 走范围确认;人工/AI 帧按确认策略处理 | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` |
|
||||||
|
| `Ctrl/Cmd+Z` | 工作区且非输入控件聚焦 | 撤销 mask 历史 | `VideoWorkspace.test.tsx`、`keyboardShortcuts.test.ts` |
|
||||||
|
| `Ctrl/Cmd+Shift+Z` / `Ctrl/Cmd+Y` | 工作区且非输入控件聚焦 | 重做 mask 历史 | `VideoWorkspace.test.tsx`、`keyboardShortcuts.test.ts` |
|
||||||
|
| 左/右方向键 | 工作区时间轴 | 切换上一帧/下一帧 | `VideoWorkspace.test.tsx` |
|
||||||
|
|
||||||
|
## 工作区范围选择自动机
|
||||||
|
|
||||||
|
`VideoWorkspace` 用 `rangeSelectionMode` 区分四类范围选择:`propagation`、`export`、`boolean`、`clear`。
|
||||||
|
|
||||||
|
| 模式 | 进入事件 | 顶栏状态 | 时间轴行为 | 确认行为 | 测试 |
|
||||||
|
|------|----------|----------|------------|----------|------|
|
||||||
|
| `propagation` | 左侧“AI自动推理” | 显示传播权重、向前/向后帧数和“开始传播” | 拖拽/点击设置传播起止帧 | 先校验当前 SAM 2.1 权重状态;可用才保存参考帧 draft/dirty seed 并提交 Celery 传播任务 | `VideoWorkspace.test.tsx` |
|
||||||
|
| `export` | 打开导出菜单并选择“特定范围帧” | 导出菜单保持打开 | 拖拽/点击设置导出起止帧 | “开始导出”保存待归档 mask 后下载 ZIP | `VideoWorkspace.test.tsx` |
|
||||||
|
| `boolean` | 区域合并/去除选择“按帧范围选择” | 显示“确认区域合并/确认重叠区域去除” | 拖拽/点击设置布尔操作范围 | 弹最终确认,只同步范围内对应传播帧,保留传播 metadata | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` |
|
||||||
|
| `clear` | 清空/DEL 选择“按帧范围选择” | 显示“确认清空” | 拖拽/点击设置清空范围 | 弹最终确认;如范围含人工/AI 帧,再询问是否删除这些帧 | `VideoWorkspace.test.tsx` |
|
||||||
|
|
||||||
|
取消规则:
|
||||||
|
|
||||||
|
- 关闭导出菜单会退出 `export` 范围选择。
|
||||||
|
- 在布尔范围确认前重新点击“合并选中/从主区域去除”,会取消旧的顶栏范围请求。
|
||||||
|
- 清空/删除传播链时选择“取消”不会删除任何 mask。
|
||||||
|
|
||||||
|
## AI 智能分割自动机
|
||||||
|
|
||||||
|
| 状态 | 进入事件 | 主要行为 | 退出事件 | 测试 |
|
||||||
|
|------|----------|----------|----------|------|
|
||||||
|
| `no-prompt` | 打开 AI 页或清空提示 | 等待正点/负点/框选 | 放置提示或框选 | `AISegmentation.test.tsx` |
|
||||||
|
| `box-prompt` | 框选完成 | 仅框选时发送 `box` prompt | 加正/负点后转 interactive | `AISegmentation.test.tsx` |
|
||||||
|
| `interactive-prompt` | 框选后加点或直接点选 | 发送累计正/负点;负点启用背景过滤 | 空结果移除旧候选 | `AISegmentation.test.tsx` |
|
||||||
|
| `candidate-selected` | 推理返回 mask 或点击候选 | 可通过语义树换标签;可删除候选 | 推送工作区、删除候选、重新推理 | `AISegmentation.test.tsx` |
|
||||||
|
| `send-blocked` | 候选缺少语义分类时点击推送 | 显示 error toast,不切模块、不改工具 | 选择语义分类 | `AISegmentation.test.tsx` |
|
||||||
|
| `model-unavailable` | `/api/ai/models/status` 返回所选 SAM 2.1 变体不可用 | 禁用不可用模型按钮和执行按钮;不调用 `/api/ai/predict` | 后端模型状态恢复可用或切换到可用变体 | `AISegmentation.test.tsx` |
|
||||||
|
|
||||||
|
## 模板与项目确认流
|
||||||
|
|
||||||
|
| 交互 | 状态机 | 测试 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` |
|
||||||
|
| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 |
|
||||||
|
| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` |
|
||||||
|
| 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.test.tsx` |
|
||||||
|
| 演示恢复出厂设置 | 管理员危险区二次确认并要求输入 `RESET_DEMO_FACTORY`;后端也校验 confirmation | `UserAdmin.test.tsx`、`backend/tests/test_admin.py` |
|
||||||
|
|
||||||
|
## 文件与导入导出交互
|
||||||
|
|
||||||
|
| 交互 | 状态机 | 测试 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 视频/DICOM 上传 | 选择文件后显示上传进度;DICOM 显示有效文件数量;上传后继续轮询解析任务进度 | `ProjectLibrary.test.tsx` |
|
||||||
|
| 显式生成帧/重新生成帧 | 只对有源视频的视频项目显示;项目名称编辑状态不显示;DICOM 项目不显示;已有帧时显示“重新生成帧”并提示会清空旧帧、标注和 mask;入队后轮询解析任务,成功后刷新项目列表并立即显示新封面 | `ProjectLibrary.test.tsx` |
|
||||||
|
| GT Mask 导入 | 选择文件后预览并选择未知 maskid 策略;非法格式返回错误;尺寸不一致最近邻拉伸;导入结果与普通 mask 同体验 | `VideoWorkspace.test.tsx`、后端 AI 测试 |
|
||||||
|
| 分割结果导出 | 默认当前帧;可选整体/范围;范围可用时间轴;导出前保存待归档 mask;按钮带导出图标和绿色强调背景 | `VideoWorkspace.test.tsx`、`api.test.ts`、后端导出测试 |
|
||||||
|
|
||||||
|
## 维护要求
|
||||||
|
|
||||||
|
新增或修改前端交互时,应同步做三件事:
|
||||||
|
|
||||||
|
1. 更新本文件中对应状态机或规则。
|
||||||
|
2. 在 `doc/09-test-plan.md` 的覆盖矩阵中写明测试归属。
|
||||||
|
3. 添加或更新组件测试,至少覆盖状态转移的进入条件、退出条件和副作用。
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
| [07-current-requirements-freeze.md](./07-current-requirements-freeze.md) | 当前版本需求冻结,测试以此为准 |
|
| [07-current-requirements-freeze.md](./07-current-requirements-freeze.md) | 当前版本需求冻结,测试以此为准 |
|
||||||
| [08-current-design-freeze.md](./08-current-design-freeze.md) | 当前版本设计冻结,记录模块、数据流和接口边界 |
|
| [08-current-design-freeze.md](./08-current-design-freeze.md) | 当前版本设计冻结,记录模块、数据流和接口边界 |
|
||||||
| [09-test-plan.md](./09-test-plan.md) | 需求到测试文件的覆盖矩阵和运行命令 |
|
| [09-test-plan.md](./09-test-plan.md) | 需求到测试文件的覆盖矩阵和运行命令 |
|
||||||
|
| [10-installation.md](./10-installation.md) | 系统安装部署指南,覆盖 PostgreSQL、Redis、MinIO、后端、Celery、前端和 SAM 2.1 权重 |
|
||||||
|
| [11-frontend-interaction-state-machines.md](./11-frontend-interaction-state-machines.md) | 前端 UI 交互细节、键盘规则、工具/范围/确认弹窗状态机和对应测试 |
|
||||||
|
|
||||||
## 状态标记
|
## 状态标记
|
||||||
|
|
||||||
|
|||||||
12
docker-compose.gpu.yml
Normal file
12
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
gpus: all
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: all
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
|
||||||
|
worker:
|
||||||
|
gpus: all
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: all
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
@@ -11,4 +11,3 @@
|
|||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
182
restart_dev_services.sh
Executable file
182
restart_dev_services.sh
Executable file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restart local development app services for Seg_Server.
|
||||||
|
# Infra services (PostgreSQL/Redis/MinIO) are checked and started if possible.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="${PROJECT_DIR:-/home/wkmgc/Desktop/Seg_Server}"
|
||||||
|
CONDA_ENV="${CONDA_ENV:-seg_server}"
|
||||||
|
CONDA_BIN="${CONDA_BIN:-${HOME}/miniconda3/bin/conda}"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||||
|
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
|
||||||
|
LOG_DIR="${LOG_DIR:-/tmp}"
|
||||||
|
PID_DIR="${PID_DIR:-/tmp}"
|
||||||
|
|
||||||
|
FASTAPI_LOG="${LOG_DIR}/seg_server_fastapi.log"
|
||||||
|
CELERY_LOG="${LOG_DIR}/seg_server_celery.log"
|
||||||
|
FRONTEND_LOG="${LOG_DIR}/seg_server_frontend.log"
|
||||||
|
MINIO_LOG="${LOG_DIR}/seg_server_minio.log"
|
||||||
|
FASTAPI_PID="${PID_DIR}/seg_server_fastapi.pid"
|
||||||
|
CELERY_PID="${PID_DIR}/seg_server_celery.pid"
|
||||||
|
FRONTEND_PID="${PID_DIR}/seg_server_frontend.pid"
|
||||||
|
MINIO_PID="${PID_DIR}/seg_server_minio.pid"
|
||||||
|
|
||||||
|
echo "== Seg_Server development restart =="
|
||||||
|
echo "Project: ${PROJECT_DIR}"
|
||||||
|
|
||||||
|
start_system_service() {
|
||||||
|
local service_name="$1"
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
sudo systemctl start "${service_name}"
|
||||||
|
else
|
||||||
|
echo "systemctl not found; please start ${service_name} manually." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_conda() {
|
||||||
|
if [[ -x "${CONDA_BIN}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v conda >/dev/null 2>&1; then
|
||||||
|
CONDA_BIN="$(command -v conda)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "conda not found; set CONDA_BIN to the conda executable." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_pidfile_group() {
|
||||||
|
local pidfile="$1"
|
||||||
|
if [[ ! -f "${pidfile}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local pid
|
||||||
|
pid="$(cat "${pidfile}" 2>/dev/null || true)"
|
||||||
|
rm -f "${pidfile}"
|
||||||
|
if [[ -z "${pid}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if kill -0 "${pid}" >/dev/null 2>&1; then
|
||||||
|
kill -- "-${pid}" >/dev/null 2>&1 || kill "${pid}" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_detached() {
|
||||||
|
local name="$1"
|
||||||
|
local workdir="$2"
|
||||||
|
local pidfile="$3"
|
||||||
|
local logfile="$4"
|
||||||
|
shift 4
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${pidfile}")" "$(dirname "${logfile}")"
|
||||||
|
: >"${logfile}"
|
||||||
|
setsid bash -c 'cd "$1" || exit 1; shift; exec "$@"' bash "${workdir}" "$@" >"${logfile}" 2>&1 < /dev/null &
|
||||||
|
local pid=$!
|
||||||
|
echo "${pid}" >"${pidfile}"
|
||||||
|
echo " ${name} pid ${pid}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_http() {
|
||||||
|
local label="$1"
|
||||||
|
local url="$2"
|
||||||
|
local logfile="$3"
|
||||||
|
local method="${4:-GET}"
|
||||||
|
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if [[ "${method}" == "HEAD" ]]; then
|
||||||
|
curl -fsS -I "${url}" >/dev/null 2>&1 && echo " ${label} ready" && return
|
||||||
|
else
|
||||||
|
curl -fsS "${url}" >/dev/null 2>&1 && echo " ${label} ready" && return
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "${label} did not become ready: ${url}" >&2
|
||||||
|
echo "Last log lines from ${logfile}:" >&2
|
||||||
|
tail -n 80 "${logfile}" >&2 || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_process() {
|
||||||
|
local label="$1"
|
||||||
|
local pidfile="$2"
|
||||||
|
local logfile="$3"
|
||||||
|
|
||||||
|
for _ in $(seq 1 8); do
|
||||||
|
local pid
|
||||||
|
pid="$(cat "${pidfile}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "${pid}" ]] && kill -0 "${pid}" >/dev/null 2>&1; then
|
||||||
|
echo " ${label} ready"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "${label} did not stay running." >&2
|
||||||
|
echo "Last log lines from ${logfile}:" >&2
|
||||||
|
tail -n 80 "${logfile}" >&2 || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_conda
|
||||||
|
|
||||||
|
echo "[1/6] Checking PostgreSQL..."
|
||||||
|
if ! pg_isready -q; then
|
||||||
|
start_system_service postgresql
|
||||||
|
fi
|
||||||
|
pg_isready >/dev/null
|
||||||
|
echo " PostgreSQL ready"
|
||||||
|
|
||||||
|
echo "[2/6] Checking Redis..."
|
||||||
|
if ! redis-cli ping >/dev/null 2>&1; then
|
||||||
|
start_system_service redis-server
|
||||||
|
fi
|
||||||
|
redis-cli ping >/dev/null
|
||||||
|
echo " Redis ready"
|
||||||
|
|
||||||
|
echo "[3/6] Checking MinIO..."
|
||||||
|
if ! curl -fsS http://localhost:9000/minio/health/live >/dev/null 2>&1; then
|
||||||
|
stop_pidfile_group "${MINIO_PID}"
|
||||||
|
start_detached "MinIO" "${PROJECT_DIR}" "${MINIO_PID}" "${MINIO_LOG}" minio server "${HOME}/minio_data" --console-address :9001
|
||||||
|
fi
|
||||||
|
wait_for_http "MinIO" "http://localhost:9000/minio/health/live" "${MINIO_LOG}"
|
||||||
|
|
||||||
|
echo "[4/6] Stopping app services..."
|
||||||
|
stop_pidfile_group "${FASTAPI_PID}"
|
||||||
|
stop_pidfile_group "${CELERY_PID}"
|
||||||
|
stop_pidfile_group "${FRONTEND_PID}"
|
||||||
|
pkill -f "uvicorn main:app --host .*--port ${BACKEND_PORT}" || true
|
||||||
|
pkill -f "uvicorn main:app" || true
|
||||||
|
pkill -f "conda run -n ${CONDA_ENV} uvicorn main:app" || true
|
||||||
|
pkill -f "celery -A celery_app:celery_app worker" || true
|
||||||
|
pkill -f "conda run -n ${CONDA_ENV} celery -A celery_app:celery_app worker" || true
|
||||||
|
pkill -f "${PROJECT_DIR}/node_modules/.bin/tsx server.ts" || true
|
||||||
|
pkill -f "npm run dev" || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
echo "[5/6] Starting backend and worker..."
|
||||||
|
start_detached "FastAPI" "${PROJECT_DIR}/backend" "${FASTAPI_PID}" "${FASTAPI_LOG}" \
|
||||||
|
"${CONDA_BIN}" run -n "${CONDA_ENV}" uvicorn main:app --host 0.0.0.0 --port "${BACKEND_PORT}"
|
||||||
|
start_detached "Celery" "${PROJECT_DIR}/backend" "${CELERY_PID}" "${CELERY_LOG}" \
|
||||||
|
"${CONDA_BIN}" run -n "${CONDA_ENV}" celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1
|
||||||
|
|
||||||
|
echo "[6/6] Starting frontend..."
|
||||||
|
start_detached "Frontend" "${PROJECT_DIR}" "${FRONTEND_PID}" "${FRONTEND_LOG}" npm run dev
|
||||||
|
|
||||||
|
wait_for_http "FastAPI" "http://127.0.0.1:${BACKEND_PORT}/health" "${FASTAPI_LOG}"
|
||||||
|
wait_for_process "Celery" "${CELERY_PID}" "${CELERY_LOG}"
|
||||||
|
wait_for_http "Frontend" "http://127.0.0.1:${FRONTEND_PORT}" "${FRONTEND_LOG}" HEAD
|
||||||
|
|
||||||
|
echo "== Restart complete =="
|
||||||
|
echo "Frontend: http://localhost:${FRONTEND_PORT}"
|
||||||
|
echo "Backend: http://localhost:${BACKEND_PORT}/docs"
|
||||||
|
echo "PID files:"
|
||||||
|
echo " FastAPI: ${FASTAPI_PID}"
|
||||||
|
echo " Celery: ${CELERY_PID}"
|
||||||
|
echo " Frontend: ${FRONTEND_PID}"
|
||||||
|
echo "Logs:"
|
||||||
|
echo " FastAPI: ${FASTAPI_LOG}"
|
||||||
|
echo " Celery: ${CELERY_LOG}"
|
||||||
|
echo " Frontend: ${FRONTEND_LOG}"
|
||||||
|
echo " MinIO: ${MINIO_LOG}"
|
||||||
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({
|
||||||
|
|||||||
22
src/App.tsx
22
src/App.tsx
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useStore } from './store/useStore';
|
import { useStore } from './store/useStore';
|
||||||
import { getProjects } from './lib/api';
|
import { getCurrentUser, getProjects } from './lib/api';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Dashboard } from './components/Dashboard';
|
import { Dashboard } from './components/Dashboard';
|
||||||
import { ProjectLibrary } from './components/ProjectLibrary';
|
import { ProjectLibrary } from './components/ProjectLibrary';
|
||||||
@@ -8,8 +8,9 @@ import { VideoWorkspace } from './components/VideoWorkspace';
|
|||||||
import { TemplateRegistry } from './components/TemplateRegistry';
|
import { TemplateRegistry } from './components/TemplateRegistry';
|
||||||
import { AISegmentation } from './components/AISegmentation';
|
import { AISegmentation } from './components/AISegmentation';
|
||||||
import { Login } from './components/Login';
|
import { Login } from './components/Login';
|
||||||
|
import { UserAdmin } from './components/UserAdmin';
|
||||||
|
|
||||||
export type ActiveModule = 'dashboard' | 'projects' | 'ai' | 'workspace' | 'templates';
|
export type ActiveModule = 'dashboard' | 'projects' | 'ai' | 'workspace' | 'templates' | 'admin';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const isAuthenticated = useStore((state) => state.isAuthenticated);
|
const isAuthenticated = useStore((state) => state.isAuthenticated);
|
||||||
@@ -17,17 +18,27 @@ export default function App() {
|
|||||||
const setActiveModule = useStore((state) => state.setActiveModule);
|
const setActiveModule = useStore((state) => state.setActiveModule);
|
||||||
const setProjects = useStore((state) => state.setProjects);
|
const setProjects = useStore((state) => state.setProjects);
|
||||||
const setError = useStore((state) => state.setError);
|
const setError = useStore((state) => state.setError);
|
||||||
|
const setCurrentUser = useStore((state) => state.setCurrentUser);
|
||||||
|
const logout = useStore((state) => state.logout);
|
||||||
|
const currentUser = useStore((state) => state.currentUser);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
getProjects()
|
Promise.all([getCurrentUser(), getProjects()])
|
||||||
.then((data) => setProjects(data))
|
.then(([user, projects]) => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
setProjects(projects);
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('Failed to fetch projects:', err);
|
console.error('Failed to fetch projects:', err);
|
||||||
|
if (err?.response?.status === 401) {
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError('获取项目列表失败');
|
setError('获取项目列表失败');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, setProjects, setError]);
|
}, [isAuthenticated, logout, setCurrentUser, setProjects, setError]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Login />;
|
return <Login />;
|
||||||
@@ -42,6 +53,7 @@ export default function App() {
|
|||||||
{activeModule === 'ai' && <AISegmentation onSendToWorkspace={() => setActiveModule('workspace')} />}
|
{activeModule === 'ai' && <AISegmentation onSendToWorkspace={() => setActiveModule('workspace')} />}
|
||||||
{activeModule === 'workspace' && <VideoWorkspace onNavigateToAI={() => setActiveModule('ai')} />}
|
{activeModule === 'workspace' && <VideoWorkspace onNavigateToAI={() => setActiveModule('ai')} />}
|
||||||
{activeModule === 'templates' && <TemplateRegistry />}
|
{activeModule === 'templates' && <TemplateRegistry />}
|
||||||
|
{activeModule === 'admin' && currentUser?.role === 'admin' && <UserAdmin />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,29 +22,60 @@ describe('AISegmentation', () => {
|
|||||||
frames: [{ id: 'frame-1', projectId: 'project-1', index: 0, url: '/frame.jpg', width: 640, height: 360 }],
|
frames: [{ id: 'frame-1', projectId: 'project-1', index: 0, url: '/frame.jpg', width: 640, height: 360 }],
|
||||||
});
|
});
|
||||||
apiMock.getAiModelStatus.mockResolvedValue({
|
apiMock.getAiModelStatus.mockResolvedValue({
|
||||||
selected_model: 'sam2',
|
selected_model: 'sam2.1_hiera_tiny',
|
||||||
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
|
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
|
||||||
models: [
|
models: [
|
||||||
{ id: 'sam2', label: 'SAM 2', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2 ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Tiny ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
||||||
{ id: 'sam3', label: 'SAM 3', available: false, loaded: false, device: 'cuda', supports: ['semantic'], message: 'SAM 3 missing runtime', package_available: false, checkpoint_exists: false, python_ok: false, torch_ok: true, cuda_required: true },
|
{ id: 'sam2.1_hiera_small', label: 'SAM 2.1 Small', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Small ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
||||||
|
{ id: 'sam2.1_hiera_base_plus', label: 'SAM 2.1 Base+', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Base+ ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
||||||
|
{ id: 'sam2.1_hiera_large', label: 'SAM 2.1 Large', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Large ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets the user choose SAM3 for subsequent predictions', async () => {
|
it('shows the SAM2.1 variant selector without exposing SAM3', async () => {
|
||||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
const sam3Button = (await screen.findByText('SAM3')).closest('button')!;
|
expect(await screen.findByText('SAM 2.1 Tiny')).toBeInTheDocument();
|
||||||
fireEvent.click(sam3Button);
|
expect(screen.getByText('tiny')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('small')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('base+')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('large')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('SAM3')).not.toBeInTheDocument();
|
||||||
|
expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny');
|
||||||
|
});
|
||||||
|
|
||||||
expect(useStore.getState().aiModel).toBe('sam3');
|
it('does not render the legacy upload-replace-background mock button', () => {
|
||||||
expect(await screen.findByText('SAM 3 missing runtime')).toBeInTheDocument();
|
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()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('局部专注模式(自动裁剪无锚区域)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('严格除杂模式(自动清理干涉点)')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('正向选点'));
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
fireEvent.click(screen.getByTestId('konva-stage'));
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
@@ -53,7 +84,7 @@ describe('AISegmentation', () => {
|
|||||||
imageId: 'frame-1',
|
imageId: 'frame-1',
|
||||||
imageWidth: 640,
|
imageWidth: 640,
|
||||||
imageHeight: 360,
|
imageHeight: 360,
|
||||||
model: 'sam2',
|
model: 'sam2.1_hiera_tiny',
|
||||||
points: [{ x: 120, y: 80, type: 'pos' }],
|
points: [{ x: 120, y: 80, type: 'pos' }],
|
||||||
options: {
|
options: {
|
||||||
crop_to_prompt: false,
|
crop_to_prompt: false,
|
||||||
@@ -63,16 +94,231 @@ describe('AISegmentation', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not run SAM2 text-only prompts as semantic segmentation', async () => {
|
it('sends the selected SAM2.1 variant to prediction', async () => {
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("例如:'分割出左侧车道上行驶的所有红色汽车'..."), {
|
fireEvent.click(await screen.findByText('small'));
|
||||||
target: { value: '胆囊' },
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
|
||||||
|
expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_small');
|
||||||
|
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
model: 'sam2.1_hiera_small',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render masks that were created in the workspace', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'workspace-mask',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||||
|
label: 'Manual Mask',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||||
|
metadata: { source: 'manual' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedMaskIds: ['workspace-mask'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryAllByTestId('konva-path')).toHaveLength(0);
|
||||||
|
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires point prompts before running SAM2 inference', async () => {
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
|
||||||
expect(apiMock.predictMask).not.toHaveBeenCalled();
|
expect(apiMock.predictMask).not.toHaveBeenCalled();
|
||||||
expect(await screen.findByText('SAM2 不支持文本语义提示;请先放置正/反向点,或切换到 SAM3 使用文本语义。')).toBeInTheDocument();
|
expect(await screen.findByText('请先放置正/反向提示点或框选区域。')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables unavailable model variants and skips AI inference', async () => {
|
||||||
|
apiMock.getAiModelStatus.mockResolvedValue({
|
||||||
|
selected_model: 'sam2.1_hiera_tiny',
|
||||||
|
gpu: { available: false, device: 'cpu', name: null, torch_available: false },
|
||||||
|
models: [
|
||||||
|
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: false, loaded: false, device: 'cpu', supports: [], message: 'missing PyTorch, sam2 package, checkpoint', package_available: false, checkpoint_exists: false, python_ok: false, torch_ok: false, cuda_required: false },
|
||||||
|
{ id: 'sam2.1_hiera_small', label: 'SAM 2.1 Small', available: false, loaded: false, device: 'cpu', supports: [], message: 'missing PyTorch, sam2 package, checkpoint', package_available: false, checkpoint_exists: false, python_ok: false, torch_ok: false, cuda_required: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('当前模型不可用')).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: /tiny/i })).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
fireEvent.click(screen.getByText('当前模型不可用'));
|
||||||
|
|
||||||
|
expect(apiMock.predictMask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a dragged box prompt for AI page inference without adding a point on click', async () => {
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('边界框选'));
|
||||||
|
const stage = screen.getByTestId('konva-stage');
|
||||||
|
fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 });
|
||||||
|
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
|
||||||
|
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
|
||||||
|
|
||||||
|
expect(screen.getByTestId('konva-rect')).toHaveAttribute('data-width', '140');
|
||||||
|
expect(await screen.findByText('已框选区域,可执行分割,或继续添加正/反向点细化。')).toBeInTheDocument();
|
||||||
|
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
|
||||||
|
expect(apiMock.predictMask).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
|
||||||
|
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
imageId: 'frame-1',
|
||||||
|
imageWidth: 640,
|
||||||
|
imageHeight: 360,
|
||||||
|
model: 'sam2.1_hiera_tiny',
|
||||||
|
points: undefined,
|
||||||
|
box: { x1: 120, y1: 80, x2: 260, y2: 200 },
|
||||||
|
options: {
|
||||||
|
crop_to_prompt: false,
|
||||||
|
auto_filter_background: true,
|
||||||
|
min_score: 0.05,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles stage drag end for move-tool canvas panning', () => {
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-has-drag-end', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centers the active frame with a large default fit inside the AI canvas', async () => {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 1000 });
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 700 });
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const stage = screen.getByTestId('konva-stage');
|
||||||
|
expect(Number(stage.getAttribute('data-scale-x'))).toBeCloseTo(1.34375, 4);
|
||||||
|
expect(Number(stage.getAttribute('data-x'))).toBeCloseTo(70, 0);
|
||||||
|
expect(Number(stage.getAttribute('data-y'))).toBeCloseTo(108, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines the AI page box prompt with later positive and negative refinement points', async () => {
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('边界框选'));
|
||||||
|
const stage = screen.getByTestId('konva-stage');
|
||||||
|
fireEvent.mouseDown(stage, { clientX: 100, clientY: 60 });
|
||||||
|
fireEvent.mouseMove(stage, { clientX: 300, clientY: 180 });
|
||||||
|
fireEvent.mouseUp(stage, { clientX: 300, clientY: 180 });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(stage, { clientX: 160, clientY: 100 });
|
||||||
|
fireEvent.click(screen.getByText('反向选点'));
|
||||||
|
fireEvent.click(stage, { clientX: 260, clientY: 150 });
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
|
||||||
|
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
points: [
|
||||||
|
{ x: 160, y: 100, type: 'pos' },
|
||||||
|
{ x: 260, y: 150, type: 'neg' },
|
||||||
|
],
|
||||||
|
box: { x1: 100, y1: 60, x2: 300, y2: 180 },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the previous AI page candidate when running the same box prompt again', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'workspace-mask',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||||
|
label: 'Manual Mask',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||||
|
metadata: { source: 'manual' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.predictMask
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-first',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-second',
|
||||||
|
pathData: 'M 20 20 L 50 20 L 50 50 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[20, 20, 50, 20, 50, 50]],
|
||||||
|
bbox: [20, 20, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('边界框选'));
|
||||||
|
const stage = screen.getByTestId('konva-stage');
|
||||||
|
fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 });
|
||||||
|
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
|
||||||
|
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-first']));
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-first']);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('执行高精度语义分割'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-second']));
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-second']);
|
||||||
|
expect(screen.getAllByTestId('konva-path')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes prompt points individually and can remove the latest point', async () => {
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 });
|
||||||
|
fireEvent.click(screen.getByText('反向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 220, clientY: 140 });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(4));
|
||||||
|
fireEvent.click(screen.getAllByTestId('konva-circle')[0]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(2));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
|
||||||
|
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
points: [{ x: 220, y: 140, type: 'neg' }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('删除最近锚点'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps only the best SAM2 candidate when the backend returns overlapping alternatives', async () => {
|
it('keeps only the best SAM2 candidate when the backend returns overlapping alternatives', async () => {
|
||||||
@@ -106,8 +352,212 @@ describe('AISegmentation', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(useStore.getState().masks).toHaveLength(1));
|
await waitFor(() => expect(useStore.getState().masks).toHaveLength(1));
|
||||||
expect(useStore.getState().masks[0].id).toBe('sam2-best');
|
expect(useStore.getState().masks[0].id).toBe('sam2-best');
|
||||||
|
expect(useStore.getState().masks[0].metadata).toEqual(expect.objectContaining({ source: 'ai_segmentation' }));
|
||||||
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-best']);
|
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-best']);
|
||||||
expect(await screen.findByText('SAM2 返回 2 个候选,已采用最高分区域。')).toBeInTheDocument();
|
expect(await screen.findByText('SAM 2.1 Tiny 返回 2 个候选,已采用最高分区域。')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjusts the AI mask preview opacity without changing mask data', async () => {
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-mask',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const maskGroup = () => screen.getAllByTestId('konva-group').find((group) => group.getAttribute('data-opacity'));
|
||||||
|
expect(maskGroup()).toHaveAttribute('data-opacity', '0.5');
|
||||||
|
fireEvent.change(screen.getByLabelText('AI 遮罩透明度'), { target: { value: '35' } });
|
||||||
|
|
||||||
|
expect(maskGroup()).toHaveAttribute('data-opacity', '0.35');
|
||||||
|
expect(useStore.getState().maskPreviewOpacity).toBe(35);
|
||||||
|
expect(useStore.getState().masks[0].segmentation).toEqual([[10, 10, 40, 10, 40, 40]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates AI candidate opacity when the shared ontology opacity slider changes', async () => {
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-mask',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const maskGroup = () => screen.getAllByTestId('konva-group').find((group) => group.getAttribute('data-opacity'));
|
||||||
|
fireEvent.change(screen.getByLabelText('遮罩透明度'), { target: { value: '80' } });
|
||||||
|
|
||||||
|
expect(maskGroup()).toHaveAttribute('data-opacity', '0.8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets positive and negative prompt points be added on top of an AI mask', async () => {
|
||||||
|
apiMock.predictMask
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-mask',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ masks: [] });
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 });
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('反向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-path'), { clientX: 220, clientY: 140 });
|
||||||
|
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(4));
|
||||||
|
fireEvent.click(screen.getByText('执行高精度语义分割'));
|
||||||
|
|
||||||
|
expect(apiMock.predictMask).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
points: [
|
||||||
|
{ x: 120, y: 80, type: 'pos' },
|
||||||
|
{ x: 220, y: 140, type: 'neg' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears only AI page candidates and keeps workspace masks in the store', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'workspace-mask',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||||
|
label: 'Manual Mask',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||||
|
metadata: { source: 'manual' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-mask',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(2));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-mask']));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('清空全体锚点'));
|
||||||
|
|
||||||
|
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask']);
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes only the selected AI candidate and preserves workspace masks', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'workspace-mask',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||||
|
label: 'Manual Mask',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||||
|
metadata: { source: 'manual' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-mask',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('删除选中候选'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask']));
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets Delete remove the selected AI candidate after a mask click selects it', async () => {
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-mask',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('视口控制'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-path'));
|
||||||
|
fireEvent.keyDown(window, { key: 'Delete' });
|
||||||
|
|
||||||
|
await waitFor(() => expect(useStore.getState().masks).toEqual([]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets a SAM2 result be selected and relabeled from the ontology panel', async () => {
|
it('lets a SAM2 result be selected and relabeled from the ontology panel', async () => {
|
||||||
@@ -159,6 +609,11 @@ describe('AISegmentation', () => {
|
|||||||
|
|
||||||
it('keeps the generated SAM2 mask selected when sending it to the workspace editor', async () => {
|
it('keeps the generated SAM2 mask selected when sending it to the workspace editor', async () => {
|
||||||
const onSendToWorkspace = vi.fn();
|
const onSendToWorkspace = vi.fn();
|
||||||
|
useStore.setState({
|
||||||
|
activeTemplateId: 'template-1',
|
||||||
|
activeClass: { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 },
|
||||||
|
activeClassId: 'class-1',
|
||||||
|
});
|
||||||
apiMock.predictMask.mockResolvedValueOnce({
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
masks: [
|
masks: [
|
||||||
{
|
{
|
||||||
@@ -186,67 +641,14 @@ describe('AISegmentation', () => {
|
|||||||
expect(onSendToWorkspace).toHaveBeenCalled();
|
expect(onSendToWorkspace).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prompts for semantic text before running SAM3 inference', async () => {
|
it('blocks sending an AI candidate to the workspace until a semantic class is selected', async () => {
|
||||||
apiMock.getAiModelStatus.mockResolvedValue({
|
const onSendToWorkspace = vi.fn();
|
||||||
selected_model: 'sam3',
|
|
||||||
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
|
|
||||||
models: [
|
|
||||||
{ id: 'sam2', label: 'SAM 2', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2 ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
|
||||||
{ id: 'sam3', label: 'SAM 3', available: true, loaded: true, device: 'cuda', supports: ['semantic'], message: 'SAM 3 ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: true },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
|
||||||
const sam3Button = (await screen.findByText('SAM3')).closest('button')!;
|
|
||||||
fireEvent.click(sam3Button);
|
|
||||||
fireEvent.click(screen.getByText('执行高精度语义分割'));
|
|
||||||
|
|
||||||
expect(apiMock.predictMask).not.toHaveBeenCalled();
|
|
||||||
expect(await screen.findByText('SAM3 当前使用文本语义提示,请先输入要分割的目标描述。')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows feedback when SAM3 semantic inference returns no masks', async () => {
|
|
||||||
apiMock.getAiModelStatus.mockResolvedValue({
|
|
||||||
selected_model: 'sam3',
|
|
||||||
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
|
|
||||||
models: [
|
|
||||||
{ id: 'sam2', label: 'SAM 2', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2 ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
|
||||||
{ id: 'sam3', label: 'SAM 3', available: true, loaded: true, device: 'cuda', supports: ['semantic'], message: 'SAM 3 ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: true },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
|
||||||
|
|
||||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
|
||||||
const sam3Button = (await screen.findByText('SAM3')).closest('button')!;
|
|
||||||
fireEvent.click(sam3Button);
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("例如:'分割出左侧车道上行驶的所有红色汽车'..."), {
|
|
||||||
target: { value: '胆囊' },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText('执行高精度语义分割'));
|
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
model: 'sam3',
|
|
||||||
points: undefined,
|
|
||||||
text: '胆囊',
|
|
||||||
})));
|
|
||||||
expect(await screen.findByText('SAM3 已完成语义推理,但没有返回区域。请尝试英文目标描述,或换到包含该目标的帧。当前提示: 胆囊')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs SAM3 semantic text inference and assigns the active class to returned masks', async () => {
|
|
||||||
apiMock.getAiModelStatus.mockResolvedValue({
|
|
||||||
selected_model: 'sam3',
|
|
||||||
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
|
|
||||||
models: [
|
|
||||||
{ id: 'sam2', label: 'SAM 2', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2 ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
|
||||||
{ id: 'sam3', label: 'SAM 3', available: true, loaded: true, device: 'cuda', supports: ['semantic'], message: 'SAM 3 ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: true },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
apiMock.predictMask.mockResolvedValueOnce({
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
masks: [
|
masks: [
|
||||||
{
|
{
|
||||||
id: 'semantic-1',
|
id: 'sam2-mask',
|
||||||
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
label: 'semantic result',
|
label: 'AI Mask',
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
segmentation: [[10, 10, 40, 10, 40, 40]],
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
bbox: [10, 10, 30, 30],
|
bbox: [10, 10, 30, 30],
|
||||||
@@ -254,43 +656,79 @@ describe('AISegmentation', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
render(<AISegmentation onSendToWorkspace={onSendToWorkspace} />);
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('推送至工作区编辑'));
|
||||||
|
|
||||||
|
const toast = screen.getByRole('status');
|
||||||
|
expect(toast).toHaveTextContent('请先在右侧语义分类树为 AI 候选区域选择语义分类,再推送至工作区。');
|
||||||
|
expect(toast.className).toContain('bg-red-950');
|
||||||
|
expect(useStore.getState().activeTool).toBe('point_pos');
|
||||||
|
expect(onSendToWorkspace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes unclassified AI candidates when leaving the AI page', async () => {
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-mask',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
|
await waitFor(() => expect(useStore.getState().masks).toHaveLength(1));
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(useStore.getState().masks).toEqual([]);
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps classified AI candidates when leaving the AI page', async () => {
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
activeTemplateId: 'template-1',
|
activeTemplateId: 'template-1',
|
||||||
activeClassId: 'class-1',
|
|
||||||
activeClass: { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 },
|
activeClass: { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 },
|
||||||
|
activeClassId: 'class-1',
|
||||||
|
});
|
||||||
|
apiMock.predictMask.mockResolvedValueOnce({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'sam2-mask',
|
||||||
|
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||||
|
label: 'AI Mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||||
|
bbox: [10, 10, 30, 30],
|
||||||
|
area: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
const { unmount } = render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||||
const sam3Button = (await screen.findByText('SAM3')).closest('button')!;
|
fireEvent.click(screen.getByText('正向选点'));
|
||||||
fireEvent.click(sam3Button);
|
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||||
fireEvent.change(screen.getByPlaceholderText("例如:'分割出左侧车道上行驶的所有红色汽车'..."), {
|
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||||
target: { value: '胆囊' },
|
await waitFor(() => expect(useStore.getState().masks[0]?.classId).toBe('class-1'));
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText('执行高精度语义分割'));
|
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
unmount();
|
||||||
imageId: 'frame-1',
|
|
||||||
imageWidth: 640,
|
expect(useStore.getState().masks).toHaveLength(1);
|
||||||
imageHeight: 360,
|
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']);
|
||||||
model: 'sam3',
|
|
||||||
points: undefined,
|
|
||||||
text: '胆囊',
|
|
||||||
options: {
|
|
||||||
crop_to_prompt: false,
|
|
||||||
auto_filter_background: true,
|
|
||||||
min_score: 0.05,
|
|
||||||
},
|
|
||||||
})));
|
|
||||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
|
||||||
id: 'semantic-1',
|
|
||||||
frameId: 'frame-1',
|
|
||||||
templateId: 'template-1',
|
|
||||||
classId: 'class-1',
|
|
||||||
className: '胆囊',
|
|
||||||
classZIndex: 30,
|
|
||||||
label: '胆囊',
|
|
||||||
color: '#ff0000',
|
|
||||||
saveStatus: 'draft',
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2 } from 'lucide-react';
|
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, 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 } 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';
|
||||||
import { OntologyInspector } from './OntologyInspector';
|
import { OntologyInspector } from './OntologyInspector';
|
||||||
import { useStore } from '../store/useStore';
|
import { TransientNotice, type NoticeState } from './TransientNotice';
|
||||||
|
import { SAM2_MODEL_OPTIONS, useStore, type Mask } from '../store/useStore';
|
||||||
import { getAiModelStatus, predictMask, type AiRuntimeStatus } from '../lib/api';
|
import { getAiModelStatus, predictMask, type AiRuntimeStatus } from '../lib/api';
|
||||||
|
|
||||||
interface AISegmentationProps {
|
interface AISegmentationProps {
|
||||||
onSendToWorkspace: () => void;
|
onSendToWorkspace: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' };
|
||||||
|
type PromptBox = { x1: number; y1: number; x2: number; y2: number };
|
||||||
|
type ToolHint = { title: string; body: string };
|
||||||
|
const DEFAULT_IMAGE_FIT_RATIO = 0.86;
|
||||||
|
|
||||||
export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||||
|
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const storeActiveTool = useStore((state) => state.activeTool);
|
const storeActiveTool = useStore((state) => state.activeTool);
|
||||||
const setActiveTool = useStore((state) => state.setActiveTool);
|
const setActiveTool = useStore((state) => state.setActiveTool);
|
||||||
const masks = useStore((state) => state.masks);
|
const masks = useStore((state) => state.masks);
|
||||||
const addMask = useStore((state) => state.addMask);
|
const setMasks = useStore((state) => state.setMasks);
|
||||||
const clearMasks = useStore((state) => state.clearMasks);
|
|
||||||
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
|
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
|
||||||
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||||
const maskHistory = useStore((state) => state.maskHistory);
|
const maskHistory = useStore((state) => state.maskHistory);
|
||||||
@@ -25,32 +31,145 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
const redoMasks = useStore((state) => state.redoMasks);
|
const redoMasks = useStore((state) => state.redoMasks);
|
||||||
const frames = useStore((state) => state.frames);
|
const frames = useStore((state) => state.frames);
|
||||||
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
||||||
const activeTemplateId = useStore((state) => state.activeTemplateId);
|
|
||||||
const activeClass = useStore((state) => state.activeClass);
|
|
||||||
const aiModel = useStore((state) => state.aiModel);
|
const aiModel = useStore((state) => state.aiModel);
|
||||||
const setAiModel = useStore((state) => state.setAiModel);
|
const setAiModel = useStore((state) => state.setAiModel);
|
||||||
|
const activeTemplateId = useStore((state) => state.activeTemplateId);
|
||||||
|
const activeClass = useStore((state) => state.activeClass);
|
||||||
|
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
|
||||||
|
const setMaskPreviewOpacity = useStore((state) => state.setMaskPreviewOpacity);
|
||||||
|
|
||||||
const [semanticText, setSemanticText] = useState('');
|
|
||||||
const [modelStatus, setModelStatus] = useState<AiRuntimeStatus | null>(null);
|
const [modelStatus, setModelStatus] = useState<AiRuntimeStatus | null>(null);
|
||||||
const [autoDeleteBg, setAutoDeleteBg] = useState(true);
|
const [autoDeleteBg, setAutoDeleteBg] = useState(true);
|
||||||
const [cropMode, setCropMode] = useState(false);
|
const [cropMode, setCropMode] = useState(false);
|
||||||
const [isInferencing, setIsInferencing] = useState(false);
|
const [isInferencing, setIsInferencing] = useState(false);
|
||||||
const [inferenceMessage, setInferenceMessage] = useState('');
|
const [inferenceMessage, setInferenceMessage] = useState('');
|
||||||
|
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||||
|
const [aiMaskIds, setAiMaskIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// Canvas state
|
// Canvas state
|
||||||
|
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [points, setPoints] = useState<{ x: number, y: number, type: 'pos'|'neg' }[]>([]);
|
const lastAutoFitKeyRef = useRef('');
|
||||||
|
const [points, setPoints] = useState<PromptPoint[]>([]);
|
||||||
|
const [promptBox, setPromptBox] = useState<PromptBox | null>(null);
|
||||||
|
const [boxStart, setBoxStart] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [boxCurrent, setBoxCurrent] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
const [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 frameMasks = currentFrame ? masks.filter((mask) => mask.frameId === currentFrame.id) : masks;
|
const frameMasks = currentFrame
|
||||||
|
? masks.filter((mask) => mask.frameId === currentFrame.id && aiMaskIdSet.has(mask.id))
|
||||||
|
: masks.filter((mask) => aiMaskIdSet.has(mask.id));
|
||||||
|
const selectedAiMasks = frameMasks.filter((mask) => selectedMaskIds.includes(mask.id));
|
||||||
|
const aiMasksToSend = selectedAiMasks.length > 0 ? selectedAiMasks : frameMasks;
|
||||||
const selectedModelStatus = modelStatus?.models.find((model) => model.id === aiModel);
|
const selectedModelStatus = modelStatus?.models.find((model) => model.id === aiModel);
|
||||||
const modelCanInfer = selectedModelStatus?.available ?? true;
|
const modelStatusLoaded = Boolean(modelStatus);
|
||||||
|
const modelCanInfer = modelStatusLoaded && Boolean(selectedModelStatus?.available);
|
||||||
|
|
||||||
const effectiveTool = storeActiveTool;
|
const effectiveTool = storeActiveTool;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (aiMaskIds.length === 0) return;
|
||||||
|
const state = useStore.getState();
|
||||||
|
const aiIds = new Set(aiMaskIds);
|
||||||
|
const unclassifiedAiIds = new Set(
|
||||||
|
state.masks
|
||||||
|
.filter((mask) => aiIds.has(mask.id) && !mask.classId && !mask.className)
|
||||||
|
.map((mask) => mask.id),
|
||||||
|
);
|
||||||
|
if (unclassifiedAiIds.size === 0) return;
|
||||||
|
|
||||||
|
useStore.setState({
|
||||||
|
masks: state.masks.filter((mask) => !unclassifiedAiIds.has(mask.id)),
|
||||||
|
selectedMaskIds: state.selectedMaskIds.filter((id) => !unclassifiedAiIds.has(id)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [aiMaskIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!canvasContainerRef.current) return;
|
||||||
|
setStageSize({
|
||||||
|
width: canvasContainerRef.current.clientWidth,
|
||||||
|
height: canvasContainerRef.current.clientHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentFrame?.id || stageSize.width <= 0 || stageSize.height <= 0) return;
|
||||||
|
const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0;
|
||||||
|
const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0;
|
||||||
|
if (imageWidth <= 0 || imageHeight <= 0) return;
|
||||||
|
|
||||||
|
const fitKey = `${currentFrame.id}:${stageSize.width}x${stageSize.height}:${imageWidth}x${imageHeight}`;
|
||||||
|
if (lastAutoFitKeyRef.current === fitKey) return;
|
||||||
|
lastAutoFitKeyRef.current = fitKey;
|
||||||
|
|
||||||
|
const nextScale = Math.max(
|
||||||
|
0.05,
|
||||||
|
Math.min(stageSize.width / imageWidth, stageSize.height / imageHeight) * DEFAULT_IMAGE_FIT_RATIO,
|
||||||
|
);
|
||||||
|
setScale(nextScale);
|
||||||
|
setPosition({
|
||||||
|
x: (stageSize.width - imageWidth * nextScale) / 2,
|
||||||
|
y: (stageSize.height - imageHeight * nextScale) / 2,
|
||||||
|
});
|
||||||
|
}, [currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]);
|
||||||
|
|
||||||
|
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 activeBox = boxStart && boxCurrent
|
||||||
|
? {
|
||||||
|
x1: Math.min(boxStart.x, boxCurrent.x),
|
||||||
|
y1: Math.min(boxStart.y, boxCurrent.y),
|
||||||
|
x2: Math.max(boxStart.x, boxCurrent.x),
|
||||||
|
y2: Math.max(boxStart.y, boxCurrent.y),
|
||||||
|
}
|
||||||
|
: promptBox;
|
||||||
|
if (!activeBox) return null;
|
||||||
|
return {
|
||||||
|
x: activeBox.x1,
|
||||||
|
y: activeBox.y1,
|
||||||
|
width: activeBox.x2 - activeBox.x1,
|
||||||
|
height: activeBox.y2 - activeBox.y1,
|
||||||
|
};
|
||||||
|
}, [boxCurrent, boxStart, promptBox]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getAiModelStatus(aiModel)
|
getAiModelStatus(aiModel)
|
||||||
@@ -65,6 +184,16 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
};
|
};
|
||||||
}, [aiModel]);
|
}, [aiModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const visibleIds = new Set(frameMasks.map((mask) => mask.id));
|
||||||
|
const nextSelectedMaskIds = selectedMaskIds.filter((id) => visibleIds.has(id));
|
||||||
|
const changed = nextSelectedMaskIds.length !== selectedMaskIds.length
|
||||||
|
|| nextSelectedMaskIds.some((id, index) => id !== selectedMaskIds[index]);
|
||||||
|
if (changed) {
|
||||||
|
setSelectedMaskIds(nextSelectedMaskIds);
|
||||||
|
}
|
||||||
|
}, [frameMasks, selectedMaskIds, setSelectedMaskIds]);
|
||||||
|
|
||||||
const handleWheel = (e: any) => {
|
const handleWheel = (e: any) => {
|
||||||
e.evt.preventDefault();
|
e.evt.preventDefault();
|
||||||
const scaleBy = 1.1;
|
const scaleBy = 1.1;
|
||||||
@@ -82,6 +211,14 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStageDragEnd = (e: any) => {
|
||||||
|
const stage = e.target;
|
||||||
|
setPosition({
|
||||||
|
x: stage.x(),
|
||||||
|
y: stage.y(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouseMove = (e: any) => {
|
const handleMouseMove = (e: any) => {
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
@@ -91,20 +228,17 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
const imageY = (pos.y - position.y) / scale;
|
const imageY = (pos.y - position.y) / scale;
|
||||||
setCursorPos({ x: imageX, y: imageY });
|
setCursorPos({ x: imageX, y: imageY });
|
||||||
}
|
}
|
||||||
|
if (effectiveTool === 'box_select' && boxStart) {
|
||||||
|
const relPos = stage.getRelativePointerPosition?.();
|
||||||
|
if (relPos) {
|
||||||
|
setBoxCurrent({ x: relPos.x, y: relPos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const runInference = useCallback(async () => {
|
const runInference = useCallback(async () => {
|
||||||
const textPrompt = semanticText.trim();
|
if (points.length === 0 && !promptBox) {
|
||||||
if (aiModel === 'sam3' && !textPrompt) {
|
setInferenceMessage('请先放置正/反向提示点或框选区域。');
|
||||||
setInferenceMessage('SAM3 当前使用文本语义提示,请先输入要分割的目标描述。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (aiModel === 'sam2' && textPrompt && points.length === 0) {
|
|
||||||
setInferenceMessage('SAM2 不支持文本语义提示;请先放置正/反向点,或切换到 SAM3 使用文本语义。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (points.length === 0 && !textPrompt) {
|
|
||||||
setInferenceMessage('请先放置正/反向提示点,或输入语义描述。');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!currentFrame?.id) {
|
if (!currentFrame?.id) {
|
||||||
@@ -112,6 +246,10 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
setInferenceMessage('请先在项目工作区选择一帧图像。');
|
setInferenceMessage('请先在项目工作区选择一帧图像。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!modelCanInfer) {
|
||||||
|
setInferenceMessage(selectedModelStatus?.message ? `当前模型不可用:${selectedModelStatus.message}` : '当前模型不可用,请检查 GPU、PyTorch/SAM2 和模型权重。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0;
|
const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0;
|
||||||
const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0;
|
const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0;
|
||||||
@@ -129,8 +267,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
model: aiModel,
|
model: aiModel,
|
||||||
points: aiModel === 'sam3' ? undefined : points.map((p) => ({ x: p.x, y: p.y, type: p.type })),
|
points: points.length > 0 ? points.map((p) => ({ x: p.x, y: p.y, type: p.type })) : undefined,
|
||||||
text: textPrompt || undefined,
|
box: promptBox || undefined,
|
||||||
options: {
|
options: {
|
||||||
crop_to_prompt: cropMode,
|
crop_to_prompt: cropMode,
|
||||||
auto_filter_background: autoDeleteBg,
|
auto_filter_background: autoDeleteBg,
|
||||||
@@ -138,29 +276,26 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const masksToApply = aiModel === 'sam2' ? result.masks.slice(0, 1) : result.masks;
|
const masksToApply = result.masks.slice(0, 1);
|
||||||
|
|
||||||
if (masksToApply.length === 0) {
|
if (masksToApply.length === 0) {
|
||||||
setInferenceMessage(aiModel === 'sam3'
|
setInferenceMessage('模型没有返回可用区域,请调整提示点后重试。');
|
||||||
? `SAM3 已完成语义推理,但没有返回区域。请尝试英文目标描述,或换到包含该目标的帧。当前提示: ${textPrompt}`
|
|
||||||
: '模型没有返回可用区域,请换一个更具体的描述或调整提示。');
|
|
||||||
} else {
|
} else {
|
||||||
setInferenceMessage(aiModel === 'sam2' && result.masks.length > 1
|
setInferenceMessage(result.masks.length > 1
|
||||||
? `SAM2 返回 ${result.masks.length} 个候选,已采用最高分区域。`
|
? `${selectedModelStatus?.label || 'SAM 2.1'} 返回 ${result.masks.length} 个候选,已采用最高分区域。`
|
||||||
: `已生成 ${masksToApply.length} 个候选区域。`);
|
: `已生成 ${masksToApply.length} 个候选区域。`);
|
||||||
}
|
}
|
||||||
const generatedMaskIds: string[] = [];
|
const generatedMasks: Mask[] = masksToApply.map((m) => {
|
||||||
masksToApply.forEach((m) => {
|
|
||||||
const label = activeClass?.name || m.label;
|
const label = activeClass?.name || m.label;
|
||||||
const color = activeClass?.color || m.color;
|
const color = activeClass?.color || m.color;
|
||||||
generatedMaskIds.push(m.id);
|
return {
|
||||||
addMask({
|
|
||||||
id: m.id,
|
id: m.id,
|
||||||
frameId: currentFrame.id,
|
frameId: currentFrame.id,
|
||||||
templateId: activeTemplateId || undefined,
|
templateId: activeTemplateId || undefined,
|
||||||
classId: activeClass?.id,
|
classId: activeClass?.id,
|
||||||
className: activeClass?.name,
|
className: activeClass?.name,
|
||||||
classZIndex: activeClass?.zIndex,
|
classZIndex: activeClass?.zIndex,
|
||||||
|
classMaskId: activeClass?.maskId,
|
||||||
saveStatus: 'draft',
|
saveStatus: 'draft',
|
||||||
saved: false,
|
saved: false,
|
||||||
pathData: m.pathData,
|
pathData: m.pathData,
|
||||||
@@ -169,10 +304,26 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
segmentation: m.segmentation,
|
segmentation: m.segmentation,
|
||||||
bbox: m.bbox,
|
bbox: m.bbox,
|
||||||
area: m.area,
|
area: m.area,
|
||||||
});
|
metadata: {
|
||||||
|
source: 'ai_segmentation',
|
||||||
|
promptBox: promptBox || null,
|
||||||
|
promptPointCount: points.length,
|
||||||
|
promptNegativePointCount: points.filter((point) => point.type === 'neg').length,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const previousAiMaskIds = new Set(aiMaskIds);
|
||||||
|
const generatedMaskIds = generatedMasks.map((mask) => mask.id);
|
||||||
|
setMasks([
|
||||||
|
...masks.filter((mask) => !previousAiMaskIds.has(mask.id)),
|
||||||
|
...generatedMasks,
|
||||||
|
]);
|
||||||
|
setAiMaskIds(generatedMaskIds);
|
||||||
if (generatedMaskIds.length > 0) {
|
if (generatedMaskIds.length > 0) {
|
||||||
setSelectedMaskIds(generatedMaskIds);
|
setSelectedMaskIds(generatedMaskIds);
|
||||||
|
} else {
|
||||||
|
setSelectedMaskIds(selectedMaskIds.filter((id) => !previousAiMaskIds.has(id)));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('AI inference failed:', err);
|
console.error('AI inference failed:', err);
|
||||||
@@ -181,21 +332,124 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsInferencing(false);
|
setIsInferencing(false);
|
||||||
}
|
}
|
||||||
}, [activeClass, activeTemplateId, addMask, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, points, semanticText, setSelectedMaskIds]);
|
}, [activeClass, activeTemplateId, aiMaskIds, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, modelCanInfer, points, promptBox, selectedMaskIds, selectedModelStatus?.label, selectedModelStatus?.message, setMasks, setSelectedMaskIds]);
|
||||||
|
|
||||||
|
const clearAiLayer = useCallback(() => {
|
||||||
|
setPoints([]);
|
||||||
|
setPromptBox(null);
|
||||||
|
setBoxStart(null);
|
||||||
|
setBoxCurrent(null);
|
||||||
|
if (aiMaskIds.length === 0) return;
|
||||||
|
const idsToRemove = new Set(aiMaskIds);
|
||||||
|
setMasks(masks.filter((mask) => !idsToRemove.has(mask.id)));
|
||||||
|
setSelectedMaskIds(selectedMaskIds.filter((id) => !idsToRemove.has(id)));
|
||||||
|
setAiMaskIds([]);
|
||||||
|
}, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]);
|
||||||
|
|
||||||
|
const deleteAiMasksById = useCallback((maskIds: string[]) => {
|
||||||
|
const aiIds = new Set(aiMaskIds);
|
||||||
|
const idsToRemove = new Set(maskIds.filter((id) => aiIds.has(id)));
|
||||||
|
if (idsToRemove.size === 0) return;
|
||||||
|
setMasks(masks.filter((mask) => !idsToRemove.has(mask.id)));
|
||||||
|
setAiMaskIds((currentIds) => currentIds.filter((id) => !idsToRemove.has(id)));
|
||||||
|
setSelectedMaskIds(selectedMaskIds.filter((id) => !idsToRemove.has(id)));
|
||||||
|
}, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]);
|
||||||
|
|
||||||
|
const deleteSelectedAiMasks = useCallback(() => {
|
||||||
|
deleteAiMasksById(selectedMaskIds);
|
||||||
|
}, [deleteAiMasksById, selectedMaskIds]);
|
||||||
|
|
||||||
|
const handleSendToWorkspace = useCallback(() => {
|
||||||
|
if (aiMasksToSend.length === 0) {
|
||||||
|
setInferenceMessage('请先执行分割并选择一个 AI 候选区域。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasMissingSemantic = aiMasksToSend.some((mask) => !mask.classId && !mask.className);
|
||||||
|
if (hasMissingSemantic) {
|
||||||
|
setInferenceMessage('');
|
||||||
|
setNotice({
|
||||||
|
id: Date.now(),
|
||||||
|
message: '请先在右侧语义分类树为 AI 候选区域选择语义分类,再推送至工作区。',
|
||||||
|
tone: 'error',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInferenceMessage('');
|
||||||
|
setActiveTool('edit_polygon');
|
||||||
|
onSendToWorkspace();
|
||||||
|
}, [aiMasksToSend, onSendToWorkspace, setActiveTool]);
|
||||||
|
|
||||||
|
const removePromptPoint = useCallback((pointIndex: number) => {
|
||||||
|
setPoints((currentPoints) => currentPoints.filter((_, index) => index !== pointIndex));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeLastPromptPoint = useCallback(() => {
|
||||||
|
setPoints((currentPoints) => currentPoints.slice(0, -1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addPromptPointFromEvent = useCallback((event: any) => {
|
||||||
|
if (effectiveTool !== 'point_pos' && effectiveTool !== 'point_neg') return false;
|
||||||
|
const stage = event.target?.getStage?.();
|
||||||
|
const pos = stage?.getRelativePointerPosition?.();
|
||||||
|
if (!pos) return false;
|
||||||
|
setPoints((currentPoints) => [
|
||||||
|
...currentPoints,
|
||||||
|
{ x: pos.x, y: pos.y, type: effectiveTool === 'point_pos' ? 'pos' : 'neg' },
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}, [effectiveTool]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const tagName = target?.tagName?.toLowerCase();
|
||||||
|
if (tagName === 'input' || tagName === 'textarea' || target?.isContentEditable) return;
|
||||||
|
if (event.key !== 'Delete' && event.key !== 'Backspace') return;
|
||||||
|
const selectedAiIds = selectedMaskIds.filter((id) => aiMaskIds.includes(id));
|
||||||
|
if (selectedAiIds.length === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
deleteAiMasksById(selectedAiIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [aiMaskIds, deleteAiMasksById, selectedMaskIds]);
|
||||||
|
|
||||||
|
const handleStageMouseDown = useCallback((event: any) => {
|
||||||
|
if (effectiveTool !== 'box_select') return;
|
||||||
|
const stage = event.target?.getStage?.();
|
||||||
|
const pos = stage?.getRelativePointerPosition?.();
|
||||||
|
if (!pos) return;
|
||||||
|
setBoxStart({ x: pos.x, y: pos.y });
|
||||||
|
setBoxCurrent({ x: pos.x, y: pos.y });
|
||||||
|
setInferenceMessage('');
|
||||||
|
}, [effectiveTool]);
|
||||||
|
|
||||||
|
const handleStageMouseUp = useCallback(() => {
|
||||||
|
if (effectiveTool !== 'box_select' || !boxStart || !boxCurrent) return;
|
||||||
|
const x1 = Math.min(boxStart.x, boxCurrent.x);
|
||||||
|
const y1 = Math.min(boxStart.y, boxCurrent.y);
|
||||||
|
const x2 = Math.max(boxStart.x, boxCurrent.x);
|
||||||
|
const y2 = Math.max(boxStart.y, boxCurrent.y);
|
||||||
|
if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) {
|
||||||
|
setPromptBox({ x1, y1, x2, y2 });
|
||||||
|
setPoints([]);
|
||||||
|
setInferenceMessage('已框选区域,可执行分割,或继续添加正/反向点细化。');
|
||||||
|
}
|
||||||
|
setBoxStart(null);
|
||||||
|
setBoxCurrent(null);
|
||||||
|
}, [boxCurrent, boxStart, effectiveTool]);
|
||||||
|
|
||||||
const handleStageClick = (e: any) => {
|
const handleStageClick = (e: any) => {
|
||||||
if (effectiveTool === 'move') return;
|
if (effectiveTool === 'move') return;
|
||||||
if (effectiveTool === 'point_pos' || effectiveTool === 'point_neg') {
|
if (effectiveTool === 'box_select') return;
|
||||||
const stage = e.target.getStage();
|
addPromptPointFromEvent(e);
|
||||||
const pos = stage.getRelativePointerPosition();
|
|
||||||
if (pos) {
|
|
||||||
setPoints([...points, { x: pos.x, y: pos.y, type: effectiveTool === 'point_pos' ? 'pos' : 'neg' }]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex bg-[#0a0a0a]">
|
<div className="w-full h-full flex bg-[#0a0a0a]">
|
||||||
|
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
|
||||||
{/* Left AI Controller Panel */}
|
{/* Left AI Controller Panel */}
|
||||||
<aside className="w-80 bg-[#0d0d0d] flex flex-col border-r border-white/5 shrink-0 z-10 overflow-hidden">
|
<aside className="w-80 bg-[#0d0d0d] flex flex-col border-r border-white/5 shrink-0 z-10 overflow-hidden">
|
||||||
<div className="h-16 border-b border-white/5 flex items-center px-6 shrink-0 justify-between">
|
<div className="h-16 border-b border-white/5 flex items-center px-6 shrink-0 justify-between">
|
||||||
@@ -206,24 +460,44 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-8">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-8">
|
||||||
{/* Model Select */}
|
{/* Model Status */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">视觉基础模型选型</h3>
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">视觉基础模型</h3>
|
||||||
<div className="bg-[#111] border border-white/5 grid grid-cols-2 gap-1 p-1 rounded-lg">
|
<div className="bg-[#111] border border-white/5 p-3 rounded-lg">
|
||||||
{(modelStatus?.models || [
|
<div className="flex items-center justify-between">
|
||||||
{ id: 'sam2' as const, label: 'SAM 2', available: true, message: '正在读取 SAM 2 状态' },
|
<span className="text-xs uppercase tracking-wider font-mono text-white">{selectedModelStatus?.label || 'SAM 2.1'}</span>
|
||||||
{ id: 'sam3' as const, label: 'SAM 3', available: false, message: '正在读取 SAM 3 状态' },
|
<span className={cn("text-xs", modelCanInfer ? "text-emerald-400" : "text-amber-400")}>
|
||||||
]).map((m) => (
|
{modelCanInfer ? '可用' : '不可用'}
|
||||||
<button
|
</span>
|
||||||
key={m.id}
|
</div>
|
||||||
className={cn("text-xs py-2 rounded-md transition-colors text-center uppercase tracking-wider font-mono", aiModel === m.id ? "bg-white/10 text-white font-medium shadow-sm" : "text-gray-500 hover:text-gray-300 hover:bg-white/5")}
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
onClick={() => setAiModel(m.id)}
|
{SAM2_MODEL_OPTIONS.map((option) => {
|
||||||
title={m.message}
|
const status = modelStatus?.models.find((model) => model.id === option.id);
|
||||||
>
|
const available = status?.available ?? false;
|
||||||
{m.label.replace(' ', '')}
|
const selected = aiModel === option.id;
|
||||||
<span className={cn("ml-1", m.available ? "text-emerald-400" : "text-amber-400")}>●</span>
|
return (
|
||||||
</button>
|
<button
|
||||||
))}
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (available) setAiModel(option.id);
|
||||||
|
}}
|
||||||
|
disabled={!available}
|
||||||
|
title={available ? option.label : status?.message || `${option.label} 不可用`}
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded border px-2 text-[10px] uppercase tracking-wider transition-colors flex items-center justify-between",
|
||||||
|
selected
|
||||||
|
? "bg-cyan-500/10 border-cyan-400/40 text-cyan-300"
|
||||||
|
: "bg-white/[0.03] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200",
|
||||||
|
!available && "opacity-45 cursor-not-allowed hover:bg-white/[0.03] hover:text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{option.shortLabel}</span>
|
||||||
|
<span className={cn("h-1.5 w-1.5 rounded-full", available ? "bg-emerald-400" : "bg-amber-400")} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[10px] text-gray-500 leading-relaxed">
|
<div className="mt-2 text-[10px] text-gray-500 leading-relaxed">
|
||||||
<div>{selectedModelStatus?.message || '正在读取模型状态...'}</div>
|
<div>{selectedModelStatus?.message || '正在读取模型状态...'}</div>
|
||||||
@@ -269,37 +543,41 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Semantic Description */}
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-3">
|
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">语义引导描述</h3>
|
|
||||||
<span className="text-[9px] bg-cyan-500/10 text-cyan-400 px-1.5 py-0.5 rounded border border-cyan-500/20 font-mono">零样本推理</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={semanticText}
|
|
||||||
onChange={e => setSemanticText(e.target.value)}
|
|
||||||
placeholder="例如:'分割出左侧车道上行驶的所有红色汽车'..."
|
|
||||||
className="w-full bg-[#111] border border-white/5 rounded-lg p-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all font-sans min-h-[100px] resize-none hover:border-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parameters */}
|
{/* Parameters */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-2">核心参数设定</h3>
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-2">核心参数设定</h3>
|
||||||
<div className="space-y-4 bg-[#111] rounded-lg p-5 border border-white/5">
|
<div className="space-y-4 bg-[#111] rounded-lg p-5 border border-white/5">
|
||||||
<div className="flex items-center justify-between cursor-pointer group" onClick={() => setCropMode(!cropMode)}>
|
<div className="flex items-center justify-between cursor-pointer group" onClick={() => setCropMode(!cropMode)}>
|
||||||
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors">自动裁剪无锚区域</span>
|
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors">局部专注模式(自动裁剪无锚区域)</span>
|
||||||
<button className={cn("w-8 h-4 rounded-full transition-colors relative", cropMode ? "bg-cyan-500" : "bg-white/20")}>
|
<button className={cn("w-8 h-4 rounded-full transition-colors relative", cropMode ? "bg-cyan-500" : "bg-white/20")}>
|
||||||
<div className={cn("absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform shadow-sm", cropMode ? "translate-x-4" : "")} />
|
<div className={cn("absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform shadow-sm", cropMode ? "translate-x-4" : "")} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between cursor-pointer group" onClick={() => setAutoDeleteBg(!autoDeleteBg)}>
|
<div className="flex items-center justify-between cursor-pointer group" onClick={() => setAutoDeleteBg(!autoDeleteBg)}>
|
||||||
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors">自动清理干涉点</span>
|
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors">严格除杂模式(自动清理干涉点)</span>
|
||||||
<button className={cn("w-8 h-4 rounded-full transition-colors relative", autoDeleteBg ? "bg-cyan-500" : "bg-white/20")}>
|
<button className={cn("w-8 h-4 rounded-full transition-colors relative", autoDeleteBg ? "bg-cyan-500" : "bg-white/20")}>
|
||||||
<div className={cn("absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform shadow-sm", autoDeleteBg ? "translate-x-4" : "")} />
|
<div className={cn("absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform shadow-sm", autoDeleteBg ? "translate-x-4" : "")} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="ai-mask-opacity" className="text-[11px] text-gray-400 uppercase tracking-wider font-medium">AI 遮罩透明度</label>
|
||||||
|
<span className="text-[10px] font-mono text-cyan-400">{maskPreviewOpacity}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="ai-mask-opacity"
|
||||||
|
aria-label="AI 遮罩透明度"
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={maskPreviewOpacity}
|
||||||
|
onChange={(event) => setMaskPreviewOpacity(Number(event.target.value))}
|
||||||
|
className="w-full accent-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,7 +594,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isInferencing ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
{isInferencing ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||||
{isInferencing ? '推理中...' : modelCanInfer ? '执行高精度语义分割' : '当前模型不可用'}
|
{isInferencing ? '推理中...' : modelCanInfer ? '执行高精度语义分割' : modelStatusLoaded ? '当前模型不可用' : '正在读取模型状态'}
|
||||||
</button>
|
</button>
|
||||||
{inferenceMessage && (
|
{inferenceMessage && (
|
||||||
<div className="rounded border border-white/10 bg-white/5 px-3 py-2 text-[11px] leading-relaxed text-gray-300">
|
<div className="rounded border border-white/10 bg-white/5 px-3 py-2 text-[11px] leading-relaxed text-gray-300">
|
||||||
@@ -324,10 +602,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleSendToWorkspace}
|
||||||
setActiveTool('edit_polygon');
|
title="AI 候选区域必须先选择语义分类,才能推送到工作区"
|
||||||
onSendToWorkspace();
|
|
||||||
}}
|
|
||||||
className="w-full py-3.5 rounded-lg flex items-center justify-center gap-2 transition-all font-medium tracking-wide text-xs uppercase bg-white/5 hover:bg-white/10 text-gray-300 border border-white/5 hover:border-white/10"
|
className="w-full py-3.5 rounded-lg flex items-center justify-center gap-2 transition-all font-medium tracking-wide text-xs uppercase bg-white/5 hover:bg-white/10 text-gray-300 border border-white/5 hover:border-white/10"
|
||||||
>
|
>
|
||||||
<SendToBack size={16} /> 推送至工作区编辑
|
<SendToBack size={16} /> 推送至工作区编辑
|
||||||
@@ -340,7 +616,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
<header className="h-16 border-b border-white/5 bg-[#111] flex items-center justify-between px-6 shrink-0">
|
<header className="h-16 border-b border-white/5 bg-[#111] flex items-center justify-between px-6 shrink-0">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h2 className="text-sm font-semibold tracking-wide text-white">模型端推理侧可视化 (Visualizer)</h2>
|
<h2 className="text-sm font-semibold tracking-wide text-white">模型端推理侧可视化 (Visualizer)</h2>
|
||||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest font-mono">{aiModel.toUpperCase()} 动态推理渲染</span>
|
<span className="text-[10px] text-gray-500 uppercase tracking-widest font-mono">SAM 2.1 动态推理渲染</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -360,28 +636,57 @@ 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">
|
<button
|
||||||
<ImageIcon size={14} /> 上传替换底图
|
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}
|
||||||
|
disabled={points.length === 0}
|
||||||
|
title="删除最近锚点"
|
||||||
|
aria-label="删除最近锚点"
|
||||||
|
>
|
||||||
|
<XCircle size={14} /> 删除最近锚点
|
||||||
</button>
|
</button>
|
||||||
<button className="text-xs text-gray-400 hover:text-white transition-colors px-3 py-1.5" onClick={() => { setPoints([]); clearMasks(); }}>
|
<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"
|
||||||
|
onClick={deleteSelectedAiMasks}
|
||||||
|
disabled={!selectedMaskIds.some((id) => aiMaskIds.includes(id))}
|
||||||
|
title="删除选中候选"
|
||||||
|
aria-label="删除选中候选"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 删除选中候选
|
||||||
|
</button>
|
||||||
|
<button className="text-xs text-gray-400 hover:text-white transition-colors px-3 py-1.5" onClick={clearAiLayer}>
|
||||||
清空全体锚点
|
清空全体锚点
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<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 ref={canvasContainerRef} 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={stageSize.width}
|
||||||
height={window.innerHeight - 64 - 64}
|
height={stageSize.height}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseDown={handleStageMouseDown}
|
||||||
|
onMouseUp={handleStageMouseUp}
|
||||||
onClick={handleStageClick}
|
onClick={handleStageClick}
|
||||||
scaleX={scale}
|
scaleX={scale}
|
||||||
scaleY={scale}
|
scaleY={scale}
|
||||||
x={position.x}
|
x={position.x}
|
||||||
y={position.y}
|
y={position.y}
|
||||||
draggable={effectiveTool === 'move'}
|
draggable={effectiveTool === 'move'}
|
||||||
|
onDragEnd={handleStageDragEnd}
|
||||||
>
|
>
|
||||||
<Layer>
|
<Layer>
|
||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
@@ -393,26 +698,54 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
opacity={0.8}
|
opacity={0.8}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{boxRect && (
|
||||||
|
<Rect
|
||||||
|
x={boxRect.x}
|
||||||
|
y={boxRect.y}
|
||||||
|
width={boxRect.width}
|
||||||
|
height={boxRect.height}
|
||||||
|
fill="rgba(59, 130, 246, 0.12)"
|
||||||
|
stroke="#60a5fa"
|
||||||
|
strokeWidth={2 / scale}
|
||||||
|
dash={[5 / scale, 5 / scale]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AI Returned Masks */}
|
{/* AI Returned Masks */}
|
||||||
{frameMasks.map((mask) => (
|
{frameMasks.map((mask) => {
|
||||||
<Group key={mask.id} opacity={selectedMaskIds.includes(mask.id) ? 0.72 : 0.45}>
|
const isSelected = selectedMaskIds.includes(mask.id);
|
||||||
|
const baseOpacity = Math.min(Math.max(maskPreviewOpacity / 100, 0.1), 1);
|
||||||
|
const previewOpacity = isSelected
|
||||||
|
? baseOpacity
|
||||||
|
: Math.max(0.12, baseOpacity * 0.62);
|
||||||
|
return (
|
||||||
|
<Group key={mask.id} opacity={previewOpacity}>
|
||||||
<Path
|
<Path
|
||||||
data={mask.pathData}
|
data={mask.pathData}
|
||||||
fill={mask.color}
|
fill={mask.color}
|
||||||
stroke={mask.color}
|
stroke={mask.color}
|
||||||
strokeWidth={(selectedMaskIds.includes(mask.id) ? 2.5 : 1) / scale}
|
strokeWidth={(isSelected ? 2.5 : 1) / scale}
|
||||||
onClick={(event: any) => {
|
onClick={(event: any) => {
|
||||||
|
if (addPromptPointFromEvent(event)) {
|
||||||
|
event.cancelBubble = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.cancelBubble = true;
|
event.cancelBubble = true;
|
||||||
setSelectedMaskIds([mask.id]);
|
setSelectedMaskIds([mask.id]);
|
||||||
}}
|
}}
|
||||||
onTap={(event: any) => {
|
onTap={(event: any) => {
|
||||||
|
if (addPromptPointFromEvent(event)) {
|
||||||
|
event.cancelBubble = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.cancelBubble = true;
|
event.cancelBubble = true;
|
||||||
setSelectedMaskIds([mask.id]);
|
setSelectedMaskIds([mask.id]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Points */}
|
{/* Points */}
|
||||||
{points.map((p, i) => (
|
{points.map((p, i) => (
|
||||||
@@ -424,10 +757,26 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
|||||||
strokeWidth={2 / scale}
|
strokeWidth={2 / scale}
|
||||||
shadowColor="black"
|
shadowColor="black"
|
||||||
shadowBlur={4}
|
shadowBlur={4}
|
||||||
|
onClick={(event: any) => {
|
||||||
|
event.cancelBubble = true;
|
||||||
|
removePromptPoint(i);
|
||||||
|
}}
|
||||||
|
onTap={(event: any) => {
|
||||||
|
event.cancelBubble = true;
|
||||||
|
removePromptPoint(i);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Circle
|
<Circle
|
||||||
radius={1.5 / scale}
|
radius={1.5 / scale}
|
||||||
fill="#ffffff"
|
fill="#ffffff"
|
||||||
|
onClick={(event: any) => {
|
||||||
|
event.cancelBubble = true;
|
||||||
|
removePromptPoint(i);
|
||||||
|
}}
|
||||||
|
onTap={(event: any) => {
|
||||||
|
event.cancelBubble = true;
|
||||||
|
removePromptPoint(i);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
25
src/components/AiAutoInferenceIcon.tsx
Normal file
25
src/components/AiAutoInferenceIcon.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrainCircuit, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AiAutoInferenceIconProps {
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiAutoInferenceIcon({ size = 20, strokeWidth = 2 }: AiAutoInferenceIconProps) {
|
||||||
|
const sparkleSize = Math.max(8, Math.round(size * 0.42));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="ai-auto-inference-icon"
|
||||||
|
className="relative inline-flex items-center justify-center"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<BrainCircuit size={size} strokeWidth={strokeWidth} />
|
||||||
|
<Sparkles
|
||||||
|
size={sparkleSize}
|
||||||
|
strokeWidth={Math.max(strokeWidth, 2.1)}
|
||||||
|
className="absolute -right-1 -top-1 text-emerald-200 drop-shadow-[0_0_4px_rgba(110,231,183,0.75)]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/AiSegmentationIcon.tsx
Normal file
25
src/components/AiSegmentationIcon.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Bot, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AiSegmentationIconProps {
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiSegmentationIcon({ size = 20, strokeWidth = 2 }: AiSegmentationIconProps) {
|
||||||
|
const sparkleSize = Math.max(9, Math.round(size * 0.48));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="ai-segmentation-icon"
|
||||||
|
className="relative inline-flex items-center justify-center"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<Bot size={size} strokeWidth={strokeWidth} />
|
||||||
|
<Sparkles
|
||||||
|
size={sparkleSize}
|
||||||
|
strokeWidth={Math.max(strokeWidth, 2.2)}
|
||||||
|
className="absolute -right-1 -top-1 text-cyan-300 drop-shadow-[0_0_4px_rgba(34,211,238,0.75)]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -94,9 +94,46 @@ describe('Dashboard', () => {
|
|||||||
expect(screen.getByText('已存标注')).toBeInTheDocument();
|
expect(screen.getByText('已存标注')).toBeInTheDocument();
|
||||||
expect(screen.getByText('真实项目.mp4')).toBeInTheDocument();
|
expect(screen.getByText('真实项目.mp4')).toBeInTheDocument();
|
||||||
expect(screen.getByText('项目状态: pending')).toBeInTheDocument();
|
expect(screen.getByText('项目状态: pending')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('系统全局数据监控')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('City_Driving_Dataset_004.mp4')).not.toBeInTheDocument();
|
expect(screen.queryByText('City_Driving_Dataset_004.mp4')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a recently completed task visible in the progress panel', async () => {
|
||||||
|
apiMock.getDashboardOverview.mockResolvedValueOnce({
|
||||||
|
summary: {
|
||||||
|
project_count: 1,
|
||||||
|
parsing_task_count: 0,
|
||||||
|
annotation_count: 0,
|
||||||
|
frame_count: 120,
|
||||||
|
template_count: 1,
|
||||||
|
system_load_percent: 8,
|
||||||
|
},
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-20',
|
||||||
|
task_id: 20,
|
||||||
|
project_id: 1,
|
||||||
|
name: 'completed.mp4',
|
||||||
|
progress: 100,
|
||||||
|
status: '解析完成',
|
||||||
|
raw_status: 'success',
|
||||||
|
error: null,
|
||||||
|
frame_count: 120,
|
||||||
|
updated_at: '2026-05-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activity: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('任务进度 (当前 / 最近)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('completed.mp4')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('解析完成')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/当前无处理任务/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('connects to the progress stream and updates progress tasks', async () => {
|
it('connects to the progress stream and updates progress tasks', async () => {
|
||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function Dashboard() {
|
|||||||
status: task.message || task.status,
|
status: task.message || task.status,
|
||||||
raw_status: task.status,
|
raw_status: task.status,
|
||||||
error: task.error,
|
error: task.error,
|
||||||
frame_count: Number(task.result?.frames_extracted || 0),
|
frame_count: Number(task.result?.frames_extracted || task.result?.processed_frame_count || 0),
|
||||||
updated_at: task.updated_at,
|
updated_at: task.updated_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,6 +188,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
clearTimeout(timer);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
unsubscribeStatus();
|
unsubscribeStatus();
|
||||||
clearInterval(checkConnection);
|
clearInterval(checkConnection);
|
||||||
@@ -288,7 +289,7 @@ export function Dashboard() {
|
|||||||
{isConnected ? 'WebSocket 已连接' : 'WebSocket 断开'}
|
{isConnected ? 'WebSocket 已连接' : 'WebSocket 断开'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-sm mt-1">系统全局数据吞吐状态与所有接入项目进度实时洞察驾驶舱。</p>
|
<p className="text-gray-400 text-sm mt-1">系统全局数据监控</p>
|
||||||
{loadError && <p className="text-red-400 text-xs mt-2">{loadError}</p>}
|
{loadError && <p className="text-red-400 text-xs mt-2">{loadError}</p>}
|
||||||
{taskActionMessage && <p className="text-amber-400 text-xs mt-2">{taskActionMessage}</p>}
|
{taskActionMessage && <p className="text-amber-400 text-xs mt-2">{taskActionMessage}</p>}
|
||||||
</header>
|
</header>
|
||||||
@@ -312,7 +313,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 bg-[#111] border border-white/5 rounded-xl p-6 min-h-[400px]">
|
<div className="lg:col-span-2 bg-[#111] border border-white/5 rounded-xl p-6 min-h-[400px]">
|
||||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6">解析队列 (后台任务)</h2>
|
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6">任务进度 (当前 / 最近)</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="text-sm text-gray-500 text-center py-12">正在读取后端 Dashboard 数据...</div>
|
<div className="text-sm text-gray-500 text-center py-12">正在读取后端 Dashboard 数据...</div>
|
||||||
@@ -371,7 +372,7 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!isLoading && tasks.length === 0 && (
|
{!isLoading && tasks.length === 0 && (
|
||||||
<div className="text-sm text-gray-500 text-center py-12">当前无处理任务</div>
|
<div className="text-sm text-gray-500 text-center py-12">当前无处理任务;生成帧或传播任务开始后会在这里显示进度。</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,348 @@ describe('FrameTimeline', () => {
|
|||||||
|
|
||||||
expect(screen.getAllByText('00:00.10').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('00:00.10').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('00:00.20').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('00:00.20').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText('底层时序视频图层截帧导航轴')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a processing progress bar with red annotation markers and blue propagation segments', () => {
|
||||||
|
useStore.setState({
|
||||||
|
currentFrameIndex: 1,
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
masks: [
|
||||||
|
{ 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',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||||
|
},
|
||||||
|
{ id: 'outside', frameId: 'other-frame', pathData: 'M 0 0 Z', label: 'Other', color: '#fff' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FrameTimeline />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('视频处理进度条')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('人工/AI 1 帧 · 自动传播 1 帧')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('current-frame-line')).toHaveStyle({ left: '50%' });
|
||||||
|
expect(screen.getByTestId('current-frame-line').className).toContain('bg-white');
|
||||||
|
expect(screen.getAllByTestId('propagated-frame-segment')).toHaveLength(1);
|
||||||
|
expect(screen.getByTestId('propagated-frame-segment').className).toContain('bg-blue-500');
|
||||||
|
expect(screen.getAllByTestId('annotated-frame-marker')).toHaveLength(1);
|
||||||
|
expect(screen.getByTestId('annotated-frame-marker').className).toContain('bg-red-500');
|
||||||
|
expect(screen.queryByLabelText('跳转到已编辑帧 3')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders propagation history with newest bright and old records capped to one blue threshold', () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f6', projectId: 'p1', index: 5, url: '/6.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f7', projectId: 'p1', index: 6, url: '/7.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
masks: Array.from({ length: 7 }, (_, index) => ({
|
||||||
|
id: `tracked-${index + 1}`,
|
||||||
|
frameId: `f${index + 1}`,
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#3b82f6',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FrameTimeline
|
||||||
|
propagationHistory={[
|
||||||
|
{ id: 'history-1', startFrame: 1, endFrame: 1, colorIndex: 0, label: '第一次传播' },
|
||||||
|
{ id: 'history-2', startFrame: 2, endFrame: 2, colorIndex: 1, label: '第二次传播' },
|
||||||
|
{ id: 'history-3', startFrame: 3, endFrame: 3, colorIndex: 2, label: '第三次传播' },
|
||||||
|
{ id: 'history-4', startFrame: 4, endFrame: 4, colorIndex: 3, label: '第四次传播' },
|
||||||
|
{ id: 'history-5', startFrame: 5, endFrame: 5, colorIndex: 4, label: '第五次传播' },
|
||||||
|
{ id: 'history-6', startFrame: 6, endFrame: 6, colorIndex: 5, label: '第六次传播' },
|
||||||
|
{ id: 'history-7', startFrame: 7, endFrame: 7, colorIndex: 6, label: '第七次传播' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const segments = screen.getAllByTestId('propagation-history-segment');
|
||||||
|
expect(segments).toHaveLength(7);
|
||||||
|
expect(segments[0]).toHaveAttribute('title', '第一次传播');
|
||||||
|
expect(segments[0]).toHaveStyle({ left: '0%' });
|
||||||
|
expect(segments[0]).toHaveAttribute('data-recency-level', '4');
|
||||||
|
expect(segments[1]).toHaveAttribute('data-recency-level', '4');
|
||||||
|
expect(segments[2]).toHaveAttribute('data-recency-level', '4');
|
||||||
|
expect(segments[3]).toHaveAttribute('data-recency-level', '3');
|
||||||
|
expect(segments[4]).toHaveAttribute('data-recency-level', '2');
|
||||||
|
expect(segments[5]).toHaveAttribute('data-recency-level', '1');
|
||||||
|
expect(segments[6]).toHaveAttribute('data-recency-level', '0');
|
||||||
|
const oldestStyle = segments[0].getAttribute('style') || '';
|
||||||
|
const newestStyle = segments[6].getAttribute('style') || '';
|
||||||
|
expect(oldestStyle).not.toContain('linear-gradient');
|
||||||
|
expect(newestStyle).not.toContain('linear-gradient');
|
||||||
|
expect(segments[0].style.backgroundColor).toBe(segments[1].style.backgroundColor);
|
||||||
|
expect(segments[6].style.backgroundColor).not.toBe(segments[0].style.backgroundColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not color propagation history frames after all masks on those frames are gone', () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
masks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FrameTimeline
|
||||||
|
propagationHistory={[
|
||||||
|
{ id: 'history-empty', startFrame: 1, endFrame: 3, colorIndex: 0, label: '已删除传播' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('propagation-history-segment')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('propagated-frame-segment')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('annotated-frame-marker')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('人工/AI 0 帧 · 自动传播 0 帧')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits propagation history around frames that no longer have propagated masks', () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'tracked-2',
|
||||||
|
frameId: 'f2',
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#3b82f6',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tracked-4',
|
||||||
|
frameId: 'f4',
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#3b82f6',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FrameTimeline
|
||||||
|
propagationHistory={[
|
||||||
|
{ id: 'history-sparse', startFrame: 1, endFrame: 5, colorIndex: 0, label: '稀疏传播' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const segments = screen.getAllByTestId('propagation-history-segment');
|
||||||
|
expect(segments).toHaveLength(2);
|
||||||
|
expect(segments[0]).toHaveStyle({ left: '20%', width: '20%' });
|
||||||
|
expect(segments[1]).toHaveStyle({ left: '60%', width: '20%' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps from the processing progress bar and frame status markers', () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
masks: [
|
||||||
|
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#ef4444' },
|
||||||
|
{
|
||||||
|
id: 'm2',
|
||||||
|
frameId: 'f4',
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#3b82f6',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FrameTimeline />);
|
||||||
|
|
||||||
|
const processingBar = screen.getByLabelText('视频处理进度条');
|
||||||
|
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
left: 0,
|
||||||
|
right: 100,
|
||||||
|
top: 0,
|
||||||
|
bottom: 10,
|
||||||
|
width: 100,
|
||||||
|
height: 10,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
fireEvent.pointerDown(processingBar, { clientX: 50, pointerId: 1 });
|
||||||
|
expect(useStore.getState().currentFrameIndex).toBe(2);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '跳转到人工/AI 标注帧 2' }));
|
||||||
|
expect(useStore.getState().currentFrameIndex).toBe(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '跳转到自动传播帧 4' }));
|
||||||
|
expect(useStore.getState().currentFrameIndex).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the propagation range overlay until range selection is active', () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FrameTimeline propagationRange={{ startFrame: 1, endFrame: 3 }} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('propagation-range-overlay')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses red thumbnail borders for manual or AI frames and blue for propagated frames', () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
masks: [
|
||||||
|
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#ef4444' },
|
||||||
|
{
|
||||||
|
id: 'm2',
|
||||||
|
frameId: 'f3',
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#3b82f6',
|
||||||
|
metadata: { propagated_from_frame_id: 'f1' },
|
||||||
|
},
|
||||||
|
{ id: 'm3', frameId: 'f4', pathData: 'M 1 1 Z', label: 'Manual', color: '#ef4444' },
|
||||||
|
{
|
||||||
|
id: 'm4',
|
||||||
|
frameId: 'f4',
|
||||||
|
pathData: 'M 2 2 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#3b82f6',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm5',
|
||||||
|
frameId: 'f5',
|
||||||
|
pathData: 'M 3 3 Z',
|
||||||
|
label: 'Tracked after smoothing',
|
||||||
|
color: '#3b82f6',
|
||||||
|
metadata: { source_annotation_id: 7, source_mask_id: 'annotation-7' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FrameTimeline />);
|
||||||
|
|
||||||
|
expect(screen.getByAltText('frame-0').closest('div')?.className).toContain('border-cyan-500');
|
||||||
|
expect(screen.getByAltText('frame-1').closest('div')?.className).toContain('border-red-500');
|
||||||
|
expect(screen.getByAltText('frame-2').closest('div')?.className).toContain('border-blue-500');
|
||||||
|
const manuallyAdjustedPropagatedTile = screen.getByAltText('frame-3').closest('div');
|
||||||
|
expect(manuallyAdjustedPropagatedTile?.className).toContain('border-red-500');
|
||||||
|
expect(manuallyAdjustedPropagatedTile?.className).toContain('inset_0_0_0_2px_rgba(59,130,246,0.85)');
|
||||||
|
expect(screen.getByAltText('frame-4').closest('div')?.className).toContain('border-blue-500');
|
||||||
|
expect(screen.getByAltText('frame-4').closest('div')?.className).not.toContain('border-red-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the current frame blue border while showing an inner red ring for annotated frames', () => {
|
||||||
|
useStore.setState({
|
||||||
|
currentFrameIndex: 1,
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
masks: [
|
||||||
|
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#ef4444' },
|
||||||
|
{
|
||||||
|
id: 'm2',
|
||||||
|
frameId: 'f2',
|
||||||
|
pathData: 'M 1 1 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#3b82f6',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FrameTimeline />);
|
||||||
|
|
||||||
|
const currentAnnotatedTile = screen.getByAltText('frame-1').closest('div');
|
||||||
|
expect(currentAnnotatedTile?.className).toContain('border-cyan-500');
|
||||||
|
expect(currentAnnotatedTile?.className).toContain('inset_0_0_0_2px_rgba(239,68,68,0.95)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects a propagation range from the playback and processing progress bars', () => {
|
||||||
|
const onPropagationRangeChange = vi.fn();
|
||||||
|
useStore.setState({
|
||||||
|
frames: [
|
||||||
|
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||||
|
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FrameTimeline
|
||||||
|
propagationRange={{ startFrame: 2, endFrame: 4 }}
|
||||||
|
propagationRangeSelectionActive
|
||||||
|
onPropagationRangeChange={onPropagationRangeChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const playbackBar = screen.getByTestId('playback-progress-bar');
|
||||||
|
vi.spyOn(playbackBar, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
left: 0,
|
||||||
|
right: 100,
|
||||||
|
top: 0,
|
||||||
|
bottom: 10,
|
||||||
|
width: 100,
|
||||||
|
height: 10,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
fireEvent.pointerDown(playbackBar, { clientX: 25, pointerId: 1 });
|
||||||
|
fireEvent.pointerMove(playbackBar, { clientX: 75, pointerId: 1 });
|
||||||
|
fireEvent.pointerUp(playbackBar, { clientX: 75, pointerId: 1 });
|
||||||
|
|
||||||
|
expect(onPropagationRangeChange).toHaveBeenLastCalledWith(2, 4);
|
||||||
|
expect(screen.getAllByTestId('propagation-range-overlay')).toHaveLength(2);
|
||||||
|
const boundaryLines = screen.getAllByTestId('range-boundary-line');
|
||||||
|
expect(boundaryLines).toHaveLength(2);
|
||||||
|
expect(boundaryLines[0]).toHaveStyle({ left: '25%' });
|
||||||
|
expect(boundaryLines[0].className).toContain('bg-fuchsia-400');
|
||||||
|
expect(boundaryLines[1]).toHaveStyle({ left: '75%' });
|
||||||
|
expect(boundaryLines[1].className).toContain('bg-lime-300');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('changes frames with left and right arrow keys without leaving bounds', () => {
|
it('changes frames with left and right arrow keys without leaving bounds', () => {
|
||||||
|
|||||||
@@ -3,12 +3,37 @@ import { Play, Pause } from 'lucide-react';
|
|||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
|
|
||||||
export function FrameTimeline() {
|
interface FrameTimelineProps {
|
||||||
|
propagationRange?: {
|
||||||
|
startFrame: number;
|
||||||
|
endFrame: number;
|
||||||
|
};
|
||||||
|
propagationHistory?: Array<{
|
||||||
|
id: string;
|
||||||
|
startFrame: number;
|
||||||
|
endFrame: number;
|
||||||
|
colorIndex: number;
|
||||||
|
label?: string;
|
||||||
|
}>;
|
||||||
|
propagationRangeSelectionActive?: boolean;
|
||||||
|
propagationRangeDisabled?: boolean;
|
||||||
|
onPropagationRangeChange?: (startFrame: number, endFrame: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FrameTimeline({
|
||||||
|
propagationRange,
|
||||||
|
propagationHistory = [],
|
||||||
|
propagationRangeSelectionActive = false,
|
||||||
|
propagationRangeDisabled = false,
|
||||||
|
onPropagationRangeChange,
|
||||||
|
}: FrameTimelineProps = {}) {
|
||||||
const frames = useStore((state) => state.frames);
|
const frames = useStore((state) => state.frames);
|
||||||
const currentProject = useStore((state) => state.currentProject);
|
const currentProject = useStore((state) => state.currentProject);
|
||||||
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
||||||
|
const masks = useStore((state) => state.masks);
|
||||||
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
|
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [rangeDragAnchorFrame, setRangeDragAnchorFrame] = useState<number | null>(null);
|
||||||
|
|
||||||
const totalFrames = frames.length;
|
const totalFrames = frames.length;
|
||||||
const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0;
|
const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0;
|
||||||
@@ -22,6 +47,51 @@ 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 isPropagatedMask = (mask: (typeof masks)[number]) => {
|
||||||
|
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source.toLowerCase() : '';
|
||||||
|
return source.includes('propagat')
|
||||||
|
|| mask.metadata?.propagated_from_frame_id !== undefined
|
||||||
|
|| mask.metadata?.source_annotation_id !== undefined
|
||||||
|
|| mask.metadata?.source_mask_id !== undefined
|
||||||
|
|| mask.metadata?.propagation_seed_key !== undefined
|
||||||
|
|| mask.metadata?.propagation_seed_signature !== undefined;
|
||||||
|
};
|
||||||
|
const propagatedFrameMarkers = useMemo(() => {
|
||||||
|
const frameIds = new Set(frames.map((frame) => frame.id));
|
||||||
|
const propagatedIds = new Set(
|
||||||
|
masks
|
||||||
|
.filter((mask) => frameIds.has(mask.frameId))
|
||||||
|
.filter(isPropagatedMask)
|
||||||
|
.map((mask) => mask.frameId),
|
||||||
|
);
|
||||||
|
return frames
|
||||||
|
.map((frame, index) => ({ frame, index }))
|
||||||
|
.filter(({ frame }) => propagatedIds.has(frame.id));
|
||||||
|
}, [frames, masks]);
|
||||||
|
const propagatedFrameIds = useMemo(
|
||||||
|
() => new Set(propagatedFrameMarkers.map(({ frame }) => frame.id)),
|
||||||
|
[propagatedFrameMarkers],
|
||||||
|
);
|
||||||
|
const propagatedFrameNumbers = useMemo(
|
||||||
|
() => new Set(propagatedFrameMarkers.map(({ index }) => index + 1)),
|
||||||
|
[propagatedFrameMarkers],
|
||||||
|
);
|
||||||
|
const annotatedFrameMarkers = useMemo(() => {
|
||||||
|
const frameIds = new Set(frames.map((frame) => frame.id));
|
||||||
|
const annotatedIds = new Set(
|
||||||
|
masks
|
||||||
|
.filter((mask) => frameIds.has(mask.frameId))
|
||||||
|
.filter((mask) => !isPropagatedMask(mask))
|
||||||
|
.map((mask) => mask.frameId),
|
||||||
|
);
|
||||||
|
return frames
|
||||||
|
.map((frame, index) => ({ frame, index }))
|
||||||
|
.filter(({ frame }) => annotatedIds.has(frame.id));
|
||||||
|
}, [frames, masks]);
|
||||||
|
const annotatedFrameIds = useMemo(
|
||||||
|
() => new Set(annotatedFrameMarkers.map(({ frame }) => frame.id)),
|
||||||
|
[annotatedFrameMarkers],
|
||||||
|
);
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
const safeSeconds = Math.max(0, seconds);
|
const safeSeconds = Math.max(0, seconds);
|
||||||
@@ -31,6 +101,137 @@ export function FrameTimeline() {
|
|||||||
return `${minutes.toString().padStart(2, '0')}:${wholeSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
return `${minutes.toString().padStart(2, '0')}:${wholeSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clampFrame = (frame: number) => Math.min(Math.max(frame, 1), Math.max(totalFrames, 1));
|
||||||
|
const normalizeRange = (startFrame: number, endFrame: number) => ({
|
||||||
|
startFrame: Math.min(clampFrame(startFrame), clampFrame(endFrame)),
|
||||||
|
endFrame: Math.max(clampFrame(startFrame), clampFrame(endFrame)),
|
||||||
|
});
|
||||||
|
const selectedRange = propagationRange
|
||||||
|
? normalizeRange(propagationRange.startFrame, propagationRange.endFrame)
|
||||||
|
: null;
|
||||||
|
const visibleSelectedRange = propagationRangeSelectionActive ? selectedRange : null;
|
||||||
|
const rangeLeft = visibleSelectedRange && totalFrames > 0 ? ((visibleSelectedRange.startFrame - 1) / totalFrames) * 100 : 0;
|
||||||
|
const rangeWidth = visibleSelectedRange && totalFrames > 0
|
||||||
|
? ((visibleSelectedRange.endFrame - visibleSelectedRange.startFrame + 1) / totalFrames) * 100
|
||||||
|
: 0;
|
||||||
|
const frameLineLeft = (frame: number) => {
|
||||||
|
if (totalFrames <= 1) return 0;
|
||||||
|
return ((clampFrame(frame) - 1) / (totalFrames - 1)) * 100;
|
||||||
|
};
|
||||||
|
const currentFrameLineLeft = totalFrames > 0 ? frameLineLeft(currentFrame) : 0;
|
||||||
|
const rangeStartLineLeft = visibleSelectedRange ? frameLineLeft(visibleSelectedRange.startFrame) : 0;
|
||||||
|
const rangeEndLineLeft = visibleSelectedRange ? frameLineLeft(visibleSelectedRange.endFrame) : 0;
|
||||||
|
const propagationHistoryColor = (ageFromNewest: number) => {
|
||||||
|
const step = Math.min(Math.max(ageFromNewest, 0), 4);
|
||||||
|
const lightness = 58 - step * 7;
|
||||||
|
const alpha = 0.88 - step * 0.085;
|
||||||
|
return {
|
||||||
|
fill: `hsla(212, 88%, ${lightness}%, ${Math.max(alpha, 0.52)})`,
|
||||||
|
glow: `hsla(212, 88%, ${Math.min(lightness + 10, 76)}%, ${0.38 - step * 0.045})`,
|
||||||
|
border: `hsla(212, 90%, ${Math.min(lightness + 18, 84)}%, ${0.72 - step * 0.045})`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const visiblePropagationHistory = useMemo(() => (
|
||||||
|
propagationHistory
|
||||||
|
.flatMap((segment, order) => {
|
||||||
|
const range = normalizeRange(segment.startFrame, segment.endFrame);
|
||||||
|
const ageFromNewest = Math.min(Math.max(propagationHistory.length - 1 - order, 0), 4);
|
||||||
|
const chunks: Array<typeof segment & { startFrame: number; endFrame: number; order: number; ageFromNewest: number }> = [];
|
||||||
|
let chunkStart: number | null = null;
|
||||||
|
for (let frameNumber = range.startFrame; frameNumber <= range.endFrame; frameNumber += 1) {
|
||||||
|
if (propagatedFrameNumbers.has(frameNumber)) {
|
||||||
|
chunkStart ??= frameNumber;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunkStart !== null) {
|
||||||
|
chunks.push({
|
||||||
|
...segment,
|
||||||
|
id: chunkStart === range.startFrame && frameNumber - 1 === range.endFrame
|
||||||
|
? segment.id
|
||||||
|
: `${segment.id}-${chunkStart}-${frameNumber - 1}`,
|
||||||
|
startFrame: chunkStart,
|
||||||
|
endFrame: frameNumber - 1,
|
||||||
|
order,
|
||||||
|
ageFromNewest,
|
||||||
|
});
|
||||||
|
chunkStart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunkStart !== null) {
|
||||||
|
chunks.push({
|
||||||
|
...segment,
|
||||||
|
id: chunkStart === range.startFrame ? segment.id : `${segment.id}-${chunkStart}-${range.endFrame}`,
|
||||||
|
startFrame: chunkStart,
|
||||||
|
endFrame: range.endFrame,
|
||||||
|
order,
|
||||||
|
ageFromNewest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
})
|
||||||
|
.filter((segment) => totalFrames > 0 && segment.endFrame >= 1 && segment.startFrame <= totalFrames)
|
||||||
|
), [propagatedFrameNumbers, propagationHistory, totalFrames]);
|
||||||
|
|
||||||
|
const frameFromPointerEvent = (event: React.PointerEvent<HTMLElement>) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0;
|
||||||
|
return clampFrame(Math.round(Math.min(Math.max(ratio, 0), 1) * Math.max(totalFrames - 1, 0)) + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const jumpToFrame = (frame: number) => {
|
||||||
|
if (totalFrames === 0) return;
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentFrame(clampFrame(frame) - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePropagationRangeFromPointer = (
|
||||||
|
event: React.PointerEvent<HTMLElement>,
|
||||||
|
anchorFrame = rangeDragAnchorFrame,
|
||||||
|
) => {
|
||||||
|
if (!propagationRangeSelectionActive || propagationRangeDisabled || totalFrames === 0 || !onPropagationRangeChange) return;
|
||||||
|
const frame = frameFromPointerEvent(event);
|
||||||
|
const startFrame = anchorFrame ?? frame;
|
||||||
|
const nextRange = normalizeRange(startFrame, frame);
|
||||||
|
onPropagationRangeChange(nextRange.startFrame, nextRange.endFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRangePointerDown = (event: React.PointerEvent<HTMLElement>) => {
|
||||||
|
if (!propagationRangeSelectionActive || propagationRangeDisabled || totalFrames === 0 || !onPropagationRangeChange) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setIsPlaying(false);
|
||||||
|
const frame = frameFromPointerEvent(event);
|
||||||
|
setRangeDragAnchorFrame(frame);
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
onPropagationRangeChange(frame, frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcessingBarPointerDown = (event: React.PointerEvent<HTMLElement>) => {
|
||||||
|
if (propagationRangeSelectionActive) {
|
||||||
|
handleRangePointerDown(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (totalFrames === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
jumpToFrame(frameFromPointerEvent(event));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFrameMarkerClick = (event: React.MouseEvent<HTMLElement>, frame: number) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (propagationRangeSelectionActive) return;
|
||||||
|
jumpToFrame(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRangePointerMove = (event: React.PointerEvent<HTMLElement>) => {
|
||||||
|
if (rangeDragAnchorFrame === null) return;
|
||||||
|
updatePropagationRangeFromPointer(event, rangeDragAnchorFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRangePointerUp = (event: React.PointerEvent<HTMLElement>) => {
|
||||||
|
if (rangeDragAnchorFrame === null) return;
|
||||||
|
updatePropagationRangeFromPointer(event, rangeDragAnchorFrame);
|
||||||
|
setRangeDragAnchorFrame(null);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying || totalFrames <= 1) return;
|
if (!isPlaying || totalFrames <= 1) return;
|
||||||
|
|
||||||
@@ -83,32 +284,48 @@ export function FrameTimeline() {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-32 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
|
<div className="h-36 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
|
||||||
<div className="h-4 bg-[#0d0d0d] flex items-center group relative">
|
<div className="h-12 bg-[#0d0d0d] flex flex-col justify-center group relative">
|
||||||
<div className="absolute left-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
|
<div className="absolute left-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
|
||||||
{formatTime(currentSeconds)}
|
{formatTime(currentSeconds)}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
|
<div className="absolute right-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
|
||||||
{formatTime(totalSeconds)}
|
{formatTime(totalSeconds)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="1"
|
min="1"
|
||||||
max={Math.max(totalFrames, 1)}
|
max={Math.max(totalFrames, 1)}
|
||||||
value={currentFrame}
|
value={currentFrame}
|
||||||
onChange={(e) => setCurrentFrame(parseInt(e.target.value) - 1)}
|
onChange={(e) => setCurrentFrame(parseInt(e.target.value) - 1)}
|
||||||
className="w-full absolute inset-0 opacity-0 cursor-ew-resize z-20"
|
className={cn(
|
||||||
|
"w-full absolute left-0 right-0 top-0 h-7 opacity-0 cursor-ew-resize z-20",
|
||||||
|
propagationRangeSelectionActive && "pointer-events-none",
|
||||||
|
)}
|
||||||
disabled={totalFrames === 0}
|
disabled={totalFrames === 0}
|
||||||
/>
|
/>
|
||||||
<div className="h-1 bg-white/10 w-full relative group-hover:h-2 transition-all">
|
<div
|
||||||
|
data-testid="playback-progress-bar"
|
||||||
|
className={cn(
|
||||||
|
"h-1 bg-white/10 w-full relative group-hover:h-2 transition-all",
|
||||||
|
propagationRangeSelectionActive && !propagationRangeDisabled && "cursor-crosshair",
|
||||||
|
)}
|
||||||
|
onPointerDown={handleRangePointerDown}
|
||||||
|
onPointerMove={handleRangePointerMove}
|
||||||
|
onPointerUp={handleRangePointerUp}
|
||||||
|
onPointerCancel={() => setRangeDragAnchorFrame(null)}
|
||||||
|
>
|
||||||
|
{visibleSelectedRange && (
|
||||||
|
<div
|
||||||
|
data-testid="propagation-range-overlay"
|
||||||
|
className="absolute inset-y-0 z-10 rounded-sm border border-amber-300/80 bg-amber-300/30 shadow-[0_0_12px_rgba(251,191,36,0.45)]"
|
||||||
|
style={{ left: `${rangeLeft}%`, width: `${rangeWidth}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
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}%` }}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
className="w-3 h-3 bg-white rounded-full absolute top-1/2 -translate-y-1/2 -ml-1.5 shadow-sm transform scale-0 group-hover:scale-100 transition-transform shadow-cyan-500/50"
|
|
||||||
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className="absolute -top-7 -translate-x-1/2 rounded bg-black/80 border border-white/10 px-2 py-0.5 text-[10px] font-mono text-cyan-300 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
className="absolute -top-7 -translate-x-1/2 rounded bg-black/80 border border-white/10 px-2 py-0.5 text-[10px] font-mono text-cyan-300 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||||
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||||
@@ -116,6 +333,114 @@ export function FrameTimeline() {
|
|||||||
{formatTime(currentSeconds)}
|
{formatTime(currentSeconds)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{totalFrames > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="current-frame-line"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute top-[18px] bottom-[8px] z-[60] w-[2px] -translate-x-1/2 rounded-full bg-white shadow-[0_0_10px_rgba(255,255,255,0.85)]"
|
||||||
|
style={{ left: `${currentFrameLineLeft}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{visibleSelectedRange && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-testid="range-boundary-line"
|
||||||
|
aria-hidden="true"
|
||||||
|
title={`范围开始帧 ${visibleSelectedRange.startFrame}`}
|
||||||
|
className="pointer-events-none absolute top-[16px] bottom-[7px] z-[65] w-[2px] -translate-x-1/2 rounded-full bg-fuchsia-400 shadow-[0_0_12px_rgba(244,114,182,0.9)]"
|
||||||
|
style={{ left: `${rangeStartLineLeft}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-testid="range-boundary-line"
|
||||||
|
aria-hidden="true"
|
||||||
|
title={`范围结束帧 ${visibleSelectedRange.endFrame}`}
|
||||||
|
className="pointer-events-none absolute top-[16px] bottom-[7px] z-[65] w-[2px] -translate-x-1/2 rounded-full bg-lime-300 shadow-[0_0_12px_rgba(190,242,100,0.9)]"
|
||||||
|
style={{ left: `${rangeEndLineLeft}%` }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-2 h-2.5 w-full relative bg-zinc-700/80 border-y border-white/10 shadow-inner",
|
||||||
|
propagationRangeSelectionActive && !propagationRangeDisabled && "cursor-crosshair",
|
||||||
|
)}
|
||||||
|
aria-label="视频处理进度条"
|
||||||
|
onPointerDown={handleProcessingBarPointerDown}
|
||||||
|
onPointerMove={handleRangePointerMove}
|
||||||
|
onPointerUp={handleRangePointerUp}
|
||||||
|
onPointerCancel={() => setRangeDragAnchorFrame(null)}
|
||||||
|
>
|
||||||
|
{visibleSelectedRange && (
|
||||||
|
<div
|
||||||
|
data-testid="propagation-range-overlay"
|
||||||
|
className="absolute inset-y-0 z-30 rounded-sm border border-amber-300/80 bg-amber-300/30 shadow-[0_0_12px_rgba(251,191,36,0.45)]"
|
||||||
|
style={{ left: `${rangeLeft}%`, width: `${rangeWidth}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{propagatedFrameMarkers.map(({ frame, index }) => {
|
||||||
|
const left = totalFrames > 0 ? (index / totalFrames) * 100 : 0;
|
||||||
|
const width = totalFrames > 0 ? 100 / totalFrames : 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={frame.id}
|
||||||
|
data-testid="propagated-frame-segment"
|
||||||
|
title={`自动传播帧 ${index + 1}`}
|
||||||
|
aria-label={`跳转到自动传播帧 ${index + 1}`}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => handleFrameMarkerClick(event, index + 1)}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 z-10 border-0 bg-blue-500/85 p-0 shadow-[0_0_8px_rgba(59,130,246,0.65)]",
|
||||||
|
propagationRangeSelectionActive ? "pointer-events-none" : "cursor-pointer hover:bg-blue-400",
|
||||||
|
)}
|
||||||
|
style={{ left: `${left}%`, width: `${width}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visiblePropagationHistory.map((segment) => {
|
||||||
|
const color = propagationHistoryColor(segment.ageFromNewest);
|
||||||
|
const left = totalFrames > 0 ? ((segment.startFrame - 1) / totalFrames) * 100 : 0;
|
||||||
|
const width = totalFrames > 0 ? ((segment.endFrame - segment.startFrame + 1) / totalFrames) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={segment.id}
|
||||||
|
data-testid="propagation-history-segment"
|
||||||
|
data-recency-level={segment.ageFromNewest}
|
||||||
|
title={segment.label || `自动传播记录:第 ${segment.startFrame}-${segment.endFrame} 帧`}
|
||||||
|
className="pointer-events-none absolute inset-y-0 z-[15] rounded-[2px] border-x"
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `${width}%`,
|
||||||
|
backgroundColor: color.fill,
|
||||||
|
borderColor: color.border,
|
||||||
|
boxShadow: `0 0 10px ${color.glow}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{annotatedFrameMarkers.map(({ frame, index }) => {
|
||||||
|
const left = totalFrames > 1 ? (index / Math.max(totalFrames - 1, 1)) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={frame.id}
|
||||||
|
data-testid="annotated-frame-marker"
|
||||||
|
title={`人工/AI 标注帧 ${index + 1}`}
|
||||||
|
aria-label={`跳转到人工/AI 标注帧 ${index + 1}`}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => handleFrameMarkerClick(event, index + 1)}
|
||||||
|
className={cn(
|
||||||
|
"absolute -top-1 z-20 h-4 w-0.5 -translate-x-1/2 rounded-full border-0 bg-red-500 p-0 shadow-[0_0_9px_rgba(239,68,68,0.8)]",
|
||||||
|
propagationRangeSelectionActive ? "pointer-events-none" : "cursor-pointer hover:bg-red-400",
|
||||||
|
)}
|
||||||
|
style={{ left: `${left}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 right-3 text-[9px] font-mono text-gray-500 pointer-events-none">
|
||||||
|
人工/AI {annotatedFrameMarkers.length} 帧 · 自动传播 {propagatedFrameMarkers.length} 帧
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex items-center px-4 gap-6">
|
<div className="flex-1 flex items-center px-4 gap-6">
|
||||||
@@ -148,13 +473,40 @@ export function FrameTimeline() {
|
|||||||
}
|
}
|
||||||
const frame = frames[idx];
|
const frame = frames[idx];
|
||||||
const isCurrent = idx === currentFrameIndex;
|
const isCurrent = idx === currentFrameIndex;
|
||||||
|
const isPropagatedFrame = propagatedFrameIds.has(frame.id);
|
||||||
|
const isAnnotatedFrame = annotatedFrameIds.has(frame.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={frame.id}
|
key={frame.id}
|
||||||
onClick={() => setCurrentFrame(idx)}
|
onClick={() => setCurrentFrame(idx)}
|
||||||
|
title={
|
||||||
|
isAnnotatedFrame
|
||||||
|
? `人工/AI 标注帧 ${idx + 1}`
|
||||||
|
: isPropagatedFrame
|
||||||
|
? `自动传播帧 ${idx + 1}`
|
||||||
|
: `视频帧 ${idx + 1}`
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5",
|
"relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5",
|
||||||
isCurrent ? "w-28 h-16 border-2 border-cyan-500 bg-gray-700 shadow-[0_0_15px_rgba(6,182,212,0.3)] z-10" : "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100"
|
isCurrent
|
||||||
|
? cn(
|
||||||
|
"w-28 h-16 border-2 border-cyan-500 bg-gray-700 z-10",
|
||||||
|
isAnnotatedFrame
|
||||||
|
? "shadow-[inset_0_0_0_2px_rgba(239,68,68,0.95),0_0_15px_rgba(6,182,212,0.3)]"
|
||||||
|
: isPropagatedFrame
|
||||||
|
? "shadow-[inset_0_0_0_2px_rgba(59,130,246,0.65),0_0_15px_rgba(6,182,212,0.3)]"
|
||||||
|
: "shadow-[0_0_15px_rgba(6,182,212,0.3)]",
|
||||||
|
)
|
||||||
|
: isAnnotatedFrame
|
||||||
|
? cn(
|
||||||
|
"w-16 h-12 border border-red-500 bg-red-950/30 opacity-85 hover:opacity-100",
|
||||||
|
isPropagatedFrame
|
||||||
|
? "shadow-[inset_0_0_0_2px_rgba(59,130,246,0.85),0_0_10px_rgba(239,68,68,0.22)]"
|
||||||
|
: "shadow-[0_0_10px_rgba(239,68,68,0.22)]",
|
||||||
|
)
|
||||||
|
: isPropagatedFrame
|
||||||
|
? "w-16 h-12 border border-blue-500 bg-blue-950/30 opacity-80 shadow-[0_0_10px_rgba(59,130,246,0.22)] hover:opacity-100"
|
||||||
|
: "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{frame.url ? (
|
{frame.url ? (
|
||||||
@@ -182,7 +534,6 @@ export function FrameTimeline() {
|
|||||||
<div className="text-xs font-mono text-cyan-300 mt-1">
|
<div className="text-xs font-mono text-cyan-300 mt-1">
|
||||||
{formatTime(currentSeconds)} <span className="text-gray-600">/</span> {formatTime(totalSeconds)}
|
{formatTime(currentSeconds)} <span className="text-gray-600">/</span> {formatTime(totalSeconds)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">底层时序视频图层截帧导航轴</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,14 +19,19 @@ describe('Login', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('logs in with the development credentials and stores the token', async () => {
|
it('logs in with the development credentials and stores the token', async () => {
|
||||||
apiMock.login.mockResolvedValueOnce({ token: 'fake-jwt-token-for-admin' });
|
apiMock.login.mockResolvedValueOnce({
|
||||||
|
token: 'jwt-token',
|
||||||
|
username: 'admin',
|
||||||
|
user: { id: 1, username: 'admin', role: 'admin' },
|
||||||
|
});
|
||||||
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '安全登录' }));
|
fireEvent.click(screen.getByRole('button', { name: '安全登录' }));
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.login).toHaveBeenCalledWith('admin', '123456'));
|
await waitFor(() => expect(apiMock.login).toHaveBeenCalledWith('admin', '123456'));
|
||||||
expect(useStore.getState().isAuthenticated).toBe(true);
|
expect(useStore.getState().isAuthenticated).toBe(true);
|
||||||
expect(localStorage.getItem('token')).toBe('fake-jwt-token-for-admin');
|
expect(useStore.getState().currentUser?.username).toBe('admin');
|
||||||
|
expect(localStorage.getItem('token')).toBe('jwt-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows backend login errors', async () => {
|
it('shows backend login errors', async () => {
|
||||||
@@ -39,4 +44,20 @@ describe('Login', () => {
|
|||||||
expect(await screen.findByText('Invalid credentials')).toBeInTheDocument();
|
expect(await screen.findByText('Invalid credentials')).toBeInTheDocument();
|
||||||
expect(useStore.getState().isAuthenticated).toBe(false);
|
expect(useStore.getState().isAuthenticated).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks login fields with browser autocomplete hints', () => {
|
||||||
|
render(<Login />);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('admin')).toHaveAttribute('autocomplete', 'username');
|
||||||
|
expect(screen.getByDisplayValue('123456')).toHaveAttribute('autocomplete', 'current-password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the product logo and updated system title copy', () => {
|
||||||
|
render(<Login />);
|
||||||
|
|
||||||
|
expect(screen.getByAltText('Logo')).toHaveAttribute('src', expect.stringContaining('logo.png'));
|
||||||
|
expect(screen.getByText('多模态影像及视频智能语义分割与标注系统')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('智能语义分割系统')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('欢迎登录协同工作站')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { BrainCircuit } from 'lucide-react';
|
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
import { login as loginApi } from '../lib/api';
|
import { login as loginApi } from '../lib/api';
|
||||||
@@ -18,7 +17,7 @@ export function Login() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await loginApi(username, password);
|
const data = await loginApi(username, password);
|
||||||
storeLogin(data.token);
|
storeLogin(data.token, data.user);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.response?.data?.detail || err?.response?.data?.error || '登录失败,请检查网络或凭证';
|
const msg = err?.response?.data?.detail || err?.response?.data?.error || '登录失败,请检查网络或凭证';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
@@ -34,10 +33,10 @@ export function Login() {
|
|||||||
<div className="relative z-10 w-full max-w-md p-8 bg-[#111] border border-white/5 rounded-2xl shadow-2xl scale-in shadow-black/50">
|
<div className="relative z-10 w-full max-w-md p-8 bg-[#111] border border-white/5 rounded-2xl shadow-2xl scale-in shadow-black/50">
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div className="w-16 h-16 bg-white rounded-2xl flex items-center justify-center text-cyan-500 shadow-lg shadow-cyan-500/20 mb-4 overflow-hidden border border-white/10">
|
<div className="w-16 h-16 bg-white rounded-2xl flex items-center justify-center text-cyan-500 shadow-lg shadow-cyan-500/20 mb-4 overflow-hidden border border-white/10">
|
||||||
<BrainCircuit size={32} />
|
<img src="/logo.png" alt="Logo" className="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-white tracking-wider mb-2">欢迎登录协同工作站</h1>
|
<h1 className="text-center text-2xl font-bold text-white tracking-wider mb-2">多模态影像及视频智能语义分割与标注系统</h1>
|
||||||
<p className="text-sm text-gray-500">AI 智能切分与多模态数据标注系统</p>
|
<p className="text-sm text-gray-500">智能语义分割系统</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
@@ -47,6 +46,7 @@ export function Login() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all font-mono"
|
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all font-mono"
|
||||||
placeholder="输入账号"
|
placeholder="输入账号"
|
||||||
/>
|
/>
|
||||||
@@ -58,6 +58,7 @@ export function Login() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all font-mono"
|
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all font-mono"
|
||||||
placeholder="输入密码"
|
placeholder="输入密码"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ describe('ModelStatusBadge', () => {
|
|||||||
resetStore();
|
resetStore();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
apiMock.getAiModelStatus.mockResolvedValue({
|
apiMock.getAiModelStatus.mockResolvedValue({
|
||||||
selected_model: 'sam2',
|
selected_model: 'sam2.1_hiera_tiny',
|
||||||
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
|
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
|
||||||
models: [
|
models: [
|
||||||
{ id: 'sam2', label: 'SAM 2', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2 ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Tiny ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
||||||
{ id: 'sam3', label: 'SAM 3', available: false, loaded: false, device: 'cuda', supports: ['semantic'], message: 'SAM 3 missing runtime', package_available: false, checkpoint_exists: false, python_ok: false, torch_ok: true, cuda_required: true },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -29,17 +28,14 @@ describe('ModelStatusBadge', () => {
|
|||||||
it('loads real model status for the selected model', async () => {
|
it('loads real model status for the selected model', async () => {
|
||||||
render(<ModelStatusBadge />);
|
render(<ModelStatusBadge />);
|
||||||
|
|
||||||
expect(await screen.findByText('SAM 2 可用')).toBeInTheDocument();
|
expect(await screen.findByText('SAM 2.1 Tiny 可用')).toBeInTheDocument();
|
||||||
expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2');
|
expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows unavailable state when SAM3 is selected but not runnable', async () => {
|
it('does not expose disabled SAM3 status in the badge', async () => {
|
||||||
useStore.getState().setAiModel('sam3');
|
|
||||||
|
|
||||||
render(<ModelStatusBadge />);
|
render(<ModelStatusBadge />);
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam3'));
|
await waitFor(() => expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny'));
|
||||||
expect(await screen.findByText('SAM 3 不可用')).toBeInTheDocument();
|
expect(screen.queryByText(/SAM 3/)).not.toBeInTheDocument();
|
||||||
expect(screen.getByTitle('SAM 3 missing runtime')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor, 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(),
|
||||||
|
deleteAnnotation: vi.fn(),
|
||||||
|
smoothMaskGeometry: vi.fn(),
|
||||||
|
updateTemplate: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
analyzeMask: apiMock.analyzeMask,
|
||||||
|
deleteAnnotation: apiMock.deleteAnnotation,
|
||||||
|
smoothMaskGeometry: apiMock.smoothMaskGeometry,
|
||||||
|
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: '已读取后端几何属性',
|
||||||
|
});
|
||||||
|
apiMock.smoothMaskGeometry.mockResolvedValue({
|
||||||
|
polygons: [[[0.12, 0.12], [0.28, 0.12], [0.28, 0.28], [0.12, 0.28]]],
|
||||||
|
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
|
||||||
|
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
|
||||||
|
bbox: [12, 12, 16, 16],
|
||||||
|
area: 256,
|
||||||
|
topology_anchor_count: 4,
|
||||||
|
topology_anchors: [],
|
||||||
|
smoothing: { strength: 35, method: 'chaikin' },
|
||||||
|
message: '已应用边缘平滑强度 35',
|
||||||
|
});
|
||||||
|
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
templates: [
|
templates: [
|
||||||
{
|
{
|
||||||
@@ -23,13 +60,91 @@ describe('OntologyInspector', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows template classes and changes the active template', () => {
|
it('shows template classes and changes the active template', () => {
|
||||||
render(<OntologyInspector />);
|
const { container } = render(<OntologyInspector />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 't1' } });
|
const templateSelect = screen.getByRole('combobox');
|
||||||
|
expect(container.querySelector('.seg-scrollbar')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('本体论与属性分类管理树')).not.toBeInTheDocument();
|
||||||
|
fireEvent.change(templateSelect, { target: { value: 't1' } });
|
||||||
|
|
||||||
expect(useStore.getState().activeTemplateId).toBe('t1');
|
expect(useStore.getState().activeTemplateId).toBe('t1');
|
||||||
expect(screen.getByText('胆囊')).toBeInTheDocument();
|
expect(screen.getByText('胆囊')).toBeInTheDocument();
|
||||||
expect(screen.getByText('肝脏')).toBeInTheDocument();
|
expect(screen.getByText('肝脏')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('maskid:1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('maskid:2')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/z:/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires confirmation and clears existing masks before switching templates', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
activeTemplateId: 't1',
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
name: '腹腔镜模板',
|
||||||
|
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 }],
|
||||||
|
rules: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't2',
|
||||||
|
name: '头颈部模板',
|
||||||
|
classes: [{ id: 'c2', name: '肿瘤', color: '#00ff00', zIndex: 20 }],
|
||||||
|
rules: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
masks: [
|
||||||
|
{ id: 'annotation-10', annotationId: '10', frameId: 'f1', pathData: 'M 0 0 Z', label: '胆囊', color: '#ff0000', saveStatus: 'saved', saved: true },
|
||||||
|
{ id: 'draft-1', frameId: 'f1', pathData: 'M 1 1 Z', label: '草稿', color: '#06b6d4', saveStatus: 'draft' },
|
||||||
|
],
|
||||||
|
selectedMaskIds: ['annotation-10'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
fireEvent.change(screen.getByRole('combobox'), { target: { value: 't2' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('确认修改激活模板')).toBeInTheDocument();
|
||||||
|
expect(useStore.getState().activeTemplateId).toBe('t1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '清空并切换' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
|
||||||
|
expect(useStore.getState().masks).toEqual([]);
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||||
|
expect(useStore.getState().activeTemplateId).toBe('t2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjusts workspace mask opacity from above the semantic tree', () => {
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('遮罩透明度'), { target: { value: '35' } });
|
||||||
|
|
||||||
|
expect(useStore.getState().maskPreviewOpacity).toBe(35);
|
||||||
|
expect(screen.getByText('35%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the matching semantic class when a mask is selected', async () => {
|
||||||
|
if (!HTMLElement.prototype.scrollIntoView) {
|
||||||
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
}
|
||||||
|
useStore.setState({
|
||||||
|
masks: [{
|
||||||
|
id: 'm1',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: '肝脏',
|
||||||
|
color: '#00ff00',
|
||||||
|
classId: 'c2',
|
||||||
|
className: '肝脏',
|
||||||
|
}],
|
||||||
|
selectedMaskIds: ['m1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
|
||||||
|
const liverButton = screen.getByRole('button', { name: /肝脏/ });
|
||||||
|
await waitFor(() => expect(useStore.getState().activeClassId).toBe('c2'));
|
||||||
|
expect(liverButton).toHaveAttribute('aria-current', 'true');
|
||||||
|
expect(document.activeElement).toBe(liverButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects a concrete class for subsequent masks', () => {
|
it('selects a concrete class for subsequent masks', () => {
|
||||||
@@ -45,10 +160,51 @@ describe('OntologyInspector', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not change existing masks when selecting a class without a selected mask', () => {
|
||||||
|
useStore.setState({
|
||||||
|
selectedMaskIds: [],
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: '旧标签',
|
||||||
|
color: '#06b6d4',
|
||||||
|
classId: 'old-class',
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
fireEvent.click(screen.getByText('肝脏'));
|
||||||
|
|
||||||
|
expect(useStore.getState().activeClassId).toBe('c2');
|
||||||
|
expect(useStore.getState().masks).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'm1',
|
||||||
|
label: '旧标签',
|
||||||
|
color: '#06b6d4',
|
||||||
|
classId: 'old-class',
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies the selected class to currently selected masks', () => {
|
it('applies the selected class to currently selected masks', () => {
|
||||||
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,30 +222,439 @@ 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: '肝脏',
|
||||||
classZIndex: 10,
|
classZIndex: 10,
|
||||||
|
classMaskId: 2,
|
||||||
label: '肝脏',
|
label: '肝脏',
|
||||||
color: '#00ff00',
|
color: '#00ff00',
|
||||||
saveStatus: 'dirty',
|
saveStatus: 'dirty',
|
||||||
saved: false,
|
saved: false,
|
||||||
}));
|
}));
|
||||||
expect(screen.getByText('当前选中区域:')).toBeInTheDocument();
|
expect(screen.queryByText('当前选中区域:')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('1')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds custom classes locally without backend persistence', () => {
|
it('applies class changes to the same propagation chain across frames', () => {
|
||||||
const { container } = render(<OntologyInspector />);
|
useStore.setState({
|
||||||
|
selectedMaskIds: ['annotation-10'],
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'annotation-10',
|
||||||
|
annotationId: '10',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: '旧标签',
|
||||||
|
color: '#06b6d4',
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'annotation-11',
|
||||||
|
annotationId: '11',
|
||||||
|
frameId: 'frame-2',
|
||||||
|
pathData: 'M 1 1 Z',
|
||||||
|
label: '旧传播标签',
|
||||||
|
color: '#06b6d4',
|
||||||
|
metadata: {
|
||||||
|
source_annotation_id: 10,
|
||||||
|
source_mask_id: 'annotation-10',
|
||||||
|
propagation_seed_key: 'annotation:10',
|
||||||
|
},
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'annotation-99',
|
||||||
|
annotationId: '99',
|
||||||
|
frameId: 'frame-3',
|
||||||
|
pathData: 'M 2 2 Z',
|
||||||
|
label: '无关区域',
|
||||||
|
color: '#ffffff',
|
||||||
|
metadata: { source_annotation_id: 99 },
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
fireEvent.click(screen.getByText('肝脏'));
|
||||||
|
|
||||||
|
const updated = useStore.getState().masks;
|
||||||
|
expect(updated.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
|
||||||
|
classId: 'c2',
|
||||||
|
className: '肝脏',
|
||||||
|
classMaskId: 2,
|
||||||
|
label: '肝脏',
|
||||||
|
color: '#00ff00',
|
||||||
|
saveStatus: 'dirty',
|
||||||
|
saved: false,
|
||||||
|
}));
|
||||||
|
expect(updated.find((mask) => mask.id === 'annotation-11')).toEqual(expect.objectContaining({
|
||||||
|
classId: 'c2',
|
||||||
|
className: '肝脏',
|
||||||
|
classMaskId: 2,
|
||||||
|
label: '肝脏',
|
||||||
|
color: '#00ff00',
|
||||||
|
saveStatus: 'dirty',
|
||||||
|
saved: false,
|
||||||
|
}));
|
||||||
|
expect(updated.find((mask) => mask.id === 'annotation-99')).toEqual(expect.objectContaining({
|
||||||
|
label: '无关区域',
|
||||||
|
color: '#ffffff',
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists custom classes to the active backend template', async () => {
|
||||||
|
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('persists dragged semantic class order as layer priority without changing maskid', async () => {
|
||||||
|
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||||
|
id: 't1',
|
||||||
|
name: '腹腔镜模板',
|
||||||
|
classes: [
|
||||||
|
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
|
||||||
|
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
});
|
||||||
|
useStore.setState({
|
||||||
|
masks: [{
|
||||||
|
id: 'm-liver',
|
||||||
|
annotationId: '42',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
classId: 'c2',
|
||||||
|
className: '肝脏',
|
||||||
|
classZIndex: 10,
|
||||||
|
pathData: 'M 0 0 Z',
|
||||||
|
label: '肝脏',
|
||||||
|
color: '#00ff00',
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
const liverButton = screen.getByRole('button', { name: /肝脏/ });
|
||||||
|
const gallbladderButton = screen.getByRole('button', { name: /胆囊/ });
|
||||||
|
const dataTransfer = {
|
||||||
|
effectAllowed: '',
|
||||||
|
dropEffect: '',
|
||||||
|
setData: vi.fn(),
|
||||||
|
getData: vi.fn(() => 'c2'),
|
||||||
|
};
|
||||||
|
|
||||||
|
fireEvent.dragStart(liverButton, { dataTransfer });
|
||||||
|
fireEvent.dragOver(gallbladderButton, { dataTransfer });
|
||||||
|
fireEvent.drop(gallbladderButton, { dataTransfer });
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||||
|
classes: [
|
||||||
|
expect.objectContaining({ id: 'c2', zIndex: 20, maskId: 2 }),
|
||||||
|
expect.objectContaining({ id: 'c1', zIndex: 10, maskId: 1 }),
|
||||||
|
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
|
||||||
|
],
|
||||||
|
})));
|
||||||
|
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||||
|
classZIndex: 20,
|
||||||
|
saveStatus: 'dirty',
|
||||||
|
saved: false,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }],
|
||||||
|
activeClass: { id: 'c3', name: '肿瘤', color: '#f97316', zIndex: 30 },
|
||||||
|
activeClassId: 'c3',
|
||||||
|
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('4 节点')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('胆囊')).toHaveLength(2);
|
||||||
|
expect(screen.queryByText('肿瘤')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('后端模型置信度')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('0.8200')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('4 节点')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: '重新提取拓扑锚点' })).not.toBeInTheDocument();
|
||||||
|
expect(apiMock.analyzeMask).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ id: 'm1' }),
|
||||||
|
expect.objectContaining({ id: 'frame-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores aborted mask analysis requests without showing an error', async () => {
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
apiMock.analyzeMask.mockRejectedValueOnce({ code: 'ECONNABORTED', message: 'Request aborted' });
|
||||||
|
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]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.analyzeMask).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(screen.queryByText('后端属性读取失败')).not.toBeInTheDocument());
|
||||||
|
expect(consoleError).not.toHaveBeenCalled();
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews backend edge smoothing while moving the slider without marking the mask dirty', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
|
||||||
|
selectedMaskIds: ['m1'],
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
annotationId: '10',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 10 10 L 30 10 L 30 30 Z',
|
||||||
|
label: '胆囊',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[10, 10, 30, 10, 30, 30]],
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.smoothMaskGeometry).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'm1' }),
|
||||||
|
expect.objectContaining({ id: 'frame-1' }),
|
||||||
|
35,
|
||||||
|
));
|
||||||
|
await waitFor(() => expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||||
|
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
|
||||||
|
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
|
||||||
|
bbox: [12, 12, 16, 16],
|
||||||
|
area: 256,
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
metadata: { geometry_smoothing_preview: { strength: 35, method: 'chaikin' } },
|
||||||
|
})));
|
||||||
|
expect(screen.getByText('已应用边缘平滑强度 35,预览中,点击应用后写入当前 mask。')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces backend edge smoothing preview while dragging the slider', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
|
||||||
|
selectedMaskIds: ['m1'],
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
annotationId: '10',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 10 10 L 30 10 L 30 30 Z',
|
||||||
|
label: '胆囊',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[10, 10, 30, 10, 30, 30]],
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '15' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '25' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('正在等待停止拖动后生成边缘平滑预览...')).toBeInTheDocument();
|
||||||
|
expect(apiMock.smoothMaskGeometry).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(219);
|
||||||
|
});
|
||||||
|
expect(apiMock.smoothMaskGeometry).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'm1' }),
|
||||||
|
expect.objectContaining({ id: 'frame-1' }),
|
||||||
|
35,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a previewed edge smoothing result to the selected mask and marks it dirty', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
|
||||||
|
selectedMaskIds: ['m1'],
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
annotationId: '10',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 10 10 L 30 10 L 30 30 Z',
|
||||||
|
label: '胆囊',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[10, 10, 30, 10, 30, 30]],
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '应用边缘平滑' })).not.toBeDisabled());
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||||
|
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
|
||||||
|
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
|
||||||
|
bbox: [12, 12, 16, 16],
|
||||||
|
area: 256,
|
||||||
|
saveStatus: 'dirty',
|
||||||
|
saved: false,
|
||||||
|
})));
|
||||||
|
expect(useStore.getState().masks[0].metadata?.geometry_smoothing).toBeUndefined();
|
||||||
|
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('已应用边缘平滑强度 35,已变为新的 mask,强度已重置为 0,请保存后生效')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies smoothing to linked propagation masks as one undoable geometry edit', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
frames: [
|
||||||
|
{ id: 'frame-0', projectId: 'p1', index: 0, url: '/0.jpg', width: 100, height: 100 },
|
||||||
|
{ id: 'frame-1', projectId: 'p1', index: 1, url: '/1.jpg', width: 100, height: 100 },
|
||||||
|
{ id: 'frame-2', projectId: 'p1', index: 2, url: '/2.jpg', width: 100, height: 100 },
|
||||||
|
],
|
||||||
|
selectedMaskIds: ['seed-mask'],
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'seed-mask',
|
||||||
|
annotationId: '10',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 10 10 L 30 10 L 30 30 Z',
|
||||||
|
label: '胆囊',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[10, 10, 30, 10, 30, 30]],
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prop-backward',
|
||||||
|
annotationId: '11',
|
||||||
|
frameId: 'frame-0',
|
||||||
|
pathData: 'M 11 11 L 31 11 L 31 31 Z',
|
||||||
|
label: '胆囊',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[11, 11, 31, 11, 31, 31]],
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
metadata: { source_annotation_id: 10, source_mask_id: 'annotation-10', propagated_from_frame_id: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prop-forward',
|
||||||
|
annotationId: '12',
|
||||||
|
frameId: 'frame-2',
|
||||||
|
pathData: 'M 12 12 L 32 12 L 32 32 Z',
|
||||||
|
label: '胆囊',
|
||||||
|
color: '#ff0000',
|
||||||
|
segmentation: [[12, 12, 32, 12, 32, 32]],
|
||||||
|
saveStatus: 'saved',
|
||||||
|
saved: true,
|
||||||
|
metadata: { source_annotation_id: 10, source_mask_id: 'annotation-10', propagated_from_frame_id: 10 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '应用边缘平滑' })).not.toBeDisabled());
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(3));
|
||||||
|
await waitFor(() => expect(useStore.getState().masks).toEqual([
|
||||||
|
expect.objectContaining({ id: 'seed-mask', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
|
||||||
|
expect.objectContaining({ id: 'prop-backward', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
|
||||||
|
expect.objectContaining({ id: 'prop-forward', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
|
||||||
|
]));
|
||||||
|
expect(useStore.getState().masks.every((mask) => !mask.metadata?.geometry_smoothing)).toBe(true);
|
||||||
|
expect(screen.getByText('已应用边缘平滑强度 35,已同步应用到传播链 3 个对应 mask,强度已重置为 0,请保存后生效')).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useStore.getState().undoMasks();
|
||||||
|
});
|
||||||
|
expect(useStore.getState().masks.map((mask) => mask.pathData)).toEqual([
|
||||||
|
'M 10 10 L 30 10 L 30 30 Z',
|
||||||
|
'M 11 11 L 31 11 L 31 31 Z',
|
||||||
|
'M 12 12 L 32 12 L 32 32 Z',
|
||||||
|
]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useStore.getState().redoMasks();
|
||||||
|
});
|
||||||
|
expect(useStore.getState().masks.every((mask) => mask.pathData === 'M 12 12 L 28 12 L 28 28 L 12 28 Z')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,159 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Layers, ChevronDown, Tag, Eye, Plus, X } from 'lucide-react';
|
import { ChevronDown, Tag, Eye, Plus, X, Loader2, GripVertical } from 'lucide-react';
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
import type { TemplateClass } from '../store/useStore';
|
import type { Mask, 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, deleteAnnotation, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
|
||||||
|
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
|
||||||
|
|
||||||
|
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
|
||||||
|
|
||||||
|
const isRequestAbortError = (err: unknown) => {
|
||||||
|
const error = err as { code?: string; message?: string; name?: string } | null;
|
||||||
|
const message = error?.message || '';
|
||||||
|
return error?.code === 'ERR_CANCELED'
|
||||||
|
|| error?.code === 'ECONNABORTED'
|
||||||
|
|| error?.name === 'AbortError'
|
||||||
|
|| /request aborted|aborted|cancell?ed/i.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
function metadataNumber(value: unknown): number | null {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFoundError(error: unknown): boolean {
|
||||||
|
const maybeError = error as { response?: { status?: number }; status?: number } | null;
|
||||||
|
return maybeError?.response?.status === 404 || maybeError?.status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAnnotationIfExists(annotationId: string) {
|
||||||
|
try {
|
||||||
|
await deleteAnnotation(annotationId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFoundError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function propagationSourceMaskTokens(value: unknown): string[] {
|
||||||
|
if (typeof value !== 'string' || value.length === 0) return [];
|
||||||
|
const tokens = [`mask:${value}`];
|
||||||
|
const annotationMatch = value.match(/^annotation-(\d+)$/);
|
||||||
|
if (annotationMatch) {
|
||||||
|
tokens.push(`annotation:${annotationMatch[1]}`);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function propagationLineageTokens(mask: { id: string; annotationId?: string; metadata?: Record<string, unknown> }): Set<string> {
|
||||||
|
const metadata = mask.metadata || {};
|
||||||
|
const tokens = new Set<string>([`mask:${mask.id}`]);
|
||||||
|
if (mask.annotationId) {
|
||||||
|
tokens.add(`annotation:${mask.annotationId}`);
|
||||||
|
}
|
||||||
|
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
|
||||||
|
if (sourceAnnotationId !== null) {
|
||||||
|
tokens.add(`annotation:${sourceAnnotationId}`);
|
||||||
|
}
|
||||||
|
propagationSourceMaskTokens(metadata.source_mask_id).forEach((token) => tokens.add(token));
|
||||||
|
if (typeof metadata.propagation_seed_key === 'string' && metadata.propagation_seed_key.length > 0) {
|
||||||
|
tokens.add(`seed-key:${metadata.propagation_seed_key}`);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPropagationChainMaskIds(selectedMask: Pick<Mask, 'id' | 'annotationId' | 'metadata'>, masks: Mask[]): Set<string> {
|
||||||
|
const selectedTokens = propagationLineageTokens(selectedMask);
|
||||||
|
return new Set(
|
||||||
|
masks
|
||||||
|
.filter((mask) => Array.from(selectedTokens).some((token) => propagationLineageTokens(mask).has(token)))
|
||||||
|
.map((mask) => mask.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
|
||||||
const setMasks = useStore((state) => state.setMasks);
|
const setMasks = useStore((state) => state.setMasks);
|
||||||
|
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||||
|
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);
|
||||||
|
const setMaskPreviewOpacity = useStore((state) => state.setMaskPreviewOpacity);
|
||||||
|
|
||||||
// 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 [dragClassId, setDragClassId] = useState<string | null>(null);
|
||||||
|
const [maskAnalysis, setMaskAnalysis] = useState<MaskAnalysisResult | null>(null);
|
||||||
|
const [analysisMessage, setAnalysisMessage] = useState('');
|
||||||
|
const [smoothingStrength, setSmoothingStrength] = useState(0);
|
||||||
|
const [isPreviewingSmoothing, setIsPreviewingSmoothing] = useState(false);
|
||||||
|
const [isSmoothingMask, setIsSmoothingMask] = useState(false);
|
||||||
|
const [pendingTemplateSwitch, setPendingTemplateSwitch] = useState<{ templateId: string | null } | null>(null);
|
||||||
|
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false);
|
||||||
|
|
||||||
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
|
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
|
||||||
const templateClasses = activeTemplate?.classes || [];
|
const templateClasses = normalizeClassMaskIds(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 selectedMaskLabel = selectedMask?.className || selectedMask?.label || '未选择';
|
||||||
|
const currentFrame = frames[currentFrameIndex] || null;
|
||||||
|
const classButtonRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
|
const skipNextAutoAnalysisRef = useRef(false);
|
||||||
|
const analysisRequestIdRef = useRef(0);
|
||||||
|
const smoothingPreviewRef = useRef<{
|
||||||
|
maskId: string;
|
||||||
|
baseMask: NonNullable<typeof selectedMask>;
|
||||||
|
strength: number;
|
||||||
|
result: SmoothMaskGeometryResult | null;
|
||||||
|
applied: boolean;
|
||||||
|
requestId: number;
|
||||||
|
} | null>(null);
|
||||||
|
const smoothingRequestIdRef = useRef(0);
|
||||||
|
const smoothingPreviewTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clearSmoothingPreviewTimer = React.useCallback(() => {
|
||||||
|
if (smoothingPreviewTimerRef.current === null) return;
|
||||||
|
window.clearTimeout(smoothingPreviewTimerRef.current);
|
||||||
|
smoothingPreviewTimerRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedMaskClass = useMemo(() => {
|
||||||
|
if (!selectedMask) return null;
|
||||||
|
const allTemplateClasses = templates.flatMap((template) => (
|
||||||
|
template.classes.map((templateClass) => ({ template, templateClass }))
|
||||||
|
));
|
||||||
|
const selectedName = selectedMask.className || selectedMask.label;
|
||||||
|
return allTemplateClasses.find(({ templateClass }) => selectedMask.classId && templateClass.id === selectedMask.classId)
|
||||||
|
|| allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName && templateClass.color === selectedMask.color)
|
||||||
|
|| allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName)
|
||||||
|
|| null;
|
||||||
|
}, [selectedMask?.classId, selectedMask?.className, selectedMask?.color, selectedMask?.id, selectedMask?.label, templates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMaskClass) return;
|
||||||
|
if (activeTemplateId !== selectedMaskClass.template.id) {
|
||||||
|
setActiveTemplateId(selectedMaskClass.template.id);
|
||||||
|
}
|
||||||
|
setActiveClass(selectedMaskClass.templateClass);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const node = classButtonRefs.current.get(selectedMaskClass.templateClass.id);
|
||||||
|
node?.scrollIntoView?.({ block: 'nearest' });
|
||||||
|
node?.focus?.({ preventScroll: true });
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [activeTemplateId, selectedMaskClass, setActiveClass, setActiveTemplateId]);
|
||||||
|
|
||||||
const handleSelectClass = (templateClass: TemplateClass) => {
|
const handleSelectClass = (templateClass: TemplateClass) => {
|
||||||
if (activeTemplate && !activeTemplateId) {
|
if (activeTemplate && !activeTemplateId) {
|
||||||
@@ -36,46 +165,458 @@ 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 targetIdSet = new Set<string>();
|
||||||
if (!selectedIdSet.has(mask.id)) return mask;
|
masks
|
||||||
|
.filter((mask) => selectedIdSet.has(mask.id))
|
||||||
|
.forEach((mask) => {
|
||||||
|
findPropagationChainMaskIds(mask, masks).forEach((maskId) => targetIdSet.add(maskId));
|
||||||
|
});
|
||||||
|
const updatedMasks = masks.map((mask) => {
|
||||||
|
if (!targetIdSet.has(mask.id)) return mask;
|
||||||
return {
|
return {
|
||||||
...mask,
|
...mask,
|
||||||
templateId: templateId || mask.templateId,
|
templateId: templateId || mask.templateId,
|
||||||
classId: templateClass.id,
|
classId: templateClass.id,
|
||||||
className: templateClass.name,
|
className: templateClass.name,
|
||||||
classZIndex: templateClass.zIndex,
|
classZIndex: templateClass.zIndex,
|
||||||
|
classMaskId: templateClass.maskId,
|
||||||
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 () => {
|
||||||
|
const requestId = analysisRequestIdRef.current + 1;
|
||||||
|
analysisRequestIdRef.current = requestId;
|
||||||
|
if (!selectedMask || !currentFrame) {
|
||||||
|
setMaskAnalysis(null);
|
||||||
|
setAnalysisMessage(selectedMask ? '当前帧信息不可用,无法读取后端属性' : '请选择一个 mask 查看后端属性');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAnalysisMessage('');
|
||||||
|
try {
|
||||||
|
const result = await analyzeMask(selectedMask, currentFrame);
|
||||||
|
if (analysisRequestIdRef.current !== requestId) return;
|
||||||
|
setMaskAnalysis(result);
|
||||||
|
setAnalysisMessage(result.message);
|
||||||
|
} catch (err) {
|
||||||
|
if (analysisRequestIdRef.current !== requestId || isRequestAbortError(err)) return;
|
||||||
|
console.error('Mask analysis failed:', err);
|
||||||
|
setMaskAnalysis(null);
|
||||||
|
setAnalysisMessage('后端属性读取失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSmoothingPreview = React.useCallback(() => {
|
||||||
|
const preview = smoothingPreviewRef.current;
|
||||||
|
if (!preview || preview.applied) {
|
||||||
|
smoothingPreviewRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = useStore.getState();
|
||||||
|
useStore.setState({
|
||||||
|
masks: state.masks.map((mask) => (mask.id === preview.maskId ? preview.baseMask : mask)),
|
||||||
|
selectedMaskIds: state.selectedMaskIds,
|
||||||
|
});
|
||||||
|
smoothingPreviewRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyActiveTemplateChange = React.useCallback((templateId: string | null) => {
|
||||||
|
setActiveTemplateId(templateId);
|
||||||
|
setActiveClass(null);
|
||||||
|
}, [setActiveClass, setActiveTemplateId]);
|
||||||
|
|
||||||
|
const requestActiveTemplateChange = React.useCallback((templateId: string | null) => {
|
||||||
|
if (templateId === (activeTemplateId || null)) return;
|
||||||
|
if (!activeTemplateId && templateId && templateId === activeTemplate?.id) {
|
||||||
|
applyActiveTemplateChange(templateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (masks.length === 0) {
|
||||||
|
applyActiveTemplateChange(templateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingTemplateSwitch({ templateId });
|
||||||
|
}, [activeTemplate?.id, activeTemplateId, applyActiveTemplateChange, masks.length]);
|
||||||
|
|
||||||
|
const confirmActiveTemplateChange = React.useCallback(async () => {
|
||||||
|
if (!pendingTemplateSwitch) return;
|
||||||
|
const nextTemplateId = pendingTemplateSwitch.templateId;
|
||||||
|
const annotationIds = Array.from(new Set(
|
||||||
|
useStore.getState().masks
|
||||||
|
.map((mask) => mask.annotationId)
|
||||||
|
.filter((annotationId): annotationId is string => Boolean(annotationId)),
|
||||||
|
));
|
||||||
|
setIsSwitchingTemplate(true);
|
||||||
|
setClassSaveMessage(annotationIds.length > 0 ? '正在清空旧模板标注...' : '正在清空旧模板遮罩...');
|
||||||
|
try {
|
||||||
|
await Promise.all(annotationIds.map(deleteAnnotationIfExists));
|
||||||
|
useStore.getState().setMasks([]);
|
||||||
|
setSelectedMaskIds([]);
|
||||||
|
applyActiveTemplateChange(nextTemplateId);
|
||||||
|
setPendingTemplateSwitch(null);
|
||||||
|
setClassSaveMessage(annotationIds.length > 0
|
||||||
|
? `已清空 ${annotationIds.length} 个旧模板标注并切换激活模板`
|
||||||
|
: '已清空旧模板遮罩并切换激活模板');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Switch active template failed:', err);
|
||||||
|
setClassSaveMessage('切换模板失败,旧标注未清空');
|
||||||
|
} finally {
|
||||||
|
setIsSwitchingTemplate(false);
|
||||||
|
}
|
||||||
|
}, [applyActiveTemplateChange, pendingTemplateSwitch, setSelectedMaskIds]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
analysisRequestIdRef.current += 1;
|
||||||
|
clearSmoothingPreviewTimer();
|
||||||
|
restoreSmoothingPreview();
|
||||||
|
};
|
||||||
|
}, [clearSmoothingPreviewTimer, restoreSmoothingPreview]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const preview = smoothingPreviewRef.current;
|
||||||
|
if (preview && preview.maskId !== selectedMask?.id) {
|
||||||
|
restoreSmoothingPreview();
|
||||||
|
}
|
||||||
|
}, [restoreSmoothingPreview, selectedMask?.id]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (skipNextAutoAnalysisRef.current) {
|
||||||
|
skipNextAutoAnalysisRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshMaskAnalysis();
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const smoothing = selectedMask?.metadata?.geometry_smoothing;
|
||||||
|
const strength = smoothing && typeof smoothing === 'object'
|
||||||
|
? Number((smoothing as Record<string, unknown>).strength)
|
||||||
|
: 0;
|
||||||
|
setSmoothingStrength(Number.isFinite(strength) ? Math.min(Math.max(strength, 0), 100) : 0);
|
||||||
|
}, [selectedMask?.id]);
|
||||||
|
|
||||||
|
const applySmoothingResultToMask = React.useCallback((
|
||||||
|
mask: Mask,
|
||||||
|
result: SmoothMaskGeometryResult,
|
||||||
|
options: { commit: boolean },
|
||||||
|
): Mask => {
|
||||||
|
const metadata = { ...(mask.metadata || {}) };
|
||||||
|
delete metadata.geometry_smoothing_preview;
|
||||||
|
if (options.commit) {
|
||||||
|
delete metadata.geometry_smoothing;
|
||||||
|
} else {
|
||||||
|
metadata.geometry_smoothing_preview = result.smoothing;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...mask,
|
||||||
|
pathData: result.pathData,
|
||||||
|
segmentation: result.segmentation,
|
||||||
|
bbox: result.bbox,
|
||||||
|
area: result.area,
|
||||||
|
metadata,
|
||||||
|
...(options.commit
|
||||||
|
? {
|
||||||
|
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
|
||||||
|
saved: mask.annotationId ? false : mask.saved,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateMaskWithSmoothingResult = React.useCallback((
|
||||||
|
maskId: string,
|
||||||
|
result: SmoothMaskGeometryResult,
|
||||||
|
options: { commit: boolean },
|
||||||
|
) => {
|
||||||
|
const state = useStore.getState();
|
||||||
|
const nextMasks = state.masks.map((mask) => (
|
||||||
|
mask.id === maskId ? applySmoothingResultToMask(mask, result, options) : mask
|
||||||
|
));
|
||||||
|
if (options.commit) {
|
||||||
|
setMasks(nextMasks);
|
||||||
|
} else {
|
||||||
|
useStore.setState({ masks: nextMasks });
|
||||||
|
}
|
||||||
|
}, [applySmoothingResultToMask, setMasks]);
|
||||||
|
|
||||||
|
const applySmoothingResultToAnalysis = React.useCallback((
|
||||||
|
result: SmoothMaskGeometryResult,
|
||||||
|
sourceMask: NonNullable<typeof selectedMask>,
|
||||||
|
suffix: string,
|
||||||
|
) => {
|
||||||
|
setMaskAnalysis({
|
||||||
|
confidence: null,
|
||||||
|
confidence_source: 'manual_or_imported',
|
||||||
|
topology_anchor_count: result.topology_anchor_count,
|
||||||
|
topology_anchors: result.topology_anchors,
|
||||||
|
area: result.area,
|
||||||
|
bbox: result.bbox,
|
||||||
|
source: sourceMask.metadata?.source as string | undefined,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
setAnalysisMessage(`${result.message}${suffix}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runSmoothingPreview = React.useCallback(async (nextStrength: number) => {
|
||||||
|
if (!selectedMask || !currentFrame) return;
|
||||||
|
|
||||||
|
const existingPreview = smoothingPreviewRef.current?.maskId === selectedMask.id
|
||||||
|
? smoothingPreviewRef.current
|
||||||
|
: null;
|
||||||
|
const baseMask = existingPreview?.baseMask || selectedMask;
|
||||||
|
const requestId = smoothingRequestIdRef.current + 1;
|
||||||
|
smoothingRequestIdRef.current = requestId;
|
||||||
|
|
||||||
|
if (nextStrength <= 0) {
|
||||||
|
clearSmoothingPreviewTimer();
|
||||||
|
smoothingPreviewRef.current = {
|
||||||
|
maskId: selectedMask.id,
|
||||||
|
baseMask,
|
||||||
|
strength: 0,
|
||||||
|
result: null,
|
||||||
|
applied: false,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
skipNextAutoAnalysisRef.current = true;
|
||||||
|
useStore.setState({
|
||||||
|
masks: useStore.getState().masks.map((mask) => (mask.id === selectedMask.id ? baseMask : mask)),
|
||||||
|
});
|
||||||
|
setAnalysisMessage('已预览恢复原始边缘,点击应用后写入当前 mask。');
|
||||||
|
setIsPreviewingSmoothing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnalysisMessage('正在生成边缘平滑预览...');
|
||||||
|
try {
|
||||||
|
const result = await smoothMaskGeometry(baseMask, currentFrame, nextStrength);
|
||||||
|
if (smoothingRequestIdRef.current !== requestId) return;
|
||||||
|
smoothingPreviewRef.current = {
|
||||||
|
maskId: selectedMask.id,
|
||||||
|
baseMask,
|
||||||
|
strength: nextStrength,
|
||||||
|
result,
|
||||||
|
applied: false,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
skipNextAutoAnalysisRef.current = true;
|
||||||
|
updateMaskWithSmoothingResult(selectedMask.id, result, { commit: false });
|
||||||
|
applySmoothingResultToAnalysis(result, baseMask, ',预览中,点击应用后写入当前 mask。');
|
||||||
|
} catch (err) {
|
||||||
|
if (smoothingRequestIdRef.current !== requestId) return;
|
||||||
|
console.error('Mask smoothing preview failed:', err);
|
||||||
|
setAnalysisMessage('边缘平滑预览失败,请检查后端服务');
|
||||||
|
} finally {
|
||||||
|
if (smoothingRequestIdRef.current === requestId) {
|
||||||
|
setIsPreviewingSmoothing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [applySmoothingResultToAnalysis, clearSmoothingPreviewTimer, currentFrame, selectedMask, updateMaskWithSmoothingResult]);
|
||||||
|
|
||||||
|
const previewSmoothing = React.useCallback((nextStrength: number) => {
|
||||||
|
setSmoothingStrength(nextStrength);
|
||||||
|
clearSmoothingPreviewTimer();
|
||||||
|
if (!selectedMask || !currentFrame) return;
|
||||||
|
if (nextStrength <= 0) {
|
||||||
|
void runSmoothingPreview(nextStrength);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsPreviewingSmoothing(true);
|
||||||
|
setAnalysisMessage('正在等待停止拖动后生成边缘平滑预览...');
|
||||||
|
smoothingPreviewTimerRef.current = window.setTimeout(() => {
|
||||||
|
smoothingPreviewTimerRef.current = null;
|
||||||
|
void runSmoothingPreview(nextStrength);
|
||||||
|
}, SMOOTHING_PREVIEW_DEBOUNCE_MS);
|
||||||
|
}, [clearSmoothingPreviewTimer, currentFrame, runSmoothingPreview, selectedMask]);
|
||||||
|
|
||||||
|
const handleApplySmoothing = async () => {
|
||||||
|
if (!selectedMask || !currentFrame) {
|
||||||
|
setAnalysisMessage('请选择一个 mask 后再应用边缘平滑');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSmoothingPreviewTimer();
|
||||||
|
smoothingRequestIdRef.current += 1;
|
||||||
|
setIsSmoothingMask(true);
|
||||||
|
setAnalysisMessage('');
|
||||||
|
try {
|
||||||
|
const existingPreview = smoothingPreviewRef.current?.maskId === selectedMask.id
|
||||||
|
&& smoothingPreviewRef.current.strength === smoothingStrength
|
||||||
|
? smoothingPreviewRef.current
|
||||||
|
: null;
|
||||||
|
const baseMask = existingPreview?.baseMask || selectedMask;
|
||||||
|
if (smoothingStrength <= 0) {
|
||||||
|
smoothingPreviewRef.current = null;
|
||||||
|
setSmoothingStrength(0);
|
||||||
|
setAnalysisMessage('边缘平滑强度为 0,当前 mask 保持原始边缘。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = useStore.getState();
|
||||||
|
const frameById = new Map(state.frames.map((frame) => [String(frame.id), frame]));
|
||||||
|
const chainMaskIds = findPropagationChainMaskIds(baseMask, state.masks);
|
||||||
|
chainMaskIds.add(selectedMask.id);
|
||||||
|
const selectedResult = existingPreview?.result || await smoothMaskGeometry(baseMask, currentFrame, smoothingStrength);
|
||||||
|
const resultEntries = new Map<string, SmoothMaskGeometryResult>();
|
||||||
|
resultEntries.set(selectedMask.id, selectedResult);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(chainMaskIds)
|
||||||
|
.filter((maskId) => maskId !== selectedMask.id)
|
||||||
|
.map(async (maskId) => {
|
||||||
|
const mask = state.masks.find((item) => item.id === maskId);
|
||||||
|
const frame = mask ? frameById.get(String(mask.frameId)) : null;
|
||||||
|
if (!mask || !frame) return;
|
||||||
|
resultEntries.set(maskId, await smoothMaskGeometry(mask, frame, smoothingStrength));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestMasks = useStore.getState().masks;
|
||||||
|
const historyBaseMasks = latestMasks.map((mask) => (mask.id === selectedMask.id ? baseMask : mask));
|
||||||
|
useStore.setState({ masks: historyBaseMasks });
|
||||||
|
const nextMasks = historyBaseMasks.map((mask) => {
|
||||||
|
const result = resultEntries.get(mask.id);
|
||||||
|
if (!result) return mask;
|
||||||
|
return applySmoothingResultToMask(mask, result, { commit: true });
|
||||||
|
});
|
||||||
|
skipNextAutoAnalysisRef.current = true;
|
||||||
|
setMasks(nextMasks);
|
||||||
|
if (smoothingPreviewRef.current) {
|
||||||
|
smoothingPreviewRef.current.applied = true;
|
||||||
|
}
|
||||||
|
smoothingPreviewRef.current = null;
|
||||||
|
setSmoothingStrength(0);
|
||||||
|
applySmoothingResultToAnalysis(
|
||||||
|
selectedResult,
|
||||||
|
baseMask,
|
||||||
|
resultEntries.size > 1
|
||||||
|
? `,已同步应用到传播链 ${resultEntries.size} 个对应 mask,强度已重置为 0,请保存后生效`
|
||||||
|
: ',已变为新的 mask,强度已重置为 0,请保存后生效',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Mask smoothing failed:', err);
|
||||||
|
setAnalysisMessage('边缘平滑失败,请检查后端服务');
|
||||||
|
} finally {
|
||||||
|
setIsSmoothingMask(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 activeClasses = templateClasses.filter((templateClass) => !isReservedUnclassifiedClass(templateClass));
|
||||||
|
const maxZ = activeClasses.length > 0 ? Math.max(...activeClasses.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(),
|
||||||
color: newClassColor,
|
color: newClassColor,
|
||||||
zIndex: maxZ + 10,
|
zIndex: maxZ + 10,
|
||||||
|
maskId: nextClassMaskId(templateClasses),
|
||||||
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: normalizeClassMaskIds([...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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReorderClass = async (sourceClassId: string, targetClassId: string) => {
|
||||||
|
if (!activeTemplate || sourceClassId === targetClassId) {
|
||||||
|
setDragClassId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourceIndex = allClasses.findIndex((item) => item.id === sourceClassId);
|
||||||
|
const targetIndex = allClasses.findIndex((item) => item.id === targetClassId);
|
||||||
|
if (sourceIndex < 0 || targetIndex < 0) {
|
||||||
|
setDragClassId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isReservedUnclassifiedClass(allClasses[sourceIndex]) || isReservedUnclassifiedClass(allClasses[targetIndex])) {
|
||||||
|
setDragClassId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reordered = [...allClasses];
|
||||||
|
const [source] = reordered.splice(sourceIndex, 1);
|
||||||
|
reordered.splice(targetIndex, 0, source);
|
||||||
|
const nextClasses = normalizeClassMaskIds(
|
||||||
|
reordered
|
||||||
|
.filter((item) => !isReservedUnclassifiedClass(item))
|
||||||
|
.map((item, index, activeItems) => ({
|
||||||
|
...item,
|
||||||
|
zIndex: (activeItems.length - index) * 10,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsSavingClass(true);
|
||||||
|
setClassSaveMessage('正在保存分类覆盖顺序...');
|
||||||
|
try {
|
||||||
|
const updated = await updateTemplate(activeTemplate.id, {
|
||||||
|
name: activeTemplate.name,
|
||||||
|
description: activeTemplate.description,
|
||||||
|
classes: nextClasses,
|
||||||
|
rules: activeTemplate.rules || [],
|
||||||
|
});
|
||||||
|
updateTemplateStore(updated);
|
||||||
|
setActiveTemplateId(updated.id);
|
||||||
|
const zIndexByClassId = new Map(nextClasses.map((item) => [item.id, item.zIndex]));
|
||||||
|
setMasks(useStore.getState().masks.map((mask) => (
|
||||||
|
mask.classId && zIndexByClassId.has(mask.classId)
|
||||||
|
? {
|
||||||
|
...mask,
|
||||||
|
classZIndex: zIndexByClassId.get(mask.classId),
|
||||||
|
saveStatus: mask.annotationId ? 'dirty' as const : mask.saveStatus,
|
||||||
|
saved: mask.annotationId ? false : mask.saved,
|
||||||
|
}
|
||||||
|
: mask
|
||||||
|
)));
|
||||||
|
const nextActiveClass = nextClasses.find((item) => item.id === activeClassId);
|
||||||
|
if (nextActiveClass) setActiveClass(nextActiveClass);
|
||||||
|
setClassSaveMessage('分类覆盖顺序已保存');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Reorder class failed:', err);
|
||||||
|
setClassSaveMessage('分类覆盖顺序保存失败');
|
||||||
|
} finally {
|
||||||
|
setIsSavingClass(false);
|
||||||
|
setDragClassId(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-60 bg-[#0d0d0d] flex flex-col border-l border-white/5 shrink-0 z-10 overflow-hidden">
|
<div className="w-60 bg-[#0d0d0d] flex flex-col border-l border-white/5 shrink-0 z-10 overflow-hidden">
|
||||||
<div className="h-14 border-b border-white/5 flex items-center px-4 shrink-0 font-medium text-[10px] uppercase tracking-widest text-gray-500">
|
<div className="flex-1 overflow-y-auto seg-scrollbar p-4 flex flex-col gap-6">
|
||||||
<Layers size={14} className="mr-2 text-gray-400" />
|
|
||||||
本体论与属性分类管理树
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-6">
|
|
||||||
{/* Template Selector */}
|
{/* Template Selector */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">当前激活模板</h3>
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">当前激活模板</h3>
|
||||||
@@ -83,9 +624,9 @@ export function OntologyInspector() {
|
|||||||
<select
|
<select
|
||||||
value={activeTemplate?.id || ''}
|
value={activeTemplate?.id || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setActiveTemplateId(e.target.value || null);
|
requestActiveTemplateChange(e.target.value || null);
|
||||||
setActiveClass(null);
|
|
||||||
}}
|
}}
|
||||||
|
disabled={isSwitchingTemplate}
|
||||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 appearance-none cursor-pointer focus:outline-none focus:border-cyan-500/50"
|
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 appearance-none cursor-pointer focus:outline-none focus:border-cyan-500/50"
|
||||||
>
|
>
|
||||||
<option value="">-- 选择模板 --</option>
|
<option value="">-- 选择模板 --</option>
|
||||||
@@ -98,33 +639,82 @@ 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>
|
||||||
|
|
||||||
|
{/* Workspace Mask Opacity */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<label htmlFor="workspace-mask-opacity" className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
||||||
|
遮罩透明度
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] font-mono text-cyan-400">{maskPreviewOpacity}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="workspace-mask-opacity"
|
||||||
|
aria-label="遮罩透明度"
|
||||||
|
type="range"
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={maskPreviewOpacity}
|
||||||
|
onChange={(event) => setMaskPreviewOpacity(Number(event.target.value))}
|
||||||
|
className="w-full accent-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Semantic Classification Tree */}
|
{/* Semantic Classification Tree */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex justify-between items-center">
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex justify-between items-center">
|
||||||
<span>语义分类树 (高度/Z-Index)</span>
|
<span>语义分类树(拖拽调层级)</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{allClasses.map(cls => (
|
{allClasses.map(cls => (
|
||||||
<div key={cls.id} className="flex flex-col gap-1">
|
<div key={cls.id} className="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
draggable={Boolean(activeTemplate) && !isSavingClass && !isReservedUnclassifiedClass(cls)}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) {
|
||||||
|
classButtonRefs.current.set(cls.id, node);
|
||||||
|
} else {
|
||||||
|
classButtonRefs.current.delete(cls.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClick={() => handleSelectClass(cls)}
|
onClick={() => handleSelectClass(cls)}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (isReservedUnclassifiedClass(cls)) return;
|
||||||
|
setDragClassId(cls.id);
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', cls.id);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!dragClassId || dragClassId === cls.id || isReservedUnclassifiedClass(cls)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sourceId = event.dataTransfer.getData('text/plain') || dragClassId;
|
||||||
|
if (sourceId) void handleReorderClass(sourceId, cls.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDragClassId(null)}
|
||||||
|
aria-current={activeClassId === cls.id ? 'true' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors text-left border',
|
'flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors text-left border',
|
||||||
activeClassId === cls.id ? 'border-cyan-500/50 bg-cyan-500/10' : 'border-transparent',
|
activeClassId === cls.id ? 'border-cyan-500/50 bg-cyan-500/10' : 'border-transparent',
|
||||||
|
dragClassId === cls.id && 'opacity-50',
|
||||||
|
isReservedUnclassifiedClass(cls) && 'cursor-default',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<GripVertical size={13} className={cn("text-gray-600 group-hover:text-gray-400", isReservedUnclassifiedClass(cls) && "text-gray-800 group-hover:text-gray-800")} aria-hidden="true" />
|
||||||
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: cls.color }} />
|
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: cls.color }} />
|
||||||
<span className="text-xs font-medium text-gray-200">{cls.name}</span>
|
<span className="text-xs font-medium text-gray-200">{cls.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-[10px] text-gray-500 font-mono">z:{cls.zIndex}</span>
|
<span className="text-[10px] text-gray-500 font-mono">maskid:{cls.maskId}</span>
|
||||||
<Eye size={14} className="text-gray-500 group-hover:text-gray-300" />
|
<Eye size={14} className="text-gray-500 group-hover:text-gray-300" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -165,7 +755,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 +763,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 */}
|
||||||
@@ -182,32 +775,77 @@ export function OntologyInspector() {
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Tag size={12} className="text-cyan-400" />
|
<Tag size={12} className="text-cyan-400" />
|
||||||
<span className="text-xs font-semibold text-gray-200">
|
<span className="text-xs font-semibold text-gray-200">
|
||||||
{activeClass?.name || activeTemplate?.name || '未选择'}
|
{selectedMaskLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<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">{selectedMaskIds.length}</span>
|
<span className="text-xs font-mono text-gray-300">{maskAnalysis?.topology_anchor_count ?? 0} 节点</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div>
|
||||||
<label className="text-[10px] text-gray-500 uppercase">感知算法置信度</label>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden">
|
<label htmlFor="mask-edge-smoothing" className="text-[10px] text-gray-500 uppercase">边缘平滑强度:</label>
|
||||||
<div className="h-full bg-green-500 w-[94%]" />
|
<span className="text-xs font-mono text-gray-300">{smoothingStrength}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-mono text-green-500 text-right">0.9412</div>
|
<input
|
||||||
|
id="mask-edge-smoothing"
|
||||||
|
aria-label="边缘平滑强度"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={smoothingStrength}
|
||||||
|
onChange={(event) => void previewSmoothing(Number(event.target.value))}
|
||||||
|
disabled={!selectedMask || isSmoothingMask}
|
||||||
|
className="w-full accent-cyan-500 disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleApplySmoothing}
|
||||||
|
disabled={!selectedMask || !currentFrame || isSmoothingMask || isPreviewingSmoothing}
|
||||||
|
className="mt-2 w-full bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/20 text-xs text-cyan-100 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSmoothingMask ? '平滑中...' : isPreviewingSmoothing ? '预览中...' : '应用边缘平滑'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
{analysisMessage && (
|
||||||
<span className="text-[10px] text-gray-500 uppercase">降维点拓扑锚点:</span>
|
<div className="text-[10px] leading-relaxed text-gray-500">{analysisMessage}</div>
|
||||||
<span className="text-xs font-mono text-gray-300">12 节点</span>
|
)}
|
||||||
</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">
|
|
||||||
重新提取内侧中轴树骨架
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{pendingTemplateSwitch && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-amber-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white">确认修改激活模板</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||||||
|
当前项目已经存在 {masks.length} 个 mask。修改激活模板会清空当前项目中所有已有 mask 和已保存标注,避免旧模板类别继续混用。
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-amber-200/70">
|
||||||
|
项目没有任何 mask 时切换模板不会提示。
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingTemplateSwitch(null)}
|
||||||
|
disabled={isSwitchingTemplate}
|
||||||
|
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void confirmActiveTemplateChange()}
|
||||||
|
disabled={isSwitchingTemplate}
|
||||||
|
className="rounded bg-amber-500 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-400 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSwitchingTemplate ? '正在切换...' : '清空并切换'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } 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';
|
||||||
@@ -7,17 +7,25 @@ import { ProjectLibrary } from './ProjectLibrary';
|
|||||||
const apiMock = vi.hoisted(() => ({
|
const apiMock = vi.hoisted(() => ({
|
||||||
getProjects: vi.fn(),
|
getProjects: vi.fn(),
|
||||||
createProject: vi.fn(),
|
createProject: vi.fn(),
|
||||||
|
updateProject: vi.fn(),
|
||||||
|
copyProject: vi.fn(),
|
||||||
uploadMedia: vi.fn(),
|
uploadMedia: vi.fn(),
|
||||||
parseMedia: vi.fn(),
|
parseMedia: vi.fn(),
|
||||||
uploadDicomBatch: vi.fn(),
|
uploadDicomBatch: vi.fn(),
|
||||||
|
deleteProject: vi.fn(),
|
||||||
|
getTask: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/api', () => ({
|
vi.mock('../lib/api', () => ({
|
||||||
getProjects: apiMock.getProjects,
|
getProjects: apiMock.getProjects,
|
||||||
createProject: apiMock.createProject,
|
createProject: apiMock.createProject,
|
||||||
|
updateProject: apiMock.updateProject,
|
||||||
|
copyProject: apiMock.copyProject,
|
||||||
uploadMedia: apiMock.uploadMedia,
|
uploadMedia: apiMock.uploadMedia,
|
||||||
parseMedia: apiMock.parseMedia,
|
parseMedia: apiMock.parseMedia,
|
||||||
uploadDicomBatch: apiMock.uploadDicomBatch,
|
uploadDicomBatch: apiMock.uploadDicomBatch,
|
||||||
|
deleteProject: apiMock.deleteProject,
|
||||||
|
getTask: apiMock.getTask,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('ProjectLibrary', () => {
|
describe('ProjectLibrary', () => {
|
||||||
@@ -38,22 +46,35 @@ describe('ProjectLibrary', () => {
|
|||||||
fireEvent.click(await screen.findByText('Demo Project'));
|
fireEvent.click(await screen.findByText('Demo Project'));
|
||||||
expect(useStore.getState().currentProject?.id).toBe('p1');
|
expect(useStore.getState().currentProject?.id).toBe('p1');
|
||||||
expect(onProjectSelect).toHaveBeenCalled();
|
expect(onProjectSelect).toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('支持导入视频文件、DICOM序列文件')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a new project from the modal', async () => {
|
it('shows the generated frame sequence FPS on project cards instead of source FPS', async () => {
|
||||||
apiMock.createProject.mockResolvedValueOnce({ id: 'p2', name: 'New Project', status: 'pending' });
|
apiMock.getProjects.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 'p-fps',
|
||||||
|
name: 'Frame Rate Demo',
|
||||||
|
status: 'ready',
|
||||||
|
frames: 120,
|
||||||
|
fps: '12FPS',
|
||||||
|
parse_fps: 12,
|
||||||
|
original_fps: 29.97,
|
||||||
|
video_path: 'uploads/demo.mp4',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
fireEvent.click(screen.getByText('新建项目'));
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('输入项目名称'), { target: { value: 'New Project' } });
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('输入项目描述'), { target: { value: 'desc' } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '创建' }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith({
|
expect(await screen.findByText('12FPS')).toBeInTheDocument();
|
||||||
name: 'New Project',
|
expect(screen.getByText('原 30.0fps')).toBeInTheDocument();
|
||||||
description: 'desc',
|
expect(screen.queryByText('30FPS')).not.toBeInTheDocument();
|
||||||
}));
|
});
|
||||||
expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p2' }));
|
|
||||||
|
it('does not expose manual project creation from the project library header', async () => {
|
||||||
|
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.getProjects).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByRole('button', { name: '新建项目' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports video by creating a project and uploading media without parsing frames', async () => {
|
it('imports video by creating a project and uploading media without parsing frames', async () => {
|
||||||
@@ -70,15 +91,45 @@ describe('ProjectLibrary', () => {
|
|||||||
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith(expect.objectContaining({
|
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
name: 'clip.mp4',
|
name: 'clip.mp4',
|
||||||
})));
|
})));
|
||||||
expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3');
|
expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3', expect.objectContaining({
|
||||||
|
onProgress: expect.any(Function),
|
||||||
|
}));
|
||||||
expect(apiMock.parseMedia).not.toHaveBeenCalled();
|
expect(apiMock.parseMedia).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('视频导入成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visualizes video upload progress while importing media', async () => {
|
||||||
|
let resolveUpload: ((value: { url: string; id: string }) => void) | undefined;
|
||||||
|
apiMock.createProject.mockResolvedValueOnce({ id: 'p-progress', name: 'large.mp4', status: 'pending' });
|
||||||
|
apiMock.uploadMedia.mockImplementationOnce((_file, _projectId, options) => {
|
||||||
|
options.onProgress({ loaded: 50, total: 100, percent: 50 });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveUpload = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
const input = container.querySelector('input[accept="video/*"]') as HTMLInputElement;
|
||||||
|
const file = new File(['video'], 'large.mp4', { type: 'video/mp4' });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '开始导入' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('正在上传视频文件')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '50');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveUpload?.({ url: 'http://file', id: 'object' });
|
||||||
|
});
|
||||||
|
expect(await screen.findByText('视频导入完成')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates frames from an imported video with the selected FPS', async () => {
|
it('generates frames from an imported video with the selected FPS', async () => {
|
||||||
apiMock.getProjects
|
apiMock.getProjects
|
||||||
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'pending', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 30 }])
|
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'pending', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 30 }])
|
||||||
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'parsing', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 12 }]);
|
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'parsing', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 12 }])
|
||||||
|
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'ready', frames: 24, video_path: 'uploads/clip.mp4', parse_fps: 12, thumbnail_url: 'http://thumb/frame.jpg' }]);
|
||||||
apiMock.parseMedia.mockResolvedValueOnce({ id: 22, status: 'queued', progress: 0 });
|
apiMock.parseMedia.mockResolvedValueOnce({ id: 22, status: 'queued', progress: 0 });
|
||||||
|
apiMock.getTask.mockResolvedValueOnce({ id: 22, status: 'success', progress: 100, message: '解析完成' });
|
||||||
|
|
||||||
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
|
||||||
@@ -87,19 +138,214 @@ describe('ProjectLibrary', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '开始生成帧' }));
|
fireEvent.click(screen.getByRole('button', { name: '开始生成帧' }));
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p4', { parseFps: 12 }));
|
await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p4', { parseFps: 12 }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('生成帧任务已入队 #22');
|
||||||
|
expect(await screen.findByText('12FPS')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('正在生成视频帧')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.getTask).toHaveBeenCalledWith(22), { timeout: 2500 });
|
||||||
|
expect(await screen.findByAltText('clip.mp4')).toHaveAttribute('src', 'http://thumb/frame.jpg');
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('视频帧生成完成,项目封面已自动更新');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports only valid DICOM files and parses the returned project', async () => {
|
it('allows regenerating an already parsed video and clears the current workspace cache', async () => {
|
||||||
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 1, message: 'ok' });
|
apiMock.getProjects
|
||||||
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 1 });
|
.mockResolvedValueOnce([{ id: 'p-ready', name: 'ready.mp4', status: 'ready', frames: 1500, video_path: 'uploads/ready.mp4', parse_fps: 30, source_type: 'video' }])
|
||||||
|
.mockResolvedValueOnce([{ id: 'p-ready', name: 'ready.mp4', status: 'parsing', frames: 0, video_path: 'uploads/ready.mp4', parse_fps: 24, source_type: 'video' }]);
|
||||||
|
apiMock.parseMedia.mockResolvedValueOnce({ status: 'queued', progress: 0 });
|
||||||
|
useStore.setState({
|
||||||
|
currentProject: { id: 'p-ready', name: 'ready.mp4', status: 'ready' },
|
||||||
|
frames: [{ id: 'old-frame', projectId: 'p-ready', index: 0, url: '/old.jpg', width: 640, height: 360 }],
|
||||||
|
masks: [{ id: 'old-mask', frameId: 'old-frame', pathData: 'M 0 0 Z', label: 'old', color: '#fff' }],
|
||||||
|
selectedMaskIds: ['old-mask'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '重新生成帧' }));
|
||||||
|
expect(screen.getByText('重新生成会清空该项目现有帧序列、标注和 mask,再按新的 FPS 从源视频生成帧。')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '开始生成帧' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p-ready', { parseFps: 30 }));
|
||||||
|
expect(useStore.getState().frames).toEqual([]);
|
||||||
|
expect(useStore.getState().masks).toEqual([]);
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides frame generation while editing a project name', async () => {
|
||||||
|
apiMock.getProjects.mockResolvedValueOnce([
|
||||||
|
{ id: 'p-edit', name: 'Editable Clip', status: 'pending', frames: 0, video_path: 'uploads/editable.mp4', parse_fps: 30, source_type: 'video' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
expect(await screen.findByRole('button', { name: '生成帧' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '修改项目名称 Editable Clip' }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: '生成帧' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show frame generation for DICOM projects', async () => {
|
||||||
|
apiMock.getProjects.mockResolvedValueOnce([
|
||||||
|
{ id: 'p-dicom', name: 'DICOM Series', status: 'ready', frames: 0, video_path: 'uploads/dicom', source_type: 'dicom' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('DICOM Series')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('DICOM')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: '生成帧' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a project from the project card without entering the workspace', async () => {
|
||||||
|
const onProjectSelect = vi.fn();
|
||||||
|
apiMock.getProjects.mockResolvedValueOnce([
|
||||||
|
{ id: 'p5', name: 'Delete Me', status: 'ready', frames: 3, fps: '30FPS' },
|
||||||
|
{ id: 'p6', name: 'Keep Me', status: 'ready', frames: 1, fps: '30FPS' },
|
||||||
|
]);
|
||||||
|
apiMock.deleteProject.mockResolvedValueOnce(undefined);
|
||||||
|
useStore.setState({
|
||||||
|
currentProject: { id: 'p5', name: 'Delete Me', status: 'ready' },
|
||||||
|
frames: [{ id: 'f1', projectId: 'p5', index: 0, url: '/1.jpg', width: 640, height: 360 }],
|
||||||
|
masks: [{ id: 'm1', frameId: 'f1', pathData: 'M 0 0 Z', label: 'Mask', color: '#06b6d4' }],
|
||||||
|
selectedMaskIds: ['m1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '删除项目 Delete Me' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.deleteProject).toHaveBeenCalledWith('p5'));
|
||||||
|
expect(onProjectSelect).not.toHaveBeenCalled();
|
||||||
|
expect(useStore.getState().projects.map((project) => project.id)).toEqual(['p6']);
|
||||||
|
expect(useStore.getState().currentProject).toBeNull();
|
||||||
|
expect(useStore.getState().frames).toEqual([]);
|
||||||
|
expect(useStore.getState().masks).toEqual([]);
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames a project from the project card without entering the workspace', async () => {
|
||||||
|
const onProjectSelect = vi.fn();
|
||||||
|
apiMock.getProjects.mockResolvedValueOnce([
|
||||||
|
{ id: 'p7', name: 'Old Name', status: 'ready', frames: 3, fps: '30FPS' },
|
||||||
|
]);
|
||||||
|
apiMock.updateProject.mockResolvedValueOnce({ id: 'p7', name: 'New Name', status: 'ready', frames: 3, fps: '30FPS' });
|
||||||
|
useStore.setState({
|
||||||
|
currentProject: { id: 'p7', name: 'Old Name', status: 'ready' },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '修改项目名称 Old Name' }));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存项目名称 Old Name' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.updateProject).toHaveBeenCalledWith('p7', { name: 'New Name' }));
|
||||||
|
expect(onProjectSelect).not.toHaveBeenCalled();
|
||||||
|
expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p7', name: 'New Name' }));
|
||||||
|
expect(useStore.getState().currentProject).toEqual(expect.objectContaining({ id: 'p7', name: 'New Name' }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('项目名称已更新');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a project as a reset project from the project card', async () => {
|
||||||
|
const onProjectSelect = vi.fn();
|
||||||
|
apiMock.getProjects
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 'p8', name: 'Source Project', status: 'ready', frames: 3, fps: '30FPS' },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 'p9', name: 'Source Project 副本', status: 'ready', frames: 3, fps: '30FPS' },
|
||||||
|
{ id: 'p8', name: 'Source Project', status: 'ready', frames: 3, fps: '30FPS' },
|
||||||
|
]);
|
||||||
|
apiMock.copyProject.mockResolvedValueOnce({ id: 'p9', name: 'Source Project 副本', status: 'ready', frames: 3, fps: '30FPS' });
|
||||||
|
|
||||||
|
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '复制项目 Source Project' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /新项目重置/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.copyProject).toHaveBeenCalledWith('p8', { mode: 'reset' }));
|
||||||
|
expect(onProjectSelect).not.toHaveBeenCalled();
|
||||||
|
expect(useStore.getState().projects.map((project) => project.id)).toEqual(['p9', 'p8']);
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('已复制为重置项目:Source Project 副本');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a project with all content from the project card', async () => {
|
||||||
|
apiMock.getProjects
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 'p10', name: 'Annotated Project', status: 'ready', frames: 2, fps: '30FPS' },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 'p11', name: 'Annotated Project 副本', status: 'ready', frames: 2, fps: '30FPS' },
|
||||||
|
{ id: 'p10', name: 'Annotated Project', status: 'ready', frames: 2, fps: '30FPS' },
|
||||||
|
]);
|
||||||
|
apiMock.copyProject.mockResolvedValueOnce({ id: 'p11', name: 'Annotated Project 副本', status: 'ready', frames: 2, fps: '30FPS' });
|
||||||
|
|
||||||
|
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '复制项目 Annotated Project' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /全内容复制/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.copyProject).toHaveBeenCalledWith('p10', { mode: 'full' }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('已全内容复制项目:Annotated Project 副本');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports valid DICOM files in natural filename order and parses the returned project', async () => {
|
||||||
|
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 3, message: 'ok' });
|
||||||
|
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 3 });
|
||||||
|
|
||||||
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
|
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
|
||||||
const dcm = new File(['dcm'], 'scan.dcm', { type: 'application/dicom' });
|
const ten = new File(['dcm10'], '10.dcm', { type: 'application/dicom' });
|
||||||
|
const two = new File(['dcm2'], '2.dcm', { type: 'application/dicom' });
|
||||||
|
const one = new File(['dcm1'], '1.dcm', { type: 'application/dicom' });
|
||||||
const ignored = new File(['txt'], 'notes.txt', { type: 'text/plain' });
|
const ignored = new File(['txt'], 'notes.txt', { type: 'text/plain' });
|
||||||
fireEvent.change(input, { target: { files: [dcm, ignored] } });
|
fireEvent.change(input, { target: { files: [ten, ignored, two, one] } });
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([dcm]));
|
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([one, two, ten], undefined, expect.objectContaining({
|
||||||
|
onProgress: expect.any(Function),
|
||||||
|
})));
|
||||||
expect(apiMock.parseMedia).toHaveBeenCalledWith('77');
|
expect(apiMock.parseMedia).toHaveBeenCalledWith('77');
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('DICOM 导入完成: 3 个文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visualizes DICOM upload progress and parsing queue handoff', async () => {
|
||||||
|
let resolveDicomUpload: ((value: { project_id: number; uploaded_count: number; message: string }) => void) | undefined;
|
||||||
|
apiMock.uploadDicomBatch.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveDicomUpload = resolve;
|
||||||
|
}));
|
||||||
|
apiMock.parseMedia.mockResolvedValueOnce({ id: 44, status: 'queued', progress: 0 });
|
||||||
|
apiMock.getTask
|
||||||
|
.mockResolvedValueOnce({ id: 44, status: 'running', progress: 55, message: '正在写入帧索引' })
|
||||||
|
.mockResolvedValueOnce({ id: 44, status: 'success', progress: 100, message: '解析完成' });
|
||||||
|
|
||||||
|
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
|
||||||
|
const one = new File(['dcm1'], '1.dcm', { type: 'application/dicom' });
|
||||||
|
const two = new File(['dcm2'], '2.dcm', { type: 'application/dicom' });
|
||||||
|
fireEvent.change(input, { target: { files: [two, one] } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalled());
|
||||||
|
const progressOptions = apiMock.uploadDicomBatch.mock.calls[0][2];
|
||||||
|
await act(async () => {
|
||||||
|
progressOptions.onProgress({ loaded: 80, total: 100, percent: 80 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('正在上传 DICOM 序列')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2 文件')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '80');
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await act(async () => {
|
||||||
|
resolveDicomUpload?.({ project_id: 78, uploaded_count: 2, message: 'ok' });
|
||||||
|
});
|
||||||
|
expect(screen.getByText('正在解析 DICOM 序列')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '0');
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1200);
|
||||||
|
});
|
||||||
|
expect(apiMock.getTask).toHaveBeenCalledWith(44);
|
||||||
|
expect(screen.getByText('正在写入帧索引')).toBeInTheDocument();
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1200);
|
||||||
|
});
|
||||||
|
expect(screen.getByText('DICOM 导入完成')).toBeInTheDocument();
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { UploadCloud, Film, Settings2, MoreHorizontal, Plus, Loader2, Activity, Images } from 'lucide-react';
|
import { UploadCloud, Film, Settings2, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch } from '../lib/api';
|
import { getProjects, createProject, updateProject, copyProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject, getTask } from '../lib/api';
|
||||||
|
import type { UploadProgress } from '../lib/api';
|
||||||
import type { Project } from '../store/useStore';
|
import type { Project } from '../store/useStore';
|
||||||
|
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
|
||||||
|
|
||||||
|
const naturalFilenameCompare = (left: File, right: File) => left.name.localeCompare(
|
||||||
|
right.name,
|
||||||
|
undefined,
|
||||||
|
{ numeric: true, sensitivity: 'base' },
|
||||||
|
);
|
||||||
|
|
||||||
interface ProjectLibraryProps {
|
interface ProjectLibraryProps {
|
||||||
onProjectSelect: () => void;
|
onProjectSelect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImportProgressState {
|
||||||
|
kind: 'video' | 'dicom';
|
||||||
|
phase: 'preparing' | 'uploading' | 'queueing' | 'parsing' | 'done' | 'error';
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
percent?: number;
|
||||||
|
fileCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||||
const projects = useStore((state) => state.projects);
|
const projects = useStore((state) => state.projects);
|
||||||
const setProjects = useStore((state) => state.setProjects);
|
const setProjects = useStore((state) => state.setProjects);
|
||||||
|
const currentProject = useStore((state) => state.currentProject);
|
||||||
const setCurrentProject = useStore((state) => state.setCurrentProject);
|
const setCurrentProject = useStore((state) => state.setCurrentProject);
|
||||||
const addProject = useStore((state) => state.addProject);
|
const setFrames = useStore((state) => state.setFrames);
|
||||||
|
const setMasks = useStore((state) => state.setMasks);
|
||||||
|
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
const [newDesc, setNewDesc] = useState('');
|
|
||||||
const [showImportMenu, setShowImportMenu] = useState(false);
|
const [showImportMenu, setShowImportMenu] = useState(false);
|
||||||
const [showVideoConfig, setShowVideoConfig] = useState(false);
|
const [showVideoConfig, setShowVideoConfig] = useState(false);
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
@@ -26,9 +42,154 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
const [showFrameConfig, setShowFrameConfig] = useState(false);
|
const [showFrameConfig, setShowFrameConfig] = useState(false);
|
||||||
const [frameParseFps, setFrameParseFps] = useState(30);
|
const [frameParseFps, setFrameParseFps] = useState(30);
|
||||||
const [isGeneratingFrames, setIsGeneratingFrames] = useState(false);
|
const [isGeneratingFrames, setIsGeneratingFrames] = useState(false);
|
||||||
|
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||||
|
const [deleteProjectTarget, setDeleteProjectTarget] = useState<Project | null>(null);
|
||||||
|
const [copyingProjectId, setCopyingProjectId] = useState<string | null>(null);
|
||||||
|
const [copyProjectTarget, setCopyProjectTarget] = useState<Project | null>(null);
|
||||||
|
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||||
|
const [editingProjectName, setEditingProjectName] = useState('');
|
||||||
|
const [renamingProjectId, setRenamingProjectId] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||||
|
const [importProgress, setImportProgress] = useState<ImportProgressState | null>(null);
|
||||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dicomInputRef = useRef<HTMLInputElement>(null);
|
const dicomInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const showNotice = (message: string, tone: NoticeTone = 'info') => {
|
||||||
|
setNotice({ id: Date.now(), message, tone });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUploadBytes = (value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||||
|
const amount = value / (1024 ** index);
|
||||||
|
return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleProgressDismiss = () => {
|
||||||
|
window.setTimeout(() => setImportProgress((current) => (
|
||||||
|
current?.phase === 'done' ? null : current
|
||||||
|
)), 1400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadProgressDetail = (progress: UploadProgress, fallback: string) => {
|
||||||
|
if (progress.total) {
|
||||||
|
return `${formatUploadBytes(progress.loaded)} / ${formatUploadBytes(progress.total)}`;
|
||||||
|
}
|
||||||
|
return `${fallback},已上传 ${formatUploadBytes(progress.loaded)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForTaskDone = async (
|
||||||
|
taskId: string | number,
|
||||||
|
onProgress: (progress: { progress?: number; message?: string | null; status?: string }) => void,
|
||||||
|
) => {
|
||||||
|
for (;;) {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 1200));
|
||||||
|
const task = await getTask(taskId);
|
||||||
|
onProgress(task);
|
||||||
|
if (['success', 'failed', 'cancelled'].includes(task.status)) return task;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshProjects = async () => {
|
||||||
|
const data = await getProjects();
|
||||||
|
setProjects(data);
|
||||||
|
if (currentProject?.id) {
|
||||||
|
const refreshedCurrentProject = data.find((project) => project.id === currentProject.id);
|
||||||
|
if (refreshedCurrentProject) {
|
||||||
|
setCurrentProject(refreshedCurrentProject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImportProgressPanel = () => {
|
||||||
|
if (!importProgress) return null;
|
||||||
|
const percent = typeof importProgress.percent === 'number'
|
||||||
|
? Math.min(100, Math.max(0, importProgress.percent))
|
||||||
|
: undefined;
|
||||||
|
const toneClass = importProgress.phase === 'error'
|
||||||
|
? 'border-red-500/25 bg-red-950/20'
|
||||||
|
: importProgress.kind === 'dicom'
|
||||||
|
? 'border-emerald-500/25 bg-emerald-950/15'
|
||||||
|
: 'border-cyan-500/25 bg-cyan-950/15';
|
||||||
|
const barClass = importProgress.phase === 'error'
|
||||||
|
? 'bg-red-400'
|
||||||
|
: importProgress.kind === 'dicom'
|
||||||
|
? 'bg-emerald-400'
|
||||||
|
: 'bg-cyan-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="导入进度"
|
||||||
|
className={cn('mb-6 rounded-lg border px-4 py-3 shadow-lg shadow-black/20', toneClass)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-100">
|
||||||
|
{importProgress.phase === 'done' ? (
|
||||||
|
<Check size={16} className="text-emerald-300" />
|
||||||
|
) : importProgress.phase === 'error' ? (
|
||||||
|
<X size={16} className="text-red-300" />
|
||||||
|
) : (
|
||||||
|
<Loader2 size={16} className="animate-spin text-cyan-300" />
|
||||||
|
)}
|
||||||
|
<span>{importProgress.title}</span>
|
||||||
|
{importProgress.fileCount && (
|
||||||
|
<span className="rounded border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] font-mono text-gray-400">
|
||||||
|
{importProgress.fileCount} 文件
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-xs text-gray-400">{importProgress.detail}</div>
|
||||||
|
</div>
|
||||||
|
{percent !== undefined && (
|
||||||
|
<div className="shrink-0 font-mono text-sm text-gray-200">{percent}%</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="导入进度"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={percent}
|
||||||
|
className="mt-3 h-2 overflow-hidden rounded-full bg-black/35"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-200',
|
||||||
|
barClass,
|
||||||
|
percent === undefined && 'w-1/3 animate-pulse',
|
||||||
|
)}
|
||||||
|
style={percent !== undefined ? { width: `${percent}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const frameSequenceLabel = (project: Project) => {
|
||||||
|
if (project.source_type === 'dicom') return 'DICOM';
|
||||||
|
if (project.video_path && (project.frames ?? 0) === 0 && project.status !== 'parsing') return '待生成帧';
|
||||||
|
if (project.parse_fps && project.parse_fps > 0) {
|
||||||
|
const rounded = Math.round(project.parse_fps * 10) / 10;
|
||||||
|
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}FPS`;
|
||||||
|
}
|
||||||
|
return project.fps || '30FPS';
|
||||||
|
};
|
||||||
|
|
||||||
|
const canGenerateFrames = (project: Project) => (
|
||||||
|
project.source_type !== 'dicom'
|
||||||
|
&& Boolean(project.video_path)
|
||||||
|
&& project.status !== 'parsing'
|
||||||
|
&& editingProjectId !== project.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const frameGenerationLabel = (project: Project) => (
|
||||||
|
(project.frames ?? 0) > 0 ? '重新生成帧' : '生成帧'
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
getProjects()
|
getProjects()
|
||||||
@@ -37,27 +198,106 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, [setProjects]);
|
}, [setProjects]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!newName.trim()) return;
|
|
||||||
setIsCreating(true);
|
|
||||||
try {
|
|
||||||
const project = await createProject({ name: newName.trim(), description: newDesc.trim() || undefined });
|
|
||||||
addProject(project);
|
|
||||||
setShowModal(false);
|
|
||||||
setNewName('');
|
|
||||||
setNewDesc('');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create project:', err);
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (project: Project) => {
|
const handleSelect = (project: Project) => {
|
||||||
setCurrentProject(project);
|
setCurrentProject(project);
|
||||||
onProjectSelect();
|
onProjectSelect();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDeleteProject = (project: Project, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (deletingProjectId) return;
|
||||||
|
setDeleteProjectTarget(project);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = async () => {
|
||||||
|
const project = deleteProjectTarget;
|
||||||
|
if (!project || deletingProjectId) return;
|
||||||
|
setDeletingProjectId(project.id);
|
||||||
|
try {
|
||||||
|
await deleteProject(project.id);
|
||||||
|
setProjects(projects.filter((item) => item.id !== project.id));
|
||||||
|
if (currentProject?.id === project.id) {
|
||||||
|
setCurrentProject(null);
|
||||||
|
setFrames([]);
|
||||||
|
setMasks([]);
|
||||||
|
setSelectedMaskIds([]);
|
||||||
|
}
|
||||||
|
setDeleteProjectTarget(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete project failed:', err);
|
||||||
|
showNotice('删除项目失败,请检查后端服务', 'error');
|
||||||
|
} finally {
|
||||||
|
setDeletingProjectId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCopyProject = (project: Project, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setCopyProjectTarget(project);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyProject = async (mode: 'reset' | 'full') => {
|
||||||
|
if (!copyProjectTarget || copyingProjectId) return;
|
||||||
|
setCopyingProjectId(copyProjectTarget.id);
|
||||||
|
try {
|
||||||
|
const copied = await copyProject(copyProjectTarget.id, { mode });
|
||||||
|
const data = await getProjects();
|
||||||
|
setProjects(data);
|
||||||
|
setCopyProjectTarget(null);
|
||||||
|
showNotice(mode === 'full'
|
||||||
|
? `已全内容复制项目:${copied.name}`
|
||||||
|
: `已复制为重置项目:${copied.name}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy project failed:', err);
|
||||||
|
showNotice('复制项目失败,请检查后端服务', 'error');
|
||||||
|
} finally {
|
||||||
|
setCopyingProjectId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginRenameProject = (project: Project, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setEditingProjectId(project.id);
|
||||||
|
setEditingProjectName(project.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRenameProject = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setEditingProjectId(null);
|
||||||
|
setEditingProjectName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitRenameProject = async (project: Project, event?: React.SyntheticEvent) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
event?.stopPropagation();
|
||||||
|
const nextName = editingProjectName.trim();
|
||||||
|
if (!nextName) {
|
||||||
|
showNotice('项目名称不能为空', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextName === project.name) {
|
||||||
|
setEditingProjectId(null);
|
||||||
|
setEditingProjectName('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRenamingProjectId(project.id);
|
||||||
|
try {
|
||||||
|
const updated = await updateProject(project.id, { name: nextName });
|
||||||
|
setProjects(projects.map((item) => (item.id === updated.id ? updated : item)));
|
||||||
|
if (currentProject?.id === updated.id) {
|
||||||
|
setCurrentProject(updated);
|
||||||
|
}
|
||||||
|
setEditingProjectId(null);
|
||||||
|
setEditingProjectName('');
|
||||||
|
showNotice('项目名称已更新', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Rename project failed:', err);
|
||||||
|
showNotice('项目名称修改失败,请检查后端服务', 'error');
|
||||||
|
} finally {
|
||||||
|
setRenamingProjectId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleVideoSelect = (file: File) => {
|
const handleVideoSelect = (file: File) => {
|
||||||
setPendingFile(file);
|
setPendingFile(file);
|
||||||
setShowVideoConfig(true);
|
setShowVideoConfig(true);
|
||||||
@@ -67,18 +307,55 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
if (!pendingFile) return;
|
if (!pendingFile) return;
|
||||||
setShowVideoConfig(false);
|
setShowVideoConfig(false);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'preparing',
|
||||||
|
title: '正在准备视频导入',
|
||||||
|
detail: `创建项目:${pendingFile.name}`,
|
||||||
|
percent: 2,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const newProject = await createProject({
|
const newProject = await createProject({
|
||||||
name: pendingFile.name,
|
name: pendingFile.name,
|
||||||
description: `导入于 ${new Date().toLocaleString()}`,
|
description: `导入于 ${new Date().toLocaleString()}`,
|
||||||
});
|
});
|
||||||
const result = await uploadMedia(pendingFile, String(newProject.id));
|
setImportProgress({
|
||||||
alert(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时,请在项目卡片点击“生成帧”。`);
|
kind: 'video',
|
||||||
|
phase: 'uploading',
|
||||||
|
title: '正在上传视频文件',
|
||||||
|
detail: pendingFile.name,
|
||||||
|
percent: 5,
|
||||||
|
});
|
||||||
|
const result = await uploadMedia(pendingFile, String(newProject.id), {
|
||||||
|
onProgress: (progress) => setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'uploading',
|
||||||
|
title: '正在上传视频文件',
|
||||||
|
detail: uploadProgressDetail(progress, pendingFile.name),
|
||||||
|
percent: progress.percent,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'done',
|
||||||
|
title: '视频导入完成',
|
||||||
|
detail: pendingFile.name,
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
showNotice(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时,请在项目卡片点击“生成帧”。`, 'success');
|
||||||
const data = await getProjects();
|
const data = await getProjects();
|
||||||
setProjects(data);
|
setProjects(data);
|
||||||
|
scheduleProgressDismiss();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload failed:', err);
|
console.error('Upload failed:', err);
|
||||||
alert('上传失败,请检查后端服务');
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'error',
|
||||||
|
title: '视频导入失败',
|
||||||
|
detail: pendingFile.name,
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
showNotice('上传失败,请检查后端服务', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setPendingFile(null);
|
setPendingFile(null);
|
||||||
@@ -95,17 +372,83 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
|
|
||||||
const handleGenerateFrames = async () => {
|
const handleGenerateFrames = async () => {
|
||||||
if (!frameProject?.id) return;
|
if (!frameProject?.id) return;
|
||||||
|
const targetProject = frameProject;
|
||||||
setIsGeneratingFrames(true);
|
setIsGeneratingFrames(true);
|
||||||
try {
|
try {
|
||||||
const task = await parseMedia(frameProject.id, { parseFps: frameParseFps });
|
const task = await parseMedia(targetProject.id, { parseFps: frameParseFps });
|
||||||
alert(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`);
|
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
|
||||||
const data = await getProjects();
|
if (currentProject?.id === targetProject.id) {
|
||||||
setProjects(data);
|
setFrames([]);
|
||||||
|
setMasks([]);
|
||||||
|
setSelectedMaskIds([]);
|
||||||
|
}
|
||||||
|
await refreshProjects();
|
||||||
setShowFrameConfig(false);
|
setShowFrameConfig(false);
|
||||||
setFrameProject(null);
|
setFrameProject(null);
|
||||||
|
if (task.id) {
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'parsing',
|
||||||
|
title: '正在生成视频帧',
|
||||||
|
detail: task.message || `项目:${targetProject.name}`,
|
||||||
|
percent: task.progress ?? 0,
|
||||||
|
});
|
||||||
|
void waitForTaskDone(task.id, (progress) => {
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'parsing',
|
||||||
|
title: '正在生成视频帧',
|
||||||
|
detail: progress.message || `任务状态: ${progress.status || 'running'}`,
|
||||||
|
percent: progress.progress,
|
||||||
|
});
|
||||||
|
}).then(async (completed) => {
|
||||||
|
if (completed.status === 'failed') {
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'error',
|
||||||
|
title: '视频帧生成失败',
|
||||||
|
detail: completed.error || completed.message || targetProject.name,
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
showNotice('视频帧生成失败,请检查后端服务或任务日志', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (completed.status === 'cancelled') {
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'error',
|
||||||
|
title: '视频帧生成已取消',
|
||||||
|
detail: targetProject.name,
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
showNotice('视频帧生成任务已取消', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refreshProjects();
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'done',
|
||||||
|
title: '视频帧生成完成',
|
||||||
|
detail: '项目封面已自动更新',
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
showNotice('视频帧生成完成,项目封面已自动更新', 'success');
|
||||||
|
scheduleProgressDismiss();
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Frame generation polling failed:', err);
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'video',
|
||||||
|
phase: 'error',
|
||||||
|
title: '视频帧生成进度同步失败',
|
||||||
|
detail: targetProject.name,
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
showNotice('视频帧生成进度同步失败,请刷新项目库重试', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Frame generation failed:', err);
|
console.error('Frame generation failed:', err);
|
||||||
alert('生成帧失败,请检查后端服务或项目源文件');
|
showNotice('生成帧失败,请检查后端服务或项目源文件', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingFrames(false);
|
setIsGeneratingFrames(false);
|
||||||
}
|
}
|
||||||
@@ -113,21 +456,91 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
|
|
||||||
const handleDicomUpload = async (files: FileList | null) => {
|
const handleDicomUpload = async (files: FileList | null) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
const dcmFiles = Array.from(files).filter((f) => f.name.toLowerCase().endsWith('.dcm'));
|
const dcmFiles = Array.from(files)
|
||||||
|
.filter((f) => f.name.toLowerCase().endsWith('.dcm'))
|
||||||
|
.sort(naturalFilenameCompare);
|
||||||
if (dcmFiles.length === 0) {
|
if (dcmFiles.length === 0) {
|
||||||
alert('未选择有效的 .dcm 文件');
|
showNotice('未选择有效的 .dcm 文件', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'dicom',
|
||||||
|
phase: 'uploading',
|
||||||
|
title: '正在上传 DICOM 序列',
|
||||||
|
detail: `${dcmFiles.length} 个文件,按文件名自然顺序上传`,
|
||||||
|
percent: 0,
|
||||||
|
fileCount: dcmFiles.length,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const result = await uploadDicomBatch(dcmFiles);
|
const result = await uploadDicomBatch(dcmFiles, undefined, {
|
||||||
await parseMedia(String(result.project_id));
|
onProgress: (progress) => setImportProgress({
|
||||||
alert(`DICOM 上传成功: ${result.uploaded_count} 个文件`);
|
kind: 'dicom',
|
||||||
|
phase: 'uploading',
|
||||||
|
title: '正在上传 DICOM 序列',
|
||||||
|
detail: uploadProgressDetail(progress, `${dcmFiles.length} 个文件`),
|
||||||
|
percent: progress.percent,
|
||||||
|
fileCount: dcmFiles.length,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'dicom',
|
||||||
|
phase: 'queueing',
|
||||||
|
title: 'DICOM 上传完成,正在创建解析任务',
|
||||||
|
detail: `${result.uploaded_count} 个文件已上传`,
|
||||||
|
percent: 92,
|
||||||
|
fileCount: result.uploaded_count,
|
||||||
|
});
|
||||||
|
const task = await parseMedia(String(result.project_id));
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'dicom',
|
||||||
|
phase: 'parsing',
|
||||||
|
title: '正在解析 DICOM 序列',
|
||||||
|
detail: task.message || '解析任务已入队',
|
||||||
|
percent: task.progress ?? 0,
|
||||||
|
fileCount: result.uploaded_count,
|
||||||
|
});
|
||||||
|
if (task.id) {
|
||||||
|
const completed = await waitForTaskDone(task.id, (progress) => {
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'dicom',
|
||||||
|
phase: 'parsing',
|
||||||
|
title: '正在解析 DICOM 序列',
|
||||||
|
detail: progress.message || `任务状态: ${progress.status || 'running'}`,
|
||||||
|
percent: progress.progress,
|
||||||
|
fileCount: result.uploaded_count,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (completed.status === 'failed') {
|
||||||
|
throw new Error(completed.error || completed.message || 'DICOM 解析失败');
|
||||||
|
}
|
||||||
|
if (completed.status === 'cancelled') {
|
||||||
|
throw new Error('DICOM 解析任务已取消');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setImportProgress({
|
||||||
|
kind: 'dicom',
|
||||||
|
phase: 'done',
|
||||||
|
title: 'DICOM 导入完成',
|
||||||
|
detail: `${result.uploaded_count} 个文件已上传并完成解析`,
|
||||||
|
percent: 100,
|
||||||
|
fileCount: result.uploaded_count,
|
||||||
|
});
|
||||||
|
showNotice(`DICOM 导入完成: ${result.uploaded_count} 个文件`, 'success');
|
||||||
const data = await getProjects();
|
const data = await getProjects();
|
||||||
setProjects(data);
|
setProjects(data);
|
||||||
|
scheduleProgressDismiss();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('DICOM upload failed:', err);
|
console.error('DICOM upload failed:', err);
|
||||||
alert('DICOM 上传失败,请检查后端服务');
|
setImportProgress({
|
||||||
|
kind: 'dicom',
|
||||||
|
phase: 'error',
|
||||||
|
title: 'DICOM 导入失败',
|
||||||
|
detail: `${dcmFiles.length} 个文件`,
|
||||||
|
percent: 100,
|
||||||
|
fileCount: dcmFiles.length,
|
||||||
|
});
|
||||||
|
showNotice('DICOM 上传失败,请检查后端服务', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
if (dicomInputRef.current) dicomInputRef.current.value = '';
|
if (dicomInputRef.current) dicomInputRef.current.value = '';
|
||||||
@@ -146,19 +559,13 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
||||||
|
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
|
||||||
<div className="flex justify-between items-end mb-8 border-b border-white/5 pb-6">
|
<div className="flex justify-between items-end mb-8 border-b border-white/5 pb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-medium tracking-tight text-white mb-2">视频与连续帧项目库</h1>
|
<h1 className="text-3xl font-medium tracking-tight text-white mb-2">视频与连续帧项目库</h1>
|
||||||
<p className="text-gray-400 text-sm">上传源文件、按帧解析配置,并结构化管理多媒体资产实体。</p>
|
<p className="text-gray-400 text-sm">支持导入视频文件、DICOM序列文件</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
className="flex items-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 text-gray-200 px-5 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={18} />
|
|
||||||
<span>新建项目</span>
|
|
||||||
</button>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowImportMenu(!showImportMenu)}
|
onClick={() => setShowImportMenu(!showImportMenu)}
|
||||||
@@ -207,6 +614,8 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ImportProgressPanel />
|
||||||
|
|
||||||
{isLoading && projects.length === 0 ? (
|
{isLoading && projects.length === 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
@@ -234,7 +643,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="absolute top-2 right-2 flex gap-2">
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
|
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
|
||||||
{proj.source_type === 'dicom' ? 'DICOM' : (proj.video_path && (proj.frames ?? 0) === 0 ? '待生成帧' : (proj.fps || '30FPS'))}
|
{frameSequenceLabel(proj)}
|
||||||
</span>
|
</span>
|
||||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
|
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
|
||||||
{proj.status === 'ready' ? (
|
{proj.status === 'ready' ? (
|
||||||
@@ -251,8 +660,75 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex flex-col gap-1">
|
<div className="p-4 flex flex-col gap-1">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<h3 className="text-sm font-medium text-gray-200 truncate pr-4" title={proj.name}>{proj.name}</h3>
|
{editingProjectId === proj.id ? (
|
||||||
<button className="text-gray-500 hover:text-gray-300"><MoreHorizontal size={16} /></button>
|
<form
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1 pr-2"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onSubmit={(event) => void commitRenameProject(proj, event)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={editingProjectName}
|
||||||
|
onChange={(event) => setEditingProjectName(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="min-w-0 flex-1 rounded border border-cyan-400/40 bg-black/30 px-2 py-1 text-sm text-gray-100 outline-none focus:border-cyan-300"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`保存项目名称 ${proj.name}`}
|
||||||
|
title="保存名称"
|
||||||
|
disabled={renamingProjectId === proj.id}
|
||||||
|
onClick={(event) => void commitRenameProject(proj, event)}
|
||||||
|
className="text-cyan-300 hover:text-cyan-100 disabled:cursor-wait disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{renamingProjectId === proj.id ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`取消修改项目名称 ${proj.name}`}
|
||||||
|
title="取消"
|
||||||
|
onClick={cancelRenameProject}
|
||||||
|
disabled={renamingProjectId === proj.id}
|
||||||
|
className="text-gray-500 hover:text-gray-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 pr-2">
|
||||||
|
<h3 className="truncate text-sm font-medium text-gray-200" title={proj.name}>{proj.name}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`修改项目名称 ${proj.name}`}
|
||||||
|
title="修改项目名称"
|
||||||
|
onClick={(event) => beginRenameProject(proj, event)}
|
||||||
|
className="shrink-0 text-gray-500 opacity-0 transition-colors hover:text-cyan-300 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`复制项目 ${proj.name}`}
|
||||||
|
title="复制项目"
|
||||||
|
disabled={copyingProjectId === proj.id || deletingProjectId === proj.id || renamingProjectId === proj.id}
|
||||||
|
onClick={(event) => openCopyProject(proj, event)}
|
||||||
|
className="text-gray-500 hover:text-emerald-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||||
|
>
|
||||||
|
{copyingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`删除项目 ${proj.name}`}
|
||||||
|
title="删除项目"
|
||||||
|
disabled={deletingProjectId === proj.id || renamingProjectId === proj.id}
|
||||||
|
onClick={(event) => openDeleteProject(proj, event)}
|
||||||
|
className="text-gray-500 hover:text-red-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||||
|
>
|
||||||
|
{deletingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono mt-2">
|
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono mt-2">
|
||||||
<span className="flex items-center gap-1.5"><Settings2 size={12} /> {proj.frames ?? 0} 帧节点</span>
|
<span className="flex items-center gap-1.5"><Settings2 size={12} /> {proj.frames ?? 0} 帧节点</span>
|
||||||
@@ -260,13 +736,13 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
<span className="flex items-center gap-1.5 text-cyan-400/80"><Activity size={12} /> 原 {proj.original_fps.toFixed(1)}fps</span>
|
<span className="flex items-center gap-1.5 text-cyan-400/80"><Activity size={12} /> 原 {proj.original_fps.toFixed(1)}fps</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{proj.video_path && (proj.frames ?? 0) === 0 && proj.status !== 'parsing' && (
|
{canGenerateFrames(proj) && (
|
||||||
<button
|
<button
|
||||||
onClick={(event) => openFrameConfig(proj, event)}
|
onClick={(event) => openFrameConfig(proj, event)}
|
||||||
className="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-200 hover:bg-cyan-500/20 transition-colors"
|
className="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-200 hover:bg-cyan-500/20 transition-colors"
|
||||||
>
|
>
|
||||||
<Images size={14} />
|
<Images size={14} />
|
||||||
生成帧
|
{frameGenerationLabel(proj)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -275,6 +751,92 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete project confirmation */}
|
||||||
|
{deleteProjectTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl border border-red-500/20 bg-[#111] p-6 shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-white">删除项目</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-gray-400">
|
||||||
|
确认删除项目“<span className="text-gray-100">{deleteProjectTarget.name}</span>”?
|
||||||
|
该操作会删除项目帧、标注、任务记录和相关 mask 元数据,无法撤销。
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteProjectTarget(null)}
|
||||||
|
disabled={deletingProjectId === deleteProjectTarget.id}
|
||||||
|
className="rounded-lg px-4 py-2 text-sm text-gray-400 transition-colors hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeleteProject()}
|
||||||
|
disabled={deletingProjectId === deleteProjectTarget.id}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-400 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{deletingProjectId === deleteProjectTarget.id && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
确认删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Copy project modal */}
|
||||||
|
{copyProjectTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-2">复制项目</h2>
|
||||||
|
<p className="text-sm text-gray-400 mb-5">
|
||||||
|
{copyProjectTarget.name}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopyProject('reset')}
|
||||||
|
disabled={copyingProjectId === copyProjectTarget.id}
|
||||||
|
className="w-full rounded-lg border border-cyan-500/25 bg-cyan-500/10 px-4 py-3 text-left transition-colors hover:bg-cyan-500/20 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium text-cyan-100">新项目重置</span>
|
||||||
|
{copyingProjectId === copyProjectTarget.id && <Loader2 size={16} className="animate-spin text-cyan-200" />}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-gray-500">复制项目媒体和已生成帧序列,清空标注与 mask 内容。</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopyProject('full')}
|
||||||
|
disabled={copyingProjectId === copyProjectTarget.id}
|
||||||
|
className="w-full rounded-lg border border-emerald-500/25 bg-emerald-500/10 px-4 py-3 text-left transition-colors hover:bg-emerald-500/20 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium text-emerald-100">全内容复制</span>
|
||||||
|
{copyingProjectId === copyProjectTarget.id && <Loader2 size={16} className="animate-spin text-emerald-200" />}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-gray-500">复制项目、帧序列、标注和已关联 mask 元数据。</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCopyProjectTarget(null)}
|
||||||
|
disabled={copyingProjectId === copyProjectTarget.id}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Video parse FPS config modal */}
|
{/* Video parse FPS config modal */}
|
||||||
{showVideoConfig && pendingFile && (
|
{showVideoConfig && pendingFile && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
@@ -306,9 +868,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
{showFrameConfig && frameProject && (
|
{showFrameConfig && frameProject && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">生成帧</h2>
|
<h2 className="text-lg font-semibold text-white mb-4">{frameGenerationLabel(frameProject)}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm text-gray-400">项目: <span className="text-gray-200">{frameProject.name}</span></div>
|
<div className="text-sm text-gray-400">项目: <span className="text-gray-200">{frameProject.name}</span></div>
|
||||||
|
{(frameProject.frames ?? 0) > 0 && (
|
||||||
|
<div className="rounded-lg border border-amber-500/25 bg-amber-950/20 px-3 py-2 text-xs leading-5 text-amber-100">
|
||||||
|
重新生成会清空该项目现有帧序列、标注和 mask,再按新的 FPS 从源视频生成帧。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">生成帧率 (FPS)</label>
|
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">生成帧率 (FPS)</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -344,58 +911,6 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New project modal */}
|
|
||||||
{showModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
||||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">新建项目</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">项目名称</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
|
|
||||||
placeholder="输入项目名称"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">描述(可选)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newDesc}
|
|
||||||
onChange={(e) => setNewDesc(e.target.value)}
|
|
||||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
|
|
||||||
placeholder="输入项目描述"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowModal(false); setNewName(''); setNewDesc(''); }}
|
|
||||||
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={isCreating || !newName.trim()}
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all",
|
|
||||||
isCreating || !newName.trim()
|
|
||||||
? "bg-cyan-500/50 text-black/70 cursor-not-allowed"
|
|
||||||
: "bg-cyan-500 hover:bg-cyan-400 text-black"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCreating && <Loader2 size={14} className="animate-spin" />}
|
|
||||||
创建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/components/Sidebar.test.tsx
Normal file
60
src/components/Sidebar.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resetStore } from '../test/storeTestUtils';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
|
vi.mock('./ModelStatusBadge', () => ({
|
||||||
|
ModelStatusBadge: () => <div>模型状态</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows admin user management only for admin users', () => {
|
||||||
|
const setActiveModule = vi.fn();
|
||||||
|
useStore.setState({ currentUser: { id: 1, username: 'admin', role: 'admin' } });
|
||||||
|
|
||||||
|
render(<Sidebar activeModule="dashboard" setActiveModule={setActiveModule} />);
|
||||||
|
|
||||||
|
const adminButton = screen.getByTitle('用户管理');
|
||||||
|
expect(adminButton.querySelector('.lucide-circle-user')).toBeInTheDocument();
|
||||||
|
fireEvent.click(adminButton);
|
||||||
|
expect(setActiveModule).toHaveBeenCalledWith('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides admin user management for non-admin users', () => {
|
||||||
|
useStore.setState({ currentUser: { id: 2, username: 'doctor', role: 'annotator' } });
|
||||||
|
|
||||||
|
render(<Sidebar activeModule="dashboard" setActiveModule={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTitle('用户管理')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an explicit AI-styled icon for AI segmentation', () => {
|
||||||
|
useStore.setState({ currentUser: { id: 2, username: 'doctor', role: 'annotator' } });
|
||||||
|
|
||||||
|
render(<Sidebar activeModule="dashboard" setActiveModule={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByTitle('AI智能分割').querySelector('[data-testid="ai-segmentation-icon"]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the public logo asset for the sidebar logo', () => {
|
||||||
|
render(<Sidebar activeModule="dashboard" setActiveModule={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByAltText('Logo')).toHaveAttribute('src', expect.stringContaining('logo.png'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a logout icon and prevents the logout tooltip from catching workspace hover', () => {
|
||||||
|
useStore.setState({ currentUser: { id: 1, username: 'admin', role: 'admin' } });
|
||||||
|
|
||||||
|
render(<Sidebar activeModule="dashboard" setActiveModule={vi.fn()} />);
|
||||||
|
|
||||||
|
const logoutButton = screen.getByTitle('当前用户:admin,点击退出');
|
||||||
|
expect(logoutButton.querySelector('.lucide-log-out')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('admin / 退出')).toHaveClass('pointer-events-none');
|
||||||
|
expect(screen.getByText('admin / 退出')).toHaveClass('invisible');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Home, FolderOpen, Edit3, LayoutTemplate, BrainCircuit } from 'lucide-react';
|
import { Home, FolderOpen, Edit3, LayoutTemplate, LogOut, UserCircle } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import type { ActiveModule } from '../App';
|
import type { ActiveModule } from '../App';
|
||||||
import { ModelStatusBadge } from './ModelStatusBadge';
|
import { ModelStatusBadge } from './ModelStatusBadge';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { AiSegmentationIcon } from './AiSegmentationIcon';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeModule: ActiveModule;
|
activeModule: ActiveModule;
|
||||||
@@ -10,18 +12,21 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
||||||
|
const currentUser = useStore((state) => state.currentUser);
|
||||||
|
const logout = useStore((state) => state.logout);
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard', icon: Home, label: '总体概况' },
|
{ id: 'dashboard', icon: Home, label: '总体概况' },
|
||||||
{ id: 'projects', icon: FolderOpen, label: '项目库' },
|
{ id: 'projects', icon: FolderOpen, label: '项目库' },
|
||||||
{ id: 'workspace', icon: Edit3, label: '分割工作区' },
|
{ id: 'workspace', icon: Edit3, label: '分割工作区' },
|
||||||
{ id: 'ai', icon: BrainCircuit, label: 'AI智能分割' },
|
{ id: 'ai', icon: AiSegmentationIcon, label: 'AI智能分割' },
|
||||||
{ id: 'templates', icon: LayoutTemplate, label: '模板库' },
|
{ id: 'templates', icon: LayoutTemplate, label: '模板库' },
|
||||||
|
...(currentUser?.role === 'admin' ? [{ id: 'admin', icon: UserCircle, label: '用户管理' }] : []),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-16 flex flex-col items-center py-6 bg-[#0d0d0d] border-r border-white/10 z-50 gap-8">
|
<aside className="w-16 flex flex-col items-center py-6 bg-[#0d0d0d] border-r border-white/10 z-50 gap-8">
|
||||||
<div className="w-10 h-10 rounded-lg overflow-hidden flex items-center justify-center bg-white">
|
<div className="w-10 h-10 rounded-lg overflow-hidden flex items-center justify-center bg-white">
|
||||||
<img src="/logo.png" alt="Logo" className="w-full h-full object-cover" />
|
<img src="/logo.png" alt="Logo" className="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-col gap-6 w-full px-2">
|
<nav className="flex flex-col gap-6 w-full px-2">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
@@ -49,6 +54,17 @@ export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="mt-auto mb-4 flex flex-col gap-4">
|
<div className="mt-auto mb-4 flex flex-col gap-4">
|
||||||
<ModelStatusBadge compact />
|
<ModelStatusBadge compact />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={currentUser ? `当前用户:${currentUser.username},点击退出` : '退出登录'}
|
||||||
|
onClick={logout}
|
||||||
|
className="group relative flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-gray-400 transition-colors hover:border-red-400/40 hover:bg-red-500/10 hover:text-red-200"
|
||||||
|
>
|
||||||
|
<LogOut size={20} />
|
||||||
|
<span className="pointer-events-none invisible absolute left-full ml-2 whitespace-nowrap rounded border border-[#333] bg-[#222] px-2 py-1 text-xs text-gray-200 opacity-0 shadow-xl transition-all group-hover:visible group-hover:opacity-100">
|
||||||
|
{currentUser ? `${currentUser.username} / 退出` : '退出登录'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ vi.mock('../lib/api', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('TemplateRegistry', () => {
|
describe('TemplateRegistry', () => {
|
||||||
|
const makeDataTransfer = () => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
effectAllowed: '',
|
||||||
|
dropEffect: '',
|
||||||
|
setData: vi.fn((key: string, value: string) => store.set(key, value)),
|
||||||
|
getData: vi.fn((key: string) => store.get(key) || ''),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetStore();
|
resetStore();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -39,6 +49,10 @@ describe('TemplateRegistry', () => {
|
|||||||
|
|
||||||
expect(await screen.findAllByText('腹腔镜胆囊切除术')).toHaveLength(2);
|
expect(await screen.findAllByText('腹腔镜胆囊切除术')).toHaveLength(2);
|
||||||
expect(screen.getByText('胆囊')).toBeInTheDocument();
|
expect(screen.getByText('胆囊')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/maskid: ?1/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('待分类')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/maskid: ?0/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/Z-Level/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a template and stores it globally', async () => {
|
it('creates a template and stores it globally', async () => {
|
||||||
@@ -47,7 +61,7 @@ describe('TemplateRegistry', () => {
|
|||||||
id: 't2',
|
id: 't2',
|
||||||
name: 'New Template',
|
name: 'New Template',
|
||||||
description: 'desc',
|
description: 'desc',
|
||||||
classes: [],
|
classes: [expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' })],
|
||||||
rules: [],
|
rules: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +74,15 @@ describe('TemplateRegistry', () => {
|
|||||||
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
|
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
name: 'New Template',
|
name: 'New Template',
|
||||||
description: 'desc',
|
description: 'desc',
|
||||||
classes: [],
|
classes: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'reserved-unclassified',
|
||||||
|
name: '待分类',
|
||||||
|
color: '#000000',
|
||||||
|
zIndex: 0,
|
||||||
|
maskId: 0,
|
||||||
|
}),
|
||||||
|
],
|
||||||
rules: [],
|
rules: [],
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
z_index: 0,
|
z_index: 0,
|
||||||
@@ -75,14 +97,81 @@ describe('TemplateRegistry', () => {
|
|||||||
fireEvent.click(screen.getByText('新建方案'));
|
fireEvent.click(screen.getByText('新建方案'));
|
||||||
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'With Classes' } });
|
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'With Classes' } });
|
||||||
fireEvent.click(screen.getByText('批量导入'));
|
fireEvent.click(screen.getByText('批量导入'));
|
||||||
|
expect(screen.queryByText('📋 载入腹腔镜胆囊切除术模板')).not.toBeInTheDocument();
|
||||||
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
|
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
|
||||||
target: { value: '{"colors":[[255,0,0]],"names":["分类A"]}' },
|
target: { value: '{"colors":[[255,0,0]],"names":["分类A"]}' },
|
||||||
});
|
});
|
||||||
|
expect(screen.getByText(/将导入 1 个分类,maskid 从 1 开始分配/)).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '导入' }));
|
fireEvent.click(screen.getByRole('button', { name: '导入' }));
|
||||||
|
|
||||||
expect(screen.getByText('分类A')).toBeInTheDocument();
|
expect(screen.getByText('分类A')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('imports the array colors/names format into the edit modal', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
fireEvent.click(screen.getByText('新建方案'));
|
||||||
|
fireEvent.click(screen.getByText('批量导入'));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
|
||||||
|
target: { value: '[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/将导入 2 个分类,maskid 从 1 开始分配/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '导入' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('分类A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('分类B')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports pasted color/name content with a label prefix and loose object keys', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
fireEvent.click(screen.getByText('新建方案'));
|
||||||
|
fireEvent.click(screen.getByText('批量导入'));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
|
||||||
|
target: {
|
||||||
|
value: `批量导入分类:{
|
||||||
|
colors: [[255,0,0], [0,255,0],],
|
||||||
|
names: ['分类A', '分类B',],
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/将导入 2 个分类,maskid 从 1 开始分配/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '导入' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('分类A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('分类B')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows JSON import errors inline instead of blocking alerts', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
fireEvent.click(screen.getByText('新建方案'));
|
||||||
|
fireEvent.click(screen.getByText('批量导入'));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
|
||||||
|
target: { value: '{broken-json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('JSON 解析失败')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '导入' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows template save errors as transient notices', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([]);
|
||||||
|
apiMock.createTemplate.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
fireEvent.click(screen.getByText('新建方案'));
|
||||||
|
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'Bad Template' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('保存失败,请查看控制台');
|
||||||
|
});
|
||||||
|
|
||||||
it('edits an existing template through the backend and store', async () => {
|
it('edits an existing template through the backend and store', async () => {
|
||||||
apiMock.getTemplates.mockResolvedValueOnce([
|
apiMock.getTemplates.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
@@ -104,7 +193,7 @@ describe('TemplateRegistry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(<TemplateRegistry />);
|
render(<TemplateRegistry />);
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /修改库视图结构/ }));
|
fireEvent.click(await screen.findByRole('button', { name: '编辑模板 旧模板' }));
|
||||||
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: '新模板' } });
|
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: '新模板' } });
|
||||||
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'new desc' } });
|
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'new desc' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||||
@@ -112,7 +201,10 @@ describe('TemplateRegistry', () => {
|
|||||||
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||||
name: '新模板',
|
name: '新模板',
|
||||||
description: 'new desc',
|
description: 'new desc',
|
||||||
classes: [expect.objectContaining({ id: 'c1', name: '胆囊' })],
|
classes: [
|
||||||
|
expect.objectContaining({ id: 'c1', name: '胆囊' }),
|
||||||
|
expect.objectContaining({ name: '待分类', maskId: 0 }),
|
||||||
|
],
|
||||||
rules: [],
|
rules: [],
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
z_index: 3,
|
z_index: 3,
|
||||||
@@ -121,6 +213,229 @@ describe('TemplateRegistry', () => {
|
|||||||
id: 't1',
|
id: 't1',
|
||||||
name: '新模板',
|
name: '新模板',
|
||||||
}));
|
}));
|
||||||
|
expect(await screen.findAllByText('新模板')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose legacy category metadata while editing template classes', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
name: '模板',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, category: '器官' }],
|
||||||
|
rules: [],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '编辑模板 模板' }));
|
||||||
|
fireEvent.click(screen.getAllByText('胆囊').at(-1)!);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('胆囊')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByDisplayValue('器官')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByPlaceholderText('类别')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the semantic tree title and opens the edit-template modal from the detail view', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
name: '模板',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' }],
|
||||||
|
rules: [],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||||
|
id: 't1',
|
||||||
|
name: '模板',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [
|
||||||
|
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||||
|
{ id: 'new-class', name: '新类别', color: '#00ff00', zIndex: 20, maskId: 2, category: '未分类' },
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('语义分类树(拖拽调层级)')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Painter's Algorithm Weight/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('器官')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /新建分类/ })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '编辑模板' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /添加分类/ }));
|
||||||
|
expect(screen.getByDisplayValue('新类别')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||||
|
classes: [
|
||||||
|
expect.objectContaining({ id: 'c1', name: '胆囊', maskId: 1 }),
|
||||||
|
expect.objectContaining({ name: '新类别', maskId: 2, category: '未分类' }),
|
||||||
|
expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' }),
|
||||||
|
],
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a class directly from the semantic tree', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
name: '模板',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [
|
||||||
|
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
|
||||||
|
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||||
|
id: 't1',
|
||||||
|
name: '模板',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [
|
||||||
|
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||||
|
{ id: 'reserved-unclassified', name: '待分类', color: '#000000', zIndex: 0, maskId: 0, category: '系统保留' },
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '删除分类 肝脏' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||||
|
classes: [
|
||||||
|
expect.objectContaining({ id: 'c1', name: '胆囊', zIndex: 10, maskId: 1 }),
|
||||||
|
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
|
||||||
|
],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
})));
|
||||||
|
await waitFor(() => expect(useStore.getState().templates[0].classes).toEqual([
|
||||||
|
expect.objectContaining({ id: 'c1', name: '胆囊' }),
|
||||||
|
expect.objectContaining({ name: '待分类', maskId: 0 }),
|
||||||
|
]));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('分类已删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a template from the active template list into a new editable template', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
name: '头颈部CT分割',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [
|
||||||
|
{ id: 'c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
|
||||||
|
{ id: 'c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
|
||||||
|
],
|
||||||
|
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
|
||||||
|
color: '#ef4444',
|
||||||
|
z_index: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't2',
|
||||||
|
name: '头颈部CT分割 副本',
|
||||||
|
description: 'existing copy',
|
||||||
|
classes: [],
|
||||||
|
rules: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.createTemplate.mockResolvedValueOnce({
|
||||||
|
id: 't3',
|
||||||
|
name: '头颈部CT分割 副本 2',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [
|
||||||
|
{ id: 'copy-c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
|
||||||
|
{ id: 'copy-c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
|
||||||
|
],
|
||||||
|
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
|
||||||
|
color: '#ef4444',
|
||||||
|
z_index: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '复制模板 头颈部CT分割' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
name: '头颈部CT分割 副本 2',
|
||||||
|
description: 'desc',
|
||||||
|
color: '#ef4444',
|
||||||
|
z_index: 10,
|
||||||
|
rules: [expect.objectContaining({ id: 'r1' })],
|
||||||
|
classes: [
|
||||||
|
expect.objectContaining({ name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' }),
|
||||||
|
expect.objectContaining({ name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' }),
|
||||||
|
expect.objectContaining({ name: '待分类', color: '#000000', zIndex: 0, maskId: 0 }),
|
||||||
|
],
|
||||||
|
})));
|
||||||
|
const payload = apiMock.createTemplate.mock.calls[0][0];
|
||||||
|
expect(payload.classes[0].id).toMatch(/^cls-copy-/);
|
||||||
|
expect(payload.classes[0].id).not.toBe('c1');
|
||||||
|
expect(useStore.getState().templates.some((template) => template.id === 't3')).toBe(true);
|
||||||
|
expect(await screen.findByText('已复制模板:头颈部CT分割 副本 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists dragged class layer order directly from the template detail view', async () => {
|
||||||
|
apiMock.getTemplates.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
name: '模板',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [
|
||||||
|
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
|
||||||
|
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||||
|
id: 't1',
|
||||||
|
name: '模板',
|
||||||
|
description: 'desc',
|
||||||
|
classes: [
|
||||||
|
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
|
||||||
|
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, maskId: 2, category: '器官' },
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TemplateRegistry />);
|
||||||
|
const gallbladderRow = (await screen.findByText('胆囊')).closest('[draggable="true"]') as HTMLElement;
|
||||||
|
const liverRow = screen.getByText('肝脏').closest('[draggable="true"]') as HTMLElement;
|
||||||
|
const dataTransfer = makeDataTransfer();
|
||||||
|
|
||||||
|
fireEvent.dragStart(gallbladderRow, { dataTransfer });
|
||||||
|
fireEvent.dragOver(liverRow, { dataTransfer });
|
||||||
|
fireEvent.drop(liverRow, { dataTransfer });
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||||
|
classes: [
|
||||||
|
expect.objectContaining({ id: 'c1', zIndex: 20, maskId: 1 }),
|
||||||
|
expect.objectContaining({ id: 'c2', zIndex: 10, maskId: 2 }),
|
||||||
|
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
|
||||||
|
],
|
||||||
|
color: '#06b6d4',
|
||||||
|
z_index: 3,
|
||||||
|
})));
|
||||||
|
await waitFor(() => expect(useStore.getState().templates[0].classes[0]).toEqual(
|
||||||
|
expect.objectContaining({ id: 'c1', zIndex: 20 }),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes an existing template after confirmation', async () => {
|
it('deletes an existing template after confirmation', async () => {
|
||||||
@@ -137,8 +452,8 @@ describe('TemplateRegistry', () => {
|
|||||||
const { container } = render(<TemplateRegistry />);
|
const { container } = render(<TemplateRegistry />);
|
||||||
|
|
||||||
await screen.findAllByText('待删除模板');
|
await screen.findAllByText('待删除模板');
|
||||||
const buttons = Array.from(container.querySelectorAll('button'));
|
fireEvent.click(container.querySelector('button[title="删除模板"]') as HTMLElement);
|
||||||
fireEvent.click(buttons[2]);
|
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.deleteTemplate).toHaveBeenCalledWith('t1'));
|
await waitFor(() => expect(apiMock.deleteTemplate).toHaveBeenCalledWith('t1'));
|
||||||
expect(useStore.getState().templates).toEqual([]);
|
expect(useStore.getState().templates).toEqual([]);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { Settings, Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import } from 'lucide-react';
|
import { Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import, Copy } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api';
|
import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api';
|
||||||
|
import { RESERVED_UNCLASSIFIED_CLASS, isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
|
||||||
import type { Template, TemplateClass } from '../store/useStore';
|
import type { Template, TemplateClass } from '../store/useStore';
|
||||||
|
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
|
||||||
|
|
||||||
// HSL to Hex color generator
|
// HSL to Hex color generator
|
||||||
function hslToHex(h: number, s: number, l: number): string {
|
function hslToHex(h: number, s: number, l: number): string {
|
||||||
@@ -22,23 +24,55 @@ function generateColor(index: number, total: number): string {
|
|||||||
return hslToHex(hue, 75, 55);
|
return hslToHex(hue, 75, 55);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LAPAROSCOPIC_COLORS = [
|
const extractImportJsonText = (raw: string): string => {
|
||||||
[134, 124, 118], [0, 157, 142], [245, 161, 0], [255, 172, 159], [146, 175, 236], [155, 62, 0],
|
let text = raw.trim();
|
||||||
[255, 91, 0], [255, 234, 0], [85, 111, 181], [155, 132, 0], [181, 227, 14], [72, 0, 255],
|
const fenceMatch = text.match(/^```(?:json|javascript|js)?\s*([\s\S]*?)\s*```$/i);
|
||||||
[255, 0, 255], [29, 32, 136], [240, 16, 116], [160, 15, 95], [0, 155, 33], [0, 160, 233],
|
if (fenceMatch) text = fenceMatch[1].trim();
|
||||||
[52, 184, 178], [66, 115, 82], [90, 120, 41], [255, 0, 0], [117, 0, 0], [167, 24, 233],
|
|
||||||
[42, 8, 66], [112, 113, 150], [0, 255, 0], [255, 255, 255], [0, 255, 255], [181, 85, 105],
|
|
||||||
[113, 102, 140], [202, 202, 200], [197, 83, 181], [136, 162, 196], [138, 251, 213],
|
|
||||||
];
|
|
||||||
|
|
||||||
const LAPAROSCOPIC_NAMES = [
|
const firstObject = text.indexOf('{');
|
||||||
'针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
|
const firstArray = text.indexOf('[');
|
||||||
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
|
const starts = [firstObject, firstArray].filter((index) => index >= 0);
|
||||||
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
|
if (starts.length > 0) {
|
||||||
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
|
const start = Math.min(...starts);
|
||||||
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
|
const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'));
|
||||||
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
|
if (end >= start) text = text.slice(start, end + 1);
|
||||||
];
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseImportJson = (raw: string): any => {
|
||||||
|
const text = extractImportJsonText(raw);
|
||||||
|
const attempts = [
|
||||||
|
text,
|
||||||
|
text
|
||||||
|
.replace(/[,]/g, ',')
|
||||||
|
.replace(/[:]/g, ':')
|
||||||
|
.replace(/([{,]\s*)(colors|names|color|name)(\s*:)/gi, '$1"$2"$3')
|
||||||
|
.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_match, inner) => `"${inner.replace(/"/g, '\\"')}"`)
|
||||||
|
.replace(/,\s*([}\]])/g, '$1'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of attempts) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(candidate);
|
||||||
|
} catch {
|
||||||
|
// Try the next normalized candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('JSON 解析失败');
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeImportRgb = (value: unknown, index: number): [number, number, number] => {
|
||||||
|
if (value === undefined) return [100, 100, 100];
|
||||||
|
if (!Array.isArray(value) || value.length < 3) {
|
||||||
|
throw new Error(`第 ${index + 1} 个颜色不是 [R,G,B] 三元组`);
|
||||||
|
}
|
||||||
|
const rgb = value.slice(0, 3).map((part) => Number(part));
|
||||||
|
if (rgb.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) {
|
||||||
|
throw new Error(`第 ${index + 1} 个颜色值必须在 0-255 之间`);
|
||||||
|
}
|
||||||
|
return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2])];
|
||||||
|
};
|
||||||
|
|
||||||
export function TemplateRegistry() {
|
export function TemplateRegistry() {
|
||||||
const templates = useStore((state) => state.templates);
|
const templates = useStore((state) => state.templates);
|
||||||
@@ -46,11 +80,15 @@ export function TemplateRegistry() {
|
|||||||
const addTemplate = useStore((state) => state.addTemplate);
|
const addTemplate = useStore((state) => state.addTemplate);
|
||||||
const updateTemplateStore = useStore((state) => state.updateTemplate);
|
const updateTemplateStore = useStore((state) => state.updateTemplate);
|
||||||
const removeTemplateStore = useStore((state) => state.removeTemplate);
|
const removeTemplateStore = useStore((state) => state.removeTemplate);
|
||||||
|
const setMasks = useStore((state) => state.setMasks);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isSavingOrder, setIsSavingOrder] = useState(false);
|
||||||
|
const [copyingTemplateId, setCopyingTemplateId] = useState<string | null>(null);
|
||||||
|
const [deleteTemplateTarget, setDeleteTemplateTarget] = useState<Template | null>(null);
|
||||||
const [showImport, setShowImport] = useState(false);
|
const [showImport, setShowImport] = useState(false);
|
||||||
const [importText, setImportText] = useState('');
|
const [importText, setImportText] = useState('');
|
||||||
|
|
||||||
@@ -59,6 +97,13 @@ export function TemplateRegistry() {
|
|||||||
const [editClasses, setEditClasses] = useState<TemplateClass[]>([]);
|
const [editClasses, setEditClasses] = useState<TemplateClass[]>([]);
|
||||||
const [editingClassId, setEditingClassId] = useState<string | null>(null);
|
const [editingClassId, setEditingClassId] = useState<string | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
const [detailDragClassId, setDetailDragClassId] = useState<string | null>(null);
|
||||||
|
const [detailDragOverClassId, setDetailDragOverClassId] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||||
|
|
||||||
|
const showNotice = (message: string, tone: NoticeTone = 'info') => {
|
||||||
|
setNotice({ id: Date.now(), message, tone });
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -72,7 +117,7 @@ export function TemplateRegistry() {
|
|||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
setEditName('');
|
setEditName('');
|
||||||
setEditDesc('');
|
setEditDesc('');
|
||||||
setEditClasses([]);
|
setEditClasses(normalizeClassMaskIds([]));
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,10 +125,69 @@ export function TemplateRegistry() {
|
|||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
setEditName(template.name);
|
setEditName(template.name);
|
||||||
setEditDesc(template.description || '');
|
setEditDesc(template.description || '');
|
||||||
setEditClasses(template.classes ? [...template.classes] : []);
|
setEditClasses(normalizeClassMaskIds(template.classes ? [...template.classes] : []));
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildNewClass = (classes: TemplateClass[]): TemplateClass => ({
|
||||||
|
id: `cls-${Date.now()}`,
|
||||||
|
name: '新类别',
|
||||||
|
color: generateColor(classes.length, Math.max(classes.length + 1, 8)),
|
||||||
|
zIndex: classes.length > 0 ? Math.max(...classes.map((c) => c.zIndex)) + 10 : 10,
|
||||||
|
maskId: nextClassMaskId(classes),
|
||||||
|
category: '未分类',
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildTemplatePayload = (template: Template | null, classes: TemplateClass[]) => ({
|
||||||
|
name: template ? template.name : editName.trim(),
|
||||||
|
description: template ? template.description || undefined : editDesc.trim() || undefined,
|
||||||
|
classes: normalizeClassMaskIds(classes),
|
||||||
|
rules: template ? template.rules || [] : [],
|
||||||
|
color: template ? template.color || '#06b6d4' : '#06b6d4',
|
||||||
|
z_index: template ? template.z_index ?? 0 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextCopyName = (name: string) => {
|
||||||
|
const baseName = `${name} 副本`;
|
||||||
|
const existingNames = new Set(templates.map((template) => template.name));
|
||||||
|
if (!existingNames.has(baseName)) return baseName;
|
||||||
|
|
||||||
|
let suffix = 2;
|
||||||
|
while (existingNames.has(`${baseName} ${suffix}`)) {
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
return `${baseName} ${suffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyTemplateClasses = (template: Template) => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
return normalizeClassMaskIds(template.classes || []).map((templateClass, index) => ({
|
||||||
|
...templateClass,
|
||||||
|
id: isReservedUnclassifiedClass(templateClass) ? RESERVED_UNCLASSIFIED_CLASS.id : `cls-copy-${timestamp}-${index}`,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const recalculateClassOrder = (classes: TemplateClass[]) => (
|
||||||
|
normalizeClassMaskIds(classes)
|
||||||
|
.filter((templateClass) => !isReservedUnclassifiedClass(templateClass))
|
||||||
|
.map((templateClass, index, activeClasses) => ({ ...templateClass, zIndex: (activeClasses.length - index) * 10 }))
|
||||||
|
.concat(normalizeClassMaskIds(classes).filter(isReservedUnclassifiedClass))
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncMaskClassOrder = (classes: TemplateClass[]) => {
|
||||||
|
const zIndexByClassId = new Map(classes.map((templateClass) => [templateClass.id, templateClass.zIndex]));
|
||||||
|
setMasks(useStore.getState().masks.map((mask) => (
|
||||||
|
mask.classId && zIndexByClassId.has(mask.classId)
|
||||||
|
? {
|
||||||
|
...mask,
|
||||||
|
classZIndex: zIndexByClassId.get(mask.classId),
|
||||||
|
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
|
||||||
|
saved: mask.annotationId ? false : mask.saved,
|
||||||
|
}
|
||||||
|
: mask
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editName.trim()) return;
|
if (!editName.trim()) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -91,49 +195,71 @@ export function TemplateRegistry() {
|
|||||||
const basePayload = {
|
const basePayload = {
|
||||||
name: editName.trim(),
|
name: editName.trim(),
|
||||||
description: editDesc.trim() || undefined,
|
description: editDesc.trim() || undefined,
|
||||||
classes: editClasses,
|
classes: normalizeClassMaskIds(editClasses),
|
||||||
rules: [],
|
rules: [],
|
||||||
color: selectedTemplate ? (selectedTemplate as any).color || '#06b6d4' : '#06b6d4',
|
color: selectedTemplate ? selectedTemplate.color || '#06b6d4' : '#06b6d4',
|
||||||
z_index: selectedTemplate ? (selectedTemplate as any).z_index ?? 0 : 0,
|
z_index: selectedTemplate ? selectedTemplate.z_index ?? 0 : 0,
|
||||||
};
|
};
|
||||||
if (selectedTemplate) {
|
if (selectedTemplate) {
|
||||||
const updated = await updateTemplate(selectedTemplate.id, basePayload);
|
const updated = await updateTemplate(selectedTemplate.id, basePayload);
|
||||||
updateTemplateStore(updated);
|
updateTemplateStore(updated);
|
||||||
|
setSelectedTemplate(updated);
|
||||||
|
syncMaskClassOrder(normalizeClassMaskIds(updated.classes || []));
|
||||||
} else {
|
} else {
|
||||||
const created = await createTemplate(basePayload);
|
const created = await createTemplate(basePayload);
|
||||||
addTemplate(created);
|
addTemplate(created);
|
||||||
|
setSelectedTemplate(created);
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save template:', err);
|
console.error('Failed to save template:', err);
|
||||||
alert('保存失败,请查看控制台');
|
showNotice('保存失败,请查看控制台', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleCopy = async (template: Template) => {
|
||||||
if (!confirm('确定要删除此模板吗?')) return;
|
setCopyingTemplateId(template.id);
|
||||||
try {
|
try {
|
||||||
await deleteTemplate(id);
|
const created = await createTemplate({
|
||||||
removeTemplateStore(id);
|
name: nextCopyName(template.name),
|
||||||
if (selectedTemplate?.id === id) {
|
description: template.description || undefined,
|
||||||
|
classes: copyTemplateClasses(template),
|
||||||
|
rules: template.rules || [],
|
||||||
|
color: template.color || '#06b6d4',
|
||||||
|
z_index: template.z_index ?? 0,
|
||||||
|
});
|
||||||
|
addTemplate(created);
|
||||||
|
setSelectedTemplate(created);
|
||||||
|
showNotice(`已复制模板:${created.name}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy template:', err);
|
||||||
|
showNotice('复制失败,请检查后端服务', 'error');
|
||||||
|
} finally {
|
||||||
|
setCopyingTemplateId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
const target = deleteTemplateTarget;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await deleteTemplate(target.id);
|
||||||
|
removeTemplateStore(target.id);
|
||||||
|
if (selectedTemplate?.id === target.id) {
|
||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
}
|
}
|
||||||
|
setDeleteTemplateTarget(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete template:', err);
|
console.error('Failed to delete template:', err);
|
||||||
|
showNotice('删除失败,请检查后端服务', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addClass = () => {
|
const addClass = () => {
|
||||||
const newClass: TemplateClass = {
|
const newClass = buildNewClass(editClasses);
|
||||||
id: `cls-${Date.now()}`,
|
setEditClasses(recalculateClassOrder([...editClasses, newClass]));
|
||||||
name: '新类别',
|
|
||||||
color: generateColor(editClasses.length, Math.max(editClasses.length + 1, 8)),
|
|
||||||
zIndex: editClasses.length > 0 ? Math.max(...editClasses.map((c) => c.zIndex)) + 10 : 10,
|
|
||||||
category: '未分类',
|
|
||||||
};
|
|
||||||
setEditClasses([...editClasses, newClass]);
|
|
||||||
setEditingClassId(newClass.id);
|
setEditingClassId(newClass.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,76 +268,150 @@ export function TemplateRegistry() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeClass = (id: string) => {
|
const removeClass = (id: string) => {
|
||||||
setEditClasses(editClasses.filter((c) => c.id !== id));
|
setEditClasses(editClasses.filter((c) => c.id !== id || isReservedUnclassifiedClass(c)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const reorderClasses = (fromIndex: number, toIndex: number) => {
|
const reorderClasses = (fromIndex: number, toIndex: number) => {
|
||||||
if (fromIndex === toIndex) return;
|
if (fromIndex === toIndex) return;
|
||||||
const items = [...editClasses];
|
const items = [...editClasses];
|
||||||
|
if (isReservedUnclassifiedClass(items[fromIndex]) || isReservedUnclassifiedClass(items[toIndex])) return;
|
||||||
const [moved] = items.splice(fromIndex, 1);
|
const [moved] = items.splice(fromIndex, 1);
|
||||||
items.splice(toIndex, 0, moved);
|
items.splice(toIndex, 0, moved);
|
||||||
// Recalculate z-index based on new order (top = highest)
|
setEditClasses(recalculateClassOrder(items));
|
||||||
const recalculated = items.map((c, i) => ({ ...c, zIndex: (items.length - i) * 10 }));
|
|
||||||
setEditClasses(recalculated);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const saveDetailClassOrder = async (sourceId: string, targetId: string) => {
|
||||||
|
if (!activeTemplate || sourceId === targetId || isSavingOrder) return;
|
||||||
|
const classes = normalizeClassMaskIds(activeTemplate.classes || []).sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
if (classes.some((templateClass) => (
|
||||||
|
(templateClass.id === sourceId || templateClass.id === targetId) && isReservedUnclassifiedClass(templateClass)
|
||||||
|
))) return;
|
||||||
|
const sourceIndex = classes.findIndex((templateClass) => templateClass.id === sourceId);
|
||||||
|
const targetIndex = classes.findIndex((templateClass) => templateClass.id === targetId);
|
||||||
|
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
|
||||||
|
|
||||||
|
const reordered = [...classes];
|
||||||
|
const [source] = reordered.splice(sourceIndex, 1);
|
||||||
|
reordered.splice(targetIndex, 0, source);
|
||||||
|
const nextClasses = recalculateClassOrder(reordered);
|
||||||
|
|
||||||
|
setIsSavingOrder(true);
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(importText);
|
const updated = await updateTemplate(activeTemplate.id, buildTemplatePayload(activeTemplate, nextClasses));
|
||||||
let colors: number[][] = [];
|
updateTemplateStore(updated);
|
||||||
let names: string[] = [];
|
setSelectedTemplate(updated);
|
||||||
|
syncMaskClassOrder(nextClasses);
|
||||||
if (Array.isArray(data) && data.length === 2 && Array.isArray(data[0]) && Array.isArray(data[1])) {
|
} catch (err) {
|
||||||
colors = data[0];
|
console.error('Failed to save template class order:', err);
|
||||||
names = data[1];
|
showNotice('层级顺序保存失败,请检查后端服务', 'error');
|
||||||
} else if (Array.isArray(data.colors) && Array.isArray(data.names)) {
|
} finally {
|
||||||
colors = data.colors;
|
setIsSavingOrder(false);
|
||||||
names = data.names;
|
setDetailDragClassId(null);
|
||||||
} else {
|
setDetailDragOverClassId(null);
|
||||||
alert('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imported: TemplateClass[] = names.map((name: string, i: number) => {
|
|
||||||
const rgb = colors[i] || [100, 100, 100];
|
|
||||||
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
|
|
||||||
return {
|
|
||||||
id: `cls-import-${Date.now()}-${i}`,
|
|
||||||
name,
|
|
||||||
color: hex,
|
|
||||||
zIndex: (names.length - i) * 10,
|
|
||||||
category: '批量导入',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setEditClasses([...editClasses, ...imported]);
|
|
||||||
setShowImport(false);
|
|
||||||
setImportText('');
|
|
||||||
} catch (e) {
|
|
||||||
alert('JSON 解析失败');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadLaparoscopic = () => {
|
const deleteDetailClass = async (classId: string) => {
|
||||||
const imported: TemplateClass[] = LAPAROSCOPIC_NAMES.map((name, i) => {
|
if (!activeTemplate || isSavingOrder) return;
|
||||||
const rgb = LAPAROSCOPIC_COLORS[i];
|
const currentClasses = normalizeClassMaskIds(activeTemplate.classes || []);
|
||||||
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
|
const targetClass = currentClasses.find((templateClass) => templateClass.id === classId);
|
||||||
return {
|
if (!targetClass || isReservedUnclassifiedClass(targetClass)) return;
|
||||||
id: `cls-lap-${Date.now()}-${i}`,
|
const nextClasses = recalculateClassOrder(
|
||||||
name,
|
currentClasses
|
||||||
color: hex,
|
.filter((templateClass) => templateClass.id !== classId)
|
||||||
zIndex: (LAPAROSCOPIC_NAMES.length - i) * 10,
|
.sort((a, b) => b.zIndex - a.zIndex),
|
||||||
category: '腹腔镜胆囊切除术',
|
);
|
||||||
};
|
if (nextClasses.length === currentClasses.length) return;
|
||||||
});
|
|
||||||
setEditClasses(imported);
|
setIsSavingOrder(true);
|
||||||
setShowImport(false);
|
try {
|
||||||
|
const updated = await updateTemplate(activeTemplate.id, buildTemplatePayload(activeTemplate, nextClasses));
|
||||||
|
updateTemplateStore(updated);
|
||||||
|
setSelectedTemplate(updated);
|
||||||
|
syncMaskClassOrder(nextClasses);
|
||||||
|
showNotice('分类已删除', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete template class:', err);
|
||||||
|
showNotice('分类删除失败,请检查后端服务', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsSavingOrder(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTemplate = selectedTemplate || templates[0] || null;
|
const parseImportClasses = () => {
|
||||||
|
const data = parseImportJson(importText);
|
||||||
|
let colors: unknown[] = [];
|
||||||
|
let names: unknown[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(data) && data.length === 2 && Array.isArray(data[0]) && Array.isArray(data[1])) {
|
||||||
|
colors = data[0];
|
||||||
|
names = data[1];
|
||||||
|
} else if (Array.isArray(data.colors) && Array.isArray(data.names)) {
|
||||||
|
colors = data.colors;
|
||||||
|
names = data.names;
|
||||||
|
} else if (Array.isArray(data.color) && Array.isArray(data.name)) {
|
||||||
|
colors = data.color;
|
||||||
|
names = data.name;
|
||||||
|
} else {
|
||||||
|
throw new Error('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
throw new Error('names 至少需要包含 1 个分类名称');
|
||||||
|
}
|
||||||
|
const firstMaskId = nextClassMaskId(editClasses);
|
||||||
|
const classes: TemplateClass[] = names.map((name, i: number) => {
|
||||||
|
const normalizedName = String(name ?? '').trim();
|
||||||
|
if (!normalizedName) {
|
||||||
|
throw new Error(`第 ${i + 1} 个分类名称为空`);
|
||||||
|
}
|
||||||
|
const rgb = normalizeImportRgb(colors[i], i);
|
||||||
|
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
|
||||||
|
return {
|
||||||
|
id: `cls-import-${Date.now()}-${i}`,
|
||||||
|
name: normalizedName,
|
||||||
|
color: hex,
|
||||||
|
zIndex: (names.length - i) * 10,
|
||||||
|
maskId: firstMaskId + i,
|
||||||
|
category: '批量导入',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
classes,
|
||||||
|
firstMaskId,
|
||||||
|
missingColorCount: Math.max(0, names.length - colors.length),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPreview = useMemo(() => {
|
||||||
|
if (!showImport || !importText.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = parseImportClasses();
|
||||||
|
return { status: 'ready' as const, ...parsed };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { status: 'error' as const, message: err?.message || 'JSON 解析失败' };
|
||||||
|
}
|
||||||
|
}, [showImport, importText, editClasses]);
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
try {
|
||||||
|
const imported = parseImportClasses();
|
||||||
|
setEditClasses(recalculateClassOrder([...editClasses, ...imported.classes]));
|
||||||
|
setShowImport(false);
|
||||||
|
setImportText('');
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotice(err?.message || 'JSON 解析失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTemplate = selectedTemplate
|
||||||
|
? templates.find((template) => template.id === selectedTemplate.id) || selectedTemplate
|
||||||
|
: templates[0] || null;
|
||||||
|
const activeTemplateClasses = normalizeClassMaskIds(activeTemplate?.classes || []).sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
||||||
|
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
|
||||||
<div className="mb-8 border-b border-white/5 pb-6">
|
<div className="mb-8 border-b border-white/5 pb-6">
|
||||||
<h1 className="text-3xl font-medium tracking-tight text-white mb-2">分割模板与分类优先级管理库</h1>
|
<h1 className="text-3xl font-medium tracking-tight text-white mb-2">分割模板与分类优先级管理库</h1>
|
||||||
<p className="text-gray-400 text-sm">定义业务语义本体树架构、约束覆盖遮罩优先级(Z-Index裁决权重),以及真实标签数据的向下兼容转换映射(Dict Translation)原则。</p>
|
<p className="text-gray-400 text-sm">定义业务语义本体树架构、约束覆盖遮罩优先级(Z-Index裁决权重),以及真实标签数据的向下兼容转换映射(Dict Translation)原则。</p>
|
||||||
@@ -253,13 +453,28 @@ export function TemplateRegistry() {
|
|||||||
<h3 className="font-medium text-gray-200 mb-1">{t.name}</h3>
|
<h3 className="font-medium text-gray-200 mb-1">{t.name}</h3>
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
|
title="复制模板"
|
||||||
|
aria-label={`复制模板 ${t.name}`}
|
||||||
|
disabled={copyingTemplateId === t.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); void handleCopy(t); }}
|
||||||
|
className={cn(
|
||||||
|
"p-1 rounded text-gray-500 hover:text-emerald-400 transition-colors disabled:cursor-wait disabled:text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copyingTemplateId === t.id ? <Loader2 size={14} className="animate-spin" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="编辑模板"
|
||||||
|
aria-label={`编辑模板 ${t.name}`}
|
||||||
onClick={(e) => { e.stopPropagation(); openEdit(t); }}
|
onClick={(e) => { e.stopPropagation(); openEdit(t); }}
|
||||||
className="p-1 rounded text-gray-500 hover:text-cyan-400 transition-colors"
|
className="p-1 rounded text-gray-500 hover:text-cyan-400 transition-colors"
|
||||||
>
|
>
|
||||||
<Edit3 size={14} />
|
<Edit3 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }}
|
title="删除模板"
|
||||||
|
aria-label={`删除模板 ${t.name}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeleteTemplateTarget(t); }}
|
||||||
className="p-1 rounded text-gray-500 hover:text-red-400 transition-colors"
|
className="p-1 rounded text-gray-500 hover:text-red-400 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
@@ -288,7 +503,7 @@ export function TemplateRegistry() {
|
|||||||
onClick={() => openEdit(activeTemplate)}
|
onClick={() => openEdit(activeTemplate)}
|
||||||
className="bg-white/5 hover:bg-white/10 border border-white/10 px-4 py-1.5 rounded text-sm text-gray-300 transition-colors flex items-center gap-2"
|
className="bg-white/5 hover:bg-white/10 border border-white/10 px-4 py-1.5 rounded text-sm text-gray-300 transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Settings size={14} /> 修改库视图结构 (Schema)
|
<Edit3 size={14} /> 编辑模板
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -297,18 +512,62 @@ export function TemplateRegistry() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-4">
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-4">
|
||||||
特定领域分类渲染级重叠裁决权重阵列 (Painter's Algorithm Weight)
|
语义分类树(拖拽调层级)
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(activeTemplate.classes || []).sort((a, b) => b.zIndex - a.zIndex).map((cls) => (
|
{activeTemplateClasses.map((cls) => (
|
||||||
<div key={cls.id} className="grid grid-cols-4 gap-4 p-3 bg-[#0d0d0d] border border-white/5 rounded items-center">
|
<div
|
||||||
<div className="col-span-1 flex items-center gap-2">
|
key={cls.id}
|
||||||
|
draggable={!isSavingOrder && !isReservedUnclassifiedClass(cls)}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (isReservedUnclassifiedClass(cls)) return;
|
||||||
|
setDetailDragClassId(cls.id);
|
||||||
|
event.dataTransfer.setData('text/plain', cls.id);
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!detailDragClassId || detailDragClassId === cls.id || isSavingOrder || isReservedUnclassifiedClass(cls)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
setDetailDragOverClassId(cls.id);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDetailDragOverClassId(null)}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sourceId = event.dataTransfer.getData('text/plain') || detailDragClassId;
|
||||||
|
if (sourceId) void saveDetailClassOrder(sourceId, cls.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDetailDragClassId(null);
|
||||||
|
setDetailDragOverClassId(null);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-4 gap-4 p-3 bg-[#0d0d0d] border rounded items-center transition-all",
|
||||||
|
detailDragOverClassId === cls.id ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5",
|
||||||
|
detailDragClassId === cls.id && "opacity-50",
|
||||||
|
isSavingOrder ? "cursor-wait" : isReservedUnclassifiedClass(cls) ? "cursor-default" : "cursor-grab active:cursor-grabbing",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="col-span-1 flex items-center gap-2 min-w-0">
|
||||||
|
<GripVertical size={14} className={cn("shrink-0", isReservedUnclassifiedClass(cls) ? "text-gray-800" : "text-gray-600")} />
|
||||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: cls.color }}></div>
|
<div className="w-3 h-3 rounded" style={{ backgroundColor: cls.color }}></div>
|
||||||
<span className="font-medium text-sm text-gray-300">{cls.name}</span>
|
<span className="font-medium text-sm text-gray-300 truncate">{cls.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 font-mono text-xs text-gray-500">优先级 Z-Level: {cls.zIndex}</div>
|
<div className="col-span-1 font-mono text-xs text-gray-500">maskid: {cls.maskId}</div>
|
||||||
<div className="col-span-2 flex justify-end">
|
<div className="col-span-2 flex justify-end">
|
||||||
<span className="bg-white/5 text-gray-400 text-xs px-2 py-1 rounded border border-white/10">{cls.category || '未分类'}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`删除分类 ${cls.name}`}
|
||||||
|
title="删除分类"
|
||||||
|
disabled={isSavingOrder || isReservedUnclassifiedClass(cls)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void deleteDetailClass(cls.id);
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-gray-500 transition-colors hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -378,12 +637,14 @@ export function TemplateRegistry() {
|
|||||||
{editClasses.map((cls, idx) => (
|
{editClasses.map((cls, idx) => (
|
||||||
<div
|
<div
|
||||||
key={cls.id}
|
key={cls.id}
|
||||||
draggable
|
draggable={!isReservedUnclassifiedClass(cls)}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
|
if (isReservedUnclassifiedClass(cls)) return;
|
||||||
e.dataTransfer.setData('text/plain', String(idx));
|
e.dataTransfer.setData('text/plain', String(idx));
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
|
if (isReservedUnclassifiedClass(cls)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDragOverIndex(idx);
|
setDragOverIndex(idx);
|
||||||
@@ -392,6 +653,7 @@ export function TemplateRegistry() {
|
|||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||||
|
if (isReservedUnclassifiedClass(cls) || Number.isNaN(fromIndex)) return;
|
||||||
reorderClasses(fromIndex, idx);
|
reorderClasses(fromIndex, idx);
|
||||||
setDragOverIndex(null);
|
setDragOverIndex(null);
|
||||||
}}
|
}}
|
||||||
@@ -401,46 +663,45 @@ export function TemplateRegistry() {
|
|||||||
dragOverIndex === idx ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5"
|
dragOverIndex === idx ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="text-gray-600 cursor-grab active:cursor-grabbing shrink-0">
|
<div className={cn("text-gray-600 shrink-0", isReservedUnclassifiedClass(cls) ? "cursor-default opacity-30" : "cursor-grab active:cursor-grabbing")}>
|
||||||
<GripVertical size={14} />
|
<GripVertical size={14} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={cls.color}
|
value={cls.color}
|
||||||
onChange={(e) => updateClass(cls.id, { color: e.target.value })}
|
onChange={(e) => updateClass(cls.id, { color: e.target.value })}
|
||||||
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer shrink-0"
|
disabled={isReservedUnclassifiedClass(cls)}
|
||||||
|
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer shrink-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{editingClassId === cls.id ? (
|
{editingClassId === cls.id ? (
|
||||||
<>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={cls.name}
|
||||||
value={cls.name}
|
onChange={(e) => updateClass(cls.id, { name: e.target.value })}
|
||||||
onChange={(e) => updateClass(cls.id, { name: e.target.value })}
|
onBlur={() => setEditingClassId(null)}
|
||||||
onBlur={() => setEditingClassId(null)}
|
onKeyDown={(e) => e.key === 'Enter' && setEditingClassId(null)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && setEditingClassId(null)}
|
autoFocus
|
||||||
autoFocus
|
readOnly={isReservedUnclassifiedClass(cls)}
|
||||||
className="flex-1 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
|
className="flex-1 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={cls.category || ''}
|
|
||||||
onChange={(e) => updateClass(cls.id, { category: e.target.value })}
|
|
||||||
placeholder="类别"
|
|
||||||
className="w-24 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className="flex-1 text-sm text-gray-300 cursor-pointer"
|
className={cn("flex-1 text-sm text-gray-300", isReservedUnclassifiedClass(cls) ? "cursor-default" : "cursor-pointer")}
|
||||||
onClick={() => setEditingClassId(cls.id)}
|
onClick={() => {
|
||||||
|
if (!isReservedUnclassifiedClass(cls)) setEditingClassId(cls.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{cls.name}
|
{cls.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-16 text-sm text-gray-500 font-mono text-right">z:{cls.zIndex}</span>
|
<span className="w-24 text-sm text-gray-500 font-mono text-right">maskid:{cls.maskId}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => removeClass(cls.id)} className="text-gray-500 hover:text-red-400 transition-colors">
|
<button
|
||||||
|
onClick={() => removeClass(cls.id)}
|
||||||
|
disabled={isReservedUnclassifiedClass(cls)}
|
||||||
|
className="text-gray-500 hover:text-red-400 transition-colors disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,13 +748,20 @@ export function TemplateRegistry() {
|
|||||||
placeholder='[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'
|
placeholder='[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'
|
||||||
className="w-full h-32 bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 font-mono focus:outline-none focus:border-cyan-500/50 resize-none"
|
className="w-full h-32 bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 font-mono focus:outline-none focus:border-cyan-500/50 resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-4">
|
{importPreview?.status === 'ready' && (
|
||||||
<button
|
<div className="mt-3 rounded-lg border border-cyan-500/20 bg-cyan-950/15 px-3 py-2 text-xs text-cyan-100">
|
||||||
onClick={loadLaparoscopic}
|
将导入 {importPreview.classes.length} 个分类,maskid 从 {importPreview.firstMaskId} 开始分配。
|
||||||
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
{importPreview.missingColorCount > 0 && (
|
||||||
>
|
<span className="ml-1 text-amber-200">{importPreview.missingColorCount} 个分类缺少颜色,将使用默认灰色。</span>
|
||||||
📋 载入腹腔镜胆囊切除术模板
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
)}
|
||||||
|
{importPreview?.status === 'error' && (
|
||||||
|
<div className="mt-3 rounded-lg border border-red-500/20 bg-red-950/20 px-3 py-2 text-xs text-red-100">
|
||||||
|
{importPreview.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end items-center mt-4">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowImport(false); setImportText(''); }}
|
onClick={() => { setShowImport(false); setImportText(''); }}
|
||||||
@@ -503,7 +771,8 @@ export function TemplateRegistry() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all"
|
disabled={importPreview?.status === 'error'}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
导入
|
导入
|
||||||
</button>
|
</button>
|
||||||
@@ -512,6 +781,32 @@ export function TemplateRegistry() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{deleteTemplateTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-red-500/20 bg-[#111] p-6 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white">删除模板</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-gray-400">
|
||||||
|
确认删除模板“<span className="text-gray-100">{deleteTemplateTarget.name}</span>”?该操作不会删除项目标注,但后续打开项目时,引用已删除分类的 mask 会进入“待分类”。
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteTemplateTarget(null)}
|
||||||
|
className="rounded-lg px-4 py-2 text-sm text-gray-400 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
className="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-400"
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,155 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { resetStore } from '../test/storeTestUtils';
|
||||||
import { ToolsPalette } from './ToolsPalette';
|
import { ToolsPalette } from './ToolsPalette';
|
||||||
|
|
||||||
describe('ToolsPalette', () => {
|
describe('ToolsPalette', () => {
|
||||||
it('switches tools and dispatches undo/redo actions when available', () => {
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches workspace editing tools without showing AI prompt or duplicate undo tools', () => {
|
||||||
const setActiveTool = vi.fn();
|
const setActiveTool = vi.fn();
|
||||||
const onUndo = vi.fn();
|
|
||||||
const onRedo = vi.fn();
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ToolsPalette
|
<ToolsPalette
|
||||||
activeTool="move"
|
activeTool="move"
|
||||||
setActiveTool={setActiveTool}
|
setActiveTool={setActiveTool}
|
||||||
onUndo={onUndo}
|
|
||||||
onRedo={onRedo}
|
|
||||||
canUndo
|
|
||||||
canRedo
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle('创建多边形 (P)'));
|
fireEvent.click(screen.getByTitle('创建多边形 (P)'));
|
||||||
fireEvent.click(screen.getByTitle('调整多边形 (E)'));
|
fireEvent.click(screen.getByTitle('调整多边形 (E)'));
|
||||||
fireEvent.click(screen.getByTitle('正向选点 (SAM)'));
|
fireEvent.click(screen.getByTitle('画笔 (B)'));
|
||||||
fireEvent.click(screen.getByTitle('撤销操作 (Ctrl+Z)'));
|
fireEvent.click(screen.getByTitle('橡皮擦 (X)'));
|
||||||
fireEvent.click(screen.getByTitle('重做操作 (Ctrl+Shift+Z)'));
|
|
||||||
|
|
||||||
expect(setActiveTool).toHaveBeenNthCalledWith(1, 'create_polygon');
|
expect(setActiveTool).toHaveBeenNthCalledWith(1, 'create_polygon');
|
||||||
expect(setActiveTool).toHaveBeenNthCalledWith(2, 'edit_polygon');
|
expect(setActiveTool).toHaveBeenNthCalledWith(2, 'edit_polygon');
|
||||||
expect(setActiveTool).toHaveBeenNthCalledWith(3, 'point_pos');
|
expect(setActiveTool).toHaveBeenNthCalledWith(3, 'brush');
|
||||||
expect(onUndo).toHaveBeenCalled();
|
expect(setActiveTool).toHaveBeenNthCalledWith(4, 'eraser');
|
||||||
expect(onRedo).toHaveBeenCalled();
|
expect(screen.queryByTitle('正向选点 (SAM)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('反向选点 (SAM)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('边界框选 (SAM)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('撤销操作 (Ctrl+Z)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('重做操作 (Ctrl+Shift+Z)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('创建点 (C)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('创建线段 (L)')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows size controls for brush and eraser tools', () => {
|
||||||
|
const { rerender } = render(<ToolsPalette activeTool="brush" setActiveTool={vi.fn()} />);
|
||||||
|
const brushSize = screen.getByLabelText('画笔大小');
|
||||||
|
fireEvent.change(brushSize, { target: { value: '36' } });
|
||||||
|
expect(useStore.getState().brushSize).toBe(36);
|
||||||
|
|
||||||
|
rerender(<ToolsPalette activeTool="eraser" setActiveTool={vi.fn()} />);
|
||||||
|
const eraserSize = screen.getByLabelText('橡皮擦大小');
|
||||||
|
fireEvent.change(eraserSize, { target: { value: '48' } });
|
||||||
|
expect(useStore.getState().eraserSize).toBe(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places GT mask import after overlap removal with a distinct violet style', () => {
|
||||||
|
const onImportGtMask = vi.fn();
|
||||||
|
render(
|
||||||
|
<ToolsPalette
|
||||||
|
activeTool="move"
|
||||||
|
setActiveTool={vi.fn()}
|
||||||
|
onImportGtMask={onImportGtMask}
|
||||||
|
canImportGtMask
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlapButton = screen.getByTitle('重叠区域去除 (-)');
|
||||||
|
const importButton = screen.getByTitle('导入 GT Mask');
|
||||||
|
fireEvent.click(importButton);
|
||||||
|
|
||||||
|
expect(onImportGtMask).toHaveBeenCalled();
|
||||||
|
expect(importButton).toHaveClass('bg-violet-500/10');
|
||||||
|
expect(overlapButton.compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes clear mask action in the left toolbar', () => {
|
||||||
|
const onClearMasks = vi.fn();
|
||||||
|
const onDeleteMasks = vi.fn();
|
||||||
|
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} onDeleteMasks={onDeleteMasks} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle('删除选中遮罩 (Del)'));
|
||||||
|
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||||
|
|
||||||
|
expect(onDeleteMasks).toHaveBeenCalled();
|
||||||
|
expect(onClearMasks).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a physical clear selection button next to the selection tool', () => {
|
||||||
|
const onClearSelection = vi.fn();
|
||||||
|
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearSelection={onClearSelection} />);
|
||||||
|
|
||||||
|
const moveButton = screen.getByTitle('拖拽 / 选择 (V)');
|
||||||
|
const clearSelectionButton = screen.getByTitle('取消选中 (Esc)');
|
||||||
|
const editButton = screen.getByTitle('调整多边形 (E)');
|
||||||
|
fireEvent.click(clearSelectionButton);
|
||||||
|
|
||||||
|
expect(onClearSelection).toHaveBeenCalled();
|
||||||
|
expect(clearSelectionButton).toHaveAttribute('aria-label', '取消选中');
|
||||||
|
expect(moveButton.compareDocumentPosition(clearSelectionButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(clearSelectionButton.compareDocumentPosition(editButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places colored auto propagation below the eraser tool', () => {
|
||||||
|
const setActiveTool = vi.fn();
|
||||||
|
const onAutoPropagate = vi.fn();
|
||||||
|
render(
|
||||||
|
<ToolsPalette
|
||||||
|
activeTool="move"
|
||||||
|
setActiveTool={setActiveTool}
|
||||||
|
onAutoPropagate={onAutoPropagate}
|
||||||
|
canAutoPropagate
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const eraserButton = screen.getByTitle('橡皮擦 (X)');
|
||||||
|
const autoButton = screen.getByRole('button', { name: 'AI自动推理' });
|
||||||
|
fireEvent.click(autoButton);
|
||||||
|
|
||||||
|
expect(autoButton).toHaveClass('bg-cyan-500/10');
|
||||||
|
expect(autoButton.querySelector('[data-testid="ai-auto-inference-icon"]')).toBeInTheDocument();
|
||||||
|
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(setActiveTool).toHaveBeenCalledWith('auto_propagate');
|
||||||
|
expect(onAutoPropagate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates drawing, editing, and external action tool groups', () => {
|
||||||
|
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} canImportGtMask />);
|
||||||
|
|
||||||
|
const separators = Array.from(container.querySelectorAll('.h-px'));
|
||||||
|
const externalActionSeparator = screen.getByTestId('tool-group-separator');
|
||||||
|
const clearSelectionButton = screen.getByTitle('取消选中 (Esc)');
|
||||||
|
const circleButton = screen.getByTitle('创建圆 (O)');
|
||||||
|
const brushButton = screen.getByTitle('画笔 (B)');
|
||||||
|
const eraserButton = screen.getByTitle('橡皮擦 (X)');
|
||||||
|
const autoButton = screen.getByRole('button', { name: 'AI自动推理' });
|
||||||
|
const mergeButton = screen.getByTitle('区域合并 (+)');
|
||||||
|
const removeButton = screen.getByTitle('重叠区域去除 (-)');
|
||||||
|
const deleteButton = screen.getByTitle('删除选中遮罩 (Del)');
|
||||||
|
const clearButton = screen.getByTitle('清空遮罩');
|
||||||
|
const importButton = screen.getByTitle('导入 GT Mask');
|
||||||
|
|
||||||
|
expect(separators).toHaveLength(3);
|
||||||
|
expect(externalActionSeparator).toBe(separators[2]);
|
||||||
|
expect(clearSelectionButton.compareDocumentPosition(circleButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(circleButton.compareDocumentPosition(separators[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(separators[0].compareDocumentPosition(brushButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(autoButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(separators[1].compareDocumentPosition(mergeButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(removeButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(deleteButton.compareDocumentPosition(clearButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(clearButton.compareDocumentPosition(separators[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(separators[2].compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
separators.forEach((separator) => {
|
||||||
|
expect(separator).toHaveClass('bg-white/15');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches to SAM trigger and calls the AI navigation hook', () => {
|
it('switches to SAM trigger and calls the AI navigation hook', () => {
|
||||||
@@ -37,9 +157,23 @@ 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)'));
|
const aiButton = screen.getByTitle('打开 AI 智能分割');
|
||||||
|
expect(aiButton.querySelector('[data-testid="ai-segmentation-icon"]')).toBeInTheDocument();
|
||||||
|
fireEvent.click(aiButton);
|
||||||
|
|
||||||
expect(setActiveTool).toHaveBeenCalledWith('sam_trigger');
|
expect(setActiveTool).toHaveBeenCalledWith('sam_trigger');
|
||||||
expect(onTriggerAI).toHaveBeenCalled();
|
expect(onTriggerAI).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses compact vertically scrollable layout for smaller workspaces', () => {
|
||||||
|
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} />);
|
||||||
|
const palette = container.firstElementChild;
|
||||||
|
|
||||||
|
expect(palette).toHaveClass('w-14');
|
||||||
|
expect(palette).toHaveClass('overflow-y-auto');
|
||||||
|
expect(palette).toHaveClass('seg-scrollbar');
|
||||||
|
expect(palette?.firstElementChild).toHaveClass('w-12');
|
||||||
|
expect(screen.getByTitle('创建多边形 (P)')).toHaveClass('h-9');
|
||||||
|
expect(screen.getByTitle('打开 AI 智能分割')).toHaveClass('h-9');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,124 +1,196 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MousePointer2, PencilLine, Hexagon, Square, Circle, Minus, Combine, Scissors, Wand2, Undo, Redo, Crosshair, PlusCircle, MinusCircle, SquareDashed } from 'lucide-react';
|
import { MousePointer2, CircleOff, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp, Trash2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { AiAutoInferenceIcon } from './AiAutoInferenceIcon';
|
||||||
|
import { AiSegmentationIcon } from './AiSegmentationIcon';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
|
||||||
interface ToolsPaletteProps {
|
interface ToolsPaletteProps {
|
||||||
activeTool: string;
|
activeTool: string;
|
||||||
setActiveTool: (tool: string) => void;
|
setActiveTool: (tool: string) => void;
|
||||||
onTriggerAI?: () => void;
|
onTriggerAI?: () => void;
|
||||||
onUndo?: () => void;
|
onAutoPropagate?: () => void;
|
||||||
onRedo?: () => void;
|
onImportGtMask?: () => void;
|
||||||
canUndo?: boolean;
|
onClearSelection?: () => void;
|
||||||
canRedo?: boolean;
|
onDeleteMasks?: () => void;
|
||||||
|
onClearMasks?: () => void;
|
||||||
|
canAutoPropagate?: boolean;
|
||||||
|
isPropagating?: boolean;
|
||||||
|
canImportGtMask?: boolean;
|
||||||
|
isImportingGtMask?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolsPalette({
|
export function ToolsPalette({
|
||||||
activeTool,
|
activeTool,
|
||||||
setActiveTool,
|
setActiveTool,
|
||||||
onTriggerAI,
|
onTriggerAI,
|
||||||
onUndo,
|
onAutoPropagate,
|
||||||
onRedo,
|
onImportGtMask,
|
||||||
canUndo = false,
|
onClearSelection,
|
||||||
canRedo = false,
|
onDeleteMasks,
|
||||||
|
onClearMasks,
|
||||||
|
canAutoPropagate = false,
|
||||||
|
isPropagating = false,
|
||||||
|
canImportGtMask = false,
|
||||||
|
isImportingGtMask = false,
|
||||||
}: ToolsPaletteProps) {
|
}: ToolsPaletteProps) {
|
||||||
|
const brushSize = useStore((state) => state.brushSize);
|
||||||
|
const eraserSize = useStore((state) => state.eraserSize);
|
||||||
|
const setBrushSize = useStore((state) => state.setBrushSize);
|
||||||
|
const setEraserSize = useStore((state) => state.setEraserSize);
|
||||||
|
const sizeControl = activeTool === 'brush'
|
||||||
|
? {
|
||||||
|
label: '画笔大小',
|
||||||
|
value: brushSize,
|
||||||
|
min: 4,
|
||||||
|
max: 96,
|
||||||
|
onChange: setBrushSize,
|
||||||
|
}
|
||||||
|
: activeTool === 'eraser'
|
||||||
|
? {
|
||||||
|
label: '橡皮擦大小',
|
||||||
|
value: eraserSize,
|
||||||
|
min: 4,
|
||||||
|
max: 128,
|
||||||
|
onChange: setEraserSize,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const tools = [
|
const tools = [
|
||||||
{ id: 'move', icon: MousePointer2, label: '拖拽 / 选择 (V)' },
|
{ id: 'move', icon: MousePointer2, label: '拖拽 / 选择 (V)' },
|
||||||
{ id: 'edit_polygon', icon: PencilLine, label: '调整多边形 (E)' },
|
{ id: 'edit_polygon', icon: PencilLine, label: '调整多边形 (E)' },
|
||||||
{ id: 'create_polygon', icon: Hexagon, label: '创建多边形 (P)' },
|
{ id: 'create_polygon', icon: Hexagon, label: '创建多边形 (P)' },
|
||||||
{ id: 'create_rectangle', icon: Square, label: '创建矩形 (R)' },
|
{ id: 'create_rectangle', icon: Square, label: '创建矩形 (R)' },
|
||||||
{ id: 'create_circle', icon: Circle, label: '创建圆 (O)' },
|
{ id: 'create_circle', icon: Circle, label: '创建圆 (O)' },
|
||||||
{ id: 'create_point', icon: Crosshair, label: '创建点 (C)' },
|
{ id: 'brush', icon: Brush, label: '画笔 (B)' },
|
||||||
{ id: 'create_line', icon: Minus, label: '创建线段 (L)' },
|
{ id: 'eraser', icon: Eraser, label: '橡皮擦 (X)' },
|
||||||
{ id: 'area_merge', icon: Combine, label: '区域合并 (+)' },
|
{ id: 'area_merge', icon: Combine, label: '区域合并 (+)' },
|
||||||
{ id: 'area_remove', icon: Scissors, label: '重叠区域去除 (-)' },
|
{ id: 'area_remove', icon: Scissors, label: '重叠区域去除 (-)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const aiTools = [
|
|
||||||
{ id: 'point_pos', icon: PlusCircle, label: '正向选点 (SAM)', color: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/30' },
|
|
||||||
{ id: 'point_neg', icon: MinusCircle, label: '反向选点 (SAM)', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30' },
|
|
||||||
{ id: 'box_select', icon: SquareDashed, label: '边界框选 (SAM)', color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-12 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-center py-4 shrink-0 z-10">
|
<div className="h-full w-14 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-start py-2 shrink-0 z-10 overflow-y-auto overflow-x-hidden overscroll-contain seg-scrollbar">
|
||||||
<div className="flex flex-col gap-4 w-full px-2">
|
<div className="flex flex-col gap-1.5 w-12 shrink-0 px-1.5">
|
||||||
{tools.map(tool => {
|
{tools.map(tool => {
|
||||||
const Icon = tool.icon;
|
const Icon = tool.icon;
|
||||||
const isActive = activeTool === tool.id;
|
const isActive = activeTool === tool.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<React.Fragment key={tool.id}>
|
||||||
key={tool.id}
|
<button
|
||||||
onClick={() => setActiveTool(tool.id)}
|
onClick={() => setActiveTool(tool.id)}
|
||||||
title={tool.label}
|
title={tool.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-10 h-10 rounded-lg flex items-center justify-center transition-all p-2",
|
"w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5",
|
||||||
isActive
|
isActive
|
||||||
? (tool.id.includes('remove') ? "bg-red-500/10 text-red-500"
|
? (tool.id.includes('remove') ? "bg-red-500/10 text-red-500"
|
||||||
: tool.id.includes('merge') ? "bg-green-500/10 text-green-500"
|
: tool.id.includes('merge') ? "bg-green-500/10 text-green-500"
|
||||||
: "bg-white/10 text-white")
|
: "bg-white/10 text-white")
|
||||||
: "text-gray-500 hover:bg-white/5 hover:text-white"
|
: "text-gray-500 hover:bg-white/5 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={16} strokeWidth={isActive ? 2.5 : 2} />
|
||||||
|
</button>
|
||||||
|
{tool.id === 'move' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
disabled={!onClearSelection}
|
||||||
|
title="取消选中 (Esc)"
|
||||||
|
aria-label="取消选中"
|
||||||
|
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-gray-400 hover:bg-white/5 hover:text-white disabled:opacity-35 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<CircleOff size={16} strokeWidth={2.1} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
>
|
{tool.id === 'eraser' && sizeControl && (
|
||||||
<Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
|
<div className="w-9 rounded-md border border-white/10 bg-white/[0.03] px-1 py-2 text-center">
|
||||||
</button>
|
<label htmlFor={`${activeTool}-size`} className="sr-only">{sizeControl.label}</label>
|
||||||
|
<input
|
||||||
|
id={`${activeTool}-size`}
|
||||||
|
aria-label={sizeControl.label}
|
||||||
|
type="range"
|
||||||
|
min={sizeControl.min}
|
||||||
|
max={sizeControl.max}
|
||||||
|
value={sizeControl.value}
|
||||||
|
onChange={(event) => sizeControl.onChange(Number(event.target.value))}
|
||||||
|
className="h-20 w-7 accent-cyan-400 [writing-mode:vertical-rl]"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-[10px] leading-none text-gray-400">{sizeControl.value}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tool.id === 'eraser' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTool('auto_propagate');
|
||||||
|
onAutoPropagate?.();
|
||||||
|
}}
|
||||||
|
disabled={!canAutoPropagate || isPropagating}
|
||||||
|
aria-label="AI自动推理"
|
||||||
|
title={isPropagating ? 'AI自动推理中...' : 'AI自动推理'}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 border",
|
||||||
|
activeTool === 'auto_propagate'
|
||||||
|
? "border-cyan-300/50 bg-cyan-500/20 text-white shadow-lg shadow-cyan-900/30"
|
||||||
|
: "border-cyan-500/25 bg-cyan-500/10 text-cyan-200 hover:bg-cyan-500/20 hover:text-white",
|
||||||
|
(!canAutoPropagate || isPropagating) && "opacity-35 cursor-not-allowed hover:bg-cyan-500/10 hover:text-cyan-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AiAutoInferenceIcon size={17} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
<div className="my-1 h-px w-9 bg-white/15" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tool.id === 'create_circle' && (
|
||||||
|
<div className="my-1 h-px w-9 bg-white/15" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="w-full h-px bg-white/10 my-1" />
|
<button
|
||||||
|
onClick={onDeleteMasks}
|
||||||
|
disabled={!onDeleteMasks}
|
||||||
|
title="删除选中遮罩 (Del)"
|
||||||
|
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-red-200 hover:bg-red-500/10 hover:text-red-100 disabled:opacity-35 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-bold leading-none">DEL</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{aiTools.map(tool => {
|
<button
|
||||||
const Icon = tool.icon;
|
onClick={onClearMasks}
|
||||||
const isActive = activeTool === tool.id;
|
disabled={!onClearMasks}
|
||||||
return (
|
title="清空遮罩"
|
||||||
<button
|
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-35 disabled:cursor-not-allowed"
|
||||||
key={tool.id}
|
>
|
||||||
onClick={() => setActiveTool(tool.id)}
|
<Trash2 size={16} strokeWidth={2.1} />
|
||||||
title={tool.label}
|
</button>
|
||||||
className={cn(
|
|
||||||
"w-10 h-10 rounded-lg flex items-center justify-center transition-all p-2 border",
|
<div data-testid="tool-group-separator" className="my-1 h-px w-9 bg-white/15" />
|
||||||
isActive
|
|
||||||
? `${tool.bg} ${tool.color} ${tool.border} shadow-[0_0_10px_rgba(255,255,255,0.05)]`
|
<button
|
||||||
: "text-gray-500 hover:bg-white/5 hover:text-white border-transparent"
|
onClick={onImportGtMask}
|
||||||
)}
|
disabled={!canImportGtMask || isImportingGtMask}
|
||||||
>
|
title={isImportingGtMask ? '正在导入 GT Mask' : '导入 GT Mask'}
|
||||||
<Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
|
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 border border-violet-500/30 bg-violet-500/10 text-violet-200 hover:bg-violet-500/20 hover:text-white disabled:opacity-35 disabled:hover:bg-violet-500/10 disabled:hover:text-violet-200 disabled:cursor-not-allowed"
|
||||||
</button>
|
>
|
||||||
)
|
<FileUp size={16} strokeWidth={2.2} />
|
||||||
})}
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
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-9 h-9 rounded-md flex items-center justify-center transition-all",
|
||||||
activeTool === 'sam_trigger'
|
activeTool === 'sam_trigger'
|
||||||
? "bg-cyan-600 text-white shadow-lg shadow-cyan-900/20"
|
? "bg-cyan-600 text-white shadow-lg shadow-cyan-900/20"
|
||||||
: "text-gray-500 hover:bg-white/5"
|
: "text-gray-500 hover:bg-white/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Wand2 size={18} strokeWidth={2} />
|
<AiSegmentationIcon size={17} strokeWidth={2.1} />
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-full h-px bg-white/10 my-1" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onUndo}
|
|
||||||
disabled={!canUndo}
|
|
||||||
className="w-10 h-10 rounded text-gray-500 hover:bg-white/5 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-500 disabled:cursor-not-allowed"
|
|
||||||
title="撤销操作 (Ctrl+Z)"
|
|
||||||
>
|
|
||||||
<Undo size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onRedo}
|
|
||||||
disabled={!canRedo}
|
|
||||||
className="w-10 h-10 rounded text-gray-500 hover:bg-white/5 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-500 disabled:cursor-not-allowed"
|
|
||||||
title="重做操作 (Ctrl+Shift+Z)"
|
|
||||||
>
|
|
||||||
<Redo size={18} />
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
src/components/TransientNotice.test.tsx
Normal file
28
src/components/TransientNotice.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TransientNotice } from './TransientNotice';
|
||||||
|
|
||||||
|
describe('TransientNotice', () => {
|
||||||
|
it('renders a non-blocking notice and dismisses it after the timeout', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TransientNotice
|
||||||
|
notice={{ id: 1, message: '操作已完成', tone: 'success' }}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
durationMs={1000}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('操作已完成');
|
||||||
|
expect(screen.getByRole('status').parentElement).toHaveClass('pointer-events-none');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/components/TransientNotice.tsx
Normal file
47
src/components/TransientNotice.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
export type NoticeTone = 'info' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface NoticeState {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
tone?: NoticeTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransientNoticeProps {
|
||||||
|
notice: NoticeState | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toneClasses: Record<NoticeTone, string> = {
|
||||||
|
info: 'border-cyan-400/30 bg-cyan-950/85 text-cyan-100 shadow-cyan-950/30',
|
||||||
|
success: 'border-emerald-400/30 bg-emerald-950/85 text-emerald-100 shadow-emerald-950/30',
|
||||||
|
error: 'border-red-400/30 bg-red-950/85 text-red-100 shadow-red-950/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransientNotice({ notice, onDismiss, durationMs = 3600 }: TransientNoticeProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) return undefined;
|
||||||
|
const timer = window.setTimeout(onDismiss, durationMs);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [durationMs, notice, onDismiss]);
|
||||||
|
|
||||||
|
if (!notice) return null;
|
||||||
|
|
||||||
|
const tone = notice.tone || 'info';
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed right-6 top-6 z-[80] max-w-sm" aria-live="polite">
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border px-4 py-3 text-xs font-medium leading-relaxed shadow-2xl backdrop-blur whitespace-pre-line',
|
||||||
|
toneClasses[tone],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notice.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/components/UserAdmin.test.tsx
Normal file
177
src/components/UserAdmin.test.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resetStore } from '../test/storeTestUtils';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { UserAdmin } from './UserAdmin';
|
||||||
|
|
||||||
|
const apiMock = vi.hoisted(() => ({
|
||||||
|
getAdminUsers: vi.fn(),
|
||||||
|
getAuditLogs: vi.fn(),
|
||||||
|
createAdminUser: vi.fn(),
|
||||||
|
updateAdminUser: vi.fn(),
|
||||||
|
deleteAdminUser: vi.fn(),
|
||||||
|
resetDemoFactory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
getAdminUsers: apiMock.getAdminUsers,
|
||||||
|
getAuditLogs: apiMock.getAuditLogs,
|
||||||
|
createAdminUser: apiMock.createAdminUser,
|
||||||
|
updateAdminUser: apiMock.updateAdminUser,
|
||||||
|
deleteAdminUser: apiMock.deleteAdminUser,
|
||||||
|
resetDemoFactory: apiMock.resetDemoFactory,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('UserAdmin', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useStore.setState({ currentUser: { id: 1, username: 'admin', role: 'admin' } });
|
||||||
|
apiMock.getAdminUsers.mockResolvedValue([
|
||||||
|
{ id: 1, username: 'admin', role: 'admin', is_active: 1 },
|
||||||
|
{ id: 2, username: 'doctor', role: 'annotator', is_active: 1 },
|
||||||
|
]);
|
||||||
|
apiMock.getAuditLogs.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
actor_user_id: 1,
|
||||||
|
action: 'admin.user_created',
|
||||||
|
target_type: 'user',
|
||||||
|
target_id: '2',
|
||||||
|
detail: { username: 'doctor' },
|
||||||
|
created_at: '2026-05-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads users and audit logs', async () => {
|
||||||
|
render(<UserAdmin />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('doctor')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('admin.user_created')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('当前管理员:admin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new users as annotators', async () => {
|
||||||
|
apiMock.createAdminUser.mockResolvedValueOnce({
|
||||||
|
id: 3,
|
||||||
|
username: 'nurse',
|
||||||
|
role: 'annotator',
|
||||||
|
is_active: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserAdmin />);
|
||||||
|
await screen.findByText('doctor');
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('用户名'), { target: { value: 'nurse' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('初始密码'), { target: { value: 'secret123' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /新增用户/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.createAdminUser).toHaveBeenCalledWith({
|
||||||
|
username: 'nurse',
|
||||||
|
password: 'secret123',
|
||||||
|
role: 'annotator',
|
||||||
|
is_active: true,
|
||||||
|
}));
|
||||||
|
expect(await screen.findByText('用户已创建')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates status and password, and deletes users', async () => {
|
||||||
|
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 });
|
||||||
|
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 });
|
||||||
|
apiMock.deleteAdminUser.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
render(<UserAdmin />);
|
||||||
|
await screen.findByText('doctor');
|
||||||
|
|
||||||
|
expect(screen.queryByText('观察员')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole('button', { name: '启用' })[1]);
|
||||||
|
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByTitle('修改密码')[1]);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('至少 6 位'), { target: { value: 'nextsecret' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认修改' }));
|
||||||
|
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { password: 'nextsecret' }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByTitle('删除用户')[1]);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||||
|
await waitFor(() => expect(apiMock.deleteAdminUser).toHaveBeenCalledWith(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires two confirmations before resetting demo factory data', async () => {
|
||||||
|
apiMock.resetDemoFactory.mockResolvedValueOnce({
|
||||||
|
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
|
||||||
|
project: {
|
||||||
|
id: '8',
|
||||||
|
name: '演视DICOM序列',
|
||||||
|
status: 'ready',
|
||||||
|
frames: 300,
|
||||||
|
fps: '30FPS',
|
||||||
|
source_type: 'dicom',
|
||||||
|
video_path: 'uploads/8/dicom',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
name: '演视LC视频序列',
|
||||||
|
status: 'ready',
|
||||||
|
frames: 750,
|
||||||
|
fps: '30FPS',
|
||||||
|
source_type: 'video',
|
||||||
|
video_path: 'uploads/7/演视LC视频序列.mp4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
name: '演视DICOM序列',
|
||||||
|
status: 'ready',
|
||||||
|
frames: 300,
|
||||||
|
fps: '30FPS',
|
||||||
|
source_type: 'dicom',
|
||||||
|
video_path: 'uploads/8/dicom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deleted_counts: { users: 1 },
|
||||||
|
message: '演示环境已恢复出厂设置',
|
||||||
|
});
|
||||||
|
apiMock.getAuditLogs.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
actor_user_id: 1,
|
||||||
|
action: 'admin.demo_factory_reset',
|
||||||
|
target_type: 'project',
|
||||||
|
target_id: '8',
|
||||||
|
detail: {},
|
||||||
|
created_at: '2026-05-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<UserAdmin />);
|
||||||
|
await screen.findByText('doctor');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '恢复演示出厂设置' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('输入 RESET_DEMO_FACTORY 确认'), {
|
||||||
|
target: { value: 'RESET_DEMO_FACTORY' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认恢复' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.resetDemoFactory).toHaveBeenCalledWith('RESET_DEMO_FACTORY'));
|
||||||
|
expect(await screen.findByText('演示环境已恢复出厂设置')).toBeInTheDocument();
|
||||||
|
expect(useStore.getState().projects).toEqual([
|
||||||
|
expect.objectContaining({ name: '演视LC视频序列', source_type: 'video' }),
|
||||||
|
expect.objectContaining({ name: '演视DICOM序列', source_type: 'dicom' }),
|
||||||
|
]);
|
||||||
|
expect(useStore.getState().frames).toEqual([]);
|
||||||
|
expect(useStore.getState().masks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reset demo data when confirmation text does not match', async () => {
|
||||||
|
render(<UserAdmin />);
|
||||||
|
await screen.findByText('doctor');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '恢复演示出厂设置' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('输入 RESET_DEMO_FACTORY 确认'), {
|
||||||
|
target: { value: 'wrong' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMock.resetDemoFactory).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('button', { name: '确认恢复' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
473
src/components/UserAdmin.tsx
Normal file
473
src/components/UserAdmin.tsx
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { KeyRound, Loader2, Plus, ShieldCheck, Trash2, UserCog } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
createAdminUser,
|
||||||
|
deleteAdminUser,
|
||||||
|
getAdminUsers,
|
||||||
|
getAuditLogs,
|
||||||
|
resetDemoFactory,
|
||||||
|
updateAdminUser,
|
||||||
|
type AdminUser,
|
||||||
|
type AuditLog,
|
||||||
|
} from '../lib/api';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
admin: '管理员',
|
||||||
|
annotator: '标注员',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAdmin() {
|
||||||
|
const currentUser = useStore((state) => state.currentUser);
|
||||||
|
const setProjects = useStore((state) => state.setProjects);
|
||||||
|
const setCurrentProject = useStore((state) => state.setCurrentProject);
|
||||||
|
const setFrames = useStore((state) => state.setFrames);
|
||||||
|
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
|
||||||
|
const setMasks = useStore((state) => state.setMasks);
|
||||||
|
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||||
|
const [newUsername, setNewUsername] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [passwordTarget, setPasswordTarget] = useState<AdminUser | null>(null);
|
||||||
|
const [nextPassword, setNextPassword] = useState('');
|
||||||
|
const [deleteUserTarget, setDeleteUserTarget] = useState<AdminUser | null>(null);
|
||||||
|
const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false);
|
||||||
|
const [factoryResetText, setFactoryResetText] = useState('');
|
||||||
|
|
||||||
|
const activeCount = useMemo(() => users.filter((user) => user.is_active).length, [users]);
|
||||||
|
const showNotice = (message: string, tone: NoticeTone = 'info') => {
|
||||||
|
setNotice({ id: Date.now(), message, tone });
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAdminData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [nextUsers, nextLogs] = await Promise.all([getAdminUsers(), getAuditLogs(100)]);
|
||||||
|
setUsers(nextUsers);
|
||||||
|
setAuditLogs(nextLogs);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load admin data:', err);
|
||||||
|
showNotice('用户管理数据加载失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAdminData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateUser = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!newUsername.trim() || newPassword.length < 6) {
|
||||||
|
showNotice('请输入用户名,并设置至少 6 位密码', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await createAdminUser({
|
||||||
|
username: newUsername.trim(),
|
||||||
|
password: newPassword,
|
||||||
|
role: 'annotator',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
setUsers((prev) => [...prev, created]);
|
||||||
|
setNewUsername('');
|
||||||
|
setNewPassword('');
|
||||||
|
showNotice('用户已创建', 'success');
|
||||||
|
setAuditLogs(await getAuditLogs(100));
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotice(err?.response?.data?.detail || '创建用户失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePatchUser = async (user: AdminUser, patch: Parameters<typeof updateAdminUser>[1]) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateAdminUser(user.id, patch);
|
||||||
|
setUsers((prev) => prev.map((item) => (item.id === user.id ? updated : item)));
|
||||||
|
showNotice('用户已更新', 'success');
|
||||||
|
setAuditLogs(await getAuditLogs(100));
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotice(err?.response?.data?.detail || '更新用户失败', 'error');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = (user: AdminUser) => {
|
||||||
|
setPasswordTarget(user);
|
||||||
|
setNextPassword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPasswordChange = async () => {
|
||||||
|
if (!passwordTarget) return;
|
||||||
|
if (nextPassword.length < 6) {
|
||||||
|
showNotice('新密码至少需要 6 位', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await handlePatchUser(passwordTarget, { password: nextPassword });
|
||||||
|
if (updated) {
|
||||||
|
setPasswordTarget(null);
|
||||||
|
setNextPassword('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
const user = deleteUserTarget;
|
||||||
|
if (!user) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await deleteAdminUser(user.id);
|
||||||
|
setUsers((prev) => prev.filter((item) => item.id !== user.id));
|
||||||
|
setDeleteUserTarget(null);
|
||||||
|
showNotice('用户已删除', 'success');
|
||||||
|
setAuditLogs(await getAuditLogs(100));
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotice(err?.response?.data?.detail || '删除用户失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFactoryReset = async () => {
|
||||||
|
if (factoryResetText !== 'RESET_DEMO_FACTORY') {
|
||||||
|
showNotice('确认文本不匹配,未执行恢复出厂设置', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
const result = await resetDemoFactory(factoryResetText);
|
||||||
|
setUsers([result.admin_user]);
|
||||||
|
setProjects(result.projects?.length ? result.projects : [result.project]);
|
||||||
|
setCurrentProject(null);
|
||||||
|
setFrames([]);
|
||||||
|
setCurrentFrame(0);
|
||||||
|
setMasks([]);
|
||||||
|
setSelectedMaskIds([]);
|
||||||
|
setAuditLogs(await getAuditLogs(100));
|
||||||
|
setShowFactoryResetConfirm(false);
|
||||||
|
setFactoryResetText('');
|
||||||
|
showNotice(result.message || '演示环境已恢复出厂设置', 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotice(err?.response?.data?.detail || '恢复演示出厂设置失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden bg-[#0a0a0a] text-gray-200">
|
||||||
|
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
|
||||||
|
<header className="border-b border-white/10 bg-[#0d0d0d] px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">用户管理后台</h1>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">账号、状态和安全审计</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span className="rounded border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-cyan-100">
|
||||||
|
当前管理员:{currentUser?.username || 'admin'}
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-white/10 bg-white/5 px-3 py-1">启用用户 {activeCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="grid min-h-0 flex-1 grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)] gap-4 overflow-hidden p-4">
|
||||||
|
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-white/10 bg-[#111]">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||||
|
<UserCog size={18} className="text-cyan-300" />
|
||||||
|
用户与权限
|
||||||
|
</div>
|
||||||
|
{isLoading && <Loader2 size={16} className="animate-spin text-cyan-300" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_auto] gap-2 border-b border-white/10 p-4">
|
||||||
|
<input
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(event) => setNewUsername(event.target.value)}
|
||||||
|
placeholder="用户名"
|
||||||
|
autoComplete="off"
|
||||||
|
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={newPassword}
|
||||||
|
type="password"
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
placeholder="初始密码"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="inline-flex items-center gap-2 rounded bg-cyan-500 px-4 py-2 text-sm font-semibold text-black transition-colors hover:bg-cyan-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
新增用户
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="sticky top-0 bg-[#151515] text-xs uppercase text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">用户</th>
|
||||||
|
<th className="px-4 py-3">角色</th>
|
||||||
|
<th className="px-4 py-3">状态</th>
|
||||||
|
<th className="px-4 py-3 text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-white/[0.03]">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-white">{user.username}</div>
|
||||||
|
<div className="text-xs text-gray-500">ID {user.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex rounded border px-2 py-1 text-xs',
|
||||||
|
user.role === 'admin'
|
||||||
|
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-100'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{roleLabels[user.role] || '标注员'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handlePatchUser(user, { is_active: !user.is_active })}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-3 py-1 text-xs',
|
||||||
|
user.is_active
|
||||||
|
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200'
|
||||||
|
: 'border-gray-500/30 bg-gray-500/10 text-gray-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.is_active ? '启用' : '停用'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChangePassword(user)}
|
||||||
|
className="rounded border border-white/10 p-2 text-gray-300 hover:border-cyan-400/40 hover:text-cyan-200"
|
||||||
|
title="修改密码"
|
||||||
|
>
|
||||||
|
<KeyRound size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteUserTarget(user)}
|
||||||
|
disabled={user.id === currentUser?.id}
|
||||||
|
className="rounded border border-white/10 p-2 text-gray-300 hover:border-red-400/40 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="删除用户"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-white/10 bg-[#111]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3 text-sm font-medium text-white">
|
||||||
|
<ShieldCheck size={18} className="text-emerald-300" />
|
||||||
|
审计日志
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{auditLogs.map((log) => (
|
||||||
|
<div key={log.id} className="rounded border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium text-cyan-100">{log.action}</span>
|
||||||
|
<span className="text-[10px] text-gray-500">{formatTime(log.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-gray-400">
|
||||||
|
actor #{log.actor_user_id ?? 'system'} {'->'} {log.target_type || 'target'} #{log.target_id || '-'}
|
||||||
|
</div>
|
||||||
|
{log.detail && Object.keys(log.detail).length > 0 && (
|
||||||
|
<pre className="mt-2 max-h-24 overflow-auto rounded bg-black/30 p-2 text-[10px] leading-relaxed text-gray-500">
|
||||||
|
{JSON.stringify(log.detail, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!auditLogs.length && !isLoading && (
|
||||||
|
<div className="py-10 text-center text-sm text-gray-500">暂无审计记录</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-red-400/20 bg-red-950/10 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-red-100">演示环境出厂设置</div>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-red-200/70">
|
||||||
|
清空演示过程产生的用户、项目帧、标注、任务和私有模板,只保留默认 admin、已生成帧的“演视LC视频序列”和已按文件名顺序生成帧的“演视DICOM序列”。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFactoryResetText('');
|
||||||
|
setShowFactoryResetConfirm(true);
|
||||||
|
}}
|
||||||
|
disabled={isResetting || isSaving}
|
||||||
|
className="shrink-0 rounded border border-red-400/40 bg-red-500/15 px-3 py-2 text-xs font-semibold text-red-100 transition-colors hover:bg-red-500/25 disabled:cursor-wait disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isResetting ? '恢复中...' : '恢复演示出厂设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{passwordTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||||||
|
<div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#151515] p-5 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white">修改密码</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-gray-400">
|
||||||
|
为用户“<span className="text-gray-100">{passwordTarget.username}</span>”设置新密码。
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={nextPassword}
|
||||||
|
onChange={(event) => setNextPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
className="mt-4 w-full rounded border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
|
||||||
|
/>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPasswordTarget(null);
|
||||||
|
setNextPassword('');
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void submitPasswordChange()}
|
||||||
|
disabled={isSaving || nextPassword.length < 6}
|
||||||
|
className="inline-flex items-center gap-2 rounded bg-cyan-500 px-3 py-2 text-xs font-semibold text-black hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
确认修改
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteUserTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||||||
|
<div className="w-full max-w-sm rounded-lg border border-red-400/20 bg-[#151515] p-5 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white">删除用户</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-gray-400">
|
||||||
|
确认删除用户“<span className="text-gray-100">{deleteUserTarget.username}</span>”?已有项目的用户建议先停用。
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteUserTarget(null)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeleteUser()}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="inline-flex items-center gap-2 rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
确认删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFactoryResetConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4">
|
||||||
|
<div className="w-full max-w-lg rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white">恢复演示出厂设置</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-red-100/80">
|
||||||
|
该操作会删除除默认 admin 外的所有用户、项目帧、标注、任务和私有模板,只保留已生成帧的“演视LC视频序列”和已按文件名顺序生成帧的“演视DICOM序列”。
|
||||||
|
</p>
|
||||||
|
<label className="mt-4 block text-xs text-gray-400" htmlFor="factory-reset-confirm">
|
||||||
|
输入 RESET_DEMO_FACTORY 确认
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="factory-reset-confirm"
|
||||||
|
value={factoryResetText}
|
||||||
|
onChange={(event) => setFactoryResetText(event.target.value)}
|
||||||
|
className="mt-2 w-full rounded border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-red-400/50"
|
||||||
|
/>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowFactoryResetConfirm(false);
|
||||||
|
setFactoryResetText('');
|
||||||
|
}}
|
||||||
|
disabled={isResetting}
|
||||||
|
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleFactoryReset()}
|
||||||
|
disabled={isResetting || factoryResetText !== 'RESET_DEMO_FACTORY'}
|
||||||
|
className="inline-flex items-center gap-2 rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isResetting && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
确认恢复
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,4 +8,39 @@
|
|||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seg-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(113, 113, 122, 0.22) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-scrollbar:hover,
|
||||||
|
.seg-scrollbar:focus-within {
|
||||||
|
scrollbar-color: rgba(34, 211, 238, 0.42) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(113, 113, 122, 0.18);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-scrollbar:hover::-webkit-scrollbar-thumb,
|
||||||
|
.seg-scrollbar:focus-within::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(34, 211, 238, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-scrollbar::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,10 +53,12 @@ describe('api client contracts', () => {
|
|||||||
name: 'Demo',
|
name: 'Demo',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
frames: 12,
|
frames: 12,
|
||||||
fps: '30FPS',
|
fps: '10FPS',
|
||||||
thumbnail_url: 'thumb',
|
thumbnail_url: 'thumb',
|
||||||
video_path: 'uploads/demo.mp4',
|
video_path: 'uploads/demo.mp4',
|
||||||
source_type: 'video',
|
source_type: 'video',
|
||||||
|
original_fps: 29.97,
|
||||||
|
parse_fps: 10,
|
||||||
createdAt: 'created',
|
createdAt: 'created',
|
||||||
updatedAt: 'updated',
|
updatedAt: 'updated',
|
||||||
}),
|
}),
|
||||||
@@ -73,6 +75,156 @@ describe('api client contracts', () => {
|
|||||||
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/projects/3', { name: 'Renamed' });
|
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/projects/3', { name: 'Renamed' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('copies projects through the copy endpoint', async () => {
|
||||||
|
const { copyProject } = await import('./api');
|
||||||
|
axiosMock.client.post.mockResolvedValueOnce({ data: { id: 4, name: 'Copied', status: 'ready', frame_count: 3 } });
|
||||||
|
|
||||||
|
await expect(copyProject('3', { mode: 'full' })).resolves.toEqual(expect.objectContaining({
|
||||||
|
id: '4',
|
||||||
|
name: 'Copied',
|
||||||
|
frames: 3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/projects/3/copy', { mode: 'full' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes projects through DELETE', async () => {
|
||||||
|
const { deleteProject } = await import('./api');
|
||||||
|
axiosMock.client.delete.mockResolvedValueOnce({ data: null });
|
||||||
|
|
||||||
|
await expect(deleteProject('3')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/projects/3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports upload progress for video media imports', async () => {
|
||||||
|
const { uploadMedia } = await import('./api');
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
|
||||||
|
axiosMock.client.post.mockResolvedValueOnce({ data: { file_url: 'http://file', object_name: 'uploads/clip.mp4' } });
|
||||||
|
|
||||||
|
await expect(uploadMedia(file, '9', { onProgress })).resolves.toEqual({
|
||||||
|
url: 'http://file',
|
||||||
|
id: 'uploads/clip.mp4',
|
||||||
|
});
|
||||||
|
const [, , config] = axiosMock.client.post.mock.calls.at(-1);
|
||||||
|
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/upload', expect.any(FormData), expect.objectContaining({
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: expect.any(Function),
|
||||||
|
}));
|
||||||
|
|
||||||
|
config.onUploadProgress({ loaded: 25, total: 100 });
|
||||||
|
expect(onProgress).toHaveBeenCalledWith({ loaded: 25, total: 100, percent: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports upload progress for DICOM batch imports', async () => {
|
||||||
|
const { uploadDicomBatch } = await import('./api');
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const file = new File(['dcm'], '1.dcm', { type: 'application/dicom' });
|
||||||
|
axiosMock.client.post.mockResolvedValueOnce({ data: { project_id: 10, uploaded_count: 1, message: 'ok' } });
|
||||||
|
|
||||||
|
await expect(uploadDicomBatch([file], undefined, { onProgress })).resolves.toEqual({
|
||||||
|
project_id: 10,
|
||||||
|
uploaded_count: 1,
|
||||||
|
message: 'ok',
|
||||||
|
});
|
||||||
|
const [, , config] = axiosMock.client.post.mock.calls.at(-1);
|
||||||
|
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/upload/dicom', expect.any(FormData), expect.objectContaining({
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: expect.any(Function),
|
||||||
|
}));
|
||||||
|
|
||||||
|
config.onUploadProgress({ loaded: 10, total: 20 });
|
||||||
|
expect(onProgress).toHaveBeenCalledWith({ loaded: 10, total: 20, percent: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes missing template class maskids without using priority as the public id', async () => {
|
||||||
|
const { getTemplates } = await import('./api');
|
||||||
|
axiosMock.client.get.mockResolvedValueOnce({
|
||||||
|
data: [{
|
||||||
|
id: 2,
|
||||||
|
name: 'Template',
|
||||||
|
mapping_rules: {
|
||||||
|
classes: [
|
||||||
|
{ id: 'c1', name: 'A', color: '#ff0000', zIndex: 100 },
|
||||||
|
{ id: 'c2', name: 'B', color: '#00ff00', zIndex: 10, maskId: 7 },
|
||||||
|
{ id: 'c3', name: 'C', color: '#0000ff', zIndex: 50 },
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getTemplates()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
classes: [
|
||||||
|
expect.objectContaining({ id: 'c1', maskId: 1, zIndex: 100 }),
|
||||||
|
expect.objectContaining({ id: 'c2', maskId: 7, zIndex: 10 }),
|
||||||
|
expect.objectContaining({ id: 'c3', maskId: 2, zIndex: 50 }),
|
||||||
|
expect.objectContaining({ id: 'reserved-unclassified', name: '待分类', maskId: 0, color: '#000000', zIndex: 0 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls admin user management and audit endpoints', async () => {
|
||||||
|
const {
|
||||||
|
getAdminUsers,
|
||||||
|
createAdminUser,
|
||||||
|
updateAdminUser,
|
||||||
|
deleteAdminUser,
|
||||||
|
getAuditLogs,
|
||||||
|
resetDemoFactory,
|
||||||
|
} = await import('./api');
|
||||||
|
axiosMock.client.get
|
||||||
|
.mockResolvedValueOnce({ data: [{ id: 1, username: 'admin', role: 'admin', is_active: 1 }] })
|
||||||
|
.mockResolvedValueOnce({ data: [{ id: 9, action: 'admin.user_created', created_at: 'now' }] });
|
||||||
|
axiosMock.client.post.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 1 } });
|
||||||
|
axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 0 } });
|
||||||
|
axiosMock.client.delete.mockResolvedValueOnce({ data: null });
|
||||||
|
|
||||||
|
await expect(getAdminUsers()).resolves.toEqual([expect.objectContaining({ username: 'admin' })]);
|
||||||
|
await createAdminUser({ username: 'doctor', password: 'secret123', role: 'annotator', is_active: true });
|
||||||
|
await updateAdminUser(2, { is_active: false });
|
||||||
|
await deleteAdminUser(2);
|
||||||
|
await expect(getAuditLogs(50)).resolves.toEqual([expect.objectContaining({ action: 'admin.user_created' })]);
|
||||||
|
|
||||||
|
expect(axiosMock.client.get).toHaveBeenNthCalledWith(1, '/api/admin/users');
|
||||||
|
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/admin/users', {
|
||||||
|
username: 'doctor',
|
||||||
|
password: 'secret123',
|
||||||
|
role: 'annotator',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { is_active: false });
|
||||||
|
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/admin/users/2');
|
||||||
|
expect(axiosMock.client.get).toHaveBeenNthCalledWith(2, '/api/admin/audit-logs', { params: { limit: 50 } });
|
||||||
|
|
||||||
|
axiosMock.client.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
|
||||||
|
project: { id: 8, name: '演视DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
|
||||||
|
projects: [
|
||||||
|
{ id: 7, name: '演视LC视频序列', status: 'ready', source_type: 'video', frame_count: 750, video_path: 'uploads/7/演视LC视频序列.mp4' },
|
||||||
|
{ id: 8, name: '演视DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
|
||||||
|
],
|
||||||
|
deleted_counts: { users: 1 },
|
||||||
|
message: '演示环境已恢复出厂设置',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(resetDemoFactory('RESET_DEMO_FACTORY')).resolves.toEqual(expect.objectContaining({
|
||||||
|
admin_user: expect.objectContaining({ username: 'admin' }),
|
||||||
|
project: expect.objectContaining({ id: '8', name: '演视DICOM序列', frames: 300, source_type: 'dicom' }),
|
||||||
|
projects: [
|
||||||
|
expect.objectContaining({ id: '7', name: '演视LC视频序列', frames: 750, source_type: 'video' }),
|
||||||
|
expect.objectContaining({ id: '8', name: '演视DICOM序列', frames: 300, source_type: 'dicom' }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
expect(axiosMock.client.post).toHaveBeenLastCalledWith('/api/admin/demo-factory-reset', {
|
||||||
|
confirmation: 'RESET_DEMO_FACTORY',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes legacy project status values returned by existing databases', async () => {
|
it('normalizes legacy project status values returned by existing databases', async () => {
|
||||||
const { getProjects } = await import('./api');
|
const { getProjects } = await import('./api');
|
||||||
axiosMock.client.get.mockResolvedValueOnce({
|
axiosMock.client.get.mockResolvedValueOnce({
|
||||||
@@ -112,6 +264,33 @@ describe('api client contracts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exports combined segmentation results with scope, outputs, and mix opacity params', async () => {
|
||||||
|
const { exportSegmentationResults } = await import('./api');
|
||||||
|
const blob = new Blob(['zip'], { type: 'application/zip' });
|
||||||
|
axiosMock.client.get.mockResolvedValueOnce({ data: blob });
|
||||||
|
|
||||||
|
await expect(exportSegmentationResults('9', {
|
||||||
|
scope: 'range',
|
||||||
|
outputs: ['gt_label', 'pro_label', 'mix_label'],
|
||||||
|
mixOpacity: 0.45,
|
||||||
|
startFrame: 2,
|
||||||
|
endFrame: 5,
|
||||||
|
frameId: '12',
|
||||||
|
})).resolves.toBe(blob);
|
||||||
|
expect(axiosMock.client.get).toHaveBeenCalledWith('/api/export/9/results', {
|
||||||
|
params: {
|
||||||
|
scope: 'range',
|
||||||
|
mask_type: undefined,
|
||||||
|
outputs: 'gt_label,pro_label,mix_label',
|
||||||
|
mix_opacity: 0.45,
|
||||||
|
start_frame: 2,
|
||||||
|
end_frame: 5,
|
||||||
|
frame_id: 12,
|
||||||
|
},
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('loads dashboard overview from the backend summary endpoint', async () => {
|
it('loads dashboard overview from the backend summary endpoint', async () => {
|
||||||
const { getDashboardOverview } = await import('./api');
|
const { getDashboardOverview } = await import('./api');
|
||||||
const overview = {
|
const overview = {
|
||||||
@@ -175,7 +354,7 @@ describe('api client contracts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lists, saves, updates, and deletes annotations with the backend annotation contract', async () => {
|
it('lists, saves, updates, and deletes annotations with the backend annotation contract', async () => {
|
||||||
const { deleteAnnotation, getProjectAnnotations, propagateMasks, saveAnnotation, updateAnnotation } = await import('./api');
|
const { deleteAnnotation, getProjectAnnotations, propagateMasks, queuePropagationTask, saveAnnotation, updateAnnotation } = await import('./api');
|
||||||
const saved = {
|
const saved = {
|
||||||
id: 1,
|
id: 1,
|
||||||
project_id: 9,
|
project_id: 9,
|
||||||
@@ -222,9 +401,13 @@ describe('api client contracts', () => {
|
|||||||
await expect(deleteAnnotation('1')).resolves.toBeUndefined();
|
await expect(deleteAnnotation('1')).resolves.toBeUndefined();
|
||||||
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/1');
|
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/1');
|
||||||
|
|
||||||
|
axiosMock.client.delete.mockRejectedValueOnce({ response: { status: 404 } });
|
||||||
|
await expect(deleteAnnotation('missing')).resolves.toBeUndefined();
|
||||||
|
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/missing');
|
||||||
|
|
||||||
axiosMock.client.post.mockResolvedValueOnce({
|
axiosMock.client.post.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
model: 'sam2',
|
model: 'sam2.1_hiera_tiny',
|
||||||
direction: 'forward',
|
direction: 'forward',
|
||||||
source_frame_id: 5,
|
source_frame_id: 5,
|
||||||
processed_frame_count: 3,
|
processed_frame_count: 3,
|
||||||
@@ -235,7 +418,7 @@ describe('api client contracts', () => {
|
|||||||
await expect(propagateMasks({
|
await expect(propagateMasks({
|
||||||
project_id: 9,
|
project_id: 9,
|
||||||
frame_id: 5,
|
frame_id: 5,
|
||||||
model: 'sam2',
|
model: 'sam2.1_hiera_tiny',
|
||||||
seed: {
|
seed: {
|
||||||
polygons: [[[0, 0], [1, 0], [1, 1]]],
|
polygons: [[[0, 0], [1, 0], [1, 1]]],
|
||||||
label: 'mask',
|
label: 'mask',
|
||||||
@@ -247,7 +430,7 @@ describe('api client contracts', () => {
|
|||||||
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/propagate', {
|
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/propagate', {
|
||||||
project_id: 9,
|
project_id: 9,
|
||||||
frame_id: 5,
|
frame_id: 5,
|
||||||
model: 'sam2',
|
model: 'sam2.1_hiera_tiny',
|
||||||
seed: {
|
seed: {
|
||||||
polygons: [[[0, 0], [1, 0], [1, 1]]],
|
polygons: [[[0, 0], [1, 0], [1, 1]]],
|
||||||
label: 'mask',
|
label: 'mask',
|
||||||
@@ -258,6 +441,48 @@ describe('api client contracts', () => {
|
|||||||
}, {
|
}, {
|
||||||
timeout: 600000,
|
timeout: 600000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
axiosMock.client.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
id: 33,
|
||||||
|
task_type: 'propagate_masks',
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
message: '自动传播任务已入队',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(queuePropagationTask({
|
||||||
|
project_id: 9,
|
||||||
|
frame_id: 5,
|
||||||
|
model: 'sam2.1_hiera_tiny',
|
||||||
|
steps: [{
|
||||||
|
seed: {
|
||||||
|
polygons: [[[0, 0], [1, 0], [1, 1]]],
|
||||||
|
label: 'mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
},
|
||||||
|
direction: 'forward',
|
||||||
|
max_frames: 30,
|
||||||
|
}],
|
||||||
|
include_source: false,
|
||||||
|
save_annotations: true,
|
||||||
|
})).resolves.toEqual(expect.objectContaining({ id: 33, task_type: 'propagate_masks' }));
|
||||||
|
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/propagate/task', {
|
||||||
|
project_id: 9,
|
||||||
|
frame_id: 5,
|
||||||
|
model: 'sam2.1_hiera_tiny',
|
||||||
|
steps: [{
|
||||||
|
seed: {
|
||||||
|
polygons: [[[0, 0], [1, 0], [1, 1]]],
|
||||||
|
label: 'mask',
|
||||||
|
color: '#06b6d4',
|
||||||
|
},
|
||||||
|
direction: 'forward',
|
||||||
|
max_frames: 30,
|
||||||
|
}],
|
||||||
|
include_source: false,
|
||||||
|
save_annotations: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports GT masks through multipart form data', async () => {
|
it('imports GT masks through multipart form data', async () => {
|
||||||
@@ -266,7 +491,7 @@ describe('api client contracts', () => {
|
|||||||
const saved = [{ id: 1, project_id: 9, frame_id: 5, template_id: null, mask_data: null, points: null, bbox: null }];
|
const saved = [{ id: 1, project_id: 9, frame_id: 5, template_id: null, mask_data: null, points: null, bbox: null }];
|
||||||
axiosMock.client.post.mockResolvedValueOnce({ data: saved });
|
axiosMock.client.post.mockResolvedValueOnce({ data: saved });
|
||||||
|
|
||||||
await expect(importGtMask(file, '9', '5', '2')).resolves.toEqual(saved);
|
await expect(importGtMask(file, '9', '5', '2', { unknownColorPolicy: 'discard' })).resolves.toEqual(saved);
|
||||||
expect(axiosMock.client.post).toHaveBeenCalledWith(
|
expect(axiosMock.client.post).toHaveBeenCalledWith(
|
||||||
'/api/ai/import-gt-mask',
|
'/api/ai/import-gt-mask',
|
||||||
expect.any(FormData),
|
expect.any(FormData),
|
||||||
@@ -277,6 +502,7 @@ describe('api client contracts', () => {
|
|||||||
expect(form.get('project_id')).toBe('9');
|
expect(form.get('project_id')).toBe('9');
|
||||||
expect(form.get('frame_id')).toBe('5');
|
expect(form.get('frame_id')).toBe('5');
|
||||||
expect(form.get('template_id')).toBe('2');
|
expect(form.get('template_id')).toBe('2');
|
||||||
|
expect(form.get('unknown_color_policy')).toBe('discard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds annotation payloads from frontend masks and restores saved annotations to masks', async () => {
|
it('builds annotation payloads from frontend masks and restores saved annotations to masks', async () => {
|
||||||
@@ -291,8 +517,10 @@ describe('api client contracts', () => {
|
|||||||
classId: 'c1',
|
classId: 'c1',
|
||||||
className: '胆囊',
|
className: '胆囊',
|
||||||
classZIndex: 20,
|
classZIndex: 20,
|
||||||
|
classMaskId: 7,
|
||||||
segmentation: [[10, 10, 90, 10, 90, 40]],
|
segmentation: [[10, 10, 90, 10, 90, 40]],
|
||||||
bbox: [10, 10, 80, 30],
|
bbox: [10, 10, 80, 30],
|
||||||
|
metadata: { geometry_smoothing: { strength: 35, method: 'chaikin' } },
|
||||||
}, frame, '2');
|
}, frame, '2');
|
||||||
|
|
||||||
expect(payload).toEqual({
|
expect(payload).toEqual({
|
||||||
@@ -300,12 +528,15 @@ describe('api client contracts', () => {
|
|||||||
frame_id: 5,
|
frame_id: 5,
|
||||||
template_id: 2,
|
template_id: 2,
|
||||||
mask_data: {
|
mask_data: {
|
||||||
|
instance_id: 'm1',
|
||||||
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
|
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
|
||||||
label: '胆囊',
|
label: '胆囊',
|
||||||
color: '#ff0000',
|
color: '#ff0000',
|
||||||
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
|
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 7 },
|
||||||
|
geometry_smoothing: { strength: 35, method: 'chaikin' },
|
||||||
},
|
},
|
||||||
bbox: [0.1, 0.2, 0.8, 0.6],
|
bbox: [0.1, 0.2, 0.8, 0.6],
|
||||||
|
points: [[0.6333333333333333, 0.4]],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(annotationToMask({
|
expect(annotationToMask({
|
||||||
@@ -317,7 +548,12 @@ describe('api client contracts', () => {
|
|||||||
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
|
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
|
||||||
label: '旧标签',
|
label: '旧标签',
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
|
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 7 },
|
||||||
|
source: 'sam2.1_hiera_tiny_propagation',
|
||||||
|
propagated_from_frame_id: 4,
|
||||||
|
instance_id: 'instance:gallbladder-1',
|
||||||
|
source_instance_id: 'gallbladder-1',
|
||||||
|
geometry_smoothing: { strength: 35, method: 'chaikin' },
|
||||||
},
|
},
|
||||||
points: [[0.5, 0.5]],
|
points: [[0.5, 0.5]],
|
||||||
bbox: null,
|
bbox: null,
|
||||||
@@ -331,6 +567,7 @@ describe('api client contracts', () => {
|
|||||||
classId: 'c1',
|
classId: 'c1',
|
||||||
className: '胆囊',
|
className: '胆囊',
|
||||||
classZIndex: 20,
|
classZIndex: 20,
|
||||||
|
classMaskId: 7,
|
||||||
label: '胆囊',
|
label: '胆囊',
|
||||||
color: '#ff0000',
|
color: '#ff0000',
|
||||||
saveStatus: 'saved',
|
saveStatus: 'saved',
|
||||||
@@ -338,6 +575,58 @@ 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,
|
||||||
|
instance_id: 'instance:gallbladder-1',
|
||||||
|
source_instance_id: 'gallbladder-1',
|
||||||
|
geometry_smoothing: { strength: 35, method: 'chaikin' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends selected mask geometry to backend smoothing and maps smoothed polygons back to pixels', async () => {
|
||||||
|
const { smoothMaskGeometry } = await import('./api');
|
||||||
|
axiosMock.client.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
polygons: [[[0.12, 0.24], [0.88, 0.24], [0.88, 0.76], [0.12, 0.76]]],
|
||||||
|
topology_anchor_count: 4,
|
||||||
|
topology_anchors: [[0.12, 0.24]],
|
||||||
|
area: 0.4,
|
||||||
|
bbox: [0.12, 0.24, 0.76, 0.52],
|
||||||
|
smoothing: { strength: 35, method: 'chaikin' },
|
||||||
|
message: '已应用边缘平滑强度 35',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await smoothMaskGeometry({
|
||||||
|
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],
|
||||||
|
}, { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 }, 35);
|
||||||
|
|
||||||
|
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/smooth-mask', {
|
||||||
|
frame_id: 5,
|
||||||
|
mask_data: {
|
||||||
|
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
|
||||||
|
label: '胆囊',
|
||||||
|
color: '#ff0000',
|
||||||
|
},
|
||||||
|
points: undefined,
|
||||||
|
bbox: [0.1, 0.2, 0.8, 0.6],
|
||||||
|
strength: 35,
|
||||||
|
method: 'chaikin',
|
||||||
|
});
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
pathData: 'M 12 12 L 88 12 L 88 38 L 12 38 Z',
|
||||||
|
segmentation: [[12, 12, 88, 12, 88, 38, 12, 38]],
|
||||||
|
bbox: [12, 12, 76, 26],
|
||||||
|
area: 1976,
|
||||||
|
smoothing: { strength: 35, method: 'chaikin' },
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -358,6 +647,136 @@ describe('api client contracts', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves hollow mask holes when saving and hydrating annotations', async () => {
|
||||||
|
const { annotationToMask, buildAnnotationPayload } = await import('./api');
|
||||||
|
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 100 };
|
||||||
|
const hollowMask = {
|
||||||
|
id: 'm-hole',
|
||||||
|
frameId: '5',
|
||||||
|
pathData: 'M 10 10 L 90 10 L 90 90 L 10 90 Z M 35 35 L 65 35 L 65 65 L 35 65 Z',
|
||||||
|
label: '中空区域',
|
||||||
|
color: '#22c55e',
|
||||||
|
segmentation: [
|
||||||
|
[10, 10, 90, 10, 90, 90, 10, 90],
|
||||||
|
[35, 35, 65, 35, 65, 65, 35, 65],
|
||||||
|
],
|
||||||
|
metadata: { hasHoles: true, polygonRingCounts: [2] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = buildAnnotationPayload('9', hollowMask, frame);
|
||||||
|
|
||||||
|
expect(payload?.mask_data).toEqual(expect.objectContaining({
|
||||||
|
polygons: [[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
|
||||||
|
holes: [[[[0.35, 0.35], [0.65, 0.35], [0.65, 0.65], [0.35, 0.65]]]],
|
||||||
|
hasHoles: true,
|
||||||
|
polygonRingCounts: [2],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hydrated = annotationToMask({
|
||||||
|
id: 33,
|
||||||
|
project_id: 9,
|
||||||
|
frame_id: 5,
|
||||||
|
template_id: null,
|
||||||
|
mask_data: {
|
||||||
|
polygons: [[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
|
||||||
|
holes: [[[[0.35, 0.35], [0.65, 0.35], [0.65, 0.65], [0.35, 0.65]]]],
|
||||||
|
label: '中空区域',
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
points: null,
|
||||||
|
bbox: null,
|
||||||
|
created_at: 'created',
|
||||||
|
updated_at: 'updated',
|
||||||
|
}, frame);
|
||||||
|
|
||||||
|
expect(hydrated).toEqual(expect.objectContaining({
|
||||||
|
segmentation: [
|
||||||
|
[10, 10, 90, 10, 90, 90, 10, 90],
|
||||||
|
[35, 35, 65, 35, 65, 65, 35, 65],
|
||||||
|
],
|
||||||
|
metadata: expect.objectContaining({ hasHoles: true, polygonRingCounts: [2] }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores disconnected polygons from one saved annotation as one mask with a combined bbox', async () => {
|
||||||
|
const { annotationToMask } = await import('./api');
|
||||||
|
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 100 };
|
||||||
|
|
||||||
|
const hydrated = annotationToMask({
|
||||||
|
id: 44,
|
||||||
|
project_id: 9,
|
||||||
|
frame_id: 5,
|
||||||
|
template_id: null,
|
||||||
|
mask_data: {
|
||||||
|
polygons: [
|
||||||
|
[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2]],
|
||||||
|
[[0.7, 0.7], [0.9, 0.7], [0.9, 0.9], [0.7, 0.9]],
|
||||||
|
],
|
||||||
|
label: '多区域',
|
||||||
|
color: '#22c55e',
|
||||||
|
source: 'sam2.1_hiera_tiny_propagation',
|
||||||
|
},
|
||||||
|
points: null,
|
||||||
|
bbox: null,
|
||||||
|
created_at: 'created',
|
||||||
|
updated_at: 'updated',
|
||||||
|
}, frame);
|
||||||
|
|
||||||
|
expect(hydrated).toEqual(expect.objectContaining({
|
||||||
|
id: 'annotation-44',
|
||||||
|
pathData: 'M 10 10 L 20 10 L 20 20 L 10 20 Z M 70 70 L 90 70 L 90 90 L 70 90 Z',
|
||||||
|
segmentation: [
|
||||||
|
[10, 10, 20, 10, 20, 20, 10, 20],
|
||||||
|
[70, 70, 90, 70, 90, 90, 70, 90],
|
||||||
|
],
|
||||||
|
bbox: [10, 10, 80, 80],
|
||||||
|
area: 500,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves propagation metadata when saving edited geometry without persisting preview-only smoothing fields', async () => {
|
||||||
|
const { buildAnnotationPayload } = await import('./api');
|
||||||
|
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 };
|
||||||
|
|
||||||
|
expect(buildAnnotationPayload('9', {
|
||||||
|
id: 'm1',
|
||||||
|
frameId: '5',
|
||||||
|
pathData: 'M 10 10 L 90 10 L 90 40 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#22c55e',
|
||||||
|
segmentation: [[10, 10, 90, 10, 90, 40]],
|
||||||
|
metadata: {
|
||||||
|
source: 'sam2_propagation',
|
||||||
|
propagated_from_frame_id: 1,
|
||||||
|
source_annotation_id: 7,
|
||||||
|
source_mask_id: 'annotation-7',
|
||||||
|
source_instance_id: 'tracked-instance-7',
|
||||||
|
instance_id: 'tracked-instance-7',
|
||||||
|
propagation_seed_key: 'annotation:7',
|
||||||
|
geometry_smoothing_preview: { strength: 35, method: 'chaikin' },
|
||||||
|
},
|
||||||
|
}, frame)).toEqual(expect.objectContaining({
|
||||||
|
mask_data: expect.objectContaining({
|
||||||
|
source: 'sam2_propagation',
|
||||||
|
propagated_from_frame_id: 1,
|
||||||
|
source_annotation_id: 7,
|
||||||
|
source_mask_id: 'annotation-7',
|
||||||
|
source_instance_id: 'tracked-instance-7',
|
||||||
|
instance_id: 'tracked-instance-7',
|
||||||
|
propagation_seed_key: 'annotation:7',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(buildAnnotationPayload('9', {
|
||||||
|
id: 'm1',
|
||||||
|
frameId: '5',
|
||||||
|
pathData: 'M 10 10 L 90 10 L 90 40 Z',
|
||||||
|
label: 'Tracked',
|
||||||
|
color: '#22c55e',
|
||||||
|
segmentation: [[10, 10, 90, 10, 90, 40]],
|
||||||
|
metadata: { geometry_smoothing_preview: { strength: 35, method: 'chaikin' } },
|
||||||
|
}, frame)?.mask_data).not.toHaveProperty('geometry_smoothing_preview');
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes positive and negative point prompts for AI prediction', async () => {
|
it('normalizes positive and negative point prompts for AI prediction', async () => {
|
||||||
const { predictMask } = await import('./api');
|
const { predictMask } = await import('./api');
|
||||||
axiosMock.client.post.mockResolvedValueOnce({
|
axiosMock.client.post.mockResolvedValueOnce({
|
||||||
@@ -384,7 +803,7 @@ describe('api client contracts', () => {
|
|||||||
points: [[0.5, 0.5], [0.1, 0.1]],
|
points: [[0.5, 0.5], [0.1, 0.1]],
|
||||||
labels: [1, 0],
|
labels: [1, 0],
|
||||||
},
|
},
|
||||||
model: 'sam2',
|
model: 'sam2.1_hiera_tiny',
|
||||||
});
|
});
|
||||||
expect(result.masks[0]).toEqual(expect.objectContaining({
|
expect(result.masks[0]).toEqual(expect.objectContaining({
|
||||||
pathData: 'M 100 50 L 300 50 L 300 150 L 100 150 Z',
|
pathData: 'M 100 50 L 300 50 L 300 150 L 100 150 Z',
|
||||||
@@ -410,10 +829,52 @@ describe('api client contracts', () => {
|
|||||||
image_id: 5,
|
image_id: 5,
|
||||||
prompt_type: 'box',
|
prompt_type: 'box',
|
||||||
prompt_data: [0.1, 0.1, 0.5, 0.5],
|
prompt_data: [0.1, 0.1, 0.5, 0.5],
|
||||||
model: 'sam2',
|
model: 'sam2.1_hiera_tiny',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: [] } });
|
||||||
@@ -437,11 +898,11 @@ describe('api client contracts', () => {
|
|||||||
points: [[0.2, 0.2], [0.4, 0.4]],
|
points: [[0.2, 0.2], [0.4, 0.4]],
|
||||||
labels: [1, 0],
|
labels: [1, 0],
|
||||||
},
|
},
|
||||||
model: 'sam2',
|
model: 'sam2.1_hiera_tiny',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses semantic prompt type for text-only AI prediction', async () => {
|
it('serializes text-only prediction as semantic when called directly', 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: [] } });
|
||||||
|
|
||||||
@@ -449,7 +910,6 @@ describe('api client contracts', () => {
|
|||||||
imageId: '6',
|
imageId: '6',
|
||||||
imageWidth: 640,
|
imageWidth: 640,
|
||||||
imageHeight: 360,
|
imageHeight: 360,
|
||||||
model: 'sam3',
|
|
||||||
text: '分割胆囊',
|
text: '分割胆囊',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -457,7 +917,7 @@ describe('api client contracts', () => {
|
|||||||
image_id: 6,
|
image_id: 6,
|
||||||
prompt_type: 'semantic',
|
prompt_type: 'semantic',
|
||||||
prompt_data: '分割胆囊',
|
prompt_data: '分割胆囊',
|
||||||
model: 'sam3',
|
model: 'sam2.1_hiera_tiny',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -484,7 +944,7 @@ describe('api client contracts', () => {
|
|||||||
points: [[0.5, 0.5]],
|
points: [[0.5, 0.5]],
|
||||||
labels: [1],
|
labels: [1],
|
||||||
},
|
},
|
||||||
model: 'sam2',
|
model: 'sam2.1_hiera_tiny',
|
||||||
options: {
|
options: {
|
||||||
crop_to_prompt: true,
|
crop_to_prompt: true,
|
||||||
auto_filter_background: true,
|
auto_filter_background: true,
|
||||||
@@ -496,18 +956,17 @@ describe('api client contracts', () => {
|
|||||||
it('loads AI model and GPU runtime status', async () => {
|
it('loads AI model and GPU runtime status', async () => {
|
||||||
const { getAiModelStatus } = await import('./api');
|
const { getAiModelStatus } = await import('./api');
|
||||||
const status = {
|
const status = {
|
||||||
selected_model: 'sam2',
|
selected_model: 'sam2.1_hiera_tiny',
|
||||||
gpu: { available: false, device: 'cpu', name: null, torch_available: true, torch_version: '2.x', cuda_version: null },
|
gpu: { available: false, device: 'cpu', name: null, torch_available: true, torch_version: '2.x', cuda_version: null },
|
||||||
models: [
|
models: [
|
||||||
{ id: 'sam2', label: 'SAM 2', available: true, loaded: false, device: 'cpu', supports: ['point'], message: 'ready', package_available: true, checkpoint_exists: true, checkpoint_path: 'model.pt', python_ok: true, torch_ok: true, cuda_required: false },
|
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cpu', supports: ['point'], message: 'ready', package_available: true, checkpoint_exists: true, checkpoint_path: 'model.pt', python_ok: true, torch_ok: true, cuda_required: false },
|
||||||
{ id: 'sam3', label: 'SAM 3', available: false, loaded: false, device: 'unavailable', supports: ['semantic'], message: 'missing runtime', package_available: false, checkpoint_exists: false, checkpoint_path: null, python_ok: false, torch_ok: true, cuda_required: true },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
axiosMock.client.get.mockResolvedValueOnce({ data: status });
|
axiosMock.client.get.mockResolvedValueOnce({ data: status });
|
||||||
|
|
||||||
await expect(getAiModelStatus('sam3')).resolves.toEqual(status);
|
await expect(getAiModelStatus('sam2.1_hiera_tiny')).resolves.toEqual(status);
|
||||||
expect(axiosMock.client.get).toHaveBeenCalledWith('/api/ai/models/status', {
|
expect(axiosMock.client.get).toHaveBeenCalledWith('/api/ai/models/status', {
|
||||||
params: { selected_model: 'sam3' },
|
params: { selected_model: 'sam2.1_hiera_tiny' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
483
src/lib/api.ts
483
src/lib/api.ts
@@ -1,6 +1,7 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError, type AxiosProgressEvent } from 'axios';
|
||||||
import type { AiModelId, Frame, Mask, Project, Template } from '../store/useStore';
|
import { DEFAULT_AI_MODEL_ID, type AiModelId, type Frame, type Mask, type Project, type Template, type UserProfile } from '../store/useStore';
|
||||||
import { API_BASE_URL } from './config';
|
import { API_BASE_URL } from './config';
|
||||||
|
import { normalizeClassMaskIds } from './maskIds';
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -28,18 +29,90 @@ apiClient.interceptors.response.use(
|
|||||||
(error: AxiosError) => {
|
(error: AxiosError) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
window.location.reload();
|
if (!error.config?.url?.includes('/api/auth/login')) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
export async function login(username: string, password: string): Promise<{ token: string }> {
|
export async function login(username: string, password: string): Promise<{ token: string; username: string; user: UserProfile }> {
|
||||||
const response = await apiClient.post('/api/auth/login', { username, password });
|
const response = await apiClient.post('/api/auth/login', { username, password });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser(): Promise<UserProfile> {
|
||||||
|
const response = await apiClient.get('/api/auth/me');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUser extends UserProfile {
|
||||||
|
is_active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: number;
|
||||||
|
actor_user_id?: number | null;
|
||||||
|
action: string;
|
||||||
|
target_type?: string | null;
|
||||||
|
target_id?: string | null;
|
||||||
|
detail?: Record<string, any> | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoFactoryResetResult {
|
||||||
|
admin_user: AdminUser;
|
||||||
|
project: Project;
|
||||||
|
projects?: Project[];
|
||||||
|
deleted_counts: Record<string, number>;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminUsers(): Promise<AdminUser[]> {
|
||||||
|
const response = await apiClient.get('/api/admin/users');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminUser(payload: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}): Promise<AdminUser> {
|
||||||
|
const response = await apiClient.post('/api/admin/users', payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminUser(id: number, payload: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
role?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}): Promise<AdminUser> {
|
||||||
|
const response = await apiClient.patch(`/api/admin/users/${id}`, payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminUser(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/api/admin/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLogs(limit = 100): Promise<AuditLog[]> {
|
||||||
|
const response = await apiClient.get('/api/admin/audit-logs', { params: { limit } });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetDemoFactory(confirmation: string): Promise<DemoFactoryResetResult> {
|
||||||
|
const response = await apiClient.post('/api/admin/demo-factory-reset', { confirmation });
|
||||||
|
return {
|
||||||
|
...response.data,
|
||||||
|
project: mapProject(response.data.project),
|
||||||
|
projects: Array.isArray(response.data.projects) ? response.data.projects.map(mapProject) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
function normalizeProjectStatus(status?: string): Project['status'] {
|
function normalizeProjectStatus(status?: string): Project['status'] {
|
||||||
const value = (status || 'pending').toLowerCase();
|
const value = (status || 'pending').toLowerCase();
|
||||||
@@ -49,6 +122,12 @@ function normalizeProjectStatus(status?: string): Project['status'] {
|
|||||||
return 'pending';
|
return 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProjectFps(value?: number | null): string {
|
||||||
|
if (!value || value <= 0) return '30FPS';
|
||||||
|
const rounded = Math.round(value * 10) / 10;
|
||||||
|
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}FPS`;
|
||||||
|
}
|
||||||
|
|
||||||
function mapProject(p: any): Project {
|
function mapProject(p: any): Project {
|
||||||
return {
|
return {
|
||||||
id: String(p.id),
|
id: String(p.id),
|
||||||
@@ -56,7 +135,7 @@ function mapProject(p: any): Project {
|
|||||||
description: p.description,
|
description: p.description,
|
||||||
status: normalizeProjectStatus(p.status),
|
status: normalizeProjectStatus(p.status),
|
||||||
frames: p.frame_count ?? 0,
|
frames: p.frame_count ?? 0,
|
||||||
fps: p.original_fps ? `${Math.round(p.original_fps)}FPS` : '30FPS',
|
fps: formatProjectFps(p.parse_fps ?? p.original_fps),
|
||||||
thumbnail_url: p.thumbnail_url,
|
thumbnail_url: p.thumbnail_url,
|
||||||
video_path: p.video_path,
|
video_path: p.video_path,
|
||||||
source_type: p.source_type,
|
source_type: p.source_type,
|
||||||
@@ -86,6 +165,14 @@ export async function updateProject(id: string, payload: Partial<Project>): Prom
|
|||||||
return mapProject(response.data);
|
return mapProject(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function copyProject(
|
||||||
|
id: string,
|
||||||
|
payload: { mode: 'reset' | 'full'; name?: string },
|
||||||
|
): Promise<Project> {
|
||||||
|
const response = await apiClient.post(`/api/projects/${id}/copy`, payload);
|
||||||
|
return mapProject(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteProject(id: string): Promise<void> {
|
export async function deleteProject(id: string): Promise<void> {
|
||||||
await apiClient.delete(`/api/projects/${id}`);
|
await apiClient.delete(`/api/projects/${id}`);
|
||||||
}
|
}
|
||||||
@@ -97,7 +184,9 @@ function _mapTemplate(t: any): Template {
|
|||||||
id: String(t.id),
|
id: String(t.id),
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
classes: mapping.classes || [],
|
color: t.color,
|
||||||
|
z_index: t.z_index,
|
||||||
|
classes: normalizeClassMaskIds(mapping.classes || []),
|
||||||
rules: mapping.rules || [],
|
rules: mapping.rules || [],
|
||||||
createdAt: t.created_at,
|
createdAt: t.created_at,
|
||||||
updatedAt: t.updated_at,
|
updatedAt: t.updated_at,
|
||||||
@@ -114,7 +203,7 @@ export async function createTemplate(payload: {
|
|||||||
description?: string;
|
description?: string;
|
||||||
color: string;
|
color: string;
|
||||||
z_index: number;
|
z_index: number;
|
||||||
classes?: { name: string; color: string; zIndex: number; category?: string }[];
|
classes?: Template['classes'];
|
||||||
rules?: any[];
|
rules?: any[];
|
||||||
}): Promise<Template> {
|
}): Promise<Template> {
|
||||||
const response = await apiClient.post('/api/templates', payload);
|
const response = await apiClient.post('/api/templates', payload);
|
||||||
@@ -131,7 +220,26 @@ export async function deleteTemplate(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
export async function uploadMedia(file: File, projectId?: string): Promise<{ url: string; id: string }> {
|
export interface UploadProgress {
|
||||||
|
loaded: number;
|
||||||
|
total?: number;
|
||||||
|
percent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadOptions {
|
||||||
|
onProgress?: (progress: UploadProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUploadProgress = (event: AxiosProgressEvent): UploadProgress => {
|
||||||
|
const total = typeof event.total === 'number' && event.total > 0 ? event.total : undefined;
|
||||||
|
return {
|
||||||
|
loaded: event.loaded,
|
||||||
|
total,
|
||||||
|
percent: total ? Math.min(100, Math.max(0, Math.round((event.loaded / total) * 100))) : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function uploadMedia(file: File, projectId?: string, options: UploadOptions = {}): Promise<{ url: string; id: string }> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
@@ -141,6 +249,7 @@ export async function uploadMedia(file: File, projectId?: string): Promise<{ url
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
|
onUploadProgress: options.onProgress ? (event) => options.onProgress?.(toUploadProgress(event)) : undefined,
|
||||||
});
|
});
|
||||||
const { file_url, object_name } = response.data;
|
const { file_url, object_name } = response.data;
|
||||||
return { url: file_url, id: object_name };
|
return { url: file_url, id: object_name };
|
||||||
@@ -160,12 +269,13 @@ export async function getProjectFrames(projectId: string): Promise<Array<{
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadDicomBatch(files: File[], projectId?: string): Promise<{ project_id: number; uploaded_count: number; message: string }> {
|
export async function uploadDicomBatch(files: File[], projectId?: string, options: UploadOptions = {}): Promise<{ project_id: number; uploaded_count: number; message: string }> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach((file) => formData.append('files', file));
|
files.forEach((file) => formData.append('files', file));
|
||||||
if (projectId) formData.append('project_id', projectId);
|
if (projectId) formData.append('project_id', projectId);
|
||||||
const response = await apiClient.post('/api/media/upload/dicom', formData, {
|
const response = await apiClient.post('/api/media/upload/dicom', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: options.onProgress ? (event) => options.onProgress?.(toUploadProgress(event)) : undefined,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -285,6 +395,7 @@ export interface SavedAnnotation {
|
|||||||
template_id: number | null;
|
template_id: number | null;
|
||||||
mask_data: {
|
mask_data: {
|
||||||
polygons?: number[][][];
|
polygons?: number[][][];
|
||||||
|
holes?: number[][][][];
|
||||||
label?: string;
|
label?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
class?: {
|
class?: {
|
||||||
@@ -292,8 +403,15 @@ export interface SavedAnnotation {
|
|||||||
name?: string;
|
name?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
zIndex?: number;
|
zIndex?: number;
|
||||||
|
maskId?: number;
|
||||||
category?: string;
|
category?: string;
|
||||||
};
|
};
|
||||||
|
source?: string;
|
||||||
|
propagated_from_frame_id?: number;
|
||||||
|
propagated_from_frame_index?: number;
|
||||||
|
geometry_smoothing?: GeometrySmoothingOptions;
|
||||||
|
score?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
points: number[][] | null;
|
points: number[][] | null;
|
||||||
bbox: number[] | null;
|
bbox: number[] | null;
|
||||||
@@ -307,6 +425,7 @@ export interface SaveAnnotationPayload {
|
|||||||
template_id?: number;
|
template_id?: number;
|
||||||
mask_data?: {
|
mask_data?: {
|
||||||
polygons: number[][][];
|
polygons: number[][][];
|
||||||
|
holes?: number[][][][];
|
||||||
label?: string;
|
label?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
class?: {
|
class?: {
|
||||||
@@ -314,8 +433,11 @@ export interface SaveAnnotationPayload {
|
|||||||
name?: string;
|
name?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
zIndex?: number;
|
zIndex?: number;
|
||||||
|
maskId?: number;
|
||||||
category?: string;
|
category?: string;
|
||||||
};
|
};
|
||||||
|
geometry_smoothing?: GeometrySmoothingOptions;
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
points?: number[][];
|
points?: number[][];
|
||||||
bbox?: number[];
|
bbox?: number[];
|
||||||
@@ -329,6 +451,7 @@ export interface PropagateMasksPayload {
|
|||||||
model?: AiModelId;
|
model?: AiModelId;
|
||||||
seed: {
|
seed: {
|
||||||
polygons?: number[][][];
|
polygons?: number[][][];
|
||||||
|
holes?: number[][][][];
|
||||||
bbox?: number[];
|
bbox?: number[];
|
||||||
points?: number[][];
|
points?: number[][];
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -341,6 +464,11 @@ export interface PropagateMasksPayload {
|
|||||||
category?: string;
|
category?: string;
|
||||||
};
|
};
|
||||||
template_id?: number;
|
template_id?: number;
|
||||||
|
source_mask_id?: string;
|
||||||
|
source_annotation_id?: number;
|
||||||
|
source_instance_id?: string;
|
||||||
|
propagation_seed_signature?: string;
|
||||||
|
smoothing?: GeometrySmoothingOptions;
|
||||||
};
|
};
|
||||||
direction?: 'forward' | 'backward' | 'both';
|
direction?: 'forward' | 'backward' | 'both';
|
||||||
max_frames?: number;
|
max_frames?: number;
|
||||||
@@ -348,6 +476,19 @@ export interface PropagateMasksPayload {
|
|||||||
save_annotations?: boolean;
|
save_annotations?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PropagateTaskPayload {
|
||||||
|
project_id: number;
|
||||||
|
frame_id: number;
|
||||||
|
model?: AiModelId;
|
||||||
|
steps: Array<{
|
||||||
|
seed: PropagateMasksPayload['seed'];
|
||||||
|
direction: 'forward' | 'backward';
|
||||||
|
max_frames: number;
|
||||||
|
}>;
|
||||||
|
include_source?: boolean;
|
||||||
|
save_annotations?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PropagateMasksResult {
|
export interface PropagateMasksResult {
|
||||||
model: AiModelId;
|
model: AiModelId;
|
||||||
direction: string;
|
direction: string;
|
||||||
@@ -357,6 +498,34 @@ 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 GeometrySmoothingOptions {
|
||||||
|
strength: number;
|
||||||
|
method: 'chaikin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmoothMaskGeometryResult {
|
||||||
|
polygons: number[][][];
|
||||||
|
pathData: string;
|
||||||
|
segmentation: number[][];
|
||||||
|
bbox: [number, number, number, number];
|
||||||
|
area: number;
|
||||||
|
topology_anchor_count: number;
|
||||||
|
topology_anchors: number[][];
|
||||||
|
smoothing: GeometrySmoothingOptions;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardTask {
|
export interface DashboardTask {
|
||||||
id: string;
|
id: string;
|
||||||
task_id?: number;
|
task_id?: number;
|
||||||
@@ -424,6 +593,82 @@ function polygonToBbox(points: number[][], width: number, height: number): [numb
|
|||||||
return [minX, minY, maxX - minX, maxY - minY];
|
return [minX, minY, maxX - minX, maxY - minY];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function polygonsToBbox(polygons: number[][][], width: number, height: number): [number, number, number, number] {
|
||||||
|
const points = polygons.flat();
|
||||||
|
if (points.length === 0) return [0, 0, 0, 0];
|
||||||
|
return polygonToBbox(points, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonAreaPixels(points: number[][], width: number, height: number): number {
|
||||||
|
if (points.length < 3) return 0;
|
||||||
|
let total = 0;
|
||||||
|
points.forEach(([x, y], index) => {
|
||||||
|
const [nx, ny] = points[(index + 1) % points.length];
|
||||||
|
total += (x * width) * (ny * height);
|
||||||
|
total -= (nx * width) * (y * height);
|
||||||
|
});
|
||||||
|
return Math.abs(total) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonRepresentativePointPixels(polygon: number[] | undefined): [number, number] | null {
|
||||||
|
if (!polygon || polygon.length < 6) return null;
|
||||||
|
const points: number[][] = [];
|
||||||
|
for (let index = 0; index < polygon.length - 1; index += 2) {
|
||||||
|
points.push([polygon[index], polygon[index + 1]]);
|
||||||
|
}
|
||||||
|
if (points.length < 3) return null;
|
||||||
|
|
||||||
|
let twiceArea = 0;
|
||||||
|
let centroidX = 0;
|
||||||
|
let centroidY = 0;
|
||||||
|
points.forEach(([x, y], index) => {
|
||||||
|
const [nextX, nextY] = points[(index + 1) % points.length];
|
||||||
|
const cross = x * nextY - nextX * y;
|
||||||
|
twiceArea += cross;
|
||||||
|
centroidX += (x + nextX) * cross;
|
||||||
|
centroidY += (y + nextY) * cross;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Math.abs(twiceArea) > 1e-6) {
|
||||||
|
return [centroidX / (3 * twiceArea), centroidY / (3 * twiceArea)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const xs = points.map(([x]) => x);
|
||||||
|
const ys = points.map(([, y]) => y);
|
||||||
|
return [
|
||||||
|
(Math.min(...xs) + Math.max(...xs)) / 2,
|
||||||
|
(Math.min(...ys) + Math.max(...ys)) / 2,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSeedPointsPixels(mask: Mask): number[][] {
|
||||||
|
if (mask.points && mask.points.length > 0) return mask.points;
|
||||||
|
return (mask.segmentation || [])
|
||||||
|
.map(polygonRepresentativePointPixels)
|
||||||
|
.filter((point): point is [number, number] => Boolean(point));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeometrySmoothing(value: unknown): GeometrySmoothingOptions | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
const strength = Number(source.strength);
|
||||||
|
if (!Number.isFinite(strength) || strength <= 0) return undefined;
|
||||||
|
return {
|
||||||
|
strength: Math.min(Math.max(strength, 0), 100),
|
||||||
|
method: 'chaikin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistableMaskMetadata(metadata?: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (!metadata) return {};
|
||||||
|
const {
|
||||||
|
geometry_smoothing: _geometrySmoothing,
|
||||||
|
geometry_smoothing_preview: _geometrySmoothingPreview,
|
||||||
|
...rest
|
||||||
|
} = metadata;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
function pixelSegmentationToNormalizedPolygons(
|
function pixelSegmentationToNormalizedPolygons(
|
||||||
segmentation: number[][] | undefined,
|
segmentation: number[][] | undefined,
|
||||||
width: number,
|
width: number,
|
||||||
@@ -444,33 +689,104 @@ function pixelSegmentationToNormalizedPolygons(
|
|||||||
.filter((points) => points.length > 0);
|
.filter((points) => points.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function metadataNumberArray(value: unknown): number[] | null {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
const counts = value
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((item) => Number.isInteger(item) && item > 0);
|
||||||
|
return counts.length === value.length ? counts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitNormalizedPolygonsAndHoles(
|
||||||
|
polygons: number[][][],
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
): { polygons: number[][][]; holes?: number[][][][] } {
|
||||||
|
const counts = metadataNumberArray(metadata?.polygonRingCounts);
|
||||||
|
if (counts && counts.reduce((sum, count) => sum + count, 0) === polygons.length) {
|
||||||
|
const outers: number[][][] = [];
|
||||||
|
const holes: number[][][][] = [];
|
||||||
|
let offset = 0;
|
||||||
|
counts.forEach((count) => {
|
||||||
|
const group = polygons.slice(offset, offset + count);
|
||||||
|
offset += count;
|
||||||
|
if (group[0]) {
|
||||||
|
outers.push(group[0]);
|
||||||
|
holes.push(group.slice(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return holes.some((group) => group.length > 0) ? { polygons: outers, holes } : { polygons: outers };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata?.hasHoles && polygons.length > 1) {
|
||||||
|
return { polygons: [polygons[0]], holes: [polygons.slice(1)] };
|
||||||
|
}
|
||||||
|
return { polygons };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNormalizedPolygonsAndHoles(
|
||||||
|
polygons: number[][][],
|
||||||
|
holes: unknown,
|
||||||
|
): { segmentationPolygons: number[][][]; polygonRingCounts?: number[]; hasHoles: boolean } {
|
||||||
|
if (!Array.isArray(holes) || holes.length === 0) {
|
||||||
|
return { segmentationPolygons: polygons, hasHoles: false };
|
||||||
|
}
|
||||||
|
const segmentationPolygons: number[][][] = [];
|
||||||
|
const polygonRingCounts: number[] = [];
|
||||||
|
let hasHoles = false;
|
||||||
|
|
||||||
|
polygons.forEach((polygon, index) => {
|
||||||
|
const holeGroup = Array.isArray(holes[index]) ? holes[index] as number[][][] : [];
|
||||||
|
segmentationPolygons.push(polygon, ...holeGroup);
|
||||||
|
polygonRingCounts.push(1 + holeGroup.length);
|
||||||
|
if (holeGroup.length > 0) hasHoles = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasHoles
|
||||||
|
? { segmentationPolygons, polygonRingCounts, hasHoles }
|
||||||
|
: { segmentationPolygons: polygons, hasHoles: false };
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAnnotationPayload(
|
export function buildAnnotationPayload(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
mask: Mask,
|
mask: Mask,
|
||||||
frame: Frame,
|
frame: Frame,
|
||||||
templateId?: string | null,
|
templateId?: string | null,
|
||||||
): SaveAnnotationPayload | null {
|
): SaveAnnotationPayload | null {
|
||||||
const polygons = pixelSegmentationToNormalizedPolygons(mask.segmentation, frame.width, frame.height);
|
const segmentationPolygons = pixelSegmentationToNormalizedPolygons(mask.segmentation, frame.width, frame.height);
|
||||||
|
if (segmentationPolygons.length === 0) return null;
|
||||||
|
const splitGeometry = splitNormalizedPolygonsAndHoles(segmentationPolygons, mask.metadata);
|
||||||
|
const polygons = splitGeometry.polygons;
|
||||||
if (polygons.length === 0) return null;
|
if (polygons.length === 0) return null;
|
||||||
const effectiveTemplateId = mask.templateId || templateId || undefined;
|
const effectiveTemplateId = mask.templateId || templateId || undefined;
|
||||||
const classMetadata = mask.classId || mask.className || mask.classZIndex !== undefined
|
const classMetadata = mask.classId || mask.className || mask.classZIndex !== undefined || mask.classMaskId !== undefined
|
||||||
? {
|
? {
|
||||||
id: mask.classId,
|
id: mask.classId,
|
||||||
name: mask.className || mask.label,
|
name: mask.className || mask.label,
|
||||||
color: mask.color,
|
color: mask.color,
|
||||||
zIndex: mask.classZIndex,
|
zIndex: mask.classZIndex,
|
||||||
|
maskId: mask.classMaskId,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const geometrySmoothing = normalizeGeometrySmoothing(mask.metadata?.geometry_smoothing);
|
||||||
|
const metadata = persistableMaskMetadata(mask.metadata);
|
||||||
|
const existingInstanceId = typeof metadata.instance_id === 'string' && metadata.instance_id.length > 0
|
||||||
|
? metadata.instance_id
|
||||||
|
: undefined;
|
||||||
|
const instanceId = existingInstanceId || (mask.annotationId ? `annotation:${mask.annotationId}` : mask.id);
|
||||||
|
|
||||||
const payload: SaveAnnotationPayload = {
|
const payload: SaveAnnotationPayload = {
|
||||||
project_id: Number(projectId),
|
project_id: Number(projectId),
|
||||||
frame_id: Number(frame.id),
|
frame_id: Number(frame.id),
|
||||||
template_id: effectiveTemplateId ? Number(effectiveTemplateId) : undefined,
|
template_id: effectiveTemplateId ? Number(effectiveTemplateId) : undefined,
|
||||||
mask_data: {
|
mask_data: {
|
||||||
|
...metadata,
|
||||||
|
instance_id: instanceId,
|
||||||
polygons,
|
polygons,
|
||||||
|
...(splitGeometry.holes ? { holes: splitGeometry.holes } : {}),
|
||||||
label: mask.label,
|
label: mask.label,
|
||||||
color: mask.color,
|
color: mask.color,
|
||||||
...(classMetadata ? { class: classMetadata } : {}),
|
...(classMetadata ? { class: classMetadata } : {}),
|
||||||
|
...(geometrySmoothing ? { geometry_smoothing: geometrySmoothing } : {}),
|
||||||
},
|
},
|
||||||
bbox: mask.bbox
|
bbox: mask.bbox
|
||||||
? [
|
? [
|
||||||
@@ -482,8 +798,9 @@ export function buildAnnotationPayload(
|
|||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mask.points) {
|
const seedPoints = maskSeedPointsPixels(mask);
|
||||||
payload.points = mask.points.map(([x, y]) => [
|
if (seedPoints.length > 0) {
|
||||||
|
payload.points = seedPoints.map(([x, y]) => [
|
||||||
clamp01(x / Math.max(frame.width, 1)),
|
clamp01(x / Math.max(frame.width, 1)),
|
||||||
clamp01(y / Math.max(frame.height, 1)),
|
clamp01(y / Math.max(frame.height, 1)),
|
||||||
]);
|
]);
|
||||||
@@ -494,10 +811,18 @@ export function buildAnnotationPayload(
|
|||||||
|
|
||||||
export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mask | null {
|
export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mask | null {
|
||||||
const polygons = annotation.mask_data?.polygons || [];
|
const polygons = annotation.mask_data?.polygons || [];
|
||||||
const firstPolygon = polygons[0];
|
const mergedGeometry = mergeNormalizedPolygonsAndHoles(polygons, annotation.mask_data?.holes);
|
||||||
|
const segmentationPolygons = mergedGeometry.segmentationPolygons;
|
||||||
|
const firstPolygon = segmentationPolygons[0];
|
||||||
if (!firstPolygon || firstPolygon.length === 0) return null;
|
if (!firstPolygon || firstPolygon.length === 0) return null;
|
||||||
const bbox = polygonToBbox(firstPolygon, frame.width, frame.height);
|
const bbox = polygonsToBbox(segmentationPolygons, frame.width, frame.height);
|
||||||
const classMetadata = annotation.mask_data?.class;
|
const classMetadata = annotation.mask_data?.class;
|
||||||
|
const { polygons: _polygons, holes: _holes, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
|
||||||
|
const restoredMetadata = {
|
||||||
|
...metadata,
|
||||||
|
...(mergedGeometry.hasHoles ? { hasHoles: true, polygonRingCounts: mergedGeometry.polygonRingCounts } : {}),
|
||||||
|
};
|
||||||
|
const hasMetadata = Object.keys(restoredMetadata).length > 0;
|
||||||
return {
|
return {
|
||||||
id: `annotation-${annotation.id}`,
|
id: `annotation-${annotation.id}`,
|
||||||
annotationId: String(annotation.id),
|
annotationId: String(annotation.id),
|
||||||
@@ -506,15 +831,88 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
|
|||||||
classId: classMetadata?.id,
|
classId: classMetadata?.id,
|
||||||
className: classMetadata?.name,
|
className: classMetadata?.name,
|
||||||
classZIndex: classMetadata?.zIndex,
|
classZIndex: classMetadata?.zIndex,
|
||||||
|
classMaskId: classMetadata?.maskId,
|
||||||
saveStatus: 'saved',
|
saveStatus: 'saved',
|
||||||
saved: true,
|
saved: true,
|
||||||
pathData: polygonToPath(firstPolygon, frame.width, frame.height),
|
pathData: segmentationPolygons.map((polygon) => polygonToPath(polygon, frame.width, frame.height)).join(' '),
|
||||||
label: classMetadata?.name || annotation.mask_data?.label || `Annotation ${annotation.id}`,
|
label: classMetadata?.name || annotation.mask_data?.label || `Annotation ${annotation.id}`,
|
||||||
color: classMetadata?.color || annotation.mask_data?.color || '#06b6d4',
|
color: classMetadata?.color || annotation.mask_data?.color || '#06b6d4',
|
||||||
segmentation: polygons.map((polygon) => polygon.flatMap(([x, y]) => [x * frame.width, y * frame.height])),
|
segmentation: segmentationPolygons.map((polygon) => polygon.flatMap(([x, y]) => [x * frame.width, y * frame.height])),
|
||||||
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: segmentationPolygons.reduce((total, polygon) => total + polygonAreaPixels(polygon, frame.width, frame.height), 0),
|
||||||
|
metadata: hasMetadata ? restoredMetadata : 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 smoothMaskGeometry(mask: Mask, frame: Frame, strength: number): Promise<SmoothMaskGeometryResult> {
|
||||||
|
const polygons = pixelSegmentationToNormalizedPolygons(mask.segmentation, frame.width, frame.height);
|
||||||
|
const response = await apiClient.post('/api/ai/smooth-mask', {
|
||||||
|
frame_id: Number(frame.id),
|
||||||
|
mask_data: {
|
||||||
|
polygons,
|
||||||
|
label: mask.label,
|
||||||
|
color: mask.color,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
strength,
|
||||||
|
method: 'chaikin',
|
||||||
|
});
|
||||||
|
const resultPolygons: number[][][] = response.data.polygons || [];
|
||||||
|
const firstPolygon = resultPolygons[0] || [];
|
||||||
|
const bbox = firstPolygon.length > 0
|
||||||
|
? polygonToBbox(firstPolygon, frame.width, frame.height)
|
||||||
|
: [0, 0, 0, 0] as [number, number, number, number];
|
||||||
|
return {
|
||||||
|
polygons: resultPolygons,
|
||||||
|
pathData: firstPolygon.length > 0 ? polygonToPath(firstPolygon, frame.width, frame.height) : '',
|
||||||
|
segmentation: resultPolygons.map((polygon) => polygon.flatMap(([x, y]) => [x * frame.width, y * frame.height])),
|
||||||
|
bbox,
|
||||||
|
area: resultPolygons.reduce((total, polygon) => total + polygonAreaPixels(polygon, frame.width, frame.height), 0),
|
||||||
|
topology_anchor_count: response.data.topology_anchor_count,
|
||||||
|
topology_anchors: response.data.topology_anchors || [],
|
||||||
|
smoothing: response.data.smoothing,
|
||||||
|
message: response.data.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,7 +955,7 @@ export async function predictMask(payload: PredictMaskPayload): Promise<PredictM
|
|||||||
image_id: Number(payload.imageId),
|
image_id: Number(payload.imageId),
|
||||||
prompt_type,
|
prompt_type,
|
||||||
prompt_data,
|
prompt_data,
|
||||||
model: payload.model || 'sam2',
|
model: payload.model || DEFAULT_AI_MODEL_ID,
|
||||||
...(payload.options ? { options: payload.options } : {}),
|
...(payload.options ? { options: payload.options } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -604,6 +1002,11 @@ export async function propagateMasks(payload: PropagateMasksPayload): Promise<Pr
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function queuePropagationTask(payload: PropagateTaskPayload): Promise<ProcessingTask> {
|
||||||
|
const response = await apiClient.post('/api/ai/propagate/task', payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveAnnotation(payload: SaveAnnotationPayload): Promise<SavedAnnotation> {
|
export async function saveAnnotation(payload: SaveAnnotationPayload): Promise<SavedAnnotation> {
|
||||||
const response = await apiClient.post('/api/ai/annotate', payload);
|
const response = await apiClient.post('/api/ai/annotate', payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -615,7 +1018,12 @@ export async function updateAnnotation(annotationId: string, payload: UpdateAnno
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAnnotation(annotationId: string): Promise<void> {
|
export async function deleteAnnotation(annotationId: string): Promise<void> {
|
||||||
await apiClient.delete(`/api/ai/annotations/${annotationId}`);
|
try {
|
||||||
|
await apiClient.delete(`/api/ai/annotations/${annotationId}`);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as AxiosError).response?.status === 404) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importGtMask(
|
export async function importGtMask(
|
||||||
@@ -623,12 +1031,14 @@ export async function importGtMask(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
frameId: string,
|
frameId: string,
|
||||||
templateId?: string | null,
|
templateId?: string | null,
|
||||||
|
options: { unknownColorPolicy?: 'discard' | 'undefined' } = {},
|
||||||
): Promise<SavedAnnotation[]> {
|
): Promise<SavedAnnotation[]> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('project_id', projectId);
|
formData.append('project_id', projectId);
|
||||||
formData.append('frame_id', frameId);
|
formData.append('frame_id', frameId);
|
||||||
if (templateId) formData.append('template_id', templateId);
|
if (templateId) formData.append('template_id', templateId);
|
||||||
|
if (options.unknownColorPolicy) formData.append('unknown_color_policy', options.unknownColorPolicy);
|
||||||
const response = await apiClient.post('/api/ai/import-gt-mask', formData, {
|
const response = await apiClient.post('/api/ai/import-gt-mask', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
@@ -655,4 +1065,37 @@ export async function exportMasks(projectId: string): Promise<Blob> {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SegmentationExportScope = 'all' | 'range' | 'current';
|
||||||
|
export type SegmentationMaskType = 'separate' | 'gt_label' | 'both';
|
||||||
|
export type SegmentationExportOutput = 'separate' | 'gt_label' | 'pro_label' | 'mix_label';
|
||||||
|
|
||||||
|
export interface ExportSegmentationResultsOptions {
|
||||||
|
scope: SegmentationExportScope;
|
||||||
|
maskType?: SegmentationMaskType;
|
||||||
|
outputs?: SegmentationExportOutput[];
|
||||||
|
mixOpacity?: number;
|
||||||
|
startFrame?: number;
|
||||||
|
endFrame?: number;
|
||||||
|
frameId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSegmentationResults(
|
||||||
|
projectId: string,
|
||||||
|
options: ExportSegmentationResultsOptions,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const response = await apiClient.get(`/api/export/${projectId}/results`, {
|
||||||
|
params: {
|
||||||
|
scope: options.scope,
|
||||||
|
mask_type: options.maskType,
|
||||||
|
outputs: options.outputs?.join(','),
|
||||||
|
mix_opacity: options.mixOpacity,
|
||||||
|
start_frame: options.startFrame,
|
||||||
|
end_frame: options.endFrame,
|
||||||
|
frame_id: options.frameId ? Number(options.frameId) : undefined,
|
||||||
|
},
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
32
src/lib/keyboardShortcuts.test.ts
Normal file
32
src/lib/keyboardShortcuts.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getUndoRedoShortcut, isEditableShortcutTarget } from './keyboardShortcuts';
|
||||||
|
|
||||||
|
function keyboardEvent(init: KeyboardEventInit) {
|
||||||
|
return new KeyboardEvent('keydown', init);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('keyboardShortcuts', () => {
|
||||||
|
it('maps common undo and redo shortcuts', () => {
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'z', ctrlKey: true }))).toBe('undo');
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Z', metaKey: true }))).toBe('undo');
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'z', ctrlKey: true, shiftKey: true }))).toBe('redo');
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'y', ctrlKey: true }))).toBe('redo');
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Y', metaKey: true }))).toBe('redo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to physical key codes when key labels are unavailable', () => {
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Process', code: 'KeyZ', ctrlKey: true }))).toBe('undo');
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Process', code: 'KeyZ', ctrlKey: true, shiftKey: true }))).toBe('redo');
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'Process', code: 'KeyY', ctrlKey: true }))).toBe('redo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores editable targets and alt-modified shortcuts', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.dispatchEvent(new Event('focus'));
|
||||||
|
expect(isEditableShortcutTarget(input)).toBe(true);
|
||||||
|
const event = keyboardEvent({ key: 'z', ctrlKey: true });
|
||||||
|
Object.defineProperty(event, 'target', { value: input });
|
||||||
|
expect(getUndoRedoShortcut(event)).toBeNull();
|
||||||
|
expect(getUndoRedoShortcut(keyboardEvent({ key: 'z', ctrlKey: true, altKey: true }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/lib/keyboardShortcuts.ts
Normal file
25
src/lib/keyboardShortcuts.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type UndoRedoShortcut = 'undo' | 'redo';
|
||||||
|
|
||||||
|
export function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tagName = target.tagName.toLowerCase();
|
||||||
|
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKey(event: KeyboardEvent, key: 'z' | 'y'): boolean {
|
||||||
|
return event.key.toLowerCase() === key || event.code === `Key${key.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUndoRedoShortcut(event: KeyboardEvent): UndoRedoShortcut | null {
|
||||||
|
if (isEditableShortcutTarget(event.target)) return null;
|
||||||
|
if (!event.metaKey && !event.ctrlKey) return null;
|
||||||
|
if (event.altKey) return null;
|
||||||
|
|
||||||
|
if (isKey(event, 'z')) {
|
||||||
|
return event.shiftKey ? 'redo' : 'undo';
|
||||||
|
}
|
||||||
|
if (isKey(event, 'y')) {
|
||||||
|
return 'redo';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
70
src/lib/maskIds.ts
Normal file
70
src/lib/maskIds.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { TemplateClass } from '../store/useStore';
|
||||||
|
|
||||||
|
export const RESERVED_UNCLASSIFIED_CLASS: TemplateClass = {
|
||||||
|
id: 'reserved-unclassified',
|
||||||
|
name: '待分类',
|
||||||
|
color: '#000000',
|
||||||
|
zIndex: 0,
|
||||||
|
maskId: 0,
|
||||||
|
category: '系统保留',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isReservedUnclassifiedClass(templateClass: Pick<TemplateClass, 'id' | 'maskId' | 'name'>): boolean {
|
||||||
|
return Number(templateClass.maskId) === 0 || templateClass.id === RESERVED_UNCLASSIFIED_CLASS.id || templateClass.name === RESERVED_UNCLASSIFIED_CLASS.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reservedUnclassifiedClass(source?: Partial<TemplateClass>): TemplateClass {
|
||||||
|
return {
|
||||||
|
...RESERVED_UNCLASSIFIED_CLASS,
|
||||||
|
...source,
|
||||||
|
id: RESERVED_UNCLASSIFIED_CLASS.id,
|
||||||
|
name: RESERVED_UNCLASSIFIED_CLASS.name,
|
||||||
|
color: RESERVED_UNCLASSIFIED_CLASS.color,
|
||||||
|
zIndex: RESERVED_UNCLASSIFIED_CLASS.zIndex,
|
||||||
|
maskId: RESERVED_UNCLASSIFIED_CLASS.maskId,
|
||||||
|
category: RESERVED_UNCLASSIFIED_CLASS.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeClassMaskIds(classes: TemplateClass[] = []): TemplateClass[] {
|
||||||
|
const used = new Set<number>();
|
||||||
|
let nextMaskId = 1;
|
||||||
|
let reservedClass: TemplateClass | undefined;
|
||||||
|
|
||||||
|
const nextAvailableMaskId = () => {
|
||||||
|
while (used.has(nextMaskId)) nextMaskId += 1;
|
||||||
|
const value = nextMaskId;
|
||||||
|
used.add(value);
|
||||||
|
nextMaskId += 1;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = classes
|
||||||
|
.filter((templateClass) => {
|
||||||
|
if (isReservedUnclassifiedClass(templateClass)) {
|
||||||
|
reservedClass ??= reservedUnclassifiedClass(templateClass);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((templateClass) => {
|
||||||
|
const parsed = Number(templateClass.maskId);
|
||||||
|
if (Number.isInteger(parsed) && parsed > 0 && !used.has(parsed)) {
|
||||||
|
used.add(parsed);
|
||||||
|
return { ...templateClass, maskId: parsed };
|
||||||
|
}
|
||||||
|
return { ...templateClass, maskId: nextAvailableMaskId() };
|
||||||
|
});
|
||||||
|
return [...normalized, reservedClass || reservedUnclassifiedClass()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextClassMaskId(classes: TemplateClass[] = []): number {
|
||||||
|
const used = new Set(
|
||||||
|
classes
|
||||||
|
.map((templateClass) => Number(templateClass.maskId))
|
||||||
|
.filter((value) => Number.isInteger(value) && value > 0),
|
||||||
|
);
|
||||||
|
let value = 1;
|
||||||
|
while (used.has(value)) value += 1;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -94,4 +94,34 @@ describe('progress websocket client', () => {
|
|||||||
unsubscribeStatus();
|
unsubscribeStatus();
|
||||||
progressWS.disconnect();
|
progressWS.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not reconnect after an intentional disconnect', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const instances: any[] = [];
|
||||||
|
class FakeWebSocket {
|
||||||
|
static CONNECTING = 0;
|
||||||
|
static OPEN = 1;
|
||||||
|
readyState = FakeWebSocket.OPEN;
|
||||||
|
onopen?: () => void;
|
||||||
|
onmessage?: (event: MessageEvent) => void;
|
||||||
|
onclose?: () => void;
|
||||||
|
onerror?: () => void;
|
||||||
|
constructor(public url: string) {
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
close = vi.fn();
|
||||||
|
send = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||||
|
|
||||||
|
const { progressWS } = await import('./websocket');
|
||||||
|
progressWS.connect();
|
||||||
|
instances[0].onopen?.();
|
||||||
|
progressWS.disconnect();
|
||||||
|
instances[0].onclose?.();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(30000);
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ProgressWebSocket {
|
|||||||
private heartbeatInterval = 15000;
|
private heartbeatInterval = 15000;
|
||||||
private shouldReconnect = false;
|
private shouldReconnect = false;
|
||||||
private shouldCloseAfterOpen = false;
|
private shouldCloseAfterOpen = false;
|
||||||
|
private manualDisconnect = false;
|
||||||
private currentInterval = 3000;
|
private currentInterval = 3000;
|
||||||
|
|
||||||
constructor(url = WS_PROGRESS_URL) {
|
constructor(url = WS_PROGRESS_URL) {
|
||||||
@@ -43,6 +44,7 @@ class ProgressWebSocket {
|
|||||||
|
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
this.shouldCloseAfterOpen = false;
|
this.shouldCloseAfterOpen = false;
|
||||||
|
this.manualDisconnect = false;
|
||||||
this.notifyStatus('connecting');
|
this.notifyStatus('connecting');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -71,7 +73,9 @@ class ProgressWebSocket {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
console.log('[WebSocket] Connection closed');
|
if (!this.manualDisconnect) {
|
||||||
|
console.log('[WebSocket] Connection closed');
|
||||||
|
}
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.notifyStatus('disconnected');
|
this.notifyStatus('disconnected');
|
||||||
@@ -97,6 +101,7 @@ class ProgressWebSocket {
|
|||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.shouldReconnect = false;
|
this.shouldReconnect = false;
|
||||||
|
this.manualDisconnect = true;
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
|
|||||||
@@ -7,16 +7,22 @@ describe('useStore', () => {
|
|||||||
resetStore();
|
resetStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to the dashboard module after login state is initialized', () => {
|
||||||
|
expect(useStore.getState().activeModule).toBe('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
it('stores and clears auth state with localStorage', () => {
|
it('stores and clears auth state with localStorage', () => {
|
||||||
useStore.getState().login('token-1');
|
useStore.getState().login('token-1', { id: 1, username: 'admin', role: 'admin' });
|
||||||
|
|
||||||
expect(useStore.getState().isAuthenticated).toBe(true);
|
expect(useStore.getState().isAuthenticated).toBe(true);
|
||||||
expect(useStore.getState().token).toBe('token-1');
|
expect(useStore.getState().token).toBe('token-1');
|
||||||
|
expect(useStore.getState().currentUser?.username).toBe('admin');
|
||||||
expect(localStorage.getItem('token')).toBe('token-1');
|
expect(localStorage.getItem('token')).toBe('token-1');
|
||||||
|
|
||||||
useStore.getState().logout();
|
useStore.getState().logout();
|
||||||
|
|
||||||
expect(useStore.getState().isAuthenticated).toBe(false);
|
expect(useStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(useStore.getState().currentUser).toBeNull();
|
||||||
expect(useStore.getState().projects).toEqual([]);
|
expect(useStore.getState().projects).toEqual([]);
|
||||||
expect(useStore.getState().frames).toEqual([]);
|
expect(useStore.getState().frames).toEqual([]);
|
||||||
expect(localStorage.getItem('token')).toBeNull();
|
expect(localStorage.getItem('token')).toBeNull();
|
||||||
@@ -31,6 +37,9 @@ describe('useStore', () => {
|
|||||||
useStore.getState().setCurrentFrame(0);
|
useStore.getState().setCurrentFrame(0);
|
||||||
useStore.getState().addMask({ id: 'm1', frameId: 'f1', pathData: 'M 0 0 Z', label: 'mask', color: '#fff' });
|
useStore.getState().addMask({ id: 'm1', frameId: 'f1', pathData: 'M 0 0 Z', label: 'mask', color: '#fff' });
|
||||||
useStore.getState().setSelectedMaskIds(['m1']);
|
useStore.getState().setSelectedMaskIds(['m1']);
|
||||||
|
useStore.getState().setMaskPreviewOpacity(35);
|
||||||
|
useStore.getState().setBrushSize(36);
|
||||||
|
useStore.getState().setEraserSize(44);
|
||||||
useStore.getState().updateMask('m1', { label: 'updated mask', saveStatus: 'dirty' });
|
useStore.getState().updateMask('m1', { label: 'updated mask', saveStatus: 'dirty' });
|
||||||
useStore.getState().addAnnotation({ id: 'a1', frameId: 'f1', type: 'mask', points: [], label: 'ann', color: '#fff' });
|
useStore.getState().addAnnotation({ id: 'a1', frameId: 'f1', type: 'mask', points: [], label: 'ann', color: '#fff' });
|
||||||
useStore.getState().addTemplate({ id: 't1', name: 'Template', classes: [], rules: [] });
|
useStore.getState().addTemplate({ id: 't1', name: 'Template', classes: [], rules: [] });
|
||||||
@@ -42,6 +51,9 @@ describe('useStore', () => {
|
|||||||
expect(useStore.getState().frames).toHaveLength(1);
|
expect(useStore.getState().frames).toHaveLength(1);
|
||||||
expect(useStore.getState().currentFrameIndex).toBe(0);
|
expect(useStore.getState().currentFrameIndex).toBe(0);
|
||||||
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
|
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
|
||||||
|
expect(useStore.getState().maskPreviewOpacity).toBe(35);
|
||||||
|
expect(useStore.getState().brushSize).toBe(36);
|
||||||
|
expect(useStore.getState().eraserSize).toBe(44);
|
||||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ label: 'updated mask', saveStatus: 'dirty' }));
|
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ label: 'updated mask', saveStatus: 'dirty' }));
|
||||||
expect(useStore.getState().annotations).toHaveLength(1);
|
expect(useStore.getState().annotations).toHaveLength(1);
|
||||||
expect(useStore.getState().templates[0].name).toBe('Template 2');
|
expect(useStore.getState().templates[0].name).toBe('Template 2');
|
||||||
|
|||||||
@@ -17,7 +17,22 @@ export interface Project {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AiModelId = 'sam2' | 'sam3';
|
export type AiModelId =
|
||||||
|
| 'sam2.1_hiera_tiny'
|
||||||
|
| 'sam2.1_hiera_small'
|
||||||
|
| 'sam2.1_hiera_base_plus'
|
||||||
|
| 'sam2.1_hiera_large';
|
||||||
|
|
||||||
|
export const DEFAULT_AI_MODEL_ID: AiModelId = 'sam2.1_hiera_tiny';
|
||||||
|
export const DEFAULT_BRUSH_SIZE = 24;
|
||||||
|
export const DEFAULT_ERASER_SIZE = 28;
|
||||||
|
|
||||||
|
export const SAM2_MODEL_OPTIONS: Array<{ id: AiModelId; label: string; shortLabel: string }> = [
|
||||||
|
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', shortLabel: 'tiny' },
|
||||||
|
{ id: 'sam2.1_hiera_small', label: 'SAM 2.1 Small', shortLabel: 'small' },
|
||||||
|
{ id: 'sam2.1_hiera_base_plus', label: 'SAM 2.1 Base+', shortLabel: 'base+' },
|
||||||
|
{ id: 'sam2.1_hiera_large', label: 'SAM 2.1 Large', shortLabel: 'large' },
|
||||||
|
];
|
||||||
|
|
||||||
export interface Frame {
|
export interface Frame {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,6 +66,7 @@ export interface Mask {
|
|||||||
classId?: string;
|
classId?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
classZIndex?: number;
|
classZIndex?: number;
|
||||||
|
classMaskId?: number;
|
||||||
saveStatus?: 'draft' | 'saved' | 'dirty' | 'saving' | 'error';
|
saveStatus?: 'draft' | 'saved' | 'dirty' | 'saving' | 'error';
|
||||||
saved?: boolean;
|
saved?: boolean;
|
||||||
pathData: string;
|
pathData: string;
|
||||||
@@ -68,6 +84,8 @@ export interface Template {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
z_index?: number;
|
||||||
classes: TemplateClass[];
|
classes: TemplateClass[];
|
||||||
rules?: TemplateRule[];
|
rules?: TemplateRule[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -79,6 +97,7 @@ export interface TemplateClass {
|
|||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
|
maskId?: number;
|
||||||
category?: string;
|
category?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
@@ -91,11 +110,20 @@ export interface TemplateRule {
|
|||||||
operation: string;
|
operation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
is_active?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
// Auth
|
// Auth
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
login: (token: string) => void;
|
currentUser: UserProfile | null;
|
||||||
|
login: (token: string, user?: UserProfile | null) => void;
|
||||||
|
setCurrentUser: (user: UserProfile | null) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
@@ -115,6 +143,9 @@ export interface AppState {
|
|||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
masks: Mask[];
|
masks: Mask[];
|
||||||
selectedMaskIds: string[];
|
selectedMaskIds: string[];
|
||||||
|
maskPreviewOpacity: number;
|
||||||
|
brushSize: number;
|
||||||
|
eraserSize: number;
|
||||||
maskHistory: Mask[][];
|
maskHistory: Mask[][];
|
||||||
maskFuture: Mask[][];
|
maskFuture: Mask[][];
|
||||||
setActiveModule: (module: string) => void;
|
setActiveModule: (module: string) => void;
|
||||||
@@ -127,6 +158,9 @@ export interface AppState {
|
|||||||
updateMask: (id: string, updates: Partial<Mask>) => void;
|
updateMask: (id: string, updates: Partial<Mask>) => void;
|
||||||
setMasks: (masks: Mask[]) => void;
|
setMasks: (masks: Mask[]) => void;
|
||||||
setSelectedMaskIds: (ids: string[]) => void;
|
setSelectedMaskIds: (ids: string[]) => void;
|
||||||
|
setMaskPreviewOpacity: (opacity: number) => void;
|
||||||
|
setBrushSize: (size: number) => void;
|
||||||
|
setEraserSize: (size: number) => void;
|
||||||
clearMasks: () => void;
|
clearMasks: () => void;
|
||||||
undoMasks: () => void;
|
undoMasks: () => void;
|
||||||
redoMasks: () => void;
|
redoMasks: () => void;
|
||||||
@@ -154,17 +188,20 @@ export interface AppState {
|
|||||||
|
|
||||||
export const useStore = create<AppState>((set) => ({
|
export const useStore = create<AppState>((set) => ({
|
||||||
// Auth
|
// Auth
|
||||||
isAuthenticated: false,
|
isAuthenticated: Boolean(localStorage.getItem('token')),
|
||||||
token: null,
|
token: localStorage.getItem('token'),
|
||||||
login: (token: string) => {
|
currentUser: null,
|
||||||
|
login: (token: string, user: UserProfile | null = null) => {
|
||||||
localStorage.setItem('token', token);
|
localStorage.setItem('token', token);
|
||||||
set({ isAuthenticated: true, token });
|
set({ isAuthenticated: true, token, currentUser: user });
|
||||||
},
|
},
|
||||||
|
setCurrentUser: (currentUser: UserProfile | null) => set({ currentUser }),
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
token: null,
|
token: null,
|
||||||
|
currentUser: null,
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
projects: [],
|
projects: [],
|
||||||
templates: [],
|
templates: [],
|
||||||
@@ -172,6 +209,9 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
annotations: [],
|
annotations: [],
|
||||||
masks: [],
|
masks: [],
|
||||||
selectedMaskIds: [],
|
selectedMaskIds: [],
|
||||||
|
maskPreviewOpacity: 50,
|
||||||
|
brushSize: DEFAULT_BRUSH_SIZE,
|
||||||
|
eraserSize: DEFAULT_ERASER_SIZE,
|
||||||
maskHistory: [],
|
maskHistory: [],
|
||||||
maskFuture: [],
|
maskFuture: [],
|
||||||
activeTemplateId: null,
|
activeTemplateId: null,
|
||||||
@@ -193,14 +233,17 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
activeModule: 'workspace',
|
activeModule: 'dashboard',
|
||||||
activeTool: 'move',
|
activeTool: 'move',
|
||||||
aiModel: 'sam2',
|
aiModel: DEFAULT_AI_MODEL_ID,
|
||||||
frames: [],
|
frames: [],
|
||||||
currentFrameIndex: 0,
|
currentFrameIndex: 0,
|
||||||
annotations: [],
|
annotations: [],
|
||||||
masks: [],
|
masks: [],
|
||||||
selectedMaskIds: [],
|
selectedMaskIds: [],
|
||||||
|
maskPreviewOpacity: 50,
|
||||||
|
brushSize: DEFAULT_BRUSH_SIZE,
|
||||||
|
eraserSize: DEFAULT_ERASER_SIZE,
|
||||||
maskHistory: [],
|
maskHistory: [],
|
||||||
maskFuture: [],
|
maskFuture: [],
|
||||||
setActiveModule: (activeModule: string) => set({ activeModule }),
|
setActiveModule: (activeModule: string) => set({ activeModule }),
|
||||||
@@ -234,6 +277,15 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
setSelectedMaskIds: (selectedMaskIds: string[]) => set({ selectedMaskIds }),
|
setSelectedMaskIds: (selectedMaskIds: string[]) => set({ selectedMaskIds }),
|
||||||
|
setMaskPreviewOpacity: (maskPreviewOpacity: number) => set({
|
||||||
|
maskPreviewOpacity: Math.min(Math.max(maskPreviewOpacity, 10), 100),
|
||||||
|
}),
|
||||||
|
setBrushSize: (brushSize: number) => set({
|
||||||
|
brushSize: Math.round(Math.min(Math.max(brushSize, 4), 96)),
|
||||||
|
}),
|
||||||
|
setEraserSize: (eraserSize: number) => set({
|
||||||
|
eraserSize: Math.round(Math.min(Math.max(eraserSize, 4), 128)),
|
||||||
|
}),
|
||||||
clearMasks: () =>
|
clearMasks: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
masks: [],
|
masks: [],
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function makeStageEvent(x = 120, y = 80) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('react-konva', () => ({
|
vi.mock('react-konva', () => ({
|
||||||
Stage: ({ children, onClick, onMouseDown, onMouseUp, onMouseMove, onWheel }: any) => {
|
Stage: ({ children, onClick, onMouseDown, onMouseUp, onMouseMove, onWheel, onDragEnd, scaleX, scaleY, x, y, width, height }: any) => {
|
||||||
const coords = (event: React.MouseEvent<HTMLDivElement>, fallbackX: number, fallbackY: number) => ({
|
const coords = (event: React.MouseEvent<HTMLDivElement>, fallbackX: number, fallbackY: number) => ({
|
||||||
x: event.clientX || fallbackX,
|
x: event.clientX || fallbackX,
|
||||||
y: event.clientY || fallbackY,
|
y: event.clientY || fallbackY,
|
||||||
@@ -40,6 +40,13 @@ vi.mock('react-konva', () => ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="konva-stage"
|
data-testid="konva-stage"
|
||||||
|
data-has-drag-end={Boolean(onDragEnd)}
|
||||||
|
data-scale-x={scaleX}
|
||||||
|
data-scale-y={scaleY}
|
||||||
|
data-x={x}
|
||||||
|
data-y={y}
|
||||||
|
data-width={width}
|
||||||
|
data-height={height}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const point = coords(event, 120, 80);
|
const point = coords(event, 120, 80);
|
||||||
onClick?.(makeStageEvent(point.x, point.y));
|
onClick?.(makeStageEvent(point.x, point.y));
|
||||||
@@ -57,13 +64,28 @@ vi.mock('react-konva', () => ({
|
|||||||
onMouseMove?.(makeStageEvent(point.x, point.y));
|
onMouseMove?.(makeStageEvent(point.x, point.y));
|
||||||
}}
|
}}
|
||||||
onWheel={() => onWheel?.(makeStageEvent())}
|
onWheel={() => onWheel?.(makeStageEvent())}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
const stageTarget: any = {
|
||||||
|
x: () => event.clientX || 0,
|
||||||
|
y: () => event.clientY || 0,
|
||||||
|
};
|
||||||
|
stageTarget.getStage = () => stageTarget;
|
||||||
|
const childTarget = {
|
||||||
|
x: () => event.clientX || 0,
|
||||||
|
y: () => event.clientY || 0,
|
||||||
|
getStage: () => stageTarget,
|
||||||
|
};
|
||||||
|
onDragEnd?.({
|
||||||
|
target: event.target === event.currentTarget ? stageTarget : childTarget,
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
Layer: ({ children }: any) => <div data-testid="konva-layer">{children}</div>,
|
Layer: ({ children }: any) => <div data-testid="konva-layer">{children}</div>,
|
||||||
Group: ({ children }: any) => <div data-testid="konva-group">{children}</div>,
|
Group: ({ children, opacity }: any) => <div data-testid="konva-group" data-opacity={opacity}>{children}</div>,
|
||||||
Image: ({ image }: any) => <img data-testid="konva-image" alt="" src={image?.src || ''} />,
|
Image: ({ image }: any) => <img data-testid="konva-image" alt="" src={image?.src || ''} />,
|
||||||
Circle: (props: any) => (
|
Circle: (props: any) => (
|
||||||
<span
|
<span
|
||||||
@@ -72,10 +94,30 @@ vi.mock('react-konva', () => ({
|
|||||||
data-x={props.x}
|
data-x={props.x}
|
||||||
data-y={props.y}
|
data-y={props.y}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const konvaEvent = { cancelBubble: false };
|
const point = {
|
||||||
|
x: event.clientX || 120,
|
||||||
|
y: event.clientY || 80,
|
||||||
|
};
|
||||||
|
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
|
||||||
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,
|
||||||
@@ -96,9 +138,16 @@ 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 konvaEvent = { cancelBubble: false };
|
const point = {
|
||||||
|
x: event.clientX || 120,
|
||||||
|
y: event.clientY || 80,
|
||||||
|
};
|
||||||
|
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
|
||||||
props.onClick?.(konvaEvent);
|
props.onClick?.(konvaEvent);
|
||||||
if (konvaEvent.cancelBubble) event.stopPropagation();
|
if (konvaEvent.cancelBubble) event.stopPropagation();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { useStore } from '../store/useStore';
|
import { DEFAULT_AI_MODEL_ID, DEFAULT_BRUSH_SIZE, DEFAULT_ERASER_SIZE, useStore } from '../store/useStore';
|
||||||
|
|
||||||
export function resetStore() {
|
export function resetStore() {
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
token: null,
|
token: null,
|
||||||
|
currentUser: null,
|
||||||
projects: [],
|
projects: [],
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
activeModule: 'workspace',
|
activeModule: 'dashboard',
|
||||||
activeTool: 'move',
|
activeTool: 'move',
|
||||||
aiModel: 'sam2',
|
aiModel: DEFAULT_AI_MODEL_ID,
|
||||||
frames: [],
|
frames: [],
|
||||||
currentFrameIndex: 0,
|
currentFrameIndex: 0,
|
||||||
annotations: [],
|
annotations: [],
|
||||||
masks: [],
|
masks: [],
|
||||||
selectedMaskIds: [],
|
selectedMaskIds: [],
|
||||||
|
maskPreviewOpacity: 50,
|
||||||
|
brushSize: DEFAULT_BRUSH_SIZE,
|
||||||
|
eraserSize: DEFAULT_ERASER_SIZE,
|
||||||
maskHistory: [],
|
maskHistory: [],
|
||||||
maskFuture: [],
|
maskFuture: [],
|
||||||
templates: [],
|
templates: [],
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ echo " ✓ FastAPI 已启动 (http://localhost:8000/docs)"
|
|||||||
# 5. 启动 Celery Worker
|
# 5. 启动 Celery Worker
|
||||||
echo "[5/6] 启动 Celery Worker..."
|
echo "[5/6] 启动 Celery Worker..."
|
||||||
cd "$PROJECT_DIR/backend"
|
cd "$PROJECT_DIR/backend"
|
||||||
nohup celery -A celery_app:celery_app worker --loglevel=info --concurrency=1 > /tmp/celery.log 2>&1 &
|
nohup celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 > /tmp/celery.log 2>&1 &
|
||||||
sleep 2
|
sleep 2
|
||||||
echo " ✓ Celery Worker 已启动"
|
echo " ✓ Celery Worker 已启动"
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
## 修改文件清单
|
## 修改文件清单
|
||||||
|
|
||||||
### 文件 1: 创建 `public/` 目录并放入 logo
|
### 文件 1: 创建 `public/` 目录并放入 logo
|
||||||
- **操作**: `mkdir public && cp logo_square.png public/logo.png`
|
- **操作**: 将系统 logo 放入 `public/logo.png`
|
||||||
- **说明**: Vite 自动将 `public/` 下文件暴露到根 URL
|
- **说明**: Vite 自动将 `public/` 下文件暴露到根 URL
|
||||||
|
|
||||||
### 文件 2: `src/components/Sidebar.tsx`(修改)
|
### 文件 2: `src/components/Sidebar.tsx`(修改)
|
||||||
|
|||||||
@@ -282,13 +282,13 @@ AI 助手运行的容器/环境与项目实际开发环境分离,后者才装
|
|||||||
2. 浏览器控制台报 `favicon.ico 404`
|
2. 浏览器控制台报 `favicon.ico 404`
|
||||||
|
|
||||||
### B. 产生原因
|
### B. 产生原因
|
||||||
1. `logo_square.png` 位于项目根目录,但 Vite 不会自动将根目录文件暴露为静态资源
|
1. 早期 logo 位于项目根目录,但 Vite 不会自动将根目录文件暴露为静态资源
|
||||||
2. 前端代码引用路径为 `/Logo.png`(首字母大写),与实际文件名 `logo_square.png` 不匹配
|
2. 前端代码引用路径为 `/Logo.png`(首字母大写),与实际静态资源路径不匹配
|
||||||
3. `index.html` 无 favicon 声明,浏览器默认请求 `/favicon.ico`
|
3. `index.html` 无 favicon 声明,浏览器默认请求 `/favicon.ico`
|
||||||
|
|
||||||
### C. 解决方案
|
### C. 解决方案
|
||||||
1. 创建 `public/` 目录(Vite 原生支持,自动暴露到根 URL)
|
1. 创建 `public/` 目录(Vite 原生支持,自动暴露到根 URL)
|
||||||
2. 将 `logo_square.png` 复制为 `public/logo.png`
|
2. 将系统 logo 统一放入并引用为 `public/logo.png`
|
||||||
3. `Sidebar.tsx` 引用路径改为 `/logo.png`
|
3. `Sidebar.tsx` 引用路径改为 `/logo.png`
|
||||||
4. `index.html` 添加 `<link rel="icon" type="image/png" href="/logo.png" />`
|
4. `index.html` 添加 `<link rel="icon" type="image/png" href="/logo.png" />`
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user