63 Commits

Author SHA1 Message Date
eb2c41a4ab 新增通用软著撰写Agent工作流
- 新增撰写Agent.md,沉淀通用软著文档生成工作流。

- 覆盖模板学习、源码分析、说明书、登记表、代码汇总三类交付物生成流程。

- 补充截图录屏验证、图文排版、代码摘抄精简和交付检查事项。

- 记录无注册功能、多数据类型、复杂功能多图展示、信息缺失等常见处理原则。
2026-05-08 03:11:28 +08:00
c8a78a1038 修复AI模型不可用状态交互
- AI智能分割页禁用不可用SAM2.1模型变体,并在模型状态未可用时阻止推理请求。

- 分割工作区AI自动推理在提交传播前校验模型状态,所有模型不可用时禁用入口,当前权重不可用时禁用开始传播。

- 传播权重下拉标记并禁用不可用权重,避免提交后无结果的假可用状态。

- 补充AI页和工作区模型不可用回归测试,扩展默认测试状态覆盖四个SAM2.1变体。

- 更新前端元素审计、交互状态机、测试计划和Docker/GPU部署说明。
2026-05-07 17:08:40 +08:00
9cde34b2fc 删除无用演示残留数据
- 删除已确认无用的 2024_2_5_王芳/StudyInfo.dat
2026-05-07 16:40:25 +08:00
2a2e6b9b6c 调整项目库拆帧与长帧序列加载
- 删除项目库右上角独立新建项目入口,保留导入视频/DICOM 自动建项目流程

- 视频项目支持已生成帧后的重新生成帧入口,并提示会清空旧帧、标注和 mask

- 后端重新拆帧任务开始前清理旧帧、旧标注和旧 mask,避免重复帧序列

- 项目帧列表接口默认返回完整帧序列,避免工作区总帧数被 1000 条默认 limit 截断

- 增加可选 docker-compose.gpu.yml,并补充 Docker 使用本机 GPU 的前提和启动说明

- 更新项目库、API 映射、恢复演示文案、后端媒体/项目测试和前端文档
2026-05-07 16:38:13 +08:00
620e95ff91 修复演示恢复项目帧数据
- 恢复演示出厂设置后直接解析演视LC视频序列并生成可打开帧序列

- 保持演视DICOM序列按文件名自然顺序恢复并生成帧

- 增加 MinIO 浏览器访问端点配置,修复 Docker 部署中封面和帧图预签名地址使用容器内主机名的问题

- 更新管理员恢复测试覆盖视频和 DICOM 帧数量

- 更新 README 和前后端契约/设计/测试文档中的演示恢复说明
2026-05-07 16:17:07 +08:00
b1131c9126 切换演示数据到 demo 目录
- 默认演示视频和 DICOM 路径改为 demo/演视LC视频序列.mp4 与 demo/演视DICOM序列/。

- 演示 DICOM 项目名统一为“演视DICOM序列”,并兼容迁移旧“演示DICOM序列”名称。

- 恢复演示出厂设置测试改为验证新文件名上传路径和新项目名。

- 同步更新用户管理提示、API 契约、安装文档、实现地图和项目指南。

- 本地与 ../Seg_Server_Docker 已实际放入 demo 演示视频和 DICOM 测试影像;数据文件受 .gitignore 保护不进入提交。
2026-05-07 15:58:29 +08:00
6f4d4efeaf 统一使用 public logo 资源
- 登录页、侧栏和 favicon 统一引用 public/logo.png 暴露的 /logo.png。

- 删除根目录 logo_square.png 和 Express 中单独提供该文件的路由。

- 同步更新组件测试、项目文档和历史工程分析说明,避免继续引用旧 logo_square。

- 同步整理 ../Seg_Server_Docker 部署包,仅保留 public/logo.png 并更新前端 Docker 构建配置。
2026-05-07 15:51:45 +08:00
d583b32221 更新品牌文案与演示项目名称
- 登录页和侧栏统一使用根目录 logo_square.png,并更新登录系统名称与副标题。

- 更新 Dashboard、项目库和工作区时间轴文案,移除底层时序视频图层说明。

- 演示视频项目显示名改为“演视LC视频序列”,启动时兼容迁移旧 Data_MyVideo_1 名称,恢复出厂设置使用新名。

- 调整侧栏用户管理入口为用户图标,底部当前用户入口为退出图标,并让退出提示不接收鼠标事件。

- 补充前端组件测试、后端演示重置测试和文档说明。
2026-05-07 15:14:53 +08:00
f6ee9fa924 修正GT未知类别舍弃模板传递
- GT Mask 导入请求改用与预览一致的 GT 模板 id,避免未激活模板时后端无法识别未知 maskid。

- 补充回归测试覆盖未显式激活模板但模板列表存在时,舍弃未知类别仍传入模板 id。
2026-05-04 06:13:36 +08:00
10fe17476d 修正GT未知类别导入为待分类
- GT Mask 未知 maskid 选择保留时落到黑色 maskid:0 的待分类类别,而不是绿色未定义类别。

- 前端导入预览中未知 maskid 使用黑色待分类覆盖色,并把按钮文案改为导入为待分类。

- 待分类兜底颜色统一为 #000000,和模板保留类、GT_label/Pro_label 导出规则一致。

- 补充后端回归断言并更新 AGENTS 与文档说明,保留 gt_unknown_class 和原始 gt_label_value 供后续重命名追溯。
2026-05-04 06:06:04 +08:00
5d73eacefe 引入实例ID驱动传播链匹配
- 前端保存标注写入并保留 instance_id,AI 自动推理 seed 携带 source_instance_id,避免同类多 mask 只按语义混在一起。

- 后端传播任务优先用 source_instance_id/instance_id 做幂等、替换和写入前清理,并保留 source_annotation_id/source_mask_id/legacy 兼容路径。

- 前端传播链匹配、删除/分类同步和布尔合并/去重加入实例 token,保持旧 lineage 和空间最近 legacy fallback。

- 补充前后端回归测试,覆盖同类别多实例传播、重传、布尔同步、断开多区域和保存/回显 metadata。

- 更新 AGENTS 与 doc 事实文档,明确 maskid 仍只用于语义分类、GT_label 和导出,不参与实例追踪。
2026-05-04 05:54:23 +08:00
1ff757e2fa 收紧传播帧布尔同步实例匹配
- 区域合并/去除同步传播帧时改用严格实例匹配,优先可靠 source_annotation_id/source_mask_id lineage。

- 旧传播结果缺少可靠 lineage 时,每个已选 mask 只匹配空间最近的一个同语义传播实例,避免同类别其它 mask 被一起合并或扣除。

- 保留点选高亮的宽松 legacy 分组,避免破坏同一传播 mask 不连通片段联动高亮体验。

- 新增 CanvasArea 回归测试,覆盖同类别多个 legacy 传播实例只合并目标实例。

- 更新 AGENTS、设计冻结和测试计划文档,明确布尔同步和高亮匹配策略的差异。
2026-05-04 05:35:04 +08:00
ee27f29495 校准系统默认配置
- 将 User ORM 默认角色改为 annotator,避免脚本直建用户时绕出第二个管理员。

- 启动默认 seed 不再把历史无 owner 项目改写归属 admin,保持共享项目库的历史元数据语义。

- 将 SAM 默认配置和环境模板统一到 SAM 2.1 tiny,并默认关闭历史 SAM3 外部 worker。

- 更新安装/实现文档,并补充默认角色、默认模型和 legacy 项目 owner 不改写的后端测试。
2026-05-04 05:24:46 +08:00
523beeb446 收敛用户角色并共享项目库
- 后端限制系统只保留默认 admin 管理员,新建用户固定为标注员,并拒绝观察员或额外管理员角色。

- 将项目、帧、媒体解析、AI 标注、任务、Dashboard 和导出接口改为共享项目库访问,标注员具备同等项目管理和标注能力。

- 前端用户管理移除角色选择和观察员入口,只展示唯一管理员与标注员状态。

- 更新后端/前端测试,覆盖唯一 admin、旧 viewer 归一为标注员、用户删除和共享项目库访问。

- 同步更新 AGENTS 与 doc 文档中的角色权限、共享项目库和测试计划说明。
2026-05-04 05:20:28 +08:00
02635abab1 生成帧完成后自动刷新项目封面
- 项目库生成帧任务入队后继续轮询任务进度,解析成功后重新拉取项目列表和当前项目对象。

- 成功后显示视频帧生成完成提示,并让后端写入的 thumbnail_url 立即刷新到项目卡片封面。

- 补充 ProjectLibrary 回归测试,覆盖生成帧完成后自动显示新封面。

- 更新前端审计、设计冻结、测试计划、状态机和项目指南文档。
2026-05-04 05:07:22 +08:00
85de1ffbb2 让新建绘制并入选中遮罩
- 多边形、矩形、圆和画笔在当前已有选中 mask 时,将新几何 union 并入选中 mask,即使区域不重叠也保持为同一个多 polygon mask。

- 无选中 mask 时仍按原新建流程创建并自动选中新 mask;画笔无选区时仍要求右侧语义分类树有选中类别。

- 补充 CanvasArea 回归测试,覆盖创建工具保留选区、分离矩形并入选中 mask、画笔无 active class 时并入选中 mask。

- 更新前端审计、需求冻结、设计冻结、状态机、测试计划和项目指南文档。
2026-05-04 04:57:53 +08:00
1971640a67 隐藏传播进度重复提示
- 传播进度蓝色面板显示任务 message 时隐藏顶栏左侧灰色 statusMessage,避免同一传播文字重复出现。

- 补充 VideoWorkspace 回归测试,确认传播运行提示只出现一次且位于进度面板内。

- 更新前端审计、设计冻结、测试计划和项目指南文档。
2026-05-04 04:44:32 +08:00
84895bd9bd 显示新建图形边界点并验证中空画笔
- 让多边形、矩形和圆创建完成后即使仍处于创建工具,也显示已选 mask 的边界顶点。

- 保持创建工具下的边界点为只读提示,不改变继续创建新 mask 的交互。

- 补充 Canvas 回归测试,覆盖矩形、圆、多边形创建后的边界点显示。

- 补充中空画笔回归测试,验证闭合画笔区域保留 hasHoles/polygonRingCounts、evenodd 渲染和内外圈顶点显示。

- 更新 README、AGENTS、前端审计、需求冻结、设计冻结、测试计划和交互状态机文档。
2026-05-04 04:36:53 +08:00
7fc4949677 调整AI自动推理入口文案和图标
- 将左侧工具栏传播入口的可访问名称和 tooltip 从“自动传播”改为“AI自动推理”。

- 新增 AI 大脑样式的 AiAutoInferenceIcon,并替换该入口原本的 AI 智能分割机器人图标。

- 更新 ToolsPalette 和 VideoWorkspace 测试,覆盖新按钮名称和 AI 大脑图标。

- 同步 README、AGENTS、前端审计、API 契约、设计冻结和测试计划文档中的入口描述。
2026-05-04 04:16:05 +08:00
87b82b882f 添加取消选中实体按钮
- 在左侧工具栏拖拽/选择下方新增“取消选中”按钮,提供等同 Esc 的可点击入口。

- 将 VideoWorkspace 的取消选中信号传入 CanvasArea,统一清空 mask 选区、临时绘制状态和顶点选择。

- 修正 Canvas 本地选区与全局 selectedMaskIds 的同步,避免取消后旧本地选区被重新发布。

- 补充 ToolsPalette、CanvasArea 回归测试,覆盖实体按钮位置、回调和 clearSelectionSignal 行为。

- 更新 README、AGENTS 与前端审计/需求冻结/设计冻结/测试计划/交互状态机文档。
2026-05-04 04:09:32 +08:00
141dd4ce4b 校正文档中的前端交互细节
- 更新设计冻结文档中 Canvas 工具状态、Esc 行为、画笔独立新建和语义树无选区逻辑,移除过宽的旧描述。

- 更新当前实现映射,补充手工绘制新建后自动选中、画笔不自动合并和 Esc 取消交互状态。

- 更新测试计划,记录本次文档一致性回查。
2026-05-04 03:59:52 +08:00
56665283aa 补充前端交互状态机文档和Esc测试
- 新增前端交互状态机文档,梳理全局状态、工作区工具、语义分类树、键盘快捷键、范围选择、AI页、模板确认和导入导出交互。

- 明确记录 Esc 行为:只取消当前 mask 选区和临时绘制状态,不删除已有 mask,也不清空 active class。

- 补充 CanvasArea 测试,覆盖 Esc 取消选中 mask 但保留 mask/active class,以及 Esc 取消进行中的多边形绘制。

- 更新文档索引、AGENTS 和测试计划,把前端交互状态机纳入事实文档和 R13 文档测试覆盖。
2026-05-04 03:55:39 +08:00
b1bb792b71 取消画笔自动合并选中遮罩
- 移除画笔与选中 mask 连通或重叠时自动 union 到旧 mask 的逻辑,画笔每次松手都生成新的独立 mask。

- 更新画笔工具提示,明确画笔会生成新的独立 mask。

- 调整 CanvasArea 回归测试,验证画笔触碰选中 mask 时仍保留旧 mask 并新建独立 mask。

- 更新 README、AGENTS、前端审计、需求冻结和测试计划文档,移除画笔自动合并描述。
2026-05-04 03:51:22 +08:00
b7de163054 修复绘制工具选区和分类应用逻辑
- 切换到创建多边形、创建矩形或创建圆时同步清空本地和全局 mask 选区,避免右侧语义分类树误改旧 mask。

- 手工创建多边形、矩形、圆后自动选中新创建的 mask,方便继续改类、编辑或保存。

- Esc 统一清空当前 mask 选区和临时绘制状态,让画笔可以在无选区状态下新建独立 mask。

- 画笔合并、新建和橡皮擦扣除后同步更新全局选区,保持右侧面板状态一致。

- 补充 CanvasArea 和 OntologyInspector 回归测试,覆盖创建工具清选区、新建自动选中、Esc 后画笔新建、无选区分类不改已有 mask。

- 更新前端审计、需求冻结、测试计划和 AGENTS 文档,记录无选区语义分类与绘制选区规则。
2026-05-04 03:45:51 +08:00
b943f5e184 强化分割结果导出按钮样式
- 为工作区分割结果导出按钮增加 FileDown 图标,让导出入口更容易识别。

- 将导出按钮从灰色弱背景调整为绿色强调背景、边框和文字样式,提升在顶栏中的区分度。

- 补充 VideoWorkspace 测试,覆盖导出按钮图标和强调背景样式。

- 更新前端元素审计、测试计划和 AGENTS 文档,记录导出按钮视觉规范。
2026-05-04 03:38:48 +08:00
f558f3b3c7 登录后默认进入总体概况页
- 将 Zustand 默认 activeModule 从 workspace 调整为 dashboard,登录后默认展示总体概况页。

- 同步测试 resetStore 的默认模块,避免测试环境与真实默认入口不一致。

- 补充 useStore 默认模块回归测试,固定 dashboard 初始入口行为。

- 更新 AGENTS 和前端实现文档,记录登录后的默认模块。
2026-05-04 03:19:12 +08:00
628bce23e0 修复画笔橡皮擦选区提示与越界绘制
- 画笔和橡皮擦模式下保留当前选中 mask 的顶点提示,并将顶点设为只读,方便确认正在处理的区域。

- 画笔和橡皮擦采样改为图像范围外不落点,离开图像再进入时不会连出跨越边界的笔触。

- 画笔/橡皮擦最终 stroke geometry 按当前帧图像边界裁剪,避免边缘笔触生成图外 polygon。

- 补充 CanvasArea 回归测试,覆盖顶点提示、图外落笔不创建 mask、靠边笔触坐标不越界。

- 更新需求冻结和测试计划文档,记录笔触边界与只读顶点提示行为。
2026-05-04 03:15:47 +08:00
94abad2794 支持跨语义传播链区域合并
- 区域合并同步时允许 A 语义传播链并入 B 语义传播链
- 传播帧同时存在 B/A 对应结果时,将 A 合并进 B 并删除 A 对应标注
- 传播帧缺少 B 对应结果但存在 A 对应结果时,将 A 结果转换为 B 语义并标记为 dirty
- 保持稳定 lineage 匹配优先,旧传播结果继续用来源帧、语义/颜色和空间最近候选兜底
- 补充 CanvasArea 回归测试覆盖跨语义 B 吸收 A 以及缺少 B 对应结果场景
- 更新项目指南和设计冻结文档
2026-05-04 03:08:41 +08:00
46b055eba8 修复旧传播帧区域合并同步
- 为缺少稳定 lineage 的旧传播结果增加参考帧到传播帧的实例匹配
- 区域合并/去除同步时按来源帧、语义/颜色和空间最近候选定位对应传播 mask
- 保持稳定 source_annotation/source_mask/seed 匹配优先,避免同类不同实例误合并
- 补充 CanvasArea 回归测试覆盖旧传播帧合并时删除对应次级 mask
- 更新项目指南和设计冻结文档
2026-05-04 02:52:38 +08:00
2f55ecfe6a 限定DEL人工帧删除范围
- 为清空/删除传播链确认区分 clear 与 delete 操作来源
- DEL/Delete 确认删除人工或 AI 帧时只删除选中或同传播链对应 mask
- 保持清空遮罩操作确认后可按原逻辑清空人工/AI 帧范围
- 调整人工/AI 帧确认弹窗文案,避免误导为 DEL 会整帧清空
- 补充 VideoWorkspace 回归测试,覆盖同一人工帧其它 mask 不被 DEL 删除
- 更新项目指南和设计冻结文档
2026-05-04 02:36:10 +08:00
0485ce4d92 保持传播多区域结果为单个遮罩
- 后端传播落库时将同一 seed 在同一目标帧的多个不连通 polygon 保存到同一 annotation
- 同步任务传播和兼容同步传播接口的多 polygon 保存逻辑
- 传播结果 bbox 改为覆盖全部不连通 polygon,并保留多 polygon scores 与 holes
- 前端回显单条多 polygon annotation 时使用组合 bbox 和真实 polygon 面积
- 补充后端传播 worker 回归测试,验证不连通结果只生成一个 annotation
- 补充前端 API 回归测试,验证多 polygon annotation 回显为一个 mask
- 更新项目指南和设计冻结文档
2026-05-04 02:32:31 +08:00
5e570f789b 修复清空所有传播帧人工帧确认
- 清空所有传播帧时按传播链 seed 到传播结果的完整帧段检查人工/AI 标注帧
- 从传播结果帧触发清空时也会弹出是否删除人工/AI 标注帧确认
- 用户确认删除人工帧时同步删除该跨度内人工/AI 帧的全部遮罩
- 补充 VideoWorkspace 回归测试覆盖中间 AI 标注帧场景
- 更新项目指南和设计冻结文档
2026-05-04 02:06:08 +08:00
07f3364718 修复旧传播分离片段联动选择
- 为缺少稳定 lineage 的旧传播结果增加兼容分组 token
- 同一传播来源、来源帧、方向、分类/标签/颜色的分离片段可联动选中高亮
- 同步 Canvas 和工作区传播链 token 逻辑,保持选择和清空链路一致
- 补充 CanvasArea 回归测试覆盖旧传播结果的不连通片段选中
- 更新项目指南和设计冻结文档
2026-05-04 02:01:49 +08:00
81a47cd405 补强传播帧清空识别
- 按帧范围清空时检查完整选择帧段,确保范围内人工/AI 标注帧会触发确认
- 将 propagation_seed_signature 和 legacy propagation source 纳入传播 mask 识别
- 同步 Canvas、时间轴和工作区的传播帧判断,避免传播帧误判为人工帧
- 更新项目指南、需求冻结和设计冻结文档中的传播链识别字段
2026-05-04 01:42:48 +08:00
3dc6c3402e 修正清空范围人工帧确认
- 按帧范围清空时检查完整用户选择帧段,确保人工/AI 标注帧会触发二次确认
- 选择删除人工帧时删除这些帧中的全部 mask,选择保留时整帧保留
- 补充人工-only 帧触发弹窗、保留人工帧和删除人工帧的回归测试
- 更新需求冻结和设计冻结文档中的人工帧清空语义
2026-05-04 01:38:18 +08:00
a680510db8 修复分离传播结果联动高亮
- 将 propagation_seed_signature 纳入传播链选择 token
- 修复合并后的分离 mask 传播到同一帧后点击一个片段另一个不联动高亮的问题
- 同步 VideoWorkspace 的传播链 token 逻辑,保持删除和清空链路一致
- 补充 CanvasArea 回归测试,覆盖同 seed signature 的分离传播片段联动选中
- 更新项目指南和设计冻结文档中的联动规则
2026-05-04 01:32:18 +08:00
45839a2e4c 增加清空传播帧人工帧确认
- DEL 和清空遮罩在清空所有传播帧时检测人工/AI 标注帧并二次确认
- 按帧范围清空传播链时检测范围内人工/AI 标注帧,支持选择否后整帧保留
- 保留人工帧时只清其它自动传播帧,避免人工帧被局部掏空
- 补充清空所有传播帧和范围清空的人工帧保留回归测试
- 更新项目指南、实现地图、前端审计、需求冻结、设计冻结和测试计划文档
2026-05-04 01:26:42 +08:00
5603872821 修复布尔范围操作重复执行
- 重新开始区域合并或重叠区域去除时取消旧的顶栏按帧范围请求
- 防止先选择按帧范围、再处理当前帧后还能继续执行旧范围合并
- 补充 VideoWorkspace 回归测试覆盖旧范围请求被取消的路径
- 更新项目指南、设计冻结和测试计划中的互斥交互说明
2026-05-04 01:04:13 +08:00
f42bf42989 优化传播范围清空交互
- 将布尔操作的选择范围弹窗改为四个操作按钮同一行展示
- 将 DEL 和清空遮罩的传播链确认改为单层弹窗,提供取消、只清当前帧、按帧范围选择和清空所有传播帧
- 为传播链清空新增时间轴范围选择、顶栏确认清空和最终确认流程,只删除所选范围内同传播链遮罩
- 补充 Canvas 和 VideoWorkspace 回归测试,覆盖按钮布局和按帧范围清空传播链
- 更新前端审计、需求冻结、设计冻结、测试计划和项目指南文档
2026-05-04 00:57:55 +08:00
2fe4623cae 修复同类多实例传播结果被清理
- 在传播结果写入前的空间清理中识别同一任务里的其它 seed,避免后续同类 seed 删除前面刚写入的传播结果。

- 保留旧结果替换逻辑:不属于本次其它 seed 的旧传播结果仍可按语义和空间重叠被新 seed 替换。

- 将同类多实例传播回归测试改为重叠输出场景,覆盖此前两个同类方块只保留一个的问题。
2026-05-04 00:44:11 +08:00
093ef6c63a 优化工作区传播和清空交互
- 手工多边形、矩形和圆在未选语义分类时默认归入 maskid:0 的待分类类别。

- 后端自动传播按来源 annotation/mask/seed key 区分同类多实例,避免多个同类型 mask 传播时互相清理。

- 左侧工具栏在橡皮擦下方新增彩色 AI 自动传播入口,传播权重和范围控件只在进入传播后显示。

- 移除顶栏重复的清空片段遮罩入口,并取消当前清空/DEL 弹窗中的按帧范围清空路径。

- Canvas 右下角显示当前帧:XX/XXX,并调整布尔操作浮层位置避免重叠。

- 更新前端和后端回归测试,覆盖待分类默认、工具栏自动传播和同类多实例传播。

- 同步 AGENTS 与 doc 文档,说明新的工具栏、清空和传播行为。
2026-05-04 00:26:11 +08:00
061f4ed25b 移除工作区顶栏模型状态徽标
- 删除分割工作区顶栏重复的紧凑 GPU/CPU 状态徽标

- 保留左侧 Sidebar 底部的模型状态显示,顶栏只保留传播权重和范围摘要

- 更新前端审计、设计冻结和 AGENTS 文档
2026-05-03 23:35:02 +08:00
b97c00900c 支持布尔操作按帧范围执行
- 区域合并和重叠区域去除新增“按帧范围选择”,复用底部时间轴范围选择并在执行前二次确认

- 布尔操作范围只处理所选帧内存在对应传播链的区域,范围外传播 mask 保持不变

- 自动传播范围选择时在顶栏显示传播权重,以及相对参考帧的向前/向后传播帧数

- Canvas 将传播链布尔操作委托给工作区统一处理范围选择,同时保留当前帧/所有传播帧快捷操作

- 增加 CanvasArea、VideoWorkspace 回归测试,覆盖布尔操作范围选择、范围执行和自动传播方向摘要

- 更新 AGENTS 与前端审计、需求冻结、设计冻结、测试计划文档
2026-05-03 23:30:47 +08:00
5ae1d15336 完善遮罩删除范围选择
- 删除/清空已保存标注前预检后端 annotation id,跳过本地陈旧 id,避免重复 DELETE 产生 404 控制台红字

- 左侧工具栏新增 DEL 删除选中遮罩入口,调整清空遮罩弹窗文案为“清空所有传播帧”,并加入按帧范围选择入口

- 区域合并和重叠区域去除在存在传播帧时弹出当前帧/所有传播帧选择,传播帧同步后保留原 lineage metadata

- 多 polygon 或分离区域组成的 mask 选中后显示全部顶点与插点手柄,同帧传播链分散 mask 点选时联动高亮

- 调整工具栏分组分隔线位置,只在清空遮罩下方保留 tool-group-separator 测试标记

- 更新 VideoWorkspace、CanvasArea、ToolsPalette 回归测试和相关项目文档
2026-05-03 22:14:00 +08:00
275be62db5 修复工作区撤销重做快捷键
- 抽出 keyboardShortcuts helper,统一识别 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z 和 Ctrl/Cmd+Y

- 快捷键监听改为 window capture 阶段,减少 Canvas/Konva 焦点或冒泡拦截导致的失效

- 兼容 event.code=KeyZ/KeyY,覆盖输入法或键盘布局下 event.key 不可靠的情况

- 保持输入框、文本域、下拉框和可编辑文本聚焦时不拦截撤销/重做

- 增加快捷键 helper 与 VideoWorkspace 回归测试,并更新需求、设计、测试计划和 AGENTS 文档
2026-05-03 21:10:38 +08:00
a22af5f7c8 统一工作区清空遮罩入口
- 移除 Canvas 右下角旧清空遮罩和应用分类按钮,清空入口统一到左侧工具栏

- 清空遮罩优先作用于当前帧选中 mask,无选中时作用于当前帧全部 mask

- 目标 mask 无传播链结果时直接清当前帧,有传播链结果时弹窗选择只清当前帧、清空传播所有帧或取消

- 保留布尔工具右下角合并/去除操作区,避免旧分类按钮误改整帧

- 更新 Canvas、工具栏、工作区测试,覆盖直接清空、传播链范围选择和取消路径

- 同步更新前端审计、需求冻结、设计冻结、测试计划和 AGENTS 说明
2026-05-03 21:06:03 +08:00
3e998b9d6b 保存标注前预检陈旧id
- dirty 标注保存前拉取后端标注 id 列表,已缺失的本地旧 annotationId 直接重新创建

- 保留 PATCH 404 兜底,覆盖预检后并发删除的情况

- 扩充分割工作区测试,区分预检缺失和 PATCH 后缺失两条路径

- 同步更新接口契约、需求冻结、设计冻结、测试计划和 AGENTS 说明
2026-05-03 19:29:36 +08:00
0ba5a8c094 修复陈旧标注保存404
- dirty 标注 PATCH 404 时改用 POST 重新创建,保留几何、分类和传播 lineage metadata

- 保存后回显替换本地旧 annotationId,避免保存改动和开始传播被陈旧 id 中断

- 增加工作区回归测试,覆盖本地旧 annotationId 重新创建流程

- 更新接口契约、需求冻结、设计冻结、测试计划和 AGENTS 说明
2026-05-03 19:26:07 +08:00
4d6bbf2b80 同步传播链编辑并保护模板切换
- 修改激活模板时,如果当前项目已有 mask,先提示确认并清空所有本地 mask 和已保存后端标注;无 mask 项目可直接切换。

- 模板库详情页将“+ 新建分类”改为带编辑图标的“编辑模板”,并打开完整模板编辑弹窗。

- 区域合并会按 propagation lineage 找到其它传播帧的对应主区域和参与区域,逐帧执行 union,只删除实际参与合并的对应 mask。

- 重叠区域去除会按 propagation lineage 同步到其它传播帧的对应区域,保留参与扣除 mask,不再只改当前帧。

- 当前帧清空遮罩会同步删除这些 mask 的关联自动传播结果,并新增左侧工具栏清空入口。

- 传播链同步编辑保留 source、source_annotation_id、source_mask_id、propagation_seed_key 等 metadata,避免时间轴帧属性变色。

- 补充模板切换确认、模板编辑按钮、左侧清空入口、传播链合并/去除和清空传播链的前端回归测试。

- 更新 AGENTS、接口契约、冻结需求、设计冻结和测试计划文档。
2026-05-03 19:10:12 +08:00
2da73f9acd 修复3000白屏的Logo加载
- 将侧边栏 logo_square.png 从 Vite 模块 import 改为 /logo_square.png 静态路径,避免浏览器把图片按 JS module 加载。

- 验证 3000 页面可挂载登录页 DOM,Sidebar 测试、类型检查、生产构建和 diff 检查通过。
2026-05-03 18:31:06 +08:00
f88f9bdbb9 支持中空mask编辑和传播保洞
- 前端按 polygonRingCounts 维护外圈/内洞 ring 分组,中空 mask 在调整多边形时显示内洞顶点和插点手柄。

- 保存与回显标注时将中空结构拆分为 mask_data.polygons 和 mask_data.holes,导入/普通 mask 共享同一编辑体验。

- 自动传播 seed 携带 holes,SAM 2 seed 栅格化时扣除内洞,避免中空 mask 以实心形式传播。

- 传播结果轮廓提取改为保留层级内洞,并在同步传播和 Celery 传播落库时写回 holes 与 hasHoles。

- 传播 seed 签名纳入 holes,并加固保存结果时 holes 与原始 polygon 索引对齐。

- 补充前端保存/回显、Canvas 内洞编辑和后端 SAM 2 hole 处理测试。

- 更新 AGENTS、接口契约、需求冻结、设计冻结和测试计划文档,移除中空结构未实现的旧描述。
2026-05-03 18:28:46 +08:00
739953bc13 更新方形Logo和头颈部CT默认分类
- 侧边栏 Logo 改为导入根目录 logo_square.png,favicon 也切换为 /logo_square.png,并让前端服务显式提供该根目录图片。

- 头颈部CT分割默认模板分类名改为纯中文,去掉括号英文翻译,颜色和 maskid 保持用户给定顺序。

- 增加旧版头颈部CT英文括号 label 的窄迁移,启动 seed 时自动把旧默认系统模板更新为纯中文默认。

- 更新前端 Logo 测试、后端默认模板和恢复出厂设置测试,覆盖纯中文分类和根目录方形 Logo。

- 更新 AGENTS、README、前端审计、需求冻结和测试计划文档,记录根目录 Logo 和头颈部CT纯中文默认分类。
2026-05-03 18:11:21 +08:00
d559cda2cb 移除模板分类编辑来源字段
- 删除编辑模板弹窗中分类编辑态的旧 category 来源文本框,避免显示未分类、批量导入或模板名等干扰信息。

- 保留分类名称编辑、颜色、maskid 和拖拽层级能力,旧 category 元数据仅作为兼容数据继续随记录保留。

- 为模板分类编辑补充回归测试,确保点击分类后不再暴露旧 category 来源字段。

- 更新 AGENTS、需求冻结、设计冻结和测试计划文档,明确编辑弹窗不展示或编辑 category 来源元信息。
2026-05-03 18:04:02 +08:00
cadacef04d 修复演示恢复默认模板覆盖逻辑
- 新增后端默认模板服务,集中维护腹腔镜胆囊切除术和头颈部CT分割的权威分类树、颜色、maskid 和层级定义。

- 演示恢复出厂设置时强制恢复系统默认模板,缺失模板会重建,已修改或删减的默认语义分类树会覆盖回默认状态。

- 清理 main.py 中重复的默认模板定义,让启动 seed 复用同一套服务逻辑,避免后续默认模板定义漂移。

- 扩展管理员恢复出厂设置测试,覆盖头颈部CT模板被改坏和腹腔镜模板缺失后的恢复结果。

- 更新 AGENTS、README 和需求/API/测试/前端审计文档,明确恢复出厂设置会权威恢复系统默认模板。
2026-05-03 17:54:19 +08:00
7f8722410c 修复模板分类批量导入格式识别
- 修复模板库批量导入只使用严格 JSON.parse 导致合法 colors/names 内容被误判非法的问题。

- 支持 [[colors], [names]] 数组格式和 {colors, names} 对象格式,并兼容带“批量导入分类:”前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容。

- 增加导入数据校验:分类名称不能为空,颜色必须是 0-255 的 RGB 三元组,缺失颜色继续使用默认灰色并保留预览提示。

- 补充 TemplateRegistry 测试,覆盖对象格式、数组格式和常见粘贴格式;同步 AGENTS 和 doc 中的模板导入说明与测试计划。

- 已验证 TemplateRegistry 组件测试、TypeScript 类型检查、生产构建、服务重启、前后端健康检查和 git diff 检查。
2026-05-03 17:45:49 +08:00
0b4e10209a 修复拆帧任务进度卡在 0%
- 将 Celery worker 启动方式改为 solo pool,避免 OpenCV/PyTorch/SAM 相关依赖在本机 prefork worker 中段错误崩溃。

- 增强开发重启脚本:启动 Celery 后检查 worker 进程是否持续存活,避免脚本误报服务已启动但任务无人消费。

- 同步一键启动脚本和安装/README 文档中的 Celery 启动命令,统一使用 --pool=solo --concurrency=1。

- 已验证脚本语法、diff 检查、服务重启、健康检查和 Celery inspect ping,确认拆帧任务可从 0% 继续执行到完成。
2026-05-03 17:30:24 +08:00
481ffa5b67 完善项目导入、模板与分割工作区交互
- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。

- 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。

- 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。

- 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。

- 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。

- 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。

- 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。

- 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
2026-05-03 17:11:59 +08:00
afcddfaeb9 feat: 完善分割工作区导入导出与管理流程
- 新增基于 JWT 当前用户的登录恢复、角色权限、用户管理、审计日志和演示出厂重置后台接口与前端管理页。

- 重串 GT_label 导出和 GT Mask 导入逻辑:导出保留类别真实 maskid,导入仅接受灰度或 RGB 等通道 maskid 图,支持未知 maskid 策略、尺寸最近邻拉伸和导入预览。

- 统一分割结果导出体验:默认当前帧,按项目抽帧顺序和 XhXXmXXsXXXms 时间戳命名 ZIP 与图片,补齐 GT/Pro/Mix/分开 Mask 输出和映射 JSON。

- 调整工作区左侧工具栏:移除创建点/线段入口,新增画笔、橡皮擦及尺寸控制,并按绘制、布尔、导入/AI 工具分组分隔。

- 扩展 Canvas 编辑能力:画笔按语义分类绘制并可自动并入连通选中 mask,橡皮擦对选中区域扣除,优化布尔操作、选区、撤销重做和保存状态联动。

- 优化自动传播时间轴显示:同一蓝色系按传播新旧递进变暗,老传播记录达到阈值后统一旧记录色,并维护范围选择与清空后的历史显示。

- 将 AI 智能分割入口替换为更明确的 AI 元素图标,并同步侧栏、工作区和 AI 页面入口表现。

- 完善模板分类、maskid 工具函数、分类树联动、遮罩透明度、边缘平滑和传播链同步相关前端状态。

- 扩展后端项目、媒体、任务、Dashboard、模板和传播 runner 的用户隔离、任务控制、进度事件与兼容处理。

- 补充前后端测试,覆盖用户管理、GT_label 往返导入导出、GT Mask 校验和预览、画笔/橡皮擦、时间轴传播历史、导出范围、WebSocket 与 API 封装。

- 更新 AGENTS、README 和 doc 文档,记录当前接口契约、实现状态、测试计划、安装说明和 maskid/GT_label 规则。
2026-05-03 03:52:32 +08:00
4c1d3dba73 feat: 完善 mask 编辑、传播平滑与开发重启闭环
功能增加:

- 新增后端 /api/ai/smooth-mask 接口,对当前 mask polygon 执行 Chaikin 边缘平滑,并返回 polygon、bbox、area 与拓扑锚点。

- 在右侧实例属性面板加入边缘平滑强度和应用边缘平滑操作,应用后将 mask 标记为 draft/dirty,并通过正常保存链路落库。

- 保存标注与传播 seed 时保留 geometry_smoothing 元数据,自动传播 forward/backward 结果保存前应用同一平滑参数。

- 自动传播 seed signature 纳入平滑参数,修改平滑强度后会触发旧同源传播结果清理并重新传播。

- 支持跨帧跟随同一传播链 mask,AI 推送回工作区时保留当前帧视角。

Bugfix:

- 修复中间帧向前传播时旧 forward/backward 同物体结果未被清理导致双重 mask 的问题。

- 修复 propagation worker 写入目标帧前只按旧方向清理导致 backward 重传残留的问题。

- 修复多边形顶点拖拽和编辑后画布视口异常移动的问题,并补充拖拽状态回写。

- 修复实例属性标题跟随全局 active class 而不是当前 mask label 的问题,并移除后端模型置信度展示。

开发与部署:

- 新增 restart_dev_services.sh,使用 setsid 独立后台重启 FastAPI、Celery 和前端,写入 pid/log 文件并做 3000/8000 健康检查。

- 明确后端或 Celery 相关改动完成后需要运行重启脚本,保证运行态加载最新代码。

测试与文档:

- 补充后端 smooth-mask、传播平滑 metadata、seed signature、传播去重方向覆盖等测试。

- 补充前端 OntologyInspector、VideoWorkspace、CanvasArea 和 api 契约测试,覆盖边缘平滑、传播参数、跨帧选区跟随和画布编辑行为。

- 更新 README、AGENTS、安装文档、前端元素审计、需求冻结、设计冻结和测试计划,记录当前真实行为与重启要求。
2026-05-02 17:04:02 +08:00
f365539ff2 fix: 避免自动传播重复叠加同源 mask
Bugfix:自动传播 worker 改为在本次目标帧段内按 seed 来源、方向、权重和签名查找旧传播结果;未修改且目标帧已覆盖时直接跳过,不再重复跑 SAM 造成 mask 堆叠。

Bugfix:同一 seed 被编辑、目标帧段只部分覆盖或切换 SAM 2.1 权重时,worker 会先删除本次目标帧段内同源旧自动传播标注,再重新传播。

Bugfix:未编辑的自动传播结果再次作为参考 seed 时会继承原始 propagation_seed_signature;编辑后的传播结果只保留 source_annotation_id/source_mask_id lineage,不继承旧签名,从而触发重传路径。

Bugfix:后端传播签名增加 canonical rounding,减少浮点精度细微变化导致未编辑 mask 被误判为已修改。

功能调整:清空片段遮罩改成与自动传播一致的时间轴范围选择流程,首次点击进入选区,拖拽选择起止帧后点击确认清空才执行。

接口契约:PropagationSeed 增加 propagation_seed_signature 字段,用于前端把未编辑传播结果绑定回原始 seed 传播链。

测试:补充前端 VideoWorkspace 范围清空、传播 lineage 传递测试;补充后端未编辑传播 seed 跳过重复传播、旧结果清理与换权重重传测试。

文档:同步更新 doc/03、doc/04、doc/07、doc/08、doc/09,明确 A/B 传播去重规则、清空片段范围选择和新增 seed signature 契约。
2026-05-02 07:11:03 +08:00
4899c8a08a feat: 完善分割工作区交互与传播去重
功能增加:点击 Canvas mask 后,右侧语义分类树会按 classId/className/label 自动匹配分类,并滚动聚焦到对应分类按钮。

功能增加:工作区新增按起止帧批量清空片段遮罩,复用传播范围输入,范围内已保存标注走 DELETE /api/ai/annotations/{id},本地 draft mask 同步移除。

功能增加:右侧语义分类树上方新增工作区 mask 透明度滑杆,写入 Zustand maskPreviewOpacity,Canvas mask 预览按该值渲染并保留选中加亮反馈。

功能增加:视频处理进度条记录最近自动传播区间,使用不同色系深浅渐变提示最近处理片段。

功能增加:工作区自动传播前会先保存 draft/dirty seed mask,使用稳定后端 source_annotation_id 入队,减少二次传播重复结果。

Bugfix:后端传播任务对旧临时 seed id、不同 SAM 2.1 权重结果做兼容清理;相同 seed 和相同权重才跳过,否则先删旧自动传播标注再重传。

Bugfix:修复 polygon 顶点拖拽结束后触发 Stage 平移导致画布中心偏移的问题,并补充测试环境对 drag target 的模拟。

Bugfix:工具提示会在数秒后自动隐藏,避免创建多边形/矩形等提示长期遮挡画布。

UI 调整:移除右侧面板顶部‘本体论与属性分类管理树’说明栏,减少无效占位。

UI 调整:左侧工具栏和右侧语义面板使用低对比 seg-scrollbar;左侧工具栏外扩滚动条槽位,避免滚动条挤占图标列。

UI 调整:工作区模型状态徽标改为紧凑显示,减少与传播权重选择重复;传播权重下拉改成深色背景和青色文字,避免灰底白字不可读。

UI 调整:缩略图状态框固定优先级,当前帧、人工/AI 标注帧、自动传播帧可用外框/内框组合同时表达。

测试:补充 VideoWorkspace、CanvasArea、FrameTimeline、OntologyInspector、ToolsPalette、useStore 和后端 test_ai 覆盖新增交互、传播去重、批量清空、透明度、滚动条和 UI 状态。

文档:同步更新 README、AGENTS 和 doc/03、doc/04、doc/07、doc/08、doc/09,记录当前功能、接口契约、需求设计冻结和测试覆盖。
2026-05-02 06:45:47 +08:00
c8c59f7ede feat: 完善分割工作区传播与交互闭环
功能增加:新增后端传播任务执行器,支持异步自动传播、传播进度、结果统计、取消/重试状态同步。

功能增加:传播请求支持指定 SAM2.1 tiny/small/base+/large 权重,并记录 seed mask、source annotation 和传播范围。

功能增加:传播逻辑增加 seed 签名,未变化的 mask 二次传播会跳过,已变化的 mask 会先清理旧自动传播结果再重新生成,避免重复重叠。

功能增加:工作区增加传播范围二次选择、传播进度提示、人工/AI 标注帧红色标识、自动传播帧蓝色标识和当前帧双层边框。

功能增加:新增临时提示组件,让工具操作提示自动消失且不阻塞后续操作。

功能增加:补充项目删除、模板删除、任务失败详情、任务取消/重试等前后端联动状态。

功能增加:新增安装部署文档,补充当前需求冻结、设计冻结、接口契约、测试计划和 AGENTS/README 项目说明。

Bugfix:修复自动传播接口 404、传播后看不到任务进度、传播结果重复堆叠和已编辑帧提示不清晰的问题。

Bugfix:修复 AI 分割框选/点选交互、单候选 mask、删除选点、工作区保存与候选 mask 推送相关问题。

Bugfix:修复 Canvas 多边形顶点拖动告警、工具栏提示缺失、项目库 FPS 展示和若干 UI 文案/可用性问题。

测试:补充 AI 分割、Canvas、Dashboard、FrameTimeline、ProjectLibrary、TemplateRegistry、ToolsPalette、VideoWorkspace、API 和后端任务/AI/dashboard 测试。

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

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

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

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

View File

@@ -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.
VITE_WS_PROGRESS_URL="ws://192.168.3.11:8000/ws/progress"
# Backend SAM runtime defaults. SAM 3 additionally requires the official sam3
# package, Python 3.12+, PyTorch 2.7+, and a CUDA-capable GPU per Meta's repo.
sam_default_model="sam2"
sam_model_path="/home/wkmgc/Desktop/Seg_Server/models/sam2_hiera_tiny.pt"
sam_model_config="configs/sam2/sam2_hiera_t.yaml"
# Backend SAM runtime defaults. Current product exposes SAM 2.1 variants only.
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_model_version="sam3"
sam3_checkpoint_path="/home/wkmgc/Desktop/Seg_Server/sam3权重/sam3.pt"
sam3_external_enabled=false

Binary file not shown.

102
AGENTS.md

File diff suppressed because one or more lines are too long

View File

@@ -12,13 +12,13 @@
## 核心功能
- **多媒体资产管理** — 支持视频MP4/AVI/MOV和 DICOM 医学影像上传;视频导入与生成帧分离,生成帧时选择目标 FPS项目卡片可删除项目及其关联帧、标注和任务记录
- **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体tiny/small/base+/large选择支持点分割point、框分割box、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割auto和 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示
- **交互式画布标注** — 基于 Konva 的高性能 Canvas支持缩放/平移/手工多边形/矩形/圆/点/线、polygon 顶点拖动/删除、边中点插点、双击边界插点、区域合并/去除、选点/框选、撤销/重做,实时渲染 Mask 遮罩
- **GT Mask 导入** — 工作区可导入 GT mask 图片,后端按非零像素值和连通域生成 polygon 标注并用 distance transform 写入 seed point前端可回显和拖动 seed point
- **本体字典管理** — 可配置的分类体系、颜色映射、图层优先级z-index
- **项目工作区** — 项目创建、帧浏览、多图层标注、已编辑帧提示、进度追踪
- **数据导出** — 支持 COCO JSON 格式和 PNG Mask 批量导出PNG ZIP 包含单标注 mask、按 z-index 融合的语义 mask 和类别映射
- **多媒体资产管理** — 支持视频MP4/AVI/MOV和 DICOM 医学影像上传项目库会在导入时显示上传进度条、已上传字节、DICOM 文件数量和上传后的解析任务进度;视频导入与生成帧分离,生成帧时选择目标 FPS项目卡片可重命名、复制或删除项目及其关联帧、标注和任务记录;复制时可选择“新项目重置”只保留媒体/帧序列,或“全内容复制”连同标注和 mask 元数据一起复制
- **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体tiny/small/base+/large选择侧栏和工作区跳转入口使用 Bot + Sparkles 组合图标强化 AI 识别;支持点分割point、框分割box、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割auto Celery 后台 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示
- **交互式画布标注** — 基于 Konva 的高性能 Canvas工作区和 AI 画布会默认居中放大底图并保留边距;工作区支持缩放/平移/手工多边形/矩形/圆/画笔/橡皮擦、polygon 顶点直接拖动/删除、边中点插点、双击边界插点、区域合并/去除、撤销/重做mask 不再显示黄色 seed point也不提供 seed point 拖动;创建多边形/矩形/圆后会自动选中新 mask 并立即显示边界点;左侧工具栏提供等同 `Esc` 的“取消选中”实体按钮,用于清空选区和临时绘制状态;画笔和橡皮擦支持尺寸调节,画笔可按当前语义分类生成新的独立连续区域,即使与选中 mask 连通或重叠也不自动合并,画笔闭合成中空区域时会保留内洞 ring 并显示内外圈顶点,橡皮擦可从选中 mask 扣除区域;删除传播 seed 或传播结果时会同步删除同一传播链上的自动传播 mask但保留其他帧独立 AI 推理/人工标注 mask未选中特定 mask 时按右侧语义分类树的内部优先级叠放显示AI 智能分割页单独提供正/反点和框选,实时渲染 Mask 遮罩
- **GT Mask 导入** — 工作区可导入 8-bit 二值/灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图;导入前会显示本地预览,不符合 8-bit 灰度/maskid 图要求时反馈错误,尺寸不同会按当前帧长宽最近邻拉伸;后端按 maskid 匹配当前模板类别并生成高精度 polygon 标注,导入后与普通 mask 一样不显示黄色 seed point并共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存链路超出现有类别的 maskid 可由用户选择舍弃或导入为“未定义类别”等待重新命名
- **本体字典管理** — 可配置的分类体系、颜色映射、稳定且跨图一致的 maskid所有模板默认带 `maskid: 0` 的黑色“待分类”保留类,该类固定在语义分类树最后、不可删除也不可拖到上层;模板库“生效中模板架构清单”可用鼠标复制现有模板为当前用户私有副本,详情页“语义分类树(拖拽调层级)”支持新建分类、拖拽调层级和垃圾桶删除 label模板编辑弹窗和右侧分类树也可拖拽调整内部图层覆盖顺序maskid 不参与排序
- **项目工作区** — 项目创建、帧浏览、多图层标注、自动传播帧提示、进度追踪
- **数据导出** — 工作区使用统一“分割结果导出”入口,可选择整体视频、特定范围帧或当前图片;特定范围帧支持输入帧号或在时间轴进度条上拖拽选择,并导出 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) │
│ localhost:8000 │
│ ├── /api/auth 登录认证 │
│ ├── /api/admin 管理员用户管理、审计日志、演示出厂重置 │
│ ├── /api/projects 项目 & 视频帧 CRUD │
│ ├── /api/templates 本体字典(分类/颜色/z-index
│ ├── /api/media 文件上传 & 异步拆帧任务创建 │
│ ├── /api/tasks Celery 后台任务状态/取消/重试/详情 │
│ ├── /api/ai SAM 2 推理与模型状态 │
│ └── /api/export COCO JSON / PNG Masks 导出
│ └── /api/export COCO JSON / PNG / 统一分割结果导出
└──────────────────────────┬──────────────────────────────────┘
│ SQLAlchemy 2.0
┌──────────────────────────▼──────────────────────────────────┐
@@ -85,7 +86,7 @@ Seg_Server/
│ ├── main.py # 应用入口CORS/生命周期/路由注册/WebSocket
│ ├── config.py # 环境变量配置Pydantic Settings
│ ├── 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 请求/响应校验模型
│ ├── minio_client.py # MinIO 上传/下载/预签名URL封装
│ ├── redis_client.py # Redis 连接封装
@@ -97,13 +98,15 @@ Seg_Server/
│ ├── setup_sam3_env.sh # 历史保留的 SAM 3 独立 Python 3.12 环境安装脚本;当前产品入口禁用
│ ├── requirements.txt # Python 依赖
│ ├── routers/ # API 路由
│ │ ├── auth.py # 登录认证
│ │ ├── auth.py # 用户表、密码哈希、JWT 登录和 /api/auth/me
│ │ ├── admin.py # 管理员用户 CRUD 和审计日志
│ │ ├── projects.py # 项目 & 帧 CRUD
│ │ ├── templates.py # 本体字典管理
│ │ ├── media.py # 上传 & 解析
│ │ ├── ai.py # SAM 推理与模型状态接口
│ │ └── export.py # 数据导出
│ └── services/ # 业务服务
│ ├── propagation_task_runner.py # Celery 自动传播任务 runner
│ ├── sam2_engine.py # SAM 2.1 变体选择、单帧推理 + video predictor 传播
│ ├── sam3_engine.py # 历史保留的 SAM 3 桥接实现;当前未接入 registry
│ ├── sam3_external_worker.py # 历史保留的独立 sam3 helper当前未被产品入口调用
@@ -121,23 +124,25 @@ Seg_Server/
│ └── components/ # 组件(扁平化目录)
│ ├── Login.tsx # 登录页
│ ├── Sidebar.tsx # 左侧导航栏
│ ├── UserAdmin.tsx # 管理员用户管理后台
│ ├── Dashboard.tsx # 总体概况仪表盘(任务进度/任务控制)
│ ├── ProjectLibrary.tsx # 项目库列表
│ ├── VideoWorkspace.tsx # 核心分割工作区布局
│ ├── CanvasArea.tsx # Konva 画布(缩放/平移/手工绘制/选点/Mask渲染
│ ├── ToolsPalette.tsx # 左侧工具栏
│ ├── ToolsPalette.tsx # 左侧紧凑工具栏(高度不足时滚动)
│ ├── OntologyInspector.tsx # 右侧本体/属性检查面板
│ ├── FrameTimeline.tsx # 底部时间轴
│ ├── AISegmentation.tsx # AI 智能分割引擎界面
│ ├── TransientNotice.tsx # 非阻塞自动消失短提示
│ └── TemplateRegistry.tsx # 模板库管理
├── models/ # SAM 2 模型权重(.pt 文件)
├── uploads/ # 临时上传目录
├── frames/ # 临时帧目录
├── doc/ # 当前实现审计、接口契约与后续实施文档
├── public/
│ └── logo.png # 侧边栏 Logo 静态资源
│ └── logo.png # 登录页、侧边栏与 favicon 使用的 Logo
├── start_services.sh # 一键启动所有服务脚本
├── server.ts # Express + Vite 前端入口(也保留少量旧版 mock API
├── server.ts # Express + Vite 前端入口(不再提供旧版 mock API
├── index.html # SPA HTML 入口
├── vite.config.ts # Vite 构建配置
├── package.json # npm 依赖与脚本
@@ -154,6 +159,7 @@ Seg_Server/
- `doc/03-frontend-element-audit.md` — 前端逐元素功能审计标注真实可用、部分可用、Mock/UI-only、接口不通
- `doc/04-api-contracts.md` — 前后端接口契约和已知不一致
- `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 权重
---
@@ -286,6 +292,10 @@ sam_default_model=sam2.1_hiera_tiny
# 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"]
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 配置:
@@ -315,7 +325,8 @@ nohup uvicorn main:app --host 0.0.0.0 --port 8000 > /tmp/fastapi.log 2>&1 &
- 测试 Redis 连接
- 懒加载所选 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/propagate` 支持当前帧 seed 区域向视频片段传播:当前使用所选 SAM 2.1 变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`
- `/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
@@ -323,13 +334,13 @@ nohup uvicorn main:app --host 0.0.0.0 --port 8000 > /tmp/fastapi.log 2>&1 &
cd ~/Desktop/Seg_Server/backend
source ~/miniconda3/etc/profile.d/conda.sh
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 视频处理可复用的标准帧序列;视频`frame_%06d.jpg` 连续命名,帧表会记录 `timestamp_ms``source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的任务进度区展示 queued/running/success/failed/cancelled 最近任务,处理中统计只计算 queued/runningWebSocket 状态由浏览器 `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/runningWebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel``/api/tasks/{id}/retry``/api/tasks/{id}` 完成任务取消、重试与失败详情查看。
### 步骤 7: 安装前端依赖并构建
@@ -365,6 +376,34 @@ cd ~/Desktop/Seg_Server
脚本将依次检查并启动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
```
---
## 访问地址与默认凭证
@@ -376,6 +415,12 @@ cd ~/Desktop/Seg_Server
| MinIO 控制台 | http://localhost:9001 | minioadmin / minioadmin |
| 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、内部层级顺序和规则。
---
## 可用命令
@@ -389,7 +434,7 @@ npm run build # 生产构建(输出到 dist/
npm run lint # TypeScript 类型检查
npm run test # Vitest watch 模式
npm run test:run # Vitest 单次运行
npm start # Node.js 运行 server.ts生产静态服务 / 旧版 mock API
npm start # Node.js 运行 server.ts生产静态服务
```
### 后端
@@ -401,7 +446,7 @@ pip install -r requirements-dev.txt # 安装后端测试
pytest tests # 后端接口测试
uvicorn main:app --host 0.0.0.0 --port 8000 --reload # 开发模式(热重载)
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
```
---
@@ -466,12 +511,13 @@ pip install -e . --no-build-isolation
- 前端 `predictMask()` 已发送后端需要的 `image_id``prompt_type``prompt_data`,并把后端 `polygons` 转成 Konva `pathData`
- 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict`
- 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。
- AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择。
- 工作区传播功能会使用当前选中区域或当前帧全部区域作为 seed按用户设置的起止帧向前/向后追踪;“传播全部可达”会覆盖项目第 1 帧到最后 1 帧。前端会按 seed 和方向顺序调用 `/api/ai/propagate`,并在完成后刷新已保存标注
- 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco`
- 工作区“导出 JSON 标注集”和“导出 PNG Mask ZIP”按钮已绑定下载流程导出前会先保存当前待归档的前端 mask
- 工作区“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,导入后会刷新并回显已保存标注和 seed point
- 工作区“结构化归档保存”按钮会把当前项目未保存 mask 写入 `POST /api/ai/annotate`,并把 dirty mask 写入 `PATCH /api/ai/annotations/{id}`
- AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签;如果换标签的 mask 属于传播链,系统会同步更新前后帧对应传播 mask 的分类元数据;“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择和当前帧视角,不会因工作区重新加载而跳回第一帧
- 工作区传播功能会使用当前打开参考帧的全部 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/resultworker 会按 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 标注帧,会显示青色外框加红色内描边,固定为外层当前帧、内层标注框
- 右侧实例属性面板的“边缘平滑强度”滑杆会先防抖预览;点击“应用边缘平滑”后会把平滑结果作为新的实际 polygon 写入当前 mask并同步写入同一传播链前后对应 mask整次操作进入同一个撤销/重做历史步骤,应用后强度重置为 0用户可继续用“调整多边形”编辑新多边形。分类和平滑这类跨传播链操作保存时会保留传播来源 metadata避免原本的自动传播帧在时间轴上被改成红色人工/AI 标注帧
- 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco``exportMasks()` 已对齐到 `/api/export/{projectId}/masks`,统一导出 `exportSegmentationResults()` 已对齐到 `/api/export/{projectId}/results`
- 工作区“分割结果导出”按钮已绑定下载流程;点击后可在下拉栏选择整体视频、特定范围帧或当前图片,默认选择当前图片,并勾选分开 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` 的“待分类”保持 0Pro_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重叠覆盖彩色分割结果/`,重叠区域按右侧语义分类树内部优先级从低到高覆盖
- 工作区左侧工具栏“导入 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 的 maskid16-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。
**验证**:

View File

@@ -14,6 +14,7 @@ class Settings(BaseSettings):
# MinIO
minio_endpoint: str = "192.168.3.11:9000"
minio_public_endpoint: str | None = None
minio_access_key: str = "minioadmin"
minio_secret_key: str = "minioadmin"
minio_secure: bool = False
@@ -24,7 +25,7 @@ class Settings(BaseSettings):
sam_model_config: str = "configs/sam2.1/sam2.1_hiera_t.yaml"
sam3_model_version: str = "sam3"
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_timeout_seconds: int = 300
sam3_status_cache_seconds: int = 30
@@ -33,6 +34,13 @@ class Settings(BaseSettings):
# App
app_env: str = "development"
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:
env_file = ".env"

View File

@@ -4,8 +4,6 @@ import asyncio
import json
import logging
import os
import shutil
import tempfile
from contextlib import asynccontextmanager, suppress
from datetime import datetime, timezone
@@ -15,12 +13,11 @@ from sqlalchemy import inspect, text
from config import settings
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 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(
level=logging.INFO,
@@ -28,104 +25,100 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
DEFAULT_VIDEO_PATH = "/home/wkmgc/Desktop/Seg_Server/Data_MyVideo_1.mp4"
def _ensure_runtime_schema_columns() -> None:
"""Add nullable columns introduced after initial create_all deployments."""
try:
inspector = inspect(engine)
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:
if "timestamp_ms" not in frame_columns:
connection.execute(text("ALTER TABLE frames ADD COLUMN timestamp_ms FLOAT"))
if "source_frame_number" not in frame_columns:
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
logger.warning("Runtime schema column check failed: %s", exc)
def _seed_default_project_sync() -> None:
"""Synchronously seed the default video project on first startup."""
import cv2
from models import Project, Frame
from services.frame_parser import parse_video, upload_frames_to_minio, extract_thumbnail
def _seed_default_admin_sync() -> None:
"""Ensure the single default admin exists without rewriting project ownership metadata."""
from routers.auth import ensure_default_admin
db = SessionLocal()
try:
existing = db.query(Project).filter(Project.name == "Data_MyVideo_1").first()
if existing is not None:
return
admin = ensure_default_admin(db)
db.commit()
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(
name="Data_MyVideo_1",
description="默认演示视频",
status=PROJECT_STATUS_PENDING,
source_type="video",
parse_fps=30.0,
def _seed_default_project_sync() -> None:
"""Synchronously seed the bundled demo video and DICOM projects on first startup."""
from models import Project
from routers.auth import ensure_default_admin
from services.demo_media import (
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)
db.commit()
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
if legacy_video is not None:
legacy_video.name = DEMO_VIDEO_PROJECT_NAME
db.commit()
logger.info("Seeded default project id=%s with %d frames", project.id, len(object_names))
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
legacy_dicom = (
db.query(Project)
.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:
logger.error("Failed to seed default project: %s", exc)
finally:
@@ -134,57 +127,22 @@ def _seed_default_project_sync() -> None:
def _seed_default_templates_sync() -> None:
"""Seed default ontology templates on first startup."""
from models import Template
db = SessionLocal()
try:
if db.query(Template).first() is not None:
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))
ensure_default_templates(db)
except Exception as exc:
logger.error("Failed to seed default templates: %s", exc)
finally:
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
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown hooks."""
@@ -196,6 +154,7 @@ async def lifespan(app: FastAPI):
try:
Base.metadata.create_all(bind=engine)
_ensure_runtime_schema_columns()
_seed_default_admin_sync()
logger.info("Database tables initialized.")
except Exception as exc: # noqa: BLE001
logger.error("Database initialization failed: %s", exc)
@@ -265,6 +224,7 @@ app.include_router(ai.router)
app.include_router(export.router)
app.include_router(dashboard.router)
app.include_router(tasks.router)
app.include_router(admin.router)
@app.get("/health", tags=["Health"])

View File

@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
BUCKET_NAME = "seg-media"
_minio_client: Optional[Minio] = None
_minio_public_client: Optional[Minio] = None
def get_minio_client() -> Minio:
@@ -29,6 +30,20 @@ def get_minio_client() -> Minio:
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:
"""Create the bucket if it does not already exist."""
client = get_minio_client()
@@ -97,7 +112,7 @@ def get_presigned_url(
Returns:
Presigned URL string.
"""
client = get_minio_client()
client = get_minio_public_client()
try:
url = client.get_presigned_url(method, BUCKET_NAME, object_name, expires=timedelta(seconds=expires))
return url

View File

@@ -17,6 +17,25 @@ from database import Base
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):
"""Project model representing a segmentation project."""
@@ -31,11 +50,13 @@ class Project(Base):
source_type = Column(String(20), default="video", nullable=False) # video | dicom
original_fps = Column(Float, nullable=True)
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())
updated_at = Column(
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")
annotations = relationship(
"Annotation", back_populates="project", cascade="all, delete-orphan"
@@ -77,8 +98,10 @@ class Template(Base):
color = Column(String(50), nullable=False)
z_index = Column(Integer, default=0, nullable=False)
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())
owner = relationship("User", back_populates="templates")
annotations = relationship(
"Annotation", back_populates="template", cascade="all, delete-orphan"
)
@@ -129,6 +152,22 @@ class Mask(Base):
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):
"""Background task state persisted for dashboard and polling."""

299
backend/routers/admin.py Normal file
View 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": "演示环境已恢复出厂设置",
}

View File

@@ -1,6 +1,7 @@
"""AI inference endpoints using selectable SAM runtimes."""
import logging
import math
import tempfile
from pathlib import Path
from typing import Any, List
@@ -8,25 +9,141 @@ from typing import Any, List
import cv2
import numpy as np
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from sqlalchemy import or_
from sqlalchemy.orm import Session
from database import get_db
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 (
AiRuntimeStatus,
MaskAnalysisRequest,
MaskAnalysisResponse,
SmoothMaskRequest,
SmoothMaskResponse,
PredictRequest,
PredictResponse,
PropagateRequest,
PropagateResponse,
PropagateTaskRequest,
ProcessingTaskOut,
AnnotationOut,
AnnotationCreate,
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
logger = logging.getLogger(__name__)
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:
@@ -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]]:
"""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)
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)
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)
if len(points) < 3:
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 [
[
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)]
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(
frames: list[Frame],
source_position: int,
@@ -224,7 +472,11 @@ def _filter_predictions(
response_model=PredictResponse,
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.
- **point**: `prompt_data` is either a list of `[[x, y], ...]` normalized
@@ -233,9 +485,7 @@ def predict(payload: PredictRequest, db: Session = Depends(get_db)) -> dict:
- **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`.
- **semantic**: disabled in the current SAM 2.1 point/box product flow.
"""
frame = db.query(Frame).filter(Frame.id == payload.image_id).first()
if not frame:
raise HTTPException(status_code=404, detail="Frame not found")
frame = _shared_frame_or_404(payload.image_id, db, current_user)
image = _load_frame_image(frame)
prompt_type = payload.prompt_type.lower()
@@ -381,7 +631,10 @@ def predict(payload: PredictRequest, db: Session = Depends(get_db)) -> dict:
response_model=AiRuntimeStatus,
summary="Get SAM model and GPU runtime status",
)
def model_status(selected_model: str | None = None) -> dict:
def model_status(
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:
return sam_registry.runtime_status(selected_model)
@@ -389,12 +642,110 @@ def model_status(selected_model: str | None = None) -> dict:
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(
"/propagate",
response_model=PropagateResponse,
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.
SAM 2 uses the official video predictor with the selected mask as the seed.
@@ -405,16 +756,8 @@ def propagate(payload: PropagateRequest, db: Session = Depends(get_db)) -> dict:
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))
project = db.query(Project).filter(Project.id == payload.project_id).first()
if not project:
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")
_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)
seed = payload.seed.model_dump(exclude_none=True)
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"
color = seed.get("color") or "#06b6d4"
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:
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:
continue
result_polygons = frame_result.get("polygons") or []
result_holes = frame_result.get("holes") 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):
if len(polygon) < 3:
continue
annotation = Annotation(
project_id=payload.project_id,
frame_id=frame.id,
template_id=template_id,
mask_data={
"polygons": [polygon],
"label": label,
"color": color,
"source": f"{model_id}_propagation",
"propagated_from_frame_id": source_frame.id,
"propagated_from_frame_index": source_frame.frame_index,
"score": scores[polygon_index] if polygon_index < len(scores) else None,
**({"class": class_metadata} if class_metadata else {}),
},
points=None,
bbox=_polygon_bbox(polygon),
)
db.add(annotation)
created.append(annotation)
polygons_to_save.append(_smooth_polygon(polygon, smoothing) if smoothing else 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=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,
**({"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()
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(
"/auto",
response_model=PredictResponse,
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."""
frame = db.query(Frame).filter(Frame.id == image_id).first()
if not frame:
raise HTTPException(status_code=404, detail="Frame not found")
frame = _shared_frame_or_404(image_id, db, current_user)
image = _load_frame_image(frame)
try:
@@ -536,16 +971,15 @@ def auto_segment(image_id: int, db: Session = Depends(get_db)) -> dict:
def save_annotation(
payload: AnnotationCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Annotation:
"""Persist an annotation (mask, points, bbox) into the database."""
project = db.query(Project).filter(Project.id == payload.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_shared_project_or_404(payload.project_id, db, current_user)
if payload.frame_id:
frame = db.query(Frame).filter(Frame.id == payload.frame_id).first()
if not frame:
raise HTTPException(status_code=404, detail="Frame not found")
_shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
if payload.template_id:
_visible_template_or_404(payload.template_id, db, current_user)
annotation = Annotation(**payload.model_dump())
db.add(annotation)
@@ -567,8 +1001,10 @@ async def import_gt_mask(
template_id: int | None = Form(None),
label: str = Form("GT Mask"),
color: str = Form("#22c55e"),
unknown_color_policy: str = Form("undefined"),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> List[Annotation]:
"""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
bitmap layer.
"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_shared_project_or_404(project_id, db, current_user)
frame = _shared_frame_or_404(frame_id, db, current_user, project_id)
frame = db.query(Frame).filter(Frame.id == frame_id, Frame.project_id == project_id).first()
if not frame:
raise HTTPException(status_code=404, detail="Frame not found")
if unknown_color_policy not in {"discard", "undefined"}:
raise HTTPException(status_code=400, detail="unknown_color_policy must be discard or undefined")
template: Template | None = None
if template_id is not None:
template = db.query(Template).filter(Template.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
template = _visible_template_or_404(template_id, db, current_user)
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:
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])
height = int(frame.height or image.shape[0])
label_values = [int(value) for value in np.unique(image) if int(value) > 0]
if not label_values:
raise HTTPException(status_code=400, detail="No foreground mask regions found")
has_multiple_labels = len(label_values) > 1
original_height, original_width = int(label_image.shape[0]), int(label_image.shape[1])
resized_to_frame = original_width != width or original_height != height
if resized_to_frame:
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] = []
for label_value in label_values:
binary = np.where(image == label_value, 255, 0).astype(np.uint8)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
annotation_label = f"{label} {label_value}" if has_multiple_labels else label
for item in import_items:
binary = item["binary"]
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
for contour in contours:
if cv2.contourArea(contour) < 1:
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:
continue
component = np.zeros_like(binary, dtype=np.uint8)
cv2.drawContours(component, [contour], -1, 1, thickness=-1)
seed_point = _component_seed_point(component, image.shape[1], image.shape[0])
bbox = _contour_bbox(contour, image.shape[1], image.shape[0])
seed_point = _component_seed_point(component, binary.shape[1], binary.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(
project_id=project_id,
frame_id=frame_id,
template_id=template_id,
mask_data={
"polygons": [polygon],
"label": annotation_label,
"color": color,
"source": "gt_mask",
"gt_label_value": label_value,
"image_size": {"width": width, "height": height},
},
mask_data=mask_data,
points=[seed_point],
bbox=bbox,
)
@@ -640,7 +1138,7 @@ async def import_gt_mask(
annotations.append(annotation)
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()
for annotation in annotations:
@@ -658,14 +1156,14 @@ def list_annotations(
project_id: int,
frame_id: int | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[Annotation]:
"""Return persisted annotations for a project, optionally scoped to one frame."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_shared_project_or_404(project_id, db, current_user)
query = db.query(Annotation).filter(Annotation.project_id == project_id)
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)
return query.order_by(Annotation.id).all()
@@ -679,17 +1177,21 @@ def update_annotation(
annotation_id: int,
payload: AnnotationUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Annotation:
"""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:
raise HTTPException(status_code=404, detail="Annotation not found")
updates = payload.model_dump(exclude_unset=True)
if "template_id" in updates and updates["template_id"] is not None:
template = db.query(Template).filter(Template.id == updates["template_id"]).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
_visible_template_or_404(updates["template_id"], db, current_user)
for field, value in updates.items():
setattr(annotation, field, value)
@@ -708,9 +1210,15 @@ def update_annotation(
def delete_annotation(
annotation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Response:
"""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:
raise HTTPException(status_code=404, detail="Annotation not found")

View File

@@ -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 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"])
password_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
bearer_scheme = HTTPBearer(auto_error=False)
SUPPORTED_ROLES = {"admin", "annotator"}
class LoginRequest(BaseModel):
@@ -11,14 +26,197 @@ class LoginRequest(BaseModel):
password: str
class LoginResponse(BaseModel):
token: str
username: str
def hash_password(password: str) -> str:
"""Hash a plain password for storage."""
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)
def login(payload: LoginRequest) -> dict:
"""Simple login for development."""
if payload.username == "admin" and payload.password == "123456":
return {"token": "fake-jwt-token-for-admin", "username": payload.username}
raise HTTPException(status_code=401, detail="Invalid credentials")
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
"""Authenticate a user and return a signed JWT."""
ensure_default_admin(db)
user = db.query(User).filter(User.username == payload.username).first()
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

View File

@@ -5,11 +5,12 @@ from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
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"])
@@ -36,6 +37,7 @@ def _iso_or_none(value: datetime | None) -> str | None:
def _task_payload(task: ProcessingTask) -> dict[str, Any]:
result = task.result or {}
return {
"id": f"task-{task.id}",
"task_id": task.id,
@@ -44,29 +46,49 @@ def _task_payload(task: ProcessingTask) -> dict[str, Any]:
"progress": task.progress,
"status": task.message or 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,
"updated_at": _iso_or_none(task.updated_at),
}
@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."""
shared_project_ids_query = db.query(Project.id)
project_count = db.query(func.count(Project.id)).scalar() or 0
frame_count = db.query(func.count(Frame.id)).scalar() or 0
annotation_count = db.query(func.count(Annotation.id)).scalar() or 0
template_count = db.query(func.count(Template.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))
.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 = (
db.query(func.count(ProcessingTask.id))
.outerjoin(Project, Project.id == ProcessingTask.project_id)
.filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES))
.scalar()
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 = (
db.query(ProcessingTask)
.outerjoin(Project, Project.id == ProcessingTask.project_id)
.order_by(ProcessingTask.created_at.desc())
.limit(50)
.all()
@@ -95,6 +117,7 @@ def get_dashboard_overview(db: Session = Depends(get_db)) -> dict[str, Any]:
recent_annotations = (
db.query(Annotation)
.filter(Annotation.project_id.in_(shared_project_ids_query))
.order_by(Annotation.updated_at.desc())
.limit(10)
.all()
@@ -111,6 +134,7 @@ def get_dashboard_overview(db: Session = Depends(get_db)) -> dict[str, Any]:
recent_templates = (
db.query(Template)
.filter(or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)))
.order_by(Template.created_at.desc())
.limit(10)
.all()

View File

@@ -4,17 +4,22 @@ import io
import json
import logging
import os
import re
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from urllib.parse import quote
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 sqlalchemy.orm import Session
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__)
router = APIRouter(prefix="/api/export", tags=["Export"])
@@ -49,6 +54,30 @@ def _annotation_z_index(annotation: Annotation) -> int:
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:
class_meta = (annotation.mask_data or {}).get("class") or {}
if isinstance(class_meta, dict):
@@ -85,38 +114,160 @@ def _annotation_color(annotation: Annotation) -> str:
return "#ffffff"
@router.get(
"/{project_id}/coco",
summary="Export annotations in COCO format",
)
def export_coco(project_id: int, db: Session = Depends(get_db)) -> StreamingResponse:
"""Export all annotations for a project as a COCO-format JSON file."""
def _hex_to_rgb(color: str) -> list[int]:
value = str(color or "").strip()
if value.startswith("#"):
value = value[1:]
if len(value) == 3:
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()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
annotations = (
db.query(Annotation)
.filter(Annotation.project_id == project_id)
.all()
)
frames = (
def _project_frames(project_id: int, db: Session) -> list[Frame]:
return (
db.query(Frame)
.filter(Frame.project_id == project_id)
.order_by(Frame.frame_index)
.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 = []
for idx, frame in enumerate(frames):
for frame in frames:
images.append({
"id": frame.id,
"file_name": frame.image_url,
"width": frame.width or 1920,
"height": frame.height or 1080,
"frame_index": idx,
"frame_index": frame.frame_index,
})
categories = []
@@ -131,14 +282,14 @@ def export_coco(project_id: int, db: Session = Depends(get_db)) -> StreamingResp
coco_annotations = []
ann_id = 1
selected_frame_ids = {frame.id for frame in frames}
for ann in annotations:
if not ann.mask_data:
if ann.frame_id not in selected_frame_ids or not ann.mask_data:
continue
polygons = ann.mask_data.get("polygons", [])
if not polygons:
continue
# Use first polygon for bbox / area approximation
first_poly = polygons[0]
xs = [p[0] 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
coco = {
return {
"info": {
"description": f"Annotations for {project.name}",
"version": "1.0",
@@ -183,39 +334,242 @@ def export_coco(project_id: int, db: Session = Depends(get_db)) -> StreamingResp
"categories": categories,
}
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}"'},
def _class_mapping_entry(annotation: Annotation) -> dict[str, Any]:
return {
"key": _annotation_class_key(annotation),
"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(
"/{project_id}/masks",
summary="Export PNG masks as a ZIP archive",
)
def export_masks(project_id: int, db: Session = Depends(get_db)) -> StreamingResponse:
"""Export individual masks plus z-index fused semantic masks inside a ZIP."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
def _parse_result_outputs(mask_type: str, outputs: str | None) -> set[str]:
allowed = {"separate", "gt_label", "pro_label", "mix_label"}
if outputs:
parsed = {item.strip() for item in outputs.split(",") if item.strip()}
invalid = parsed - allowed
if invalid:
raise HTTPException(status_code=400, detail=f"Invalid outputs: {', '.join(sorted(invalid))}")
return parsed or allowed
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
annotations = (
db.query(Annotation)
.filter(Annotation.project_id == project_id)
.all()
)
frames = (
db.query(Frame)
.filter(Frame.project_id == project_id)
.order_by(Frame.frame_index)
.all()
)
if image_bytes:
decoded = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
if decoded is not None:
if decoded.shape[1] != width or decoded.shape[0] != height:
decoded = cv2.resize(decoded, (width, height), interpolation=cv2.INTER_AREA)
return decoded
return np.zeros((height, width, 3), dtype=np.uint8)
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] = {}
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]
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
frame_masks: dict[int, list[tuple[Annotation, np.ndarray]]] = {}
for ann in annotations:
if not ann.mask_data:
continue
polygons = ann.mask_data.get("polygons", [])
if not polygons:
continue
include_individual = mask_type in {"separate", "both"}
include_semantic = mask_type in {"gt_label", "both"}
frame_masks: dict[int, list[tuple[Annotation, np.ndarray]]] = {}
selected_frame_ids = {frame.id for frame in frames}
width = ann.frame.width if ann.frame else 1920
height = ann.frame.height if ann.frame else 1080
combined = np.zeros((height, width), dtype=np.uint8)
for ann in annotations:
if ann.frame_id not in selected_frame_ids or not ann.mask_data:
continue
polygons = ann.mask_data.get("polygons", [])
if not polygons:
continue
for poly in polygons:
mask = _mask_from_polygon(poly, width, height)
combined = np.maximum(combined, mask)
width = ann.frame.width if ann.frame else 1920
height = ann.frame.height if ann.frame else 1080
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)
fname = f"mask_{ann.id:06d}.png"
zf.writestr(fname, encoded.tobytes())
if ann.frame_id is not None:
frame_masks.setdefault(ann.frame_id, []).append((ann, combined))
zf.writestr(f"{individual_prefix}mask_{ann.id:06d}.png", encoded.tobytes())
if include_semantic and ann.frame_id is not None:
frame_masks.setdefault(ann.frame_id, []).append((ann, combined))
if include_semantic:
for frame in frames:
entries = frame_masks.get(frame.id, [])
if not entries:
continue
width = frame.width or 1920
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])):
semantic[mask > 0] = class_value(ann)
_, 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(
"semantic_classes.json",
f"{semantic_prefix}semantic_classes.json",
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)
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",
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)},
)

View File

@@ -1,6 +1,7 @@
"""Media upload and parsing endpoints."""
import logging
import re
from pathlib import Path
from typing import List, Optional
@@ -9,8 +10,9 @@ from sqlalchemy.orm import Session
from database import get_db
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 routers.auth import require_editor
from schemas import ProcessingTaskOut
from statuses import PROJECT_STATUS_PARSING, PROJECT_STATUS_PENDING, TASK_STATUS_QUEUED
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"}
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:
return Path(filename).suffix.lower()
@@ -34,6 +43,7 @@ async def upload_media(
file: UploadFile = File(...),
project_id: Optional[int] = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> dict:
"""Accept a video, image, or DICOM file and store it in MinIO.
@@ -63,12 +73,11 @@ async def upload_media(
if project_id:
project = db.query(Project).filter(Project.id == project_id).first()
if project:
project.video_path = object_name
db.commit()
logger.info("Linked upload to project_id=%s", project_id)
else:
logger.warning("Project id=%s not found for upload linkage", project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.video_path = object_name
db.commit()
logger.info("Linked upload to project_id=%s", project_id)
else:
# Auto-create a project named after the file
project = Project(
@@ -77,6 +86,7 @@ async def upload_media(
status=PROJECT_STATUS_PENDING,
video_path=object_name,
source_type="video",
owner_user_id=current_user.id,
)
db.add(project)
db.commit()
@@ -109,6 +119,7 @@ async def upload_dicom_batch(
files: List[UploadFile] = File(...),
project_id: Optional[int] = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> dict:
"""Upload multiple .dcm files for a DICOM series.
@@ -118,6 +129,12 @@ async def upload_dicom_batch(
if not files:
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 = []
if project_id:
@@ -126,12 +143,13 @@ async def upload_dicom_batch(
raise HTTPException(status_code=404, detail="Project not found")
else:
# Create new DICOM project
first_name = files[0].filename or "DICOM_Series"
first_name = sorted_files[0].filename or "DICOM_Series"
project = Project(
name=first_name,
description=f"DICOM series with {len(files)} files",
description=f"DICOM series with {len(sorted_files)} files",
status=PROJECT_STATUS_PENDING,
source_type="dicom",
owner_user_id=current_user.id,
)
db.add(project)
db.commit()
@@ -139,9 +157,7 @@ async def upload_dicom_batch(
project_id = project.id
logger.info("Auto-created DICOM project id=%s", project_id)
for file in files:
if not file.filename or not file.filename.lower().endswith(".dcm"):
continue
for file in sorted_files:
data = await file.read()
object_name = f"uploads/{project_id}/dicom/{file.filename}"
try:
@@ -173,6 +189,7 @@ def parse_media(
max_frames: Optional[int] = Query(None, gt=0),
target_width: int = Query(640, ge=64, le=4096),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> ProcessingTask:
"""Create a background task for media frame extraction.

View File

@@ -1,20 +1,44 @@
"""Project and Frame CRUD endpoints."""
import logging
from typing import List
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models import Project, Frame
from schemas import ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut
from models import Annotation, Mask, Project, Frame, User
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
logger = logging.getLogger(__name__)
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
# ---------------------------------------------------------------------------
@@ -24,9 +48,13 @@ router = APIRouter(prefix="/api/projects", tags=["Projects"])
status_code=status.HTTP_201_CREATED,
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."""
project = Project(**payload.model_dump())
project = Project(**payload.model_dump(), owner_user_id=current_user.id)
db.add(project)
db.commit()
db.refresh(project)
@@ -39,13 +67,21 @@ def create_project(payload: ProjectCreate, db: Session = Depends(get_db)) -> Pro
response_model=List[ProjectOut],
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."""
projects = db.query(Project).offset(skip).limit(limit).all()
projects = (
db.query(Project)
.offset(skip)
.limit(limit)
.all()
)
for p in projects:
p.frame_count = len(p.frames)
if p.thumbnail_url:
p.thumbnail_url = get_presigned_url(p.thumbnail_url, expires=3600)
_prepare_project_response(p)
return projects
@@ -54,15 +90,91 @@ def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)
response_model=ProjectOut,
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."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.frame_count = len(project.frames)
if project.thumbnail_url:
project.thumbnail_url = get_presigned_url(project.thumbnail_url, expires=3600)
return project
return _prepare_project_response(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(
@@ -74,6 +186,7 @@ def update_project(
project_id: int,
payload: ProjectUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Project:
"""Update project fields partially."""
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")
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)
db.commit()
@@ -94,7 +211,11 @@ def update_project(
status_code=status.HTTP_204_NO_CONTENT,
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."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
@@ -118,6 +239,7 @@ def create_frame(
project_id: int,
payload: FrameCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Frame:
"""Register a new frame under a project."""
project = db.query(Project).filter(Project.id == project_id).first()
@@ -139,22 +261,24 @@ def create_frame(
def list_frames(
project_id: int,
skip: int = 0,
limit: int = 1000,
limit: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[Frame]:
"""Retrieve all frames belonging to a project."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
frames = (
query = (
db.query(Frame)
.filter(Frame.project_id == project_id)
.order_by(Frame.frame_index)
.offset(skip)
.limit(limit)
.all()
)
if limit is not None:
query = query.limit(limit)
frames = query.all()
for frame in frames:
frame.image_url = get_presigned_url(frame.image_url, expires=3600)
return frames
@@ -165,11 +289,20 @@ def list_frames(
response_model=FrameOut,
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."""
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()
)
if not frame:

View File

@@ -9,8 +9,9 @@ from sqlalchemy.orm import Session
from celery_app import celery_app
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 routers.auth import get_current_user, require_editor
from schemas import ProcessingTaskOut
from statuses import (
PROJECT_STATUS_PARSING,
@@ -21,7 +22,7 @@ from statuses import (
TASK_STATUS_FAILED,
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"])
logger = logging.getLogger(__name__)
@@ -31,8 +32,14 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
def _get_task_or_404(task_id: int, db: Session) -> ProcessingTask:
task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
def _get_task_or_404(task_id: int, db: Session, current_user: User) -> ProcessingTask:
_ = current_user
task = (
db.query(ProcessingTask)
.outerjoin(Project, Project.id == ProcessingTask.project_id)
.filter(ProcessingTask.id == task_id)
.first()
)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@@ -48,9 +55,11 @@ def list_tasks(
status: str | None = None,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[ProcessingTask]:
"""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:
query = query.filter(ProcessingTask.project_id == project_id)
if status is not None:
@@ -59,15 +68,23 @@ def list_tasks(
@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 _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")
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."""
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:
raise HTTPException(
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")
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."""
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}:
raise HTTPException(
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()
if not project:
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")
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,
payload=payload,
)
project.status = PROJECT_STATUS_PARSING
if not is_propagation_task:
project.status = PROJECT_STATUS_PARSING
db.add(task)
db.commit()
db.refresh(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
db.commit()
db.refresh(task)

View File

@@ -4,23 +4,48 @@ import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import or_
from sqlalchemy.orm import Session
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
logger = logging.getLogger(__name__)
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:
"""Pack classes/rules into mapping_rules for DB storage."""
mapping = data.get("mapping_rules") or {}
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:
mapping["rules"] = data.pop("rules")
if "classes" in mapping:
mapping["classes"] = _normalize_template_classes(mapping.get("classes"))
data["mapping_rules"] = mapping
return data
@@ -29,7 +54,7 @@ def _unpack_template(template: Template) -> Template:
"""Unpack mapping_rules into classes/rules for response."""
mapping = template.mapping_rules or {}
# 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", [])
return template
@@ -40,11 +65,15 @@ def _unpack_template(template: Template) -> Template:
status_code=status.HTTP_201_CREATED,
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."""
data = payload.model_dump()
data = _pack_mapping_rules(data)
template = Template(**data)
template = Template(**data, owner_user_id=current_user.id)
db.add(template)
db.commit()
db.refresh(template)
@@ -62,9 +91,16 @@ def list_templates(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[Template]:
"""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:
_unpack_template(t)
return templates
@@ -75,9 +111,16 @@ def list_templates(
response_model=TemplateOut,
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."""
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:
raise HTTPException(status_code=404, detail="Template not found")
_unpack_template(template)
@@ -93,9 +136,13 @@ def update_template(
template_id: int,
payload: TemplateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Template:
"""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:
raise HTTPException(status_code=404, detail="Template not found")
@@ -118,9 +165,16 @@ def update_template(
status_code=status.HTTP_204_NO_CONTENT,
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."""
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:
raise HTTPException(status_code=404, detail="Template not found")

View File

@@ -1,10 +1,59 @@
"""Pydantic schemas for request/response validation."""
from datetime import datetime
from typing import Optional, Any
from typing import Literal, Optional, Any
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
# ---------------------------------------------------------------------------
@@ -34,15 +83,29 @@ class ProjectUpdate(BaseModel):
parse_fps: Optional[float] = None
class ProjectCopyRequest(BaseModel):
mode: Literal["reset", "full"] = "reset"
name: Optional[str] = None
class ProjectOut(ProjectBase):
model_config = ConfigDict(from_attributes=True)
id: int
owner_user_id: Optional[int] = None
created_at: datetime
updated_at: datetime
frame_count: int = 0
class DemoFactoryResetOut(BaseModel):
admin_user: UserOut
project: ProjectOut
projects: list[ProjectOut]
deleted_counts: dict[str, int]
message: str
# ---------------------------------------------------------------------------
# Frame schemas
# ---------------------------------------------------------------------------
@@ -98,6 +161,7 @@ class TemplateOut(TemplateBase):
model_config = ConfigDict(from_attributes=True)
id: int
owner_user_id: Optional[int] = None
created_at: datetime
@@ -190,8 +254,47 @@ class PredictResponse(BaseModel):
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):
polygons: Optional[list[list[list[float]]]] = None
holes: Optional[list[list[list[list[float]]]]] = None
bbox: Optional[list[float]] = None
points: Optional[list[list[float]]] = None
labels: Optional[list[int]] = None
@@ -199,6 +302,11 @@ class PropagationSeed(BaseModel):
color: Optional[str] = None
class_metadata: Optional[dict[str, Any]] = 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):
@@ -221,6 +329,21 @@ class PropagateResponse(BaseModel):
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):
id: str
label: str

View 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)

View 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)

View File

@@ -2,6 +2,7 @@
import logging
import os
import re
import shutil
import subprocess
from pathlib import Path
@@ -16,6 +17,14 @@ from minio_client import upload_file, BUCKET_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:
"""Read the original frame rate of a video file."""
cap = cv2.VideoCapture(video_path)
@@ -150,7 +159,8 @@ def parse_dicom(
"""
os.makedirs(output_dir, exist_ok=True)
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] = []

View File

@@ -11,10 +11,11 @@ from typing import Any
from sqlalchemy.orm import Session
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 services.frame_parser import (
extract_thumbnail,
natural_filename_key,
parse_dicom,
parse_video,
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:
db.refresh(task)
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)
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)
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)
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:
_ensure_not_cancelled(db, task)
if obj.object_name.lower().endswith(".dcm"):

View 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

View File

@@ -507,7 +507,7 @@ class SAM2Engine:
if source_image is None:
raise RuntimeError("Failed to decode source frame for SAM 2 propagation.")
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():
bbox = seed.get("bbox")
if isinstance(bbox, list) and len(bbox) == 4:
@@ -543,15 +543,18 @@ class SAM2Engine:
if masks.ndim == 4:
masks = masks[:, 0]
polygons = []
holes = []
scores = []
for mask in masks:
polygon = self._mask_to_polygon(mask > 0)
if polygon:
mask_polygons, mask_holes = self._mask_to_polygon_data(mask > 0)
for polygon_index, polygon in enumerate(mask_polygons):
polygons.append(polygon)
holes.append(mask_holes[polygon_index] if polygon_index < len(mask_holes) else [])
scores.append(1.0)
results[int(out_frame_idx)] = {
"frame_index": int(out_frame_idx),
"polygons": polygons,
"holes": holes,
"scores": scores,
"object_ids": [int(obj_id) for obj_id in list(out_obj_ids)],
}
@@ -574,19 +577,49 @@ class SAM2Engine:
@staticmethod
def _mask_to_polygon(mask: np.ndarray) -> list[list[float]]:
"""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
if mask.dtype != 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]
largest = []
for cnt in contours:
if len(cnt) > len(largest):
largest = cnt
if len(largest) < 3:
return []
return [[float(pt[0][0]) / w, float(pt[0][1]) / h] for pt in largest]
if hierarchy is None:
return [], []
def contour_to_polygon(contour: np.ndarray) -> list[list[float]]:
if len(contour) < 3:
return []
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
def _dummy_polygons(w: int, h: int) -> list[list[list[float]]]:
@@ -601,11 +634,16 @@ class SAM2Engine:
]
@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
mask = np.zeros((height, width), dtype=np.uint8)
for polygon in polygons:
for polygon_index, polygon in enumerate(polygons):
if len(polygon) < 3:
continue
pts = np.array(
@@ -619,6 +657,21 @@ class SAM2Engine:
dtype=np.int32,
)
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)
@staticmethod

View File

@@ -19,7 +19,7 @@ if str(BACKEND_DIR) not in sys.path:
from database import Base, get_db # 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()
@@ -32,6 +32,7 @@ def db_session() -> Iterator[Session]:
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
session = TestingSessionLocal()
auth.ensure_default_admin(session)
try:
yield session
finally:
@@ -56,6 +57,7 @@ def app(db_session: Session) -> FastAPI:
test_app.include_router(export.router)
test_app.include_router(dashboard.router)
test_app.include_router(tasks.router)
test_app.include_router(admin.router)
@test_app.get("/health")
def health_check() -> dict[str, str]:
@@ -67,6 +69,10 @@ def app(db_session: Session) -> FastAPI:
@pytest.fixture()
def client(app: FastAPI) -> Iterator[TestClient]:
def client(app: FastAPI, db_session: Session) -> Iterator[TestClient]:
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

268
backend/tests/test_admin.py Normal file
View 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

View File

@@ -2,10 +2,11 @@ def test_login_success(client):
response = client.post("/api/auth/login", json={"username": "admin", "password": "123456"})
assert response.status_code == 200
assert response.json() == {
"token": "fake-jwt-token-for-admin",
"username": "admin",
}
body = response.json()
assert body["token"]
assert body["token_type"] == "bearer"
assert body["username"] == "admin"
assert body["user"]["username"] == "admin"
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.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

View File

@@ -110,3 +110,31 @@ def test_dashboard_overview_keeps_recent_success_tasks_in_progress_list(client,
"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

View File

@@ -1,19 +1,31 @@
import zipfile
import json
from io import BytesIO
from urllib.parse import unquote
import cv2
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):
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={
"project_id": project["id"],
"frame_index": 0,
"image_url": "frames/0.jpg",
"width": 100,
"height": 50,
"timestamp_ms": 1250.0,
"source_frame_number": 37,
}).json()
template = client.post("/api/templates", json={
"name": "Category",
@@ -113,6 +125,425 @@ def test_export_masks_uses_z_index_for_semantic_fusion(client):
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):
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/results").status_code == 404

View File

@@ -48,15 +48,37 @@ def test_upload_dicom_batch_filters_files_and_creates_project(client, monkeypatc
response = client.post(
"/api/media/upload/dicom",
files=[
("files", ("a.dcm", b"dcm", "application/dicom")),
("files", ("10.dcm", b"dcm10", "application/dicom")),
("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
data = response.json()
assert data["uploaded_count"] == 1
assert uploaded == [f"uploads/{data['project_id']}/dicom/a.dcm"]
assert data["uploaded_count"] == 3
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):
@@ -194,6 +216,155 @@ def test_parse_task_runner_registers_frames(client, db_session, monkeypatch, tmp
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):
from models import ProcessingTask
from services.media_task_runner import run_parse_media_task

View File

@@ -1,4 +1,5 @@
from models import Annotation, Frame, Mask, ProcessingTask, Project
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):
@@ -41,11 +42,37 @@ def test_project_crud_and_frames(client, monkeypatch):
assert updated.json()["name"] == "Renamed"
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}")
assert deleted.status_code == 204
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
@@ -82,10 +109,97 @@ def test_delete_project_cascades_related_records(client, db_session):
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):
assert client.get("/api/projects/999").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.post("/api/projects/999/copy", json={"mode": "reset"}).status_code == 404
assert client.post("/api/projects/999/frames", json={
"project_id": 999,
"frame_index": 0,
@@ -93,3 +207,38 @@ def test_project_and_frame_404s(client):
}).status_code == 404
assert client.get("/api/projects/999/frames").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"

View File

@@ -78,3 +78,27 @@ def test_sam2_status_exposes_selectable_variants(monkeypatch, tmp_path):
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

View File

@@ -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"
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):
project = client.post("/api/projects", json={
"name": "Done",

View File

@@ -37,3 +37,84 @@ def test_template_404s(client):
assert client.get("/api/templates/999").status_code == 404
assert client.patch("/api/templates/999", json={"name": "x"}).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)

View File

@@ -5,6 +5,7 @@ import logging
from celery_app import celery_app
from database import SessionLocal
from services.media_task_runner import run_parse_media_task
from services.propagation_task_runner import run_propagate_project_task
logger = logging.getLogger(__name__)
@@ -20,3 +21,16 @@ def parse_project_media(task_id: int) -> dict:
raise exc
finally:
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()

View File

@@ -36,23 +36,22 @@ Word 方案描述的理想系统包含:
| MinIO 对象存储 | 已落地 | `backend/minio_client.py` |
| Redis 连接 | 已落地 | 用于 Celery broker/result backend并通过 `seg:progress` pub/sub 转发任务进度 |
| 视频拆帧 | 已落地 | `backend/services/frame_parser.py``backend/routers/media.py` |
| DICOM 批量导入 | 部分落地 | 上传和解析存在,项目级体验还需完善 |
| DICOM 批量导入 | 落地 | 上传、文件名自然排序、解析任务创建和项目库解析进度回显均已接入 |
| WebSocket 进度 | 已落地 | 拆帧进度写入任务表后发布到 Redis `seg:progress`FastAPI 广播到 `/ws/progress` |
| 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 做语义融合裁决,前端预览裁决尚未落地 |
| 标注持久化 | 部分落地 | 后端有 `Annotation`前端已接入新增、回显、分类更新、当前帧删除、手工绘制、GT mask 导入、seed point 编辑、polygon 顶点拖动/删除、边中点插点和多 polygon 子区域编辑;复杂洞结构编辑未落地 |
| COCO / Mask 导出 | 已落地基础能力 | `backend/routers/export.py`COCO JSON PNG mask ZIP 前端按钮均已接入,ZIP 包含单标注 mask、语义融合 mask 和类别映射 |
| 模板库 | 部分落地 | 分类、颜色、maskid、JSON 批量导入预览和拖拽排序能存储和编辑;右侧语义分类树也可拖拽调整内部覆盖顺序PNG mask 导出时会按内部优先级做语义融合裁决,前端预览裁决尚未落地 |
| 标注持久化 | 部分落地 | 后端有 `Annotation` 表,前端已接入新增、回显、分类更新、传播链前后帧同目标同步换类、当前帧删除、手工绘制、GT mask 导入、polygon 顶点拖动/删除、边中点插点和多 polygon 子区域编辑;复杂洞结构编辑未落地 |
| 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` 作为历史实现保留;由于当前系统不给文本提示,前端不再展示 SAM 3后端 registry 也不暴露 `sam3`。官方没有 SAM 3 tiny/small 权重,当前可选最小真实 SAM 权重仍是 SAM 2.1 tiny。
- Celery 异步任务队列:已注册 Celery app 和拆帧 worker task`/api/media/parse` 会创建任务表记录并入队
- GT mask 导入:当前已支持二值/多类别 mask 导入,后端会按非零像素值拆分区域,生成 polygon 标注和距离变换 seed point骨架提取、HDBSCAN 和模板自动映射尚未实现。
- Mask 到点区域的拓扑降维:当前完成 distance transform seed point 和前端 seed point 拖拽编辑骨架提取、HDBSCAN 等增强尚未实现。
- 类别优先级融合PNG mask 导出时已按 zIndex 生成语义融合 mask前端裁决预览尚未实现。
- GT mask 导入:当前仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图导入,后端会按 maskid 拆分区域,生成高精度 polygon 标注;超出现有类别的 maskid 可舍弃或导入为黑色 `maskid:0` 的“待分类”16-bit/uint16 GT_label 和普通彩色类别图会被拒绝尺寸不一致会自动最近邻拉伸到当前帧骨架提取、HDBSCAN 和更复杂的模板自动映射尚未实现
- Mask 到点区域的拓扑降维:后端保留 distance transform seed point 数据兼容;前端不再显示黄色 seed point也不提供 seed point 拖拽编辑骨架提取、HDBSCAN 等增强尚未实现。
- 类别优先级融合PNG 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 子区域编辑、可区域合并/去除、可用可选 SAM 2.1 做点/框 AI 推理、可对点/框 prompt 做裁剪推理和背景过滤、可用 SAM 2.1 进行视频片段传播、可导入多类别 GT mask、可编辑 seed point、可保存标注、可导出 COCO/语义 mask ZIP、可查看 Dashboard 后端概览”的全栈雏形。下一阶段最重要的是继续补齐复杂洞结构编辑、GT mask 骨架/聚类增强和传播任务异步化
当前项目已经从 UI 原型推进到“可上传、可异步拆帧、可取消/重试任务、可查看失败详情、可实时查看任务进度、可浏览项目帧、可维护模板、可手工绘制、可逐点编辑 polygon、可边中点插点、可多 polygon 子区域编辑、可区域合并/去除、可用可选 SAM 2.1 做点/框 AI 推理、可对点/框 prompt 做裁剪推理和背景过滤、可用 SAM 2.1 后台任务进行视频片段传播、可导入多类别 GT mask、可保存标注、可导出 COCO/语义 mask ZIP、可查看 Dashboard 后端概览”的全栈雏形。下一阶段最重要的是继续补齐复杂洞结构编辑、GT mask 骨架/聚类增强和前端语义融合预览

View File

@@ -9,7 +9,7 @@
- 前端服务:`server.ts`
- 默认访问:`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。
- 测试 Redis。
- Seed 默认模板。
- 如果存在 `Data_MyVideo_1.mp4`,创建默认项目并拆前 100 帧
- 如果存在 `demo/演视LC视频序列.mp4``demo/演视DICOM序列/`创建名为“演视LC视频序列”的默认演示视频项目和名为“演视DICOM序列”的演示 DICOM 项目,视频和 DICOM 都会生成帧DICOM 按文件名自然顺序读取;启动时会把旧显示名 `Data_MyVideo_1` / `演示DICOM序列` 迁移为新显示名
## 前端模块切换
`App.tsx` 使用 Zustand 中的 `activeModule` 做模块切换,没有使用路由库。
`useStore` 默认 `activeModule``dashboard`,因此用户登录后默认进入“总体概况”页。
| activeModule | 组件 | 页面 |
|--------------|------|------|
@@ -37,6 +38,7 @@
| `workspace` | `VideoWorkspace` | 分割工作区 |
| `ai` | `AISegmentation` | AI 智能分割页 |
| `templates` | `TemplateRegistry` | 模板库 |
| `admin` | `UserAdmin` | 管理员用户后台,仅 `role=admin` 可见 |
未登录时,`App.tsx` 直接渲染 `Login`
@@ -44,14 +46,14 @@
全局状态在 `src/store/useStore.ts` 中,主要包括:
- 登录状态:`isAuthenticated``token`
- 登录状态:`isAuthenticated``token``currentUser`
- 项目:`projects``currentProject`
- 工作区:`activeModule``activeTool``frames``currentFrameIndex`
- 标注与 mask`annotations``masks`
- 模板:`templates``activeTemplateId`
- UI`isLoading``error`
当前状态管理是前端内存状态,没有持久化到 localStorage,除了登录 token
当前状态管理主要是前端内存状态;登录 token 会持久化到 `localStorage`,刷新后再通过 `/api/auth/me` 恢复当前用户
## 数据流
@@ -59,18 +61,28 @@
1. `Login.tsx` 调用 `login()`
2. `src/lib/api.ts` 请求 `POST /api/auth/login`
3. FastAPI `backend/routers/auth.py` 校验 `admin / 123456`
4. 前端把返回 token 写入 localStorage。
3. FastAPI `backend/routers/auth.py` 查询 `users` 表并校验密码哈希
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()` 获取项目。
2. 上传视频时先 `createProject()`,再 `uploadMedia()`;导入视频不自动调用 `parseMedia()`
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` 拆帧。
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}` 查看失败详情。
### 工作区浏览
@@ -100,6 +112,6 @@
- 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。
- 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。任务取消、重试和失败详情已接入前后端。
- 后端路由大多未做真实鉴权
- 后端已接入 Bearer JWT 鉴权、共享项目库和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求默认 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展

View File

@@ -12,24 +12,37 @@
| 元素 | 位置 | 状态 | 说明 |
|------|------|------|------|
| 登录拦截 | `App.tsx` | 真实可用 | 未登录显示 `Login`,登录后显示主界面 |
| 模块切换 | `Sidebar.tsx` + `App.tsx` | 真实可用 | 切换 `dashboard/projects/workspace/ai/templates` |
| Logo | `Sidebar.tsx` | 真实可用 | 使用 `/logo.png`,文件存在于 `public/logo.png` |
| 模块切换 | `Sidebar.tsx` + `App.tsx` | 真实可用 | 切换 `dashboard/projects/workspace/ai/templates`“AI智能分割”入口使用 Bot + Sparkles 组合图标,强化 AI 语义 |
| Logo | `Login.tsx` / `Sidebar.tsx` | 真实可用 | 登录页、侧边栏和 favicon 都使用 `public/logo.png`,运行时访问路径为 `/logo.png` |
| GPU 状态圆标 | `Sidebar.tsx` | 真实可用 | 通过 `GET /api/ai/models/status` 显示 GPU/CPU 和当前模型可用性 |
## 登录页
| 元素 | 状态 | 说明 |
|------|------|------|
| 用户名/密码输入 | 真实可用 | 默认填入 `admin / 123456` |
| 安全登录按钮 | 真实可用 | 调用 `POST /api/auth/login` |
| 用户名/密码输入 | 真实可用 | 默认填入 `admin / 123456`,用户名使用 `autocomplete=username`,密码使用 `autocomplete=current-password` |
| 安全登录按钮 | 真实可用 | 调用 `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 系统概况
| 元素 | 状态 | 说明 |
|------|------|------|
| 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/success/failed/cancelled 任务生成;统计卡片中的处理中任务数只计算 queued/running |
| 任务取消 | 真实可用 | queued/running 任务显示取消按钮,调用 `POST /api/tasks/{task_id}/cancel` |
| 任务重试 | 真实可用 | failed/cancelled 任务显示重试按钮,调用 `POST /api/tasks/{task_id}/retry` 创建新任务 |
@@ -45,72 +58,80 @@
| 项目列表 | 真实可用 | 调用 `GET /api/projects` |
| 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 |
| 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` |
| 新建项目 | 真实可用 | 调用 `POST /api/projects` |
| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧 |
| 生成帧按钮 | 真实可用 | 对已导入源视频且尚无帧、非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })` |
| 新建项目 | 已移除入口 | 项目库不再展示独立“新建项目”按钮;导入视频/DICOM 时自动创建项目,后端 `POST /api/projects` 保留给导入流程和兼容调用 |
| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧;上传期间显示项目库导入进度条、百分比和已上传字节 |
| 生成帧/重新生成帧按钮 | 真实可用 | 对已导入源视频且非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })`;已有帧时显示“重新生成帧”,后端会先清空旧帧、标注和 mask任务入队后项目库继续轮询 `GET /api/tasks/{task_id}`,解析成功后立即重新拉取项目列表,使后端新写入的 `thumbnail_url` 自动刷新到项目封面 |
| 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS |
| 导入 DICOM 序列 | 部分可用 | 可上传 `.dcm` 并触发解析;体验和错误反馈较粗 |
| 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 |
| 导入 DICOM 序列 | 真实可用 | 可上传 `.dcm` 并触发解析;上传前按文件名自然顺序排序,后端解析也保持同一顺序;上传期间显示导入进度条、有效 DICOM 文件数量和已上传字节,上传完成后继续显示解析任务进度直到完成、失败或取消 |
| 项目状态徽标 | 真实可用 | 项目状态统一为 `pending/parsing/ready/error`,前端兼容归一化旧状态值 |
| 删除项目按钮 | 真实可用 | 点击垃圾桶按钮会确认删除,调用 `DELETE /api/projects/{id}`成功后从项目库移除若删除的是当前项目会清空工作区当前项目、帧、mask 和选区 |
| alert 成功/失败提示 | 真实可用但粗糙 | 使用浏览器 `alert` |
| 操作成功/失败提示 | 真实可用 | 使用非阻塞 `TransientNotice` 浮层,自动消失,不会拦截后续按钮、输入框或画布操作 |
## 工作区 VideoWorkspace
| 元素 | 状态 | 说明 |
|------|------|------|
| 当前项目名 | 真实可用 | 读取 `currentProject.name` |
| 顶栏操作提示 | 真实可用 | 保存、导出、传播范围选择等短反馈会自动消失;保存/导出/传播进行中和无帧项目提示会保留到状态变化 |
| 自动加载项目帧 | 真实可用 | 调用 `GET /api/projects/{id}/frames` |
| 无帧项目提示 | 真实可用 | 如果 `video_path` 存在但无帧,只提示回到项目库生成帧,不自动创建拆帧任务 |
| SAM 模型状态徽标 | 真实可用 | 调用 `GET /api/ai/models/status`,显示当前启用的 SAM 2 与 GPU 状态 |
| SAM 模型状态徽标 | 真实可用 | 左侧 Sidebar 底部保留紧凑 GPU/CPU 状态徽标;工作区顶栏不再重复显示该徽标,传播权重下拉和自动传播范围摘要只在进入自动传播后显示 |
| 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask回显时保留当前项目帧里尚未保存的 AI/手工 draft mask避免从 AI 页推送的候选被覆盖 |
| “导出 JSON 标注集”按钮 | 真实可用 | 导出前会保存未归档 mask然后调用 `exportCoco()` 下载 JSON |
| “导出 PNG Mask ZIP”按钮 | 真实可用 | 导出前会保存未归档 mask后调用 `GET /api/export/{project_id}/masks` 下载 ZIP后端同时包含单标注 mask、每帧语义融合 mask 和 `semantic_classes.json` |
| “导入 GT Mask”按钮 | 真实可用 | 选择图片后调用 `POST /api/ai/import-gt-mask`,后端按非零像素值和连通域生成 polygon 标注与距离变换 seed point再回显到工作区 |
| 传播对象/起止帧/按范围传播 | 真实可用 | 可选择“选中区域”或“当前帧全部”,并用起止帧定义从当前帧向前/向后的追踪范围;前端会按 seed mask 和方向顺序调用 `POST /api/ai/propagate`,当前启用 SAM 2 video predictor完成后刷新已保存标注 |
| “传播全部可达”按钮 | 真实可用 | 一键把传播范围设为项目第 1 帧到最后 1 帧,并按当前传播对象把当前帧区域向前后所有可达帧传播 |
| “结构化归档保存”按钮 | 真实可用 | 未保存 mask 写入 `POST /api/ai/annotate`dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask避免仍显示未保存 |
| “分割结果导出”按钮 | 真实可用 | 原“导出 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` 的“待分类”与背景同为 0Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 |
| “导入 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并共用拓扑锚点统计、边缘平滑、编辑、分类和保存链路 |
| 参考帧/起止帧/传播权重/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 |
| 清空片段遮罩 | 已移除 | 顶栏不再提供重复的“清空片段遮罩”;当前帧清空和 DEL 删除只从左侧工具栏或键盘触发,存在传播链时在同一弹窗提供取消/只清当前帧/按帧范围选择/清空所有传播帧 |
| 保存状态按钮 | 真实可用 | 顶栏按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;未保存 mask 写入 `POST /api/ai/annotate`dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask避免仍显示未保存 |
## CanvasArea 画布
| 元素 | 状态 | 说明 |
|------|------|------|
| 当前帧底图显示 | 真实可用 | `useImage(frameUrl)` 加载当前帧 URL |
| 当前帧底图显示 | 真实可用 | `useImage(frameUrl)` 加载当前帧 URL;切换帧或容器尺寸变化时会按 86% 适配比例居中放大显示,默认留出画布边距,不铺满整个画布 |
| 滚轮缩放 | 真实可用 | 改变 Konva Stage scale |
| 拖拽平移 | 真实可用 | activeTool 为 `move` 时 Stage draggable |
| 拖拽平移 | 真实可用 | activeTool 为 `move` 时 Stage draggable,拖拽结束会回写 React position state避免 Konva 节点位置和前端状态脱节 |
| 光标坐标显示 | 真实可用 | 根据 pointer position 计算 |
| 正向/反向选点 | 真实可用 | UI 能加点,并按当前帧 `frame.id` 调用 `/api/ai/predict`;结果需点击归档保存才持久化 |
| 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 |
| AI 推理中提示 | 真实可用 | 请求期间会显示 |
| 手工多边形/矩形/圆/点/线 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆/线拖拽生成 polygon点工具生成小区域;绘制工具可在已有 mask 上继续落点;均写入 `Mask.segmentation`,可归档保存 |
| Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染 |
| Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;拖动顶点会重算 `pathData/segmentation/bbox/area`,已保存 mask 标为 dirty选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask已保存 mask 会同步调用后端删除 |
| GT seed point 回显/编辑 | 真实可用 | 已保存标注的 `points` 会显示为黄色 seed 点;拖动后标记为 dirty归档保存会更新后端 |
| 应用分类 | 真实可用 | Canvas 右下角按钮可将当前选择的模板分类应用到本帧 mask右侧语义分类树点击分类时会优先改当前已选 mask已保存 mask 会标为 dirty归档保存时更新后端 |
| 清空遮罩 | 真实可用 | 工作区中会删除当前帧已保存标注并清空当前帧本地 mask |
| 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 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` 渲染;普通 mask 和导入 mask 都不显示黄色 seed point未选中特定 mask 时,当前帧 mask 会按右侧“语义分类树”拖拽得到的内部覆盖优先级从低到高渲染,使高优先级类别显示在上层;有选中 mask 时保留编辑态置顶行为,方便操作 |
| Mask 透明度 | 真实可用 | 右侧语义分类树上方的“遮罩透明度”滑杆写入全局 `maskPreviewOpacity`,工作区 Canvas 和 AI 智能分割页都会使用该值调整 mask 预览透明度,选中 mask 会在该基础上略微加亮 |
| 传播链跨帧选区跟随 | 真实可用 | 用户选中某个 mask 后切到同一自动传播结果覆盖的其他帧时,`CanvasArea` 会根据 `source_annotation_id``source_mask_id``propagation_seed_key` 查找目标帧对应传播 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 工具栏
| 元素 | 状态 | 说明 |
|------|------|------|
| 工具分组分隔线 | 真实可用 | 拖拽/选择到创建圆为绘制/基础编辑组,画笔/橡皮擦/AI自动推理为局部与追踪组区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask 和 AI 智能分割为外部动作组;组间使用浅灰横线分隔,`data-testid="tool-group-separator"` 位于清空遮罩下方的外部动作组分隔线 |
| 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 |
| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持拖动顶点、点击边中点插点、双击边界按位置插点 |
| 多边形/矩形/圆/点/线 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成可保存的 polygon mask |
| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask右下角显示已选数量和操作按钮合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;使用 `polygon-clipping` 做 union / difference合并会保留主 mask 并移除被合并 mask去除会从主 mask 扣除后续选中 mask内含扣除会保留 hole ring 并用 even-odd 规则渲染 |
| 正向选点/反向选点/框选 | 部分可用 | 会影响 Canvas 交互,并能触发已对齐的 AI 推理接口;点击工作区内已有 SAM 提示点会优先删除该提示点并重新推理,不会冒泡成新增提示点或 mask 选择 |
| 魔法棒 SAM 触发 | 部分可用 | 切到 AI 页面;不是直接执行推理 |
| 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`支持工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y |
| 取消选中 | 真实可用 | 位于拖拽/选择按钮下方,实体按钮等同 `Esc`:清空当前 mask 选区、临时绘制点/笔触和顶点选择,不删除 mask、不清空 active class |
| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持按住顶点直接拖动、点击边中点插点、双击边界按位置插点 |
| 多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成或编辑可保存的 polygon mask画笔/橡皮擦在工具栏显示尺寸滑杆 |
| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask右下角显示已选数量和操作按钮合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;布尔选择态中第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,避免主区域和扣除区域看起来像随机阴影差异;使用 `polygon-clipping` 做 union / difference若选中的主区域和参与区域存在传播帧对应 mask会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择按帧范围选择会进入和传播一致的时间轴范围选择点击顶栏确认后再弹最终确认只处理范围内存在对应传播链的帧合并会保留主 mask 并移除被合并 mask且移除次级 mask 时会同步删除其同链自动传播结果;去除会从主 mask 扣除后续选中 mask内含扣除会保留 hole ring 并用 even-odd 规则渲染 |
| 导入 GT Mask | 真实可用 | 位于“重叠区域去除”之后,点击后打开文件选择器,并在上传前选择未知类别处理策略;该入口不切换 activeTool |
| 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 时间轴
| 元素 | 状态 | 说明 |
|------|------|------|
| 帧缩略图 | 真实可用 | 使用 `frames[].url` |
| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)` |
| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 |
| 顶部 range 拖动 | 真实可用 | 改变当前帧 |
| 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` |
| 已编辑帧进度线 | 真实可用 | 根据当前项目帧内的 `masks` 计算有编辑/标注的帧,并在顶部进度条上覆盖琥珀色竖线;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达,点击已编辑竖线可跳转到对应帧 |
| 播放进度条 / 视频处理进度 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;当前帧位置用一条白色竖线贯穿两条进度条,避免和青色播放进度、红/蓝处理状态混淆;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `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 |
| 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 |
@@ -118,33 +139,36 @@
| 元素 | 状态 | 说明 |
|------|------|------|
| 模板选择 | 部分可用 | 读取全局 templates可切换 activeTemplateId |
| 分类树展示 / 换标签 | 真实可用 | 显示模板 classes 和本地 customClasses点击分类会设为后续新 mask 的 activeClass如果 Canvas 已选 mask则同步更新已选 mask 的标签、颜色和 class 元数据 |
| 添加自定义分类 | 部分可用 | 只存在组件本地状态,不保存到后端 |
| 置信度条 | Mock / UI-only | 固定 `0.9412` |
| 拓扑锚点数量 | Mock / UI-only | 固定 `12 节点` |
| 重新提取骨架按钮 | Mock / UI-only | 无事件 |
| 模板选择 | 真实可用 | 读取全局 templates可切换 activeTemplateId并会驱动分类树、mask 分类和导出类别信息 |
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`默认低对比融入深色侧栏hover/focus 时才增强显示 |
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes点击分类会设为后续新 mask 的 activeClass如果 Canvas 无选中 mask则不会改变已有 mask如果 Canvas 已选 mask则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class并滚动/聚焦到对应分类按钮 |
| 添加自定义分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store |
| 目标实例属性标题 | 真实可用 | “特定目标实例属性追踪”下方显示当前选中 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 页
| 元素 | 状态 | 说明 |
|------|------|------|
| SAM 2.1 变体选择 / 模型状态 | 真实可用 | AI 页可选 tiny/small/base+/large调用 `GET /api/ai/models/status?selected_model=<variant>` 展示所选变体和 GPU 状态;只有本地存在 checkpoint 的变体显示可用 |
| SAM 2.1 变体选择 / 模型状态 | 真实可用 | AI 页可选 tiny/small/base+/large调用 `GET /api/ai/models/status?selected_model=<variant>` 展示所选变体和 GPU 状态;只有本地存在 checkpoint 且 PyTorch/SAM2 依赖可用的变体显示可用,不可用变体不能点击,执行按钮显示“当前模型不可用”并阻止推理请求 |
| 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口AI 页中点击已有候选 mask 时也会继续添加当前正/反向提示点点击已有提示点会删除该点SAM 2.1 框选后会携带原始框和累计正/反点细化同一个候选 mask |
| 边界框选 | 真实可用 | AI 页选择工具后可在画布拖拽蓝色虚线框;执行分割时会随 `/api/ai/predict` 发送 `box`,框选后继续添加正/反点会发送 interactive prompt |
| AI 画布上下文提示 | 真实可用 | 选择正向点、反向点、边界框选或视口控制时,画布左上角提示点击/拖拽、删除提示点和执行推理的操作方式 |
| SAM 3 入口 | 当前禁用 | 因当前系统不提供文本提示,前端不再显示 SAM 3 模型选择、文本输入或 SAM 3 框选入口;后端 `model=sam3` 返回不支持 |
| 语义文本输入 | 当前禁用 | AI 页不再提供文本语义输入;后端收到 `semantic` prompt 会返回 400 |
| 参数开关 | 真实可用 | UI 展示为“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”,只是为了让用户更容易理解,不重命名内部字段;`cropMode` 会随 `/api/ai/predict` 发送 `crop_to_prompt`,后端对点/框 prompt 裁剪推理区域并回映射 polygon`autoDeleteBg` 会发送 `auto_filter_background``min_score`,后端过滤低分结果和覆盖负向点的结果 |
| 遮罩清晰度 | 真实可用 | 调节 AI 页候选 mask 的预览透明度,只影响本页显示,不改变 mask 几何、分类或保存数据 |
| AI 遮罩透明度 | 真实可用 | 调节共享的 `maskPreviewOpacity`AI 页候选 mask 和右侧“遮罩透明度”滑杆联动,只影响预览显示,不改变 mask 几何、分类或保存数据 |
| 执行高精度语义分割 | 真实可用 | 使用当前项目帧和所选 SAM 2.1 变体调用 `/api/ai/predict`SAM 2.1 需要点/框提示且只采用最高分候选AI 页只渲染本页最新候选,不显示工作区已有 mask重复执行会替换上一次 AI 页候选而不是叠加;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 |
| 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的未保存 mask工作区回显后端标注时不会覆盖这类 draft mask |
| 上传替换底图 | Mock / UI-only | 按钮无事件 |
| 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的未保存 mask 和当前帧视角;推送前会校验当前 AI 候选 mask 必须已有 `classId``className`,未选择语义分类时会用右上角 error toast 提示用户先点右侧语义分类树,不允许进入工作区;如果用户直接离开 AI 页,未分类 AI 候选会被清理,避免无语义 mask 进入工作区;工作区回显后端标注时不会覆盖这类 draft mask,也不会强制跳回第一帧 |
| 撤销/重做 | 真实可用 | 绑定全局 mask 历史栈 |
| 删除最近锚点 | 真实可用 | 删除 AI 页最近一次放置的正/反向提示点,不影响已生成候选 mask 或工作区 mask |
| 删除选中候选 | 真实可用 | 删除 AI 页当前选中的本页候选 mask不会删除工作区已有 maskDelete/Backspace 也遵循同一范围 |
| 清空全体锚点 | 真实可用 | 清空 AI 页提示点和本页生成的候选 mask不删除工作区已有 mask |
| 退档推送至工作区重组 | 部分可用 | 只切回工作区,共用 masks store但没有保存/确认流程 |
| 背景图 | 部分可用 | 优先显示当前项目帧;没有项目帧时仍回退到 Unsplash 演示图 |
| 背景图 / 空状态 | 真实可用 | 优先显示当前项目帧;没有项目帧时显示空状态提示,不再回退到外部演示图片 |
| AI 画布初始视图 | 真实可用 | 当前帧在 AI 画布中默认居中,并按 86% 适配比例尽量放大但保留边距 |
## TemplateRegistry 模板库
@@ -155,13 +179,12 @@
| 编辑模板 | 真实可用 | 调用 `PATCH /api/templates/{id}` |
| 删除模板 | 真实可用 | 调用 `DELETE /api/templates/{id}` |
| 添加/删除分类 | 真实可用 | 保存在模板 `mapping_rules.classes` |
| 拖拽排序 | 真实可用 | 重算 zIndex保存时写后端 |
| JSON 批量导入 | 部分可用 | 前端解析 JSON 并加入编辑态,保存后才落库 |
| 载入腹腔镜 35 分类 | 真实可用 | 前端内置数据;后端也 seed 默认模板 |
| mapping rules | 部分可用 | 可存 `rules`,但无实际映射执行引擎 |
| 拖拽排序 | 真实可用 | 模板库详情页、模板编辑弹窗和工作区右侧语义分类树都可拖拽调整内部覆盖优先级,保存时写后端;模板库详情页拖拽会刷新当前详情并同步当前工作区同类 mask 的 `classZIndex`,工作区拖拽也会同步当前同类 mask 的 `classZIndex` 并标记待保存;界面只显示类别稳定 maskidmaskid 不作为排序规范;黑色 `maskid: 0` 的“待分类”保留类固定在最后,不可删除或拖拽上移 |
| JSON 批量导入 | 真实可用 | 前端解析 `[[colors], [names]]``{colors, names}` 两种格式,并兼容带前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容显示导入数量、maskid 起点和缺失颜色提示;导入后加入编辑态,保存模板时落库 |
| 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 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单和若干检查面板指标仍未落地
当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看

View File

@@ -15,39 +15,43 @@ timeout: 30000
Authorization: Bearer <token>
```
当前后端多数接口没有鉴权依赖,所以这个 header 主要是前端侧行为
当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,所有登录用户可读取,`admin/annotator` 可写入
## 前端封装的 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` 等字段 |
| `createProject(payload)` | `POST /api/projects` | 对齐 | 支持 `name``description``parse_fps` |
| `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 |
| `createTemplate(payload)` | `POST /api/templates` | 对齐 | 后端会打包 classes/rules 到 mapping_rules |
| `updateTemplate(id, payload)` | `PATCH /api/templates/{id}` | 对齐 | 模板编辑页使用 |
| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 |
| `uploadMedia(file, projectId)` | `POST /api/media/upload` | 对齐 | multipart form-data |
| `uploadDicomBatch(files, projectId)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data |
| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task由项目库“生成帧”显式调用,支持 `parse_fps``max_frames``target_width` |
| `uploadMedia(file, projectId, options?)` | `POST /api/media/upload` | 对齐 | multipart form-data`options.onProgress` 用于项目库上传进度 |
| `uploadDicomBatch(files, projectId, options?)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data`options.onProgress` 用于项目库上传进度,上传完成后项目库轮询解析任务进度 |
| `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}` | 对齐 | 查询异步任务状态 |
| `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery |
| `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` |
| `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 当前帧 seed mask 向视频片段传播,并保存后续帧标注 |
| `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | seed 同步传播接口,供后端兼容和测试使用 |
| `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` | 对齐 | 前端加载工作区时用于回显已保存标注 |
| `saveAnnotation(payload)` | `POST /api/ai/annotate` | 对齐 | 工作区归档保存当前项目未保存 mask |
| `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask |
| `deleteAnnotation(annotationId)` | `DELETE /api/ai/annotations/{annotation_id}` | 对齐 | 工作区清空当前帧已保存标注 |
| `importGtMask(file, projectId, frameId, templateId?)` | `POST /api/ai/import-gt-mask` | 对齐 | multipart 上传 GT mask,后端按非零像素值/连通域生成 polygon 标注 seed point |
| `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask;保存链路会先预检后端标注 id已知缺失则直接用同一几何和 metadata 调用 `saveAnnotation()` 重新创建;预检后仍遇到 404 时也会重新创建并回显替换本地旧 id |
| `deleteAnnotation(annotationId)` | `DELETE /api/ai/annotations/{annotation_id}` | 对齐 | 工作区清空当前帧、关联传播帧、DEL/键盘删除和切换激活模板时删除已保存标注;批量删除前会先读取当前项目 annotation 列表,跳过本地陈旧 id避免重复 DELETE 产生 404 |
| `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 的 maskid16-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 初始统计、队列和活动日志 |
| `exportCoco(projectId)` | `GET /api/export/{projectId}/coco` | 对齐 | 后端实际是 `GET /api/export/{project_id}/coco` |
| `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 接口
@@ -56,6 +60,10 @@ Authorization: Bearer <token>
| 方法 | 路径 | 用途 |
|------|------|------|
| 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` | 创建项目 |
| GET | `/api/projects` | 项目列表 |
| GET | `/api/projects/{project_id}` | 项目详情 |
@@ -77,7 +85,9 @@ Authorization: Bearer <token>
| POST | `/api/tasks/{task_id}/cancel` | 取消后台任务 |
| POST | `/api/tasks/{task_id}/retry` | 重试失败或取消的后台任务 |
| POST | `/api/ai/predict` | 当前启用 SAM 2 点/框/interactive 推理 |
| POST | `/api/ai/propagate` | 当前启用 SAM 2 视频片段传播并保存标注 |
| 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 模型状态 |
| POST | `/api/ai/auto` | 自动分割 |
| POST | `/api/ai/annotate` | 保存 AI 标注 |
@@ -88,6 +98,7 @@ Authorization: Bearer <token>
| GET | `/api/dashboard/overview` | Dashboard 聚合快照 |
| GET | `/api/export/{project_id}/coco` | 导出 COCO JSON |
| 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` | 健康检查 |
| 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": "胆囊",
"color": "#ffae00",
"zIndex": 280,
"maskId": 1,
"category": "腹腔镜胆囊切除术"
}
],
@@ -228,7 +240,7 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask避免同
### 视频片段传播请求体
后端接口仍以单个 seed 为单位。工作区前端的“按范围传播/传播全部可达”会在本地根据当前帧、起止帧和传播对象,把多个 seed 或前后双向范围拆成多次顺序调用,避免同时启动多个视频 tracker。
`POST /api/ai/propagate` 仍是单 seed 同步接口。工作区实际使用 `POST /api/ai/propagate/task`:当前打开帧作为参考帧,该帧全部 mask 作为 seed用户设置传播起始帧和传播结束帧后前端会在本地把多个 seed 或前后双向范围拆成 `steps`,一次提交为 `propagate_masks` 后台任务,避免长 HTTP 请求和多个视频 tracker 并发抢占 GPU
单次调用示例:
@@ -246,13 +258,39 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask避免同
"bbox": [0.1, 0.1, 0.2, 0.2],
"label": "胆囊",
"color": "#ff0000",
"class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20},
"template_id": 2
"class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20, "maskId": 1},
"template_id": 2,
"source_instance_id": "instance-123"
}
}
```
SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source``<model_id>_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 进度流显示该任务。
## 已完成的接口对齐
@@ -264,20 +302,22 @@ SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2`
- `saveAnnotation()` 已接入 `POST /api/ai/annotate`
- `getProjectAnnotations()` 已接入 `GET /api/ai/annotations`
- `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}`
- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的 polygon 标注、原始 `gt_label_value` seed point。
- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`;工作区批量删除前会先用 `GET /api/ai/annotations` 预检存在的 id跳过本地陈旧 id
- `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`
- `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}`
- `cancelTask()` 已接入 `POST /api/tasks/{taskId}/cancel`
- `retryTask()` 已接入 `POST /api/tasks/{taskId}/retry`
- `getDashboardOverview()` 已从 `processing_tasks` 聚合解析队列。
- 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 裁决。
- 统一导出 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` 推导;部署时仍要确认浏览器能访问该地址。
- 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` 支撑前端把外圈/内洞重新组合成可编辑结构

View File

@@ -26,7 +26,7 @@
已完成:
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`
4. 工作区加载帧后调用 `GET /api/ai/annotations` 回显已保存标注。
5. 工作区“清空遮罩”调用 `DELETE /api/ai/annotations/{annotation_id}` 删除当前帧已保存标注。
@@ -37,15 +37,16 @@
2. 逐点几何编辑器已支持拖动/删除顶点、边中点插入新点和多 polygon 子区域编辑;后续增强为复杂洞结构编辑。
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`
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 的解析队列现在已经从“项目状态派生”升级为任务
## 阶段 6GT 导入与点区域(已完成基础增强版)
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 接口。
3. 后端按非零像素值拆分多类别 mask。
4. 后端使用 OpenCV contour 提取每个类别下的连通域。
5. 后端使用 distance transform 生成 `points` seed。
4. 后端使用 OpenCV 高精度 contour 提取每个类别下的连通域,尽量保留边界细节,并用点数上限保护前端性能
5. 后端保留 distance transform `points` seed 供数据兼容
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
2. 导出 mask 时按 zIndex 从低到高覆盖。
1. 标注保存时记录 template class id / name / maskid并保留内部覆盖优先级
2. 导出 mask 时按内部优先级从低到高覆盖。
3. 同类语义值在融合图中共享同一个 class value。
4. 跨类重叠由高 zIndex 覆盖低 zIndex
4. 跨类重叠由更高内部优先级覆盖更低内部优先级maskid 不作为排序规范
剩余建议:
@@ -129,7 +130,7 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
## 阶段 7.5:视频片段传播(已完成基础闭环)
当前工作区传播功能会使用当前帧的选中 mask 或当前帧全部 mask 作为 seed按用户设置的起止帧向前、向后或双向传播,并把结果写入后端标注表。“传播全部可达”会把范围设置为项目第 1 帧到最后 1 帧
当前工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed按用户设置的传播起始帧和传播结束帧向前、向后或双向传播,并把结果写入后端标注表。前端只保留一个“自动传播”按钮,减少传播对象选择带来的歧义
已完成:
@@ -138,7 +139,7 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
3. SAM 2 路径使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()`
4. SAM 3 video tracker 路径已从当前产品入口禁用,相关 helper 仅保留作后续恢复参考。
5. 后端会跳过源帧,把传播结果保存到后续帧 `annotations`,并在完成后由前端刷新回显。
6. 前端已经支持传播对象选择、起止帧范围和“传播全部可达”;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。
6. 前端已经支持参考帧、起止帧范围和单按钮自动传播;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。
剩余建议:
@@ -153,4 +154,4 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前
- SAM/GPU 状态已改为 `GET /api/ai/models/status` 驱动。
- 撤销/重做按钮已接全局 mask 历史栈。
- “重新提取内侧中轴树骨架”接真实接口,否则标为未实现。
- AI 独立页不要固定 Unsplash 图,应从当前项目帧或上传文件进入
- AI 独立页已移除固定 Unsplash 演示图;没有当前项目帧时显示空状态。后续如果要支持独立图片分析,应接正式上传入口和项目/帧关联

View File

@@ -7,21 +7,30 @@
## R1 登录与会话
- 系统提供登录页。
- 默认开发凭证为 `admin / 123456`
- 登录成功后前端保存 token,并进入主应用。
- 默认开发管理员为启动时种子化的 `admin / 123456`,密码以哈希形式存入 `users`
- 登录成功后前端保存签名 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 项目管理
- 前端展示项目库,并从 `GET /api/projects` 获取项目列表。
- 用户可以新建项目,前端调用 `POST /api/projects`
- 项目库不提供独立“新建项目”按钮;导入视频或 DICOM 时由前端/后端自动创建项目,后端仍保留 `POST /api/projects` 供导入流程和兼容接口使用
- 用户可以选择项目,进入工作区。
- 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。
- 用户可以对已导入且尚未生成帧的视频项目点击“生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务。
- 用户可以对已导入源视频的视频项目点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;如果项目已有帧,重新生成前必须清空该项目现有帧、标注和 mask避免重复帧序列项目名称编辑状态下不能显示/触发生成帧入口DICOM 项目不能显示生成帧入口
- 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。
- 用户可以在项目库项目卡片上修改项目名称,名称不能为空。
- 用户可以在项目卡片删除按钮旁复制项目;复制时可选择“新项目重置”或“全内容复制”。新项目重置必须复制项目媒体字段和已生成帧序列,但不复制标注或 mask 元数据;全内容复制必须额外复制标注和关联 mask 元数据,并将复制标注重新指向新项目中的对应帧。任务运行历史不复制。
- 用户可以在项目卡片上删除项目;前端调用 `DELETE /api/projects/{id}`删除成功后从项目库移除若删除当前项目则清空工作区当前项目、帧、mask 和选区。
- 后端支持项目创建、列表、详情、局部更新和删除。
- 后端支持项目创建、列表、详情、局部更新、复制和删除。
- 后端删除项目时通过 ORM 级联删除项目帧、标注、导出 mask 元数据和后台任务记录。
- 后端支持项目帧创建、列表和单帧查询。
@@ -30,9 +39,11 @@
- 后端允许上传视频、图片、DICOM 文件,其他扩展名返回 400。
- 未提供项目 ID 上传时,后端自动创建项目。
- 提供项目 ID 上传时,后端把上传对象关联到该项目。
- 项目库导入视频和导入 DICOM 序列时,前端必须显示导入进度条;浏览器提供上传总字节数时显示百分比和已上传/总字节数未提供总字节数时显示已上传字节的非确定进度。DICOM 导入还必须显示本次有效 `.dcm` 文件数量,并在上传完成后持续显示解析任务进度,直到成功、失败或取消。
- 拆帧接口根据项目 `source_type` 处理视频或 DICOM。
- 拆帧接口支持 `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`
- 每条帧记录包含 `frame_index``image_url``width``height``timestamp_ms``source_frame_number`
- 任务完成结果包含 `frame_sequence` 元数据:`original_fps``parse_fps``frame_count``duration_ms``target_width`、帧宽高和对象存储前缀。
@@ -46,31 +57,45 @@
- 工作区根据当前项目加载帧列表。
- 若项目有媒体但无帧,工作区只提示需要先在项目库生成帧,不再自动触发拆帧。
- 工作区加载帧时必须获取项目完整帧序列,不能被接口默认分页截断到 1000 帧。
- 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`,格式为 `mm:ss.cc`
- 时间轴顶部进度条上覆盖琥珀色竖线标记,基于当前项目帧内的 `masks` 标出已有编辑/标注的帧;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达,点击已编辑竖线可跳转到对应帧
- 时间轴顶部播放进度条只表达当前播放位置;其下方的视频处理进度条表达处理状态:当前帧位置用白色竖线贯穿播放进度条和视频处理进度条;人工绘制或 AI 智能分割生成的帧显示红色竖线,自动传播生成的帧显示蓝色区段,最近自动传播处理过的片段叠加同一蓝色系纯色条,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,帮助识别第一次、第二次、第 N 次传播清空片段遮罩后与清空范围重叠的最近传播历史条必须同步移除或裁剪不应继续显示已经清空的传播范围未处理背景使用中性灰以和标记保持明显区分。进入自动传播或清空遮罩范围选择时起始帧和结束帧必须额外显示两条贯穿两条进度条的高对比边界线颜色避开青色播放进度、红色标注、蓝色传播、amber 选区和深色背景。底部帧可视化栏中,人工/AI 标注帧缩略图边框为红色,自动传播/推理帧缩略图边框为蓝色,当前帧仍用青色外框高亮优先;如果同一帧既有人工/AI 标注又有自动传播结果,红色人工/AI 标注框优先保留,自动传播状态只作为蓝色内描边或次级提示;如果当前帧同时是人工/AI 标注帧,则显示青色外框加红色内描边,外层选中框和内层标注框顺序不能交换
- 自动传播提交前支持独立选择传播权重,范围限定为 SAM 2.1 tiny/small/base+/large 四个权重变体;该选择只影响传播任务,不提供 SAM2/SAM3 家族切换,也不改变 AI 智能分割页的单帧推理权重。
## R5 工具栏
- 工具栏可以切换当前 active tool。
- 正向点、反向点、框选工具会影响 Canvas 交互
- 魔法棒按钮切换到 AI 页面
- 多边形、矩形、圆、点、线工具会在 Canvas 上生成可保存的 polygon mask
- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆、线通过拖拽生成;点工具生成小点区域
- 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉
- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;拖动顶点会更新 mask 几何并把已保存 mask 标记为 dirty
- 工作区左侧工具栏不展示正向点、反向点、框选工具;这些入口只属于 AI 智能分割页
- 侧栏“AI智能分割”和工作区工具栏 AI 跳转入口必须使用带明确 AI 语义的图标,而不是普通魔法棒等泛化工具图标
- 工作区 AI 智能分割入口切换到 AI 页面
- 多边形、矩形、圆、画笔、橡皮擦工具会在 Canvas 上生成或编辑可保存的 polygon mask左侧工具栏不再提供创建点和创建线段入口
- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆通过拖拽生成;点击创建多边形、创建矩形或创建圆工具时必须保留当前 mask 选区;如果当前有选中 mask新建多边形/矩形/圆必须并入该选中 mask即使两块区域不重叠也作为同一个多 polygon mask如果当前没有选中 mask才创建新 mask 并自动选中,在仍处于创建工具时显示该 mask 边界顶点作为只读选中提示;画笔和橡皮擦支持调整大小
- 画笔工具在语义分类树有选中类别或当前已有选中 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 边界时,会在最接近的线段上按双击位置新增顶点。
- 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。
- 选中整个 mask 且未选中具体顶点时Delete/Backspace 删除该 mask已保存 mask 同步调用后端删除接口
- 撤销、重做绑定全局 `maskHistory/maskFuture`支持工具栏按钮、AI 页按钮和 Canvas 快捷键
- 区域合并工具支持多选当前帧 mask并使用 polygon union 生成合并后的主 mask。
- 区域去除工具支持多选当前帧 mask并从第一个选中的主 mask 中扣除后续选中 mask
- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击
- 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染
- 中空 mask 必须保留外圈与内洞 ring 分组;进入“调整多边形”后,外圈和内洞都应显示可拖动顶点与边中点插入手柄,内洞顶点拖动、插点和保存后的回显都不能把 mask 变成实心
- 多 polygon 或分离区域组成的同一个 mask 进入“调整多边形”后,所有分离 polygon 都应显示可拖动顶点与边中点插入手柄,不能只显示第一个主区域
- 选中整个 mask 且未选中具体顶点时Delete/Backspace 删除该 mask左侧 `DEL` 按钮复用同一删除链路。删除已保存 mask 前,前端必须用当前后端标注列表预检 `annotationId`,只对仍存在的 id 发送 `DELETE /api/ai/annotations/{id}`,避免本地陈旧 id 导致浏览器控制台出现 404 红字;如果删除对象属于自动传播链或是传播 seed应同步删除同一传播链上的自动传播 mask但不能删除其他帧独立 AI 推理或人工标注 mask。
- 撤销、重做绑定全局 `maskHistory/maskFuture`,工作区支持顶栏按钮和全局快捷键 `Ctrl/Cmd+Z``Ctrl/Cmd+Shift+Z``Ctrl/Cmd+Y`;快捷键监听应在 capture 阶段处理,并在 `event.key` 不可靠时兼容 `event.code=KeyZ/KeyY`但输入框、文本域、下拉框和可编辑文本聚焦时不能拦截AI 页支持自己的按钮;左侧工具栏不重复放置撤销/重做入口
- 区域合并工具支持多选当前帧 mask并使用 polygon union 生成合并后的主 mask若主区域和参与区域存在同一传播链上的对应 mask合并前必须弹出范围选择让用户选择只处理当前帧、处理所有传播帧或按帧范围选择按帧范围选择进入和自动传播/清空一致的时间轴范围选择,点击确认后再弹出最终确认。选择所有传播帧或范围帧时,同一次合并必须同步应用到对应传播帧中的主区域和参与区域,只删除每个已同步帧里的参与合并 mask不能把未参与本次同步或范围外的同链对象整链误删
- 区域去除工具支持多选当前帧 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 推理
@@ -90,25 +115,33 @@
- SAM 2.1 不支持文本语义提示;当前 AI 页面不提供文本语义输入,必须使用点/框提示。
- SAM 2.1 点提示和 auto fallback 默认只采用一个最高分候选 mask避免多个候选 mask 作为同一结果重叠显示。
- AI 页面只渲染本页最新生成的候选 mask重复执行高精度分割会替换上一次 AI 页候选,工作区已有手工、保存、传播或 GT 导入 mask 不会自动进入 AI 画布,也不会被替换。
- AI 页面提供“遮罩清晰度”滑杆,调节本页候选 mask 预览透明度,不改变 mask 几何、分类或保存数据。
- AI 页面提供“AI 遮罩透明度”滑杆,并与右侧“遮罩透明度”共享 `maskPreviewOpacity`;调节任一入口都会改变 AI 候选 mask 预览透明度,不改变 mask 几何、分类或保存数据。
- AI 页面参数开关展示文案使用“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”;这是 UI 可读性文案,不改变 `cropMode``autoDeleteBg` 或后端 `options` 字段。
- AI 页面生成的 SAM 2.1 mask 会写入全局 `masks`,自动同步到当前项目帧,并写入全局 `selectedMaskIds`;右侧语义分类树可以直接给新生成 mask 换标签。
- AI 页“清空全体锚点”只清空本页提示点和本页生成的候选 mask不删除工作区已有 mask。
- AI 页面“推送至工作区编辑”会切回工作区并把工具切到“调整多边形”,保留当前选中的 AI mask 以便继续编辑轮廓和归档保存
- AI 页面“推送至工作区编辑”必须先校验待推送 AI 候选 mask 已有语义分类;没有 `classId``className` 时用右上角 error toast 明确提示并停留在 AI 页,不允许进入工作区,确保工作区内 mask 都有语义
- 如果用户不通过推送按钮而是直接离开 AI 页面,仍未选择语义分类的 AI 候选 mask 必须从全局 `masks``selectedMaskIds` 中清理,避免无语义候选通过侧栏切换进入工作区。
- AI 页面“推送至工作区编辑”在语义校验通过后会切回工作区并把工具切到“调整多边形”,保留当前选中的 AI mask 和当前帧视角,以便继续编辑轮廓和归档保存;如果 AI 操作发生在非第一帧,回到工作区后不得强制跳回第一帧。
- 工作区加载后端已保存标注时,必须保留当前项目帧里尚未保存的 AI/手工 draft mask避免 AI 页推送到工作区的候选 mask 被异步回显流程覆盖。
- 语义文本提示 `semantic` 当前被后端禁用并返回 400。
- SAM 3 源码和历史测试保留,但不属于当前产品可用功能;前端不再展示 SAM 3 入口,后端 registry 不暴露 `sam3`
- 工作区传播功能允许选择传播对象:“选中区域”或“当前帧全部”;选中区域模式需要当前帧至少一个已选 mask全部模式会使用当前帧所有 mask
- 工作区传播功能允许设置起止帧;前端以当前帧为 seed只向起止范围内位于当前帧之前和之后的帧传播,源帧不重复保存。
- 工作区“传播全部可达”会把起止帧设为项目第 1 帧到最后 1 帧,并按当前传播对象传播到所有可达前后帧
- 前端复用单 seed 后端接口;多个 seed 或双向范围会被拆成多次顺序调用 `POST /api/ai/propagate`,避免并发抢占 GPU
- `POST /api/ai/propagate` 当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()`
- 传播结果会写入后续帧 `annotations``mask_data.source` 标记为 `<model_id>_propagation`,并保留 label、color 和 class 元数据
- 工作区传播功能以当前打开帧作为参考帧,并使用该帧全部 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 predictorseed 签名也必须包含 `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` 转成 Konva `pathData``segmentation``bbox``area`
- AI 推理结果先存放在前端 store 的 `masks` 中,点击“结构化归档保存后持久化到后端标注表。
- AI 推理结果先存放在前端 store 的 `masks` 中,顶栏保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;点击保存后持久化到后端标注表。
## R7 标注保存
@@ -117,30 +150,41 @@
- 后端提供 `GET /api/ai/annotations` 查询项目标注,可选按 `frame_id` 过滤。
- 后端提供 `PATCH /api/ai/annotations/{annotation_id}` 更新已保存标注的 `mask_data``points``bbox``template_id`
- 后端提供 `DELETE /api/ai/annotations/{annotation_id}` 删除已保存标注。
- 当前前端“结构化归档保存”会保存当前项目未保存 mask并会更新已标记为 dirty 的已保存 mask。
- 当前前端保存状态按钮会保存当前项目未保存 mask并会更新已标记为 dirty 的已保存 mask。
- 如果 dirty mask 携带的本地旧 `annotationId` 在后端已经不存在,前端保存链路必须先用当前后端标注列表做存在性预检,已知缺失的 id 直接用同一几何和 metadata 重新 `POST` 创建标注;如果预检后发生并发删除导致 `PATCH` 返回 404也必须降级为重新创建并重新拉取后端标注替换本地旧 id点击“开始传播”前的参考帧保存也必须复用该容错逻辑不能因陈旧 id 中断传播。
- 保存成功后,前端会重新拉取后端标注,并用后端 saved annotation 替换本次提交的 draft mask未提交的其他 draft mask 仍保留。
- 工作区“清空遮罩”删除当前帧已保存标注并清空当前帧未保存 mask。
- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask不弹确认若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。
- 工作区加载项目帧后会查询已保存标注并回显。
- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`
- 后端导入 GT mask 时按非零像素值拆分多类别区域,再按连通域生成 polygon 标注,并通过距离变换写入 seed point
- 前端会回显导入标注的 seed point拖动 seed point 后,已保存标注会变为 dirty归档保存时会更新后端 `points`
- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为黑色 `maskid:0` 的“待分类”,并保留原始 `gt_label_value` 等待后续重新命名
- 后端导入 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 模板库
- 前端展示模板列表,调用 `GET /api/templates`
- 用户可以新建、编辑、删除模板。
- 用户可以新建、编辑、删除模板,也可以在“生效中模板架构清单”中用鼠标复制现有模板为当前用户私有副本
- 模板分类存放在 `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 本体检查面板
- 工作区右侧可以选择模板。
- 面板显示模板分类和组件本地自定义分类
- 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes`
- 右侧面板修改激活模板时,如果当前项目已有任意 mask必须弹窗提示用户确认确认后清空当前项目所有本地 mask 和已保存后端标注,再切换激活模板。当前项目没有任何 mask 时切换激活模板不需要提示。
- 用户可以选择具体分类;新 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
@@ -160,11 +204,21 @@
- 后端支持 `GET /api/export/{project_id}/coco` 导出 COCO JSON。
- 后端支持 `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 值、颜色和类别 keyGT_label 必须固定输出 8-bit uint8 PNG背景值固定为 0语义类别值使用类别真实 maskid缺失 maskid 的旧标注才补下一个可用正整数,且同一类别跨图片保持一致;`maskid: 0` 的“待分类”必须在映射中保留为 0GT_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 封装已对齐后端路径。
- 当前前端 `exportMasks()` API 封装已对齐后端路径。
- 工作区“导出 JSON 标注集”按钮已绑定下载事件;导出前会先保存当前未归档 mask
- 工作区“导出 PNG Mask ZIP”按钮已绑定下载事件导出前会先保存当前未归档 mask
- 当前前端 `exportSegmentationResults()` API 封装已对齐统一导出路径
- 工作区“分割结果导出”按钮已替代原 JSON/PNG 两个按钮;点击后在下拉栏内选择导出范围、勾选导出内容,并在选择 Mix_label 时调节遮罩透明度和查看当前/待导出第一帧预览;导出范围默认选中“当前图片”,导出前会先保存当前未归档 mask。选择“特定范围帧”时用户既可以直接修改起止帧输入框也可以像自动传播、清空遮罩一样在播放进度条或视频处理进度条上点击/拖拽选择导出范围
- PNG mask ZIP 包含单标注二值 mask、按 zIndex 融合后的每帧语义 mask 和 `semantic_classes.json`
- 统一导出的 GT_label 图必须是 8-bit uint8 PNG背景值固定为 0所有语义类别值优先保留模板类别真实 maskid缺失 maskid 的旧标注才按下一个可用正整数补值;有效类别值范围为 0-255其中 0 仅用于背景和系统保留的“待分类”。
## R12 配置

View File

@@ -12,7 +12,7 @@
- FastAPI 后端 API。
- 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 响应转换 |
| 配置 | `src/lib/config.ts` | 推导 API 和 WebSocket 地址 |
| WebSocket | `src/lib/websocket.ts` | 进度流连接、订阅、连接状态通知、心跳和重连 |
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态 |
| 登录页 | `src/components/Login.tsx` | 调用登录 API写入 store |
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;左侧 Sidebar 底部使用 compact 形态显示 GPU/CPU 状态,工作区顶栏不再重复显示,具体传播权重只在进入自动传播后由顶栏下拉负责 |
| 登录页 | `src/components/Login.tsx` | 使用 `public/logo.png` 和系统标题文案,调用登录 API写入 store |
| Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 |
| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、删除、导入视频/DICOM、显式生成帧 |
| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、重命名、删除、复制、导入视频/DICOM、显式生成帧/重新生成帧 |
| 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板组织工具栏、Canvas、本体面板、时间轴 |
| Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask |
| 工具栏 | `src/components/ToolsPalette.tsx` | 切换工具、跳转 AI 页面、触发 mask 撤销/重做 |
| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、已编辑帧标记、左右方向键切帧、播放和当前/总时长显示 |
| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、分类树、本地自定义分类 |
| 工具栏 | `src/components/ToolsPalette.tsx` | 切换工作区编辑工具、提供等同 `Esc` 的“取消选中”实体按钮、在“重叠区域去除”后触发当前帧/传播链清空、GT Mask 导入和 AI 页面跳转AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择/取消选中到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、清空遮罩/导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px按钮列固定 48px滚动条使用右侧外扩空间和低对比 `seg-scrollbar` |
| 工作区顶栏 | `src/components/VideoWorkspace.tsx` | 保存状态按钮(“保存 X 个改动”/“已全部保存”)、导出/传播/按起止帧批量清空遮罩、显式撤销/重做按钮和工作区快捷键 |
| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、播放进度、视频处理进度条、自动传播历史片段、自动传播/布尔操作/导出范围选择、左右方向键切帧、播放和当前/总时长显示 |
| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、工作区 mask 透明度、分类树、后端自定义分类、mask 后端属性分析;内容过长时自身滚动,滚动条使用低对比 `seg-scrollbar` |
| AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 |
| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、排序 |
| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、详情页和编辑弹窗拖拽排序 |
| 短提示浮层 | `src/components/TransientNotice.tsx` | 项目库和模板库的非阻塞成功/失败提示,自动消失 |
## 后端模块
@@ -42,13 +44,15 @@
| 应用入口 | `backend/main.py` | FastAPI app、CORS、路由注册、健康检查、WebSocket |
| 配置 | `backend/config.py` | Pydantic settings |
| 数据库 | `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 请求/响应模型 |
| 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 |
| Templates | `backend/routers/templates.py` | 模板 CRUD 和 mapping_rules 打包/解包 |
| Media | `backend/routers/media.py` | 上传媒体和拆帧 |
| AI | `backend/routers/ai.py` | 当前启用 SAM 2 推理、视频传播、模型状态和标注保存 |
| 传播任务 | `backend/services/propagation_task_runner.py` | Celery 中执行自动传播 steps写任务进度并保存传播标注 |
| Export | `backend/routers/export.py` | COCO 和 PNG 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 桥接源码和脚本;当前未接入 registry |
@@ -74,18 +78,32 @@
1. `Login` 收集用户名和密码。
2. `login()` 调用 `POST /api/auth/login`
3. 成功后 store 写入 tokenApp 渲染主界面
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` 创建项目。
2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`
3. 用户在项目卡片点击“生成帧”,在弹窗中选择目标 FPS。
2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`上传期间项目库显示导入进度条、百分比和已上传字节,完成后短暂显示“视频导入完成”。
3. 用户在视频项目卡片点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS。已有帧的视频重新生成时会提示该操作会清空现有帧序列、标注和 mask后端任务开始时删除旧帧和旧标注再写入新的标准帧序列。任务入队后项目库会继续轮询任务进度解析成功后自动重新拉取项目列表和当前项目对象使后端生成的 `thumbnail_url` 立即显示为项目封面,无需刷新页面或重新进入项目库。
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、帧数、时长、尺寸和对象存储前缀。
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 导入和导出链路,差异只存在于项目库导入入口和后端解析器。
### 任务控制
@@ -99,12 +117,21 @@
### 工作区加载
1. `VideoWorkspace` 根据 `currentProject.id` 调用 `getProjectFrames()`
2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`
2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`;帧列表接口默认返回完整帧序列,工作区右下角总帧数以实际项目帧数为准
3. 帧数据映射为 store `Frame[]`,包含 `timestampMs``sourceFrameNumber`,供时间轴和后续视频传播使用。
4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask但保留没有 `annotationId` 的未保存 draft mask这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。
5. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态
6. `FrameTimeline` 根据当前项目 `frames` 全局 `masks` 计算有编辑/标注的帧,并在顶部时间进度条上覆盖可点击的琥珀色竖线;当前帧不额外渲染竖线,由播放进度条末端、时间提示和缩略图高亮表达
7. 当前帧传入 `CanvasArea`
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 点/框推理
@@ -124,65 +151,76 @@
14. AI 页面提示点由本地 `points` 状态维护;点击已渲染提示点会按 index 删除对应点,“删除最近锚点”会删除数组最后一个点,不改动候选 mask 列表。
15. AI 页面候选 mask 删除只接受当前 `aiMaskIds` 范围内的已选 id“删除选中候选”和 Delete/Backspace 都复用该范围过滤,避免删除工作区已有 mask。
16. AI 页面参数开关文案只做展示增强:“局部专注模式(自动裁剪无锚区域)”仍控制 `cropMode/crop_to_prompt`,“严格除杂模式(自动清理干涉点)”仍控制 `autoDeleteBg/auto_filter_background/min_score`
17. AI 页面“遮罩清晰度”滑杆只调节候选 mask 的 Konva preview opacity不写入 `Mask.segmentation`、分类元数据或后端 payload。
18. Canvas 按当前帧过滤并渲染 mask
19. 新 mask 会带上当前选择的模板分类元数据,包括 `classId``className``classZIndex``metadata.source=ai_segmentation` 和保存状态 `draft`
20. 用户点击“结构化归档保存”后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`dirty mask 调用 `PATCH /api/ai/annotations/{annotation_id}`;保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换
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。
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. 用户在工作区选择传播对象:`selected` 表示当前帧已选 mask`all` 表示当前帧所有 mask
2. 用户设置起止帧;“传播全部可达”会把起止帧设为 1 到项目总帧数
3. `VideoWorkspace` 以当前帧为 seed将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。
4. `VideoWorkspace` `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color 和 class 元数据
5. 前端对每个 seed、每个方向顺序调用 `POST /api/ai/propagate``include_source=false``save_annotations=true`;顺序调用是为了避免多个视频 tracker 并发抢占 GPU
6. 后端按项目帧序列截取片段,下载对应帧到临时 `frame_%06d.jpg` 目录,保持当前帧在片段中的相对索引
7. `model` 为任一 SAM 2.1 变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask再用 `propagate_in_video()` 传播
8. `model=sam3` 当前不支持SAM 3 video tracker 代码保留但没有接入产品路径
9. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧,`mask_data.source` 记录模型传播来源
8. 前端传播完成后重新调用 `GET /api/ai/annotations` 并回显新标注
1. 用户在工作区打开一帧作为参考帧;该帧全部 mask 都会作为传播 seed不再提供传播对象下拉
2. 用户点击左侧工具栏橡皮擦下方的彩色 AI 大脑图标“AI自动推理”后可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重;该入口不提供 SAM2/SAM3 家族切换,默认跟随全局 AI 权重,用户手动选择后不再被 AI 页权重切换覆盖;未进入自动传播时顶栏不显示传播权重
3. `VideoWorkspace` 以当前参考帧为 seed将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。
4. `VideoWorkspace` 在提交传播前会先调用现有归档保存链路保存当前项目中的 draft/dirty mask并重新读取 store 中的回显结果;参考帧 seed 因此优先携带稳定的后端 `source_annotation_id`,避免用前端临时 mask id 生成传播结果后,二次传播无法找到旧结果
5. `VideoWorkspace``buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`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` metadatapayload 仍可透传给后端兼容处理;当前前端平滑应用会直接改写 polygon 几何并移除该参数
6. 前端把传播权重 id、每个 seed、每个方向组装成 `steps`,一次调用 `POST /api/ai/propagate/task``include_source=false``save_annotations=true`;接口先规范化/校验 `model` 字段中的权重 id再创建 `processing_tasks.task_type=propagate_masks` 并投递 Celery避免长 HTTP 请求阻塞前端等待
7. `VideoWorkspace` 记录返回的 `task_id`,轮询 `GET /api/tasks/{task_id}` 显示任务 message、步骤进度、已处理帧次和已保存区域数传播进度存在时顶栏只在蓝色进度面板内显示任务 message隐藏左侧灰色状态文字避免同一提示重复出现任务运行期间提供取消传播按钮调用通用 `POST /api/tasks/{task_id}/cancel`
8. Celery worker 逐 step 顺序执行传播,避免多个视频 tracker 并发抢占 GPU每个 step 开始/完成都会写入 `processing_tasks.progress/result/message` 并发布 Redis `seg:progress`Dashboard 可同步显示。每个 step 开始前worker 会在本次目标帧段内用 seed 来源 id、传播方向和 seed 签名查找旧传播标注:同权重、签名相同且目标帧都已有结果时跳过该 seed签名不同、目标帧只部分覆盖或本次使用了其他 SAM 2.1 权重则先删除本次目标帧段内对应方向的旧自动传播标注,再执行新的 video predictor 传播;若历史 seed 签名中包含 `geometry_smoothing`,仍按完整签名参与兼容去重。对同一参考帧多个同类别 seedworker 优先以 `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。
3. 多边形工具逐次记录节点,三点后点击首节点或按 Enter 时生成闭合 polygon。
4. mask path 只在 `move``edit_polygon``area_merge``area_remove` 工具下拦截点击;绘制和 AI prompt 工具点击已有 mask 时继续冒泡给 Stage
5. mask 写入 `pathData`、像素 `segmentation``bbox``area` 和当前模板分类元数据
6. `addMask()``setMasks()``updateMask()``clearMasks()` 会维护 `maskHistory/maskFuture`
7. 工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y 调用 `undoMasks()` / `redoMasks()`
4. Canvas 左上角根据当前工具和操作阶段显示上下文短提示;多边形提示会随已放置点数切换,明确 Enter 完成、Esc 取消和点击首节点闭合。`Esc` 和左侧工具栏“取消选中”按钮只取消当前 mask 选区、临时多边形点、矩形/圆拖拽状态、画笔/橡皮擦临时笔触和顶点选择,不删除已有 mask也不清空右侧语义分类树的当前类别。提示会在工具或操作状态变化时出现并在数秒后自动隐藏避免长期遮挡底图
5. mask path 只在 `move``edit_polygon``area_merge``area_remove` 工具下拦截点击;绘制、画笔、橡皮擦和 AI prompt 工具点击已有 mask 时继续冒泡给 Stage
6. 画笔/橡皮擦尺寸保存在 Zustand 中;拖动期间只保留采样后的圆形笔触预览,鼠标松开后再用 `polygon-clipping` 计算一次几何结果,避免拖动中反复重算复杂 polygon。画笔有选中 mask 时会把本次采样笔触 union 进选中 mask即使笔触和旧区域不重叠也形成同一个多 polygon mask没有选中 mask 时才按当前语义分类创建新的独立 mask如果画笔笔触闭合形成中空区域`segmentation` 保留外圈和内洞 ring`metadata.hasHoles/polygonRingCounts` 记录 ring 分组,并使用 even-odd 渲染;橡皮擦则对当前选中 mask 执行 difference 扣除
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 逐点编辑
1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 第一条 polygon 的顶点控制点和边中点插入手柄。
2. 拖动顶点后,前端重算 `pathData`、像素 `segmentation``bbox``area`
3. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点
4. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty``saved=false`
5. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端
6. 选中顶点后 Delete/Backspace 可删除顶点;前端保持 polygon 至少三点
7. 选中具体顶点但选中了 mask 时,Delete/Backspace 从前端 store 删除该 mask如果包含 `annotationId`,通过工作区回调调用后端删除接口
1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 所有可编辑 polygon 的顶点控制点和边中点插入手柄;多 polygon 或分离区域组成的同一个 mask 不再只显示第一条 polygon
2. 顶点 `mousedown/dragstart` 会立即设置当前顶点选择;拖动过程中通过 `dragMove` 实时重算 `pathData`、像素 `segmentation``bbox``area`,不需要先单击顶点再拖动
3. Stage 的 `onDragEnd` 只处理 Stage 自身拖拽polygon 顶点等子节点拖拽结束事件会被忽略,避免子节点坐标误写入 Canvas `position` 导致视口跳动
4. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点
5. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty``saved=false`
6. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端
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 组成选择集。
2. 合并/去除模式隐藏 polygon 顶点和边中点编辑手柄,并在右下角显示已选数量;少于两个 mask 时操作按钮禁用。
3. `CanvasArea``Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon
4. `area_merge` 使用 union更新第一个选中的主 mask并从前端 store 移除后续被合并 mask如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注
5. `area_remove` 使用 difference从第一个选中的主 mask 中扣除后续选中 mask扣除对象本身保留如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring渲染时使用 even-odd fill
6. 结果会重算 `pathData``segmentation``bbox``area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算
3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除
4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常
5. `CanvasArea``Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon
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 导入
1. 工作区“导入 GT Mask”选择图片文件。
1. 工作区左侧工具栏“导入 GT Mask”选择图片文件;入口位于“重叠区域去除”之后
2. 前端 `importGtMask()` 以 multipart form-data 调用 `POST /api/ai/import-gt-mask`,携带 `project_id``frame_id`
3. 后端验证项目、帧、模板后使用 OpenCV 读取灰度 mask。
4. 后端按非零像素值拆分多类别标签。
5. 后端对每个类别的前景做 contour 提取,每个连通域保存为一个 `Annotation`
6. `points` 字段保存距离变换中心 seed point`mask_data.polygons` 保存 normalized polygon`mask_data.gt_label_value` 保存原始像素类别值
5. 后端对每个类别的前景做高精度 contour 提取,每个连通域保存为一个 `Annotation`;轮廓使用未压缩链提取并以较小 `approxPolyDP` epsilon 保留细节,超过点数上限时才逐步增加简化强度或抽样
6. `points` 字段保存距离变换中心 seed point 供数据兼容`mask_data.polygons` 保存 normalized polygon`mask_data.holes` 保存与外圈对齐的内洞,`mask_data.gt_label_value` 保存原始像素类别值;导入后的 polygon 与普通 mask 走同一套拓扑锚点统计、边缘平滑、编辑和保存链路
7. 前端重新读取项目标注并回显。
8. `annotationToMask()` 会把 normalized seed point 转成像素坐标Canvas 以可拖拽点显示;拖动后 `buildAnnotationPayload()` 会把点再归一化写回后端
8. `annotationToMask()` 仍可把后端 `points` 转成像素坐标保存在 mask 数据中,但 Canvas 不显示 seed point也不提供拖动普通 polygon 若没有后端 seed point保存逻辑可按 polygon 自动计算内部代表点写入,以保持数据兼容
### 模板管理
@@ -191,13 +229,19 @@
3. 保存时调用 `createTemplate()``updateTemplate()`
4. 后端把 `classes``rules` 打包进 `mapping_rules`
5. 返回时再解包给前端。
6. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`;切换工具、切换帧或卸载 Canvas 时会清空选择
7. `AISegmentation` 生成 mask 后会写入全局 `masks` 并把生成的 mask id 写入 `selectedMaskIds`点击 AI 页预览 mask 也会更新 `selectedMaskIds`
8. AI 页“推送至工作区编辑”会切换到工作区并把 `activeTool` 设为 `edit_polygon``CanvasArea` 初始读取全局 `selectedMaskIds`,让 AI 页选中的 mask 在工作区继续保持选中
9. 工作区帧/标注异步加载完成后,`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask不会用后端回显结果直接覆盖整个 `masks` store
10. `OntologyInspector` 可以选择具体分类;选择结果进入全局 store`CanvasArea``AISegmentation` 新建/更新 mask 时使用
11. 如果 `selectedMaskIds` 中存在当前 store 的 mask点击分类时会立即更新这些 mask 的 `templateId``classId``className``classZIndex``label``color`
12. 已保存 mask 被重新分类后进入 `dirty``saved=false`,继续复用工作区归档保存的 PATCH 链路
6. 模板详情页和编辑弹窗都支持拖拽调整语义类别层级顺序;拖拽后重算 `zIndex`,保存到后端模板并刷新当前详情页,`maskId` 保持不变。所有模板都会归一化包含黑色 `maskId: 0` 的“待分类”保留类,该类固定在语义分类树最后,不参与删除和拖拽上移。编辑弹窗点击分类后只编辑分类名称,不展示或编辑旧 `category` 来源元信息。编辑弹窗中的 JSON 批量导入支持 `[[colors], [names]]``{colors, names}` 两种格式,并兼容带前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容导入前会先显示分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示,确认导入后进入编辑态,保存模板时落库
7. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`切换到多边形、矩形、圆、画笔、橡皮擦、移动、调整多边形、区域合并或重叠区域去除时保留当前选区,创建工具会把新几何并入当前选中 mask只有 `Esc`、左侧“取消选中”、删除 mask、AI prompt 等显式离开选区的动作会清空旧 mask 选区。切换帧时会优先沿传播链跟随同一 mask找不到对应结果时才清空卸载 Canvas 时清空选择
8. `AISegmentation` 生成 mask 后会写入全局 `masks` 并把生成的 mask id 写入 `selectedMaskIds`;点击 AI 页预览 mask 也会更新 `selectedMaskIds`
9. AI 页“推送至工作区编辑”会先检查待推送 AI 候选 mask 是否具备 `classId``className`;缺少语义分类时清空普通推理反馈,并通过 `TransientNotice` 右上角 error toast 提示用户先点右侧语义分类树,不切换模块、不修改工具状态
10. `AISegmentation` 卸载时会清理仍缺少 `classId/className` 的本页 AI 候选,并同步移除对应 `selectedMaskIds`,避免用户绕过推送按钮从侧栏切到工作区时带入无语义 mask。
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` 非阻塞提示,默认数秒后自动消失。
### 导出
@@ -205,7 +249,9 @@
2. PNG mask 导出会把 normalized polygon 渲染为单标注二值 mask。
3. PNG mask 导出还会按 `mask_data.class.zIndex` 或模板 `z_index` 从低到高覆盖,生成每帧语义融合 mask。
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``原始图片/`;原始图片文件名使用 `视频名称_时间戳_项目帧序号`。导出会保留类别真实 maskidGT_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 不变。
## 接口契约
@@ -214,15 +260,17 @@
- `updateProject()` 使用 `PATCH /api/projects/{id}`
- `exportCoco()` 使用 `GET /api/export/{projectId}/coco`
- `exportMasks()` 使用 `GET /api/export/{projectId}/masks`
- `exportSegmentationResults()` 使用 `GET /api/export/{projectId}/results`,通过 query 参数选择范围和 mask 类型。
- `cancelTask()` 使用 `POST /api/tasks/{taskId}/cancel`
- `retryTask()` 使用 `POST /api/tasks/{taskId}/retry`
- `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`
- `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`
- `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`,用于生成标准帧序列。
- `getProjectFrames()` 返回帧图像 URL、宽高、`timestamp_ms``source_frame_number`
- 后端 `/api/ai/predict` 当前支持 SAM 2.1 的 point、box、interactive`semantic` 文本提示禁用并返回 400。
@@ -230,7 +278,7 @@
- SAM 2.1 点提示和 auto fallback 只返回一个最高分候选,避免同一提示产生多个重叠候选 mask。
- SAM 3 前端入口、后端 registry 入口和状态展示均已禁用;`model=sam3` 会返回不支持。
- 后端 `/api/ai/predict` 支持可选 `options``crop_to_prompt` 会对 point/box/interactive prompt 做局部裁剪推理并回映射 polygon`auto_filter_background` 会按 `min_score` 和负向点过滤结果。
- 后端 `/api/ai/propagate` 当前支持所选 SAM 2.1 mask seed 视频传播;当前前端默认向后传播 30 帧并保存结果标注
- 后端 `/api/ai/propagate/task` 当前支持所选 SAM 2.1 mask seed 视频传播后台任务;同步 `/api/ai/propagate` 仍保留为单 seed 兼容接口
- 后端 `/api/ai/models/status` 返回 GPU 和四个 SAM 2.1 变体的真实运行状态。
- point prompt 支持旧数组形式和 `{ points, labels }` 对象形式。
@@ -249,7 +297,8 @@
以下能力属于当前冻结版本的占位或半可用功能:
- 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 语义并标记为 dirtyCanvas 右下角不再提供旧的“应用分类”按钮,避免没选区时误改整帧;区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择,范围选择复用时间轴和确认弹窗,并保留传播帧来源 metadata选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除同步后端前会预检 id同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留
- SAM 3 文本语义分割已从当前产品路径中禁用相关源码保留恢复时需要重新接入前端入口、registry、状态接口和测试。
- 自定义分类只存在本地组件状态
- GT mask 导入已完成多类别像素值拆分、contour、distance transform seed point 和前端 seed point 拖拽编辑骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。
- 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`
- 选中 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 聚类和模板自动映射尚未实现。

View File

@@ -14,54 +14,70 @@
| 需求 | 测试文件 | 覆盖点 |
|------|----------|--------|
| R1 登录与会话 | `src/components/Login.test.tsx`, `backend/tests/test_auth.py` | 成功登录、失败提示、后端 401 |
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片删除、DELETE 契约、后端 CRUD、删除级联、帧列表 |
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、显式生成帧 FPS 选择、扩展名校验、自动建项目、关联项目、创建异步任务、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、回显已保存标注时保留本地未保存 draft mask、缩略图/range/已编辑帧进度条竖线标记、当前帧由进度条末端和缩略图高亮表达/左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 工具切换、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、polygon 顶点拖动/删除、边中点插点、双击边界按位置插点、整块 mask 删除、区域合并/去除、内含去除 hole 渲染、合并模式隐藏编辑手柄、工作区 SAM 提示点点击删除且不冒泡新增点、撤销/重做历史栈 |
| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按选中/全量 seed 和起止帧范围传播、传播全部可达、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
| R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、seed point 回显/归一化、项目不存在、帧不存在 |
| R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、JSON 分类导入、mapping_rules 解包/打包、后端模板 CRUD |
| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 模板选择、分类展示、具体分类选择、Canvas 选区同步、点击分类给已选 mask 换标签、自定义分类本地添加 |
| 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 语义融合 |
| 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`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目库不展示独立新建项目按钮、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、完整帧列表不默认截断到 1000、项目按当前 JWT 用户隔离 |
| 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` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/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/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` | 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` | 保存状态按钮“保存 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/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/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、主动断开不重连 |
| 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 推导 |
| 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` | 已覆盖 |
| R2 | 项目列表/创建/选择、视频导入、DICOM 导入、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、显式 FPS 生成帧、视频/DICOM 拆帧任务`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `test_media.py`, `test_tasks.py` | 已覆盖 |
| R4 | 工作区加载帧、无帧项目不自动解析、后端标注回显保留本地未保存 draft mask、Canvas 底图、缩略图/range/已编辑帧进度条竖线标记、当前帧由进度条末端和缩略图高亮表达/左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
| R5 | 工具切换、调整多边形入口、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
| R5 | 顶点编辑、边中点插点、双击边界按位置插点、顶点删除、整块删除、工作区 SAM 提示点删除优先级、撤销/重做、区域合并、区域去除、hole even-odd 渲染 | `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 |
| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按范围/全部可达传播、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_sam2_engine.py` | 已覆盖 |
| R7 | 保存、保存后替换已提交 draft、查询、更新、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 回写 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 |
| R8 | 模板加载、新建、编辑、删除、JSON 分类导入、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `api.test.ts`, `test_templates.py` | 已覆盖 |
| R9 | 模板选择、分类展示、分类选择、已选 mask 换标签、自定义本地分类、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 |
| 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 不显示生成帧、完整帧列表不默认截断到 1000、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.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 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 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 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、清空遮罩唯一左侧入口、Canvas 右下角旧清空/应用分类按钮移除、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
| 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.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 | 保存状态按钮“保存 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 | 模板加载、新建、编辑、删除、删除模板站内确认、鼠标复制模板为私有副本并保留 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 回显为 `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` | 已覆盖 |
| 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` | 已覆盖 |
| 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 2.1 变体选择测试,验证前端不展示 SAM 3 入口、选择 small 后请求携带对应模型,且未放置点提示时不发起推理。
- R6补充 SAM 2 纯文本提示拦截、SAM 2 多候选只保留最高分、SAM 2 engine 单候选请求测试,避免多个重叠候选 mask 被同时叠加。
- R6补充 Canvas 工作区 SAM 2 反向点背景过滤测试,覆盖请求 options 和过滤为空时清除旧候选 mask。
- R6补充 `ModelStatusBadge.test.tsx` 中 SAM 3 不展示测试,避免禁用入口重新出现在前端。
- R6补充后端 `selected_model=sam3` 拒绝测试和 semantic 禁用测试,避免后端继续暴露 SAM 3 产品能力。
- R6补充 `POST /api/ai/propagate` 后端测试,验证 seed mask 传播结果会保存为后续帧标注并保留 class 元数据。
- R6补充 `propagateMasks()` API 封装和 `VideoWorkspace` 传播按钮测试,验证当前选中区域会发送到后端视频传播接口
- R6补充 `propagateMasks()` 同步兼容接口和 `queuePropagationTask()` 任务接口测试,验证当前参考帧全部 mask 会按范围组装为后台传播 steps
- 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补充 `ProjectLibrary.test.tsx``api.test.ts` 中上传进度测试,验证视频/DICOM 上传通过 Axios `onUploadProgress` 回调更新项目库导入进度条,并显示 DICOM 文件数量和解析任务轮询进度。
- R3补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms``source_frame_number``result.frame_sequence` 元数据。
- 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
View File

@@ -0,0 +1,639 @@
# Installation / 部署安装指南
本文件记录当前仓库的真实安装和部署方式。它面向一台新的 Linux 机器,目标是跑起完整系统:
- React 前端:默认 `http://localhost:3000`
- FastAPI 后端:默认 `http://localhost:8000`
- PostgreSQL项目、帧、模板、标注、任务元数据
- RedisCelery broker/result backend 与进度 pub/sub
- MinIO视频、DICOM、拆帧图片等对象存储
- Celery worker执行视频/DICOM 拆帧等后台任务
- SAM 2.1:当前产品启用 tiny/small/base+/largeSAM 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 个终端分别启动。
终端 AFastAPI 后端
```bash
conda activate seg_server
cd /home/wkmgc/Desktop/Seg_Server/backend
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
终端 BCelery 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。

View 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. 添加或更新组件测试,至少覆盖状态转移的进入条件、退出条件和副作用。

View File

@@ -20,6 +20,8 @@
| [07-current-requirements-freeze.md](./07-current-requirements-freeze.md) | 当前版本需求冻结,测试以此为准 |
| [08-current-design-freeze.md](./08-current-design-freeze.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
View 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

View File

@@ -11,4 +11,3 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

182
restart_dev_services.sh Executable file
View 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}"

View File

@@ -8,40 +8,6 @@ async function startServer() {
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
if (process.env.NODE_ENV !== "production") {
const vite = await createViteServer({

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { useStore } from './store/useStore';
import { getProjects } from './lib/api';
import { getCurrentUser, getProjects } from './lib/api';
import { Sidebar } from './components/Sidebar';
import { Dashboard } from './components/Dashboard';
import { ProjectLibrary } from './components/ProjectLibrary';
@@ -8,8 +8,9 @@ import { VideoWorkspace } from './components/VideoWorkspace';
import { TemplateRegistry } from './components/TemplateRegistry';
import { AISegmentation } from './components/AISegmentation';
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() {
const isAuthenticated = useStore((state) => state.isAuthenticated);
@@ -17,17 +18,27 @@ export default function App() {
const setActiveModule = useStore((state) => state.setActiveModule);
const setProjects = useStore((state) => state.setProjects);
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(() => {
if (isAuthenticated) {
getProjects()
.then((data) => setProjects(data))
Promise.all([getCurrentUser(), getProjects()])
.then(([user, projects]) => {
setCurrentUser(user);
setProjects(projects);
})
.catch((err) => {
console.error('Failed to fetch projects:', err);
if (err?.response?.status === 401) {
logout();
return;
}
setError('获取项目列表失败');
});
}
}, [isAuthenticated, setProjects, setError]);
}, [isAuthenticated, logout, setCurrentUser, setProjects, setError]);
if (!isAuthenticated) {
return <Login />;
@@ -42,6 +53,7 @@ export default function App() {
{activeModule === 'ai' && <AISegmentation onSendToWorkspace={() => setActiveModule('workspace')} />}
{activeModule === 'workspace' && <VideoWorkspace onNavigateToAI={() => setActiveModule('ai')} />}
{activeModule === 'templates' && <TemplateRegistry />}
{activeModule === 'admin' && currentUser?.role === 'admin' && <UserAdmin />}
</main>
</div>
);

View File

@@ -26,6 +26,9 @@ describe('AISegmentation', () => {
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
models: [
{ 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: '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 },
],
});
});
@@ -42,6 +45,30 @@ describe('AISegmentation', () => {
expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny');
});
it('does not render the legacy upload-replace-background mock button', () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.queryByText('上传替换底图')).not.toBeInTheDocument();
});
it('shows an empty state instead of a demo image when no project frame is selected', () => {
useStore.setState({ frames: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.getByText('请先在项目库选择项目并生成帧')).toBeInTheDocument();
});
it('shows contextual guidance for prompt tools', () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
expect(screen.getByText(/点击目标内部添加正向点/)).toBeInTheDocument();
fireEvent.click(screen.getByText('边界框选'));
expect(screen.getByText(/按住并拖拽建立框选区域/)).toBeInTheDocument();
});
it('passes enabled inference parameters to the backend', async () => {
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
@@ -113,6 +140,26 @@ describe('AISegmentation', () => {
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()} />);
@@ -145,6 +192,26 @@ describe('AISegmentation', () => {
}));
});
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()} />);
@@ -312,13 +379,41 @@ describe('AISegmentation', () => {
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.72');
fireEvent.change(screen.getByLabelText('遮罩清晰度'), { target: { value: '35' } });
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({
@@ -514,6 +609,11 @@ describe('AISegmentation', () => {
it('keeps the generated SAM2 mask selected when sending it to the workspace editor', async () => {
const onSendToWorkspace = vi.fn();
useStore.setState({
activeTemplateId: 'template-1',
activeClass: { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 },
activeClassId: 'class-1',
});
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
@@ -541,4 +641,94 @@ describe('AISegmentation', () => {
expect(onSendToWorkspace).toHaveBeenCalled();
});
it('blocks sending an AI candidate to the workspace until a semantic class is selected', async () => {
const onSendToWorkspace = vi.fn();
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={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({
activeTemplateId: 'template-1',
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,
},
],
});
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[0]?.classId).toBe('class-1'));
unmount();
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']);
});
});

View File

@@ -1,9 +1,10 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva';
import useImage from 'use-image';
import { OntologyInspector } from './OntologyInspector';
import { TransientNotice, type NoticeState } from './TransientNotice';
import { SAM2_MODEL_OPTIONS, useStore, type Mask } from '../store/useStore';
import { getAiModelStatus, predictMask, type AiRuntimeStatus } from '../lib/api';
@@ -13,8 +14,11 @@ interface AISegmentationProps {
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) {
const canvasContainerRef = useRef<HTMLDivElement>(null);
const storeActiveTool = useStore((state) => state.activeTool);
const setActiveTool = useStore((state) => state.setActiveTool);
const masks = useStore((state) => state.masks);
@@ -31,35 +35,123 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
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 [modelStatus, setModelStatus] = useState<AiRuntimeStatus | null>(null);
const [autoDeleteBg, setAutoDeleteBg] = useState(true);
const [cropMode, setCropMode] = useState(false);
const [maskOpacity, setMaskOpacity] = useState(72);
const [isInferencing, setIsInferencing] = useState(false);
const [inferenceMessage, setInferenceMessage] = useState('');
const [notice, setNotice] = useState<NoticeState | null>(null);
const [aiMaskIds, setAiMaskIds] = useState<string[]>([]);
// Canvas state
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
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 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(previewUrl);
const [image] = useImage(currentFrame?.url || '');
const aiMaskIdSet = new Set(aiMaskIds);
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 modelCanInfer = selectedModelStatus?.available ?? true;
const modelStatusLoaded = Boolean(modelStatus);
const modelCanInfer = modelStatusLoaded && Boolean(selectedModelStatus?.available);
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
? {
@@ -119,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 stage = e.target.getStage();
if (!stage) return;
@@ -146,6 +246,10 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
setInferenceMessage('请先在项目工作区选择一帧图像。');
return;
}
if (!modelCanInfer) {
setInferenceMessage(selectedModelStatus?.message ? `当前模型不可用:${selectedModelStatus.message}` : '当前模型不可用,请检查 GPU、PyTorch/SAM2 和模型权重。');
return;
}
const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0;
const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0;
@@ -191,6 +295,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
classId: activeClass?.id,
className: activeClass?.name,
classZIndex: activeClass?.zIndex,
classMaskId: activeClass?.maskId,
saveStatus: 'draft',
saved: false,
pathData: m.pathData,
@@ -227,7 +332,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
} finally {
setIsInferencing(false);
}
}, [activeClass, activeTemplateId, aiMaskIds, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, points, promptBox, selectedMaskIds, selectedModelStatus?.label, setMasks, setSelectedMaskIds]);
}, [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([]);
@@ -254,6 +359,27 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
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));
}, []);
@@ -323,6 +449,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
return (
<div className="w-full h-full flex bg-[#0a0a0a]">
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
{/* Left AI Controller Panel */}
<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">
@@ -352,12 +479,17 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
<button
key={option.id}
type="button"
onClick={() => setAiModel(option.id)}
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"
: "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>
@@ -431,17 +563,18 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
<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"></label>
<span className="text-[10px] font-mono text-cyan-400">{maskOpacity}%</span>
<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="20"
min="10"
max="100"
step="5"
value={maskOpacity}
onChange={(event) => setMaskOpacity(Number(event.target.value))}
value={maskPreviewOpacity}
onChange={(event) => setMaskPreviewOpacity(Number(event.target.value))}
className="w-full accent-cyan-400"
/>
</div>
@@ -461,7 +594,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
)}
>
{isInferencing ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
{isInferencing ? '推理中...' : modelCanInfer ? '执行高精度语义分割' : '当前模型不可用'}
{isInferencing ? '推理中...' : modelCanInfer ? '执行高精度语义分割' : modelStatusLoaded ? '当前模型不可用' : '正在读取模型状态'}
</button>
{inferenceMessage && (
<div className="rounded border border-white/10 bg-white/5 px-3 py-2 text-[11px] leading-relaxed text-gray-300">
@@ -469,10 +602,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
</div>
)}
<button
onClick={() => {
setActiveTool('edit_polygon');
onSendToWorkspace();
}}
onClick={handleSendToWorkspace}
title="AI 候选区域必须先选择语义分类,才能推送到工作区"
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} />
@@ -505,9 +636,6 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
<Redo size={14} />
</button>
<div className="w-px h-4 bg-white/10 mx-1"></div>
<button className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5">
<ImageIcon size={14} />
</button>
<button
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}
@@ -533,10 +661,21 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
</header>
<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
width={window.innerWidth - 320 - 64}
height={window.innerHeight - 64 - 64}
width={stageSize.width}
height={stageSize.height}
onWheel={handleWheel}
onMouseMove={handleMouseMove}
onMouseDown={handleStageMouseDown}
@@ -547,6 +686,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
x={position.x}
y={position.y}
draggable={effectiveTool === 'move'}
onDragEnd={handleStageDragEnd}
>
<Layer>
{/* Background Image */}
@@ -575,9 +715,10 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
{/* AI Returned Masks */}
{frameMasks.map((mask) => {
const isSelected = selectedMaskIds.includes(mask.id);
const baseOpacity = Math.min(Math.max(maskPreviewOpacity / 100, 0.1), 1);
const previewOpacity = isSelected
? maskOpacity / 100
: Math.max(0.18, (maskOpacity / 100) * 0.62);
? baseOpacity
: Math.max(0.12, baseOpacity * 0.62);
return (
<Group key={mask.id} opacity={previewOpacity}>
<Path

View 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>
);
}

View 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

View File

@@ -94,6 +94,7 @@ describe('Dashboard', () => {
expect(screen.getByText('已存标注')).toBeInTheDocument();
expect(screen.getByText('真实项目.mp4')).toBeInTheDocument();
expect(screen.getByText('项目状态: pending')).toBeInTheDocument();
expect(screen.getByText('系统全局数据监控')).toBeInTheDocument();
expect(screen.queryByText('City_Driving_Dataset_004.mp4')).not.toBeInTheDocument();
});

View File

@@ -42,7 +42,7 @@ export function Dashboard() {
status: task.message || task.status,
raw_status: task.status,
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,
});
@@ -188,6 +188,7 @@ export function Dashboard() {
return () => {
mounted = false;
clearTimeout(timer);
unsubscribe();
unsubscribeStatus();
clearInterval(checkConnection);
@@ -288,7 +289,7 @@ export function Dashboard() {
{isConnected ? 'WebSocket 已连接' : 'WebSocket 断开'}
</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>}
{taskActionMessage && <p className="text-amber-400 text-xs mt-2">{taskActionMessage}</p>}
</header>

View File

@@ -49,9 +49,10 @@ describe('FrameTimeline', () => {
expect(screen.getAllByText('00:00.10').length).toBeGreaterThan(0);
expect(screen.getAllByText('00:00.20').length).toBeGreaterThan(0);
expect(screen.queryByText('底层时序视频图层截帧导航轴')).not.toBeInTheDocument();
});
it('overlays edited frame markers as amber vertical lines on the time progress bar', () => {
it('renders a processing progress bar with red annotation markers and blue propagation segments', () => {
useStore.setState({
currentFrameIndex: 1,
frames: [
@@ -61,20 +62,335 @@ describe('FrameTimeline', () => {
],
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' },
{
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.getByText('已编辑 2 帧')).toBeInTheDocument();
expect(screen.queryByTestId('current-frame-line')).not.toBeInTheDocument();
expect(screen.getByLabelText('跳转到已编辑帧 2').className).toContain('before:bg-amber-300');
expect(screen.getByLabelText('跳转到已编辑帧 3').className).toContain('before:h-5');
expect(screen.getByLabelText('跳转到已编辑帧 3').className).not.toContain('h-2 w-2');
fireEvent.click(screen.getByLabelText('跳转到已编辑帧 3'));
expect(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', () => {

View File

@@ -3,13 +3,37 @@ import { Play, Pause } from 'lucide-react';
import { cn } from '../lib/utils';
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 currentProject = useStore((state) => state.currentProject);
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
const masks = useStore((state) => state.masks);
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
const [isPlaying, setIsPlaying] = useState(false);
const [rangeDragAnchorFrame, setRangeDragAnchorFrame] = useState<number | null>(null);
const totalFrames = frames.length;
const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0;
@@ -23,17 +47,51 @@ export function FrameTimeline() {
}, [currentProject?.original_fps, currentProject?.parse_fps]);
const currentSeconds = totalFrames > 0 ? currentFrameIndex / timeBaseFps : 0;
const totalSeconds = totalFrames > 0 ? Math.max(totalFrames - 1, 0) / timeBaseFps : 0;
const editedFrameMarkers = useMemo(() => {
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 editedIds = new Set(
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 }) => editedIds.has(frame.id));
.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 safeSeconds = Math.max(0, seconds);
@@ -43,6 +101,137 @@ export function FrameTimeline() {
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(() => {
if (!isPlaying || totalFrames <= 1) return;
@@ -95,46 +284,48 @@ export function FrameTimeline() {
: [];
return (
<div className="h-32 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
<div className="h-7 bg-[#0d0d0d] flex items-center group relative">
<div className="h-36 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
<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">
{formatTime(currentSeconds)}
</div>
<div className="absolute right-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
{formatTime(totalSeconds)}
</div>
<input
<input
type="range"
min="1"
max={Math.max(totalFrames, 1)}
value={currentFrame}
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}
/>
<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
className="h-full bg-cyan-500 absolute left-0"
style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
/>
{editedFrameMarkers.map(({ frame, index }) => {
const left = totalFrames > 0 ? ((index + 1) / totalFrames) * 100 : 0;
return (
<button
key={frame.id}
type="button"
aria-label={`跳转到已编辑帧 ${index + 1}`}
title={`已编辑帧 ${index + 1}`}
onClick={() => setCurrentFrame(index)}
className={cn(
"absolute left-0 top-1/2 z-30 w-3 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-sm transition-all",
"before:absolute before:left-1/2 before:top-1/2 before:w-px before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:content-['']",
"before:h-5 before:bg-amber-300 before:shadow-[0_0_8px_rgba(251,191,36,0.5)] hover:before:h-7 hover:before:bg-amber-100"
)}
style={{ left: `${left}%` }}
/>
);
})}
<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"
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
@@ -142,8 +333,113 @@ export function FrameTimeline() {
{formatTime(currentSeconds)}
</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">
{editedFrameMarkers.length}
/AI {annotatedFrameMarkers.length} · {propagatedFrameMarkers.length}
</div>
</div>
@@ -177,13 +473,40 @@ export function FrameTimeline() {
}
const frame = frames[idx];
const isCurrent = idx === currentFrameIndex;
const isPropagatedFrame = propagatedFrameIds.has(frame.id);
const isAnnotatedFrame = annotatedFrameIds.has(frame.id);
return (
<div
key={frame.id}
onClick={() => setCurrentFrame(idx)}
title={
isAnnotatedFrame
? `人工/AI 标注帧 ${idx + 1}`
: isPropagatedFrame
? `自动传播帧 ${idx + 1}`
: `视频帧 ${idx + 1}`
}
className={cn(
"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 ? (
@@ -211,7 +534,6 @@ export function FrameTimeline() {
<div className="text-xs font-mono text-cyan-300 mt-1">
{formatTime(currentSeconds)} <span className="text-gray-600">/</span> {formatTime(totalSeconds)}
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-widest mt-1"></div>
</div>
</div>
</div>

View File

@@ -19,14 +19,19 @@ describe('Login', () => {
});
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 />);
fireEvent.click(screen.getByRole('button', { name: '安全登录' }));
await waitFor(() => expect(apiMock.login).toHaveBeenCalledWith('admin', '123456'));
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 () => {
@@ -39,4 +44,20 @@ describe('Login', () => {
expect(await screen.findByText('Invalid credentials')).toBeInTheDocument();
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();
});
});

View File

@@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { BrainCircuit } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { login as loginApi } from '../lib/api';
@@ -18,7 +17,7 @@ export function Login() {
try {
const data = await loginApi(username, password);
storeLogin(data.token);
storeLogin(data.token, data.user);
} catch (err: any) {
const msg = err?.response?.data?.detail || err?.response?.data?.error || '登录失败,请检查网络或凭证';
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="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">
<BrainCircuit size={32} />
<img src="/logo.png" alt="Logo" className="h-full w-full object-contain" />
</div>
<h1 className="text-2xl font-bold text-white tracking-wider mb-2"></h1>
<p className="text-sm text-gray-500">AI </p>
<h1 className="text-center text-2xl font-bold text-white tracking-wider mb-2"></h1>
<p className="text-sm text-gray-500"></p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
@@ -47,6 +46,7 @@ export function Login() {
type="text"
value={username}
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"
placeholder="输入账号"
/>
@@ -58,6 +58,7 @@ export function Login() {
type="password"
value={password}
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"
placeholder="输入密码"
/>

View File

@@ -1,12 +1,49 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore';
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', () => {
beforeEach(() => {
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({
templates: [
{
@@ -23,13 +60,91 @@ describe('OntologyInspector', () => {
});
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(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', () => {
@@ -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', () => {
useStore.setState({
selectedMaskIds: ['m1'],
masks: [
{
id: 'm2',
frameId: 'frame-1',
pathData: 'M 10 10 Z',
label: '未选区域',
color: '#ffffff',
saveStatus: 'draft',
},
{
id: 'm1',
annotationId: '99',
@@ -66,30 +222,439 @@ describe('OntologyInspector', () => {
fireEvent.click(screen.getByText('肝脏'));
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',
classId: 'c2',
className: '肝脏',
classZIndex: 10,
classMaskId: 2,
label: '肝脏',
color: '#00ff00',
saveStatus: 'dirty',
saved: false,
}));
expect(screen.getByText('当前选中区域:')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.queryByText('当前选中区域:')).not.toBeInTheDocument();
});
it('adds custom classes locally without backend persistence', () => {
const { container } = render(<OntologyInspector />);
it('applies class changes to the same propagation chain across frames', () => {
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!;
fireEvent.click(within(customSection).getByRole('button'));
fireEvent.change(screen.getByPlaceholderText('分类名称'), { target: { value: '新局部分类' } });
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().templates[0].classes).toHaveLength(2);
expect(container).toHaveTextContent('2 个分类来自模板 + 1 个自定义');
expect(useStore.getState().templates[0].classes).toHaveLength(3);
});
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);
});
});

View File

@@ -1,30 +1,159 @@
import React, { useState } from 'react';
import { Layers, ChevronDown, Tag, Eye, Plus, X } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, Tag, Eye, Plus, X, Loader2, GripVertical } from 'lucide-react';
import { useStore } from '../store/useStore';
import type { TemplateClass } from '../store/useStore';
import type { Mask, TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
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() {
const templates = useStore((state) => state.templates);
const activeTemplateId = useStore((state) => state.activeTemplateId);
const activeClassId = useStore((state) => state.activeClassId);
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 selectedMaskIds = useStore((state) => state.selectedMaskIds);
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
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 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 [newClassName, setNewClassName] = useState('');
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 templateClasses = activeTemplate?.classes || [];
const allClasses = [...templateClasses, ...customClasses].sort((a, b) => b.zIndex - a.zIndex);
const templateClasses = normalizeClassMaskIds(activeTemplate?.classes || []);
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) => {
if (activeTemplate && !activeTemplateId) {
@@ -36,46 +165,458 @@ export function OntologyInspector() {
if (!hasSelectedMasks) return;
const templateId = activeTemplate?.id || activeTemplateId || undefined;
setMasks(masks.map((mask) => {
if (!selectedIdSet.has(mask.id)) return mask;
const targetIdSet = new Set<string>();
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 {
...mask,
templateId: templateId || mask.templateId,
classId: templateClass.id,
className: templateClass.name,
classZIndex: templateClass.zIndex,
classMaskId: templateClass.maskId,
label: templateClass.name,
color: templateClass.color,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
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;
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 = {
id: `custom-${Date.now()}`,
name: newClassName.trim(),
color: newClassColor,
zIndex: maxZ + 10,
maskId: nextClassMaskId(templateClasses),
category: '自定义',
};
setCustomClasses([...customClasses, newClass]);
handleSelectClass(newClass);
setNewClassName('');
setShowAddForm(false);
setIsSavingClass(true);
setClassSaveMessage('');
try {
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 (
<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">
<Layers size={14} className="mr-2 text-gray-400" />
</div>
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-6">
<div className="flex-1 overflow-y-auto seg-scrollbar p-4 flex flex-col gap-6">
{/* Template Selector */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2"></h3>
@@ -83,9 +624,9 @@ export function OntologyInspector() {
<select
value={activeTemplate?.id || ''}
onChange={(e) => {
setActiveTemplateId(e.target.value || null);
setActiveClass(null);
requestActiveTemplateChange(e.target.value || 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"
>
<option value="">-- --</option>
@@ -98,33 +639,82 @@ export function OntologyInspector() {
{activeTemplate && (
<div className="mt-2 text-[10px] text-gray-600">
{activeTemplate.classes?.length ?? 0}
{customClasses.length > 0 && ` + ${customClasses.length} 个自定义`}
</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 */}
<div>
<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>
<div className="space-y-2">
{allClasses.map(cls => (
<div key={cls.id} className="flex flex-col gap-1">
<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)}
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(
'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',
dragClassId === cls.id && 'opacity-50',
isReservedUnclassifiedClass(cls) && 'cursor-default',
)}
>
<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="text-xs font-medium text-gray-200">{cls.name}</span>
</div>
<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" />
</div>
</button>
@@ -165,7 +755,7 @@ export function OntologyInspector() {
onKeyDown={(e) => e.key === 'Enter' && handleAddCustom()}
/>
<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 onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
<X size={14} />
@@ -173,6 +763,9 @@ export function OntologyInspector() {
</div>
</div>
)}
{classSaveMessage && (
<div className="mt-2 text-[10px] text-gray-500">{classSaveMessage}</div>
)}
</div>
{/* Current Active Object Properties */}
@@ -182,32 +775,77 @@ export function OntologyInspector() {
<div className="flex items-center gap-2 mb-3">
<Tag size={12} className="text-cyan-400" />
<span className="text-xs font-semibold text-gray-200">
{activeClass?.name || activeTemplate?.name || '未选择'}
{selectedMaskLabel}
</span>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-500 uppercase">:</span>
<span className="text-xs font-mono text-gray-300">{selectedMaskIds.length}</span>
<span className="text-[10px] text-gray-500 uppercase">:</span>
<span className="text-xs font-mono text-gray-300">{maskAnalysis?.topology_anchor_count ?? 0} </span>
</div>
<div className="space-y-1">
<label className="text-[10px] text-gray-500 uppercase"></label>
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden">
<div className="h-full bg-green-500 w-[94%]" />
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="mask-edge-smoothing" className="text-[10px] text-gray-500 uppercase">:</label>
<span className="text-xs font-mono text-gray-300">{smoothingStrength}%</span>
</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 className="flex items-center justify-between">
<span className="text-[10px] text-gray-500 uppercase">:</span>
<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>
{analysisMessage && (
<div className="text-[10px] leading-relaxed text-gray-500">{analysisMessage}</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>
);
}

View File

@@ -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 { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore';
@@ -7,19 +7,25 @@ import { ProjectLibrary } from './ProjectLibrary';
const apiMock = vi.hoisted(() => ({
getProjects: vi.fn(),
createProject: vi.fn(),
updateProject: vi.fn(),
copyProject: vi.fn(),
uploadMedia: vi.fn(),
parseMedia: vi.fn(),
uploadDicomBatch: vi.fn(),
deleteProject: vi.fn(),
getTask: vi.fn(),
}));
vi.mock('../lib/api', () => ({
getProjects: apiMock.getProjects,
createProject: apiMock.createProject,
updateProject: apiMock.updateProject,
copyProject: apiMock.copyProject,
uploadMedia: apiMock.uploadMedia,
parseMedia: apiMock.parseMedia,
uploadDicomBatch: apiMock.uploadDicomBatch,
deleteProject: apiMock.deleteProject,
getTask: apiMock.getTask,
}));
describe('ProjectLibrary', () => {
@@ -40,22 +46,35 @@ describe('ProjectLibrary', () => {
fireEvent.click(await screen.findByText('Demo Project'));
expect(useStore.getState().currentProject?.id).toBe('p1');
expect(onProjectSelect).toHaveBeenCalled();
expect(screen.getByText('支持导入视频文件、DICOM序列文件')).toBeInTheDocument();
});
it('creates a new project from the modal', async () => {
apiMock.createProject.mockResolvedValueOnce({ id: 'p2', name: 'New Project', status: 'pending' });
it('shows the generated frame sequence FPS on project cards instead of source FPS', async () => {
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()} />);
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({
name: 'New Project',
description: 'desc',
}));
expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p2' }));
expect(await screen.findByText('12FPS')).toBeInTheDocument();
expect(screen.getByText('原 30.0fps')).toBeInTheDocument();
expect(screen.queryByText('30FPS')).not.toBeInTheDocument();
});
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 () => {
@@ -72,15 +91,45 @@ describe('ProjectLibrary', () => {
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith(expect.objectContaining({
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(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 () => {
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: '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.getTask.mockResolvedValueOnce({ id: 22, status: 'success', progress: 100, message: '解析完成' });
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
@@ -89,6 +138,62 @@ describe('ProjectLibrary', () => {
fireEvent.click(screen.getByRole('button', { name: '开始生成帧' }));
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('allows regenerating an already parsed video and clears the current workspace cache', async () => {
apiMock.getProjects
.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 () => {
@@ -107,6 +212,7 @@ describe('ProjectLibrary', () => {
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();
@@ -117,17 +223,129 @@ describe('ProjectLibrary', () => {
expect(useStore.getState().selectedMaskIds).toEqual([]);
});
it('imports only valid DICOM files and parses the returned project', async () => {
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 1, message: 'ok' });
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 1 });
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 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' });
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(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();
});
});

View File

@@ -1,28 +1,40 @@
import React, { useState, useEffect, useRef } from 'react';
import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2 } from 'lucide-react';
import { UploadCloud, Film, Settings2, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject } 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 { 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 {
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) {
const projects = useStore((state) => state.projects);
const setProjects = useStore((state) => state.setProjects);
const currentProject = useStore((state) => state.currentProject);
const setCurrentProject = useStore((state) => state.setCurrentProject);
const addProject = useStore((state) => state.addProject);
const setFrames = useStore((state) => state.setFrames);
const setMasks = useStore((state) => state.setMasks);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [showModal, setShowModal] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [showImportMenu, setShowImportMenu] = useState(false);
const [showVideoConfig, setShowVideoConfig] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
@@ -31,9 +43,153 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const [frameParseFps, setFrameParseFps] = useState(30);
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 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(() => {
setIsLoading(true);
getProjects()
@@ -42,33 +198,20 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
.finally(() => setIsLoading(false));
}, [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) => {
setCurrentProject(project);
onProjectSelect();
};
const handleDeleteProject = async (project: Project, event: React.MouseEvent) => {
const openDeleteProject = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
if (deletingProjectId) return;
const confirmed = window.confirm(`确认删除项目“${project.name}”?\n该操作会删除项目帧、标注、任务记录和相关 mask 元数据,无法撤销。`);
if (!confirmed) return;
setDeleteProjectTarget(project);
};
const handleDeleteProject = async () => {
const project = deleteProjectTarget;
if (!project || deletingProjectId) return;
setDeletingProjectId(project.id);
try {
await deleteProject(project.id);
@@ -79,14 +222,82 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
setMasks([]);
setSelectedMaskIds([]);
}
setDeleteProjectTarget(null);
} catch (err) {
console.error('Delete project failed:', err);
alert('删除项目失败,请检查后端服务');
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) => {
setPendingFile(file);
setShowVideoConfig(true);
@@ -96,18 +307,55 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
if (!pendingFile) return;
setShowVideoConfig(false);
setIsLoading(true);
setImportProgress({
kind: 'video',
phase: 'preparing',
title: '正在准备视频导入',
detail: `创建项目:${pendingFile.name}`,
percent: 2,
});
try {
const newProject = await createProject({
name: pendingFile.name,
description: `导入于 ${new Date().toLocaleString()}`,
});
const result = await uploadMedia(pendingFile, String(newProject.id));
alert(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时请在项目卡片点击“生成帧”。`);
setImportProgress({
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();
setProjects(data);
scheduleProgressDismiss();
} catch (err) {
console.error('Upload failed:', err);
alert('上传失败,请检查后端服务');
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频导入失败',
detail: pendingFile.name,
percent: 100,
});
showNotice('上传失败,请检查后端服务', 'error');
} finally {
setIsLoading(false);
setPendingFile(null);
@@ -124,17 +372,83 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const handleGenerateFrames = async () => {
if (!frameProject?.id) return;
const targetProject = frameProject;
setIsGeneratingFrames(true);
try {
const task = await parseMedia(frameProject.id, { parseFps: frameParseFps });
alert(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`);
const data = await getProjects();
setProjects(data);
const task = await parseMedia(targetProject.id, { parseFps: frameParseFps });
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
if (currentProject?.id === targetProject.id) {
setFrames([]);
setMasks([]);
setSelectedMaskIds([]);
}
await refreshProjects();
setShowFrameConfig(false);
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) {
console.error('Frame generation failed:', err);
alert('生成帧失败,请检查后端服务或项目源文件');
showNotice('生成帧失败,请检查后端服务或项目源文件', 'error');
} finally {
setIsGeneratingFrames(false);
}
@@ -142,21 +456,91 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const handleDicomUpload = async (files: FileList | null) => {
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) {
alert('未选择有效的 .dcm 文件');
showNotice('未选择有效的 .dcm 文件', 'error');
return;
}
setIsLoading(true);
setImportProgress({
kind: 'dicom',
phase: 'uploading',
title: '正在上传 DICOM 序列',
detail: `${dcmFiles.length} 个文件,按文件名自然顺序上传`,
percent: 0,
fileCount: dcmFiles.length,
});
try {
const result = await uploadDicomBatch(dcmFiles);
await parseMedia(String(result.project_id));
alert(`DICOM 上传成功: ${result.uploaded_count} 个文件`);
const result = await uploadDicomBatch(dcmFiles, undefined, {
onProgress: (progress) => setImportProgress({
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();
setProjects(data);
scheduleProgressDismiss();
} catch (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 {
setIsLoading(false);
if (dicomInputRef.current) dicomInputRef.current.value = '';
@@ -175,19 +559,13 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
return (
<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>
<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 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">
<button
onClick={() => setShowImportMenu(!showImportMenu)}
@@ -236,6 +614,8 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</div>
</div>
<ImportProgressPanel />
{isLoading && projects.length === 0 ? (
<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) => (
@@ -263,7 +643,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
)}
<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">
{proj.source_type === 'dicom' ? 'DICOM' : (proj.video_path && (proj.frames ?? 0) === 0 ? '待生成帧' : (proj.fps || '30FPS'))}
{frameSequenceLabel(proj)}
</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">
{proj.status === 'ready' ? (
@@ -280,17 +660,75 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</div>
<div className="p-4 flex flex-col gap-1">
<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>
<button
type="button"
aria-label={`删除项目 ${proj.name}`}
title="删除项目"
disabled={deletingProjectId === proj.id}
onClick={(event) => handleDeleteProject(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>
{editingProjectId === proj.id ? (
<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 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>
@@ -298,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>
)}
</div>
{proj.video_path && (proj.frames ?? 0) === 0 && proj.status !== 'parsing' && (
{canGenerateFrames(proj) && (
<button
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"
>
<Images size={14} />
{frameGenerationLabel(proj)}
</button>
)}
</div>
@@ -313,6 +751,92 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</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 */}
{showVideoConfig && pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
@@ -344,9 +868,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
{showFrameConfig && frameProject && (
<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>
<h2 className="text-lg font-semibold text-white mb-4">{frameGenerationLabel(frameProject)}</h2>
<div className="space-y-4">
<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>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"> (FPS)</label>
<div className="flex items-center gap-3">
@@ -382,58 +911,6 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</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>
);
}

View 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');
});
});

View File

@@ -1,8 +1,10 @@
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 type { ActiveModule } from '../App';
import { ModelStatusBadge } from './ModelStatusBadge';
import { useStore } from '../store/useStore';
import { AiSegmentationIcon } from './AiSegmentationIcon';
interface SidebarProps {
activeModule: ActiveModule;
@@ -10,18 +12,21 @@ interface SidebarProps {
}
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
const currentUser = useStore((state) => state.currentUser);
const logout = useStore((state) => state.logout);
const navItems = [
{ id: 'dashboard', icon: Home, label: '总体概况' },
{ id: 'projects', icon: FolderOpen, label: '项目库' },
{ id: 'workspace', icon: Edit3, label: '分割工作区' },
{ id: 'ai', icon: BrainCircuit, label: 'AI智能分割' },
{ id: 'ai', icon: AiSegmentationIcon, label: 'AI智能分割' },
{ id: 'templates', icon: LayoutTemplate, label: '模板库' },
...(currentUser?.role === 'admin' ? [{ id: 'admin', icon: UserCircle, label: '用户管理' }] : []),
] as const;
return (
<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">
<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>
<nav className="flex flex-col gap-6 w-full px-2">
{navItems.map((item) => {
@@ -49,6 +54,17 @@ export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
</nav>
<div className="mt-auto mb-4 flex flex-col gap-4">
<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>
</aside>
);

View File

@@ -19,6 +19,16 @@ vi.mock('../lib/api', () => ({
}));
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(() => {
resetStore();
vi.clearAllMocks();
@@ -39,6 +49,10 @@ describe('TemplateRegistry', () => {
expect(await screen.findAllByText('腹腔镜胆囊切除术')).toHaveLength(2);
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 () => {
@@ -47,7 +61,7 @@ describe('TemplateRegistry', () => {
id: 't2',
name: 'New Template',
description: 'desc',
classes: [],
classes: [expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' })],
rules: [],
});
@@ -60,7 +74,15 @@ describe('TemplateRegistry', () => {
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
name: 'New Template',
description: 'desc',
classes: [],
classes: [
expect.objectContaining({
id: 'reserved-unclassified',
name: '待分类',
color: '#000000',
zIndex: 0,
maskId: 0,
}),
],
rules: [],
color: '#06b6d4',
z_index: 0,
@@ -75,14 +97,81 @@ describe('TemplateRegistry', () => {
fireEvent.click(screen.getByText('新建方案'));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'With Classes' } });
fireEvent.click(screen.getByText('批量导入'));
expect(screen.queryByText('📋 载入腹腔镜胆囊切除术模板')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '{"colors":[[255,0,0]],"names":["分类A"]}' },
});
expect(screen.getByText(/将导入 1 个分类maskid 从 1 开始分配/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入' }));
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: [[25500], [02550],],
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 () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
@@ -104,7 +193,7 @@ describe('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')[1], { target: { value: 'new desc' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
@@ -112,7 +201,10 @@ describe('TemplateRegistry', () => {
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
name: '新模板',
description: 'new desc',
classes: [expect.objectContaining({ id: 'c1', name: '胆囊' })],
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊' }),
expect.objectContaining({ name: '待分类', maskId: 0 }),
],
rules: [],
color: '#06b6d4',
z_index: 3,
@@ -121,6 +213,229 @@ describe('TemplateRegistry', () => {
id: 't1',
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 () => {
@@ -137,8 +452,8 @@ describe('TemplateRegistry', () => {
const { container } = render(<TemplateRegistry />);
await screen.findAllByText('待删除模板');
const buttons = Array.from(container.querySelectorAll('button'));
fireEvent.click(buttons[2]);
fireEvent.click(container.querySelector('button[title="删除模板"]') as HTMLElement);
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => expect(apiMock.deleteTemplate).toHaveBeenCalledWith('t1'));
expect(useStore.getState().templates).toEqual([]);

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react';
import { Settings, Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import } from 'lucide-react';
import React, { useState, useEffect, useMemo } from 'react';
import { Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
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 { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
// HSL to Hex color generator
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);
}
const LAPAROSCOPIC_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],
];
const extractImportJsonText = (raw: string): string => {
let text = raw.trim();
const fenceMatch = text.match(/^```(?:json|javascript|js)?\s*([\s\S]*?)\s*```$/i);
if (fenceMatch) text = fenceMatch[1].trim();
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() {
const templates = useStore((state) => state.templates);
@@ -46,11 +80,15 @@ export function TemplateRegistry() {
const addTemplate = useStore((state) => state.addTemplate);
const updateTemplateStore = useStore((state) => state.updateTemplate);
const removeTemplateStore = useStore((state) => state.removeTemplate);
const setMasks = useStore((state) => state.setMasks);
const [isLoading, setIsLoading] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [showModal, setShowModal] = 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 [importText, setImportText] = useState('');
@@ -59,6 +97,13 @@ export function TemplateRegistry() {
const [editClasses, setEditClasses] = useState<TemplateClass[]>([]);
const [editingClassId, setEditingClassId] = useState<string | 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(() => {
setIsLoading(true);
@@ -72,7 +117,7 @@ export function TemplateRegistry() {
setSelectedTemplate(null);
setEditName('');
setEditDesc('');
setEditClasses([]);
setEditClasses(normalizeClassMaskIds([]));
setShowModal(true);
};
@@ -80,10 +125,69 @@ export function TemplateRegistry() {
setSelectedTemplate(template);
setEditName(template.name);
setEditDesc(template.description || '');
setEditClasses(template.classes ? [...template.classes] : []);
setEditClasses(normalizeClassMaskIds(template.classes ? [...template.classes] : []));
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 () => {
if (!editName.trim()) return;
setIsSaving(true);
@@ -91,49 +195,71 @@ export function TemplateRegistry() {
const basePayload = {
name: editName.trim(),
description: editDesc.trim() || undefined,
classes: editClasses,
classes: normalizeClassMaskIds(editClasses),
rules: [],
color: selectedTemplate ? (selectedTemplate as any).color || '#06b6d4' : '#06b6d4',
z_index: selectedTemplate ? (selectedTemplate as any).z_index ?? 0 : 0,
color: selectedTemplate ? selectedTemplate.color || '#06b6d4' : '#06b6d4',
z_index: selectedTemplate ? selectedTemplate.z_index ?? 0 : 0,
};
if (selectedTemplate) {
const updated = await updateTemplate(selectedTemplate.id, basePayload);
updateTemplateStore(updated);
setSelectedTemplate(updated);
syncMaskClassOrder(normalizeClassMaskIds(updated.classes || []));
} else {
const created = await createTemplate(basePayload);
addTemplate(created);
setSelectedTemplate(created);
}
setShowModal(false);
} catch (err) {
console.error('Failed to save template:', err);
alert('保存失败,请查看控制台');
showNotice('保存失败,请查看控制台', 'error');
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('确定要删除此模板吗?')) return;
const handleCopy = async (template: Template) => {
setCopyingTemplateId(template.id);
try {
await deleteTemplate(id);
removeTemplateStore(id);
if (selectedTemplate?.id === id) {
const created = await createTemplate({
name: nextCopyName(template.name),
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);
}
setDeleteTemplateTarget(null);
} catch (err) {
console.error('Failed to delete template:', err);
showNotice('删除失败,请检查后端服务', 'error');
}
};
const addClass = () => {
const newClass: TemplateClass = {
id: `cls-${Date.now()}`,
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]);
const newClass = buildNewClass(editClasses);
setEditClasses(recalculateClassOrder([...editClasses, newClass]));
setEditingClassId(newClass.id);
};
@@ -142,76 +268,150 @@ export function TemplateRegistry() {
};
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) => {
if (fromIndex === toIndex) return;
const items = [...editClasses];
if (isReservedUnclassifiedClass(items[fromIndex]) || isReservedUnclassifiedClass(items[toIndex])) return;
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
// Recalculate z-index based on new order (top = highest)
const recalculated = items.map((c, i) => ({ ...c, zIndex: (items.length - i) * 10 }));
setEditClasses(recalculated);
setEditClasses(recalculateClassOrder(items));
};
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 {
const data = JSON.parse(importText);
let colors: number[][] = [];
let names: string[] = [];
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 {
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 updated = await updateTemplate(activeTemplate.id, buildTemplatePayload(activeTemplate, nextClasses));
updateTemplateStore(updated);
setSelectedTemplate(updated);
syncMaskClassOrder(nextClasses);
} catch (err) {
console.error('Failed to save template class order:', err);
showNotice('层级顺序保存失败,请检查后端服务', 'error');
} finally {
setIsSavingOrder(false);
setDetailDragClassId(null);
setDetailDragOverClassId(null);
}
};
const loadLaparoscopic = () => {
const imported: TemplateClass[] = LAPAROSCOPIC_NAMES.map((name, i) => {
const rgb = LAPAROSCOPIC_COLORS[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-lap-${Date.now()}-${i}`,
name,
color: hex,
zIndex: (LAPAROSCOPIC_NAMES.length - i) * 10,
category: '腹腔镜胆囊切除术',
};
});
setEditClasses(imported);
setShowImport(false);
const deleteDetailClass = async (classId: string) => {
if (!activeTemplate || isSavingOrder) return;
const currentClasses = normalizeClassMaskIds(activeTemplate.classes || []);
const targetClass = currentClasses.find((templateClass) => templateClass.id === classId);
if (!targetClass || isReservedUnclassifiedClass(targetClass)) return;
const nextClasses = recalculateClassOrder(
currentClasses
.filter((templateClass) => templateClass.id !== classId)
.sort((a, b) => b.zIndex - a.zIndex),
);
if (nextClasses.length === currentClasses.length) return;
setIsSavingOrder(true);
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 (
<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">
<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>
@@ -253,13 +453,28 @@ export function TemplateRegistry() {
<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">
<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); }}
className="p-1 rounded text-gray-500 hover:text-cyan-400 transition-colors"
>
<Edit3 size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }}
<button
title="删除模板"
aria-label={`删除模板 ${t.name}`}
onClick={(e) => { e.stopPropagation(); setDeleteTemplateTarget(t); }}
className="p-1 rounded text-gray-500 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
@@ -288,7 +503,7 @@ export function TemplateRegistry() {
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"
>
<Settings size={14} /> (Schema)
<Edit3 size={14} />
</button>
)}
</div>
@@ -297,18 +512,62 @@ export function TemplateRegistry() {
<div className="space-y-6">
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-4">
(Painter's Algorithm Weight)
</h3>
<div className="space-y-2">
{(activeTemplate.classes || []).sort((a, b) => b.zIndex - a.zIndex).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 className="col-span-1 flex items-center gap-2">
{activeTemplateClasses.map((cls) => (
<div
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>
<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 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">
<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>
))}
@@ -378,12 +637,14 @@ export function TemplateRegistry() {
{editClasses.map((cls, idx) => (
<div
key={cls.id}
draggable
draggable={!isReservedUnclassifiedClass(cls)}
onDragStart={(e) => {
if (isReservedUnclassifiedClass(cls)) return;
e.dataTransfer.setData('text/plain', String(idx));
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
if (isReservedUnclassifiedClass(cls)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(idx);
@@ -392,6 +653,7 @@ export function TemplateRegistry() {
onDrop={(e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (isReservedUnclassifiedClass(cls) || Number.isNaN(fromIndex)) return;
reorderClasses(fromIndex, idx);
setDragOverIndex(null);
}}
@@ -401,46 +663,45 @@ export function TemplateRegistry() {
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} />
</div>
<input
type="color"
value={cls.color}
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 ? (
<>
<input
type="text"
value={cls.name}
onChange={(e) => updateClass(cls.id, { name: e.target.value })}
onBlur={() => setEditingClassId(null)}
onKeyDown={(e) => e.key === 'Enter' && setEditingClassId(null)}
autoFocus
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"
/>
</>
<input
type="text"
value={cls.name}
onChange={(e) => updateClass(cls.id, { name: e.target.value })}
onBlur={() => setEditingClassId(null)}
onKeyDown={(e) => e.key === 'Enter' && setEditingClassId(null)}
autoFocus
readOnly={isReservedUnclassifiedClass(cls)}
className="flex-1 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
/>
) : (
<>
<span
className="flex-1 text-sm text-gray-300 cursor-pointer"
onClick={() => setEditingClassId(cls.id)}
className={cn("flex-1 text-sm text-gray-300", isReservedUnclassifiedClass(cls) ? "cursor-default" : "cursor-pointer")}
onClick={() => {
if (!isReservedUnclassifiedClass(cls)) setEditingClassId(cls.id);
}}
>
{cls.name}
</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} />
</button>
</div>
@@ -487,13 +748,20 @@ export function TemplateRegistry() {
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"
/>
<div className="flex justify-between items-center mt-4">
<button
onClick={loadLaparoscopic}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
📋 载入腹腔镜胆囊切除术模板
</button>
{importPreview?.status === 'ready' && (
<div className="mt-3 rounded-lg border border-cyan-500/20 bg-cyan-950/15 px-3 py-2 text-xs text-cyan-100">
{importPreview.classes.length} maskid {importPreview.firstMaskId}
{importPreview.missingColorCount > 0 && (
<span className="ml-1 text-amber-200">{importPreview.missingColorCount} 使</span>
)}
</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">
<button
onClick={() => { setShowImport(false); setImportText(''); }}
@@ -503,7 +771,8 @@ export function TemplateRegistry() {
</button>
<button
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>
@@ -512,6 +781,32 @@ export function TemplateRegistry() {
</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>
);
}

View File

@@ -1,35 +1,155 @@
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';
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 onUndo = vi.fn();
const onRedo = vi.fn();
render(
<ToolsPalette
activeTool="move"
setActiveTool={setActiveTool}
onUndo={onUndo}
onRedo={onRedo}
canUndo
canRedo
/>,
);
fireEvent.click(screen.getByTitle('创建多边形 (P)'));
fireEvent.click(screen.getByTitle('调整多边形 (E)'));
fireEvent.click(screen.getByTitle('正向选点 (SAM)'));
fireEvent.click(screen.getByTitle('撤销操作 (Ctrl+Z)'));
fireEvent.click(screen.getByTitle('重做操作 (Ctrl+Shift+Z)'));
fireEvent.click(screen.getByTitle('画笔 (B)'));
fireEvent.click(screen.getByTitle('橡皮擦 (X)'));
expect(setActiveTool).toHaveBeenNthCalledWith(1, 'create_polygon');
expect(setActiveTool).toHaveBeenNthCalledWith(2, 'edit_polygon');
expect(setActiveTool).toHaveBeenNthCalledWith(3, 'point_pos');
expect(onUndo).toHaveBeenCalled();
expect(onRedo).toHaveBeenCalled();
expect(setActiveTool).toHaveBeenNthCalledWith(3, 'brush');
expect(setActiveTool).toHaveBeenNthCalledWith(4, 'eraser');
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', () => {
@@ -37,9 +157,23 @@ describe('ToolsPalette', () => {
const onTriggerAI = vi.fn();
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(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');
});
});

View File

@@ -1,124 +1,196 @@
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 { AiAutoInferenceIcon } from './AiAutoInferenceIcon';
import { AiSegmentationIcon } from './AiSegmentationIcon';
import { useStore } from '../store/useStore';
interface ToolsPaletteProps {
activeTool: string;
setActiveTool: (tool: string) => void;
onTriggerAI?: () => void;
onUndo?: () => void;
onRedo?: () => void;
canUndo?: boolean;
canRedo?: boolean;
onAutoPropagate?: () => void;
onImportGtMask?: () => void;
onClearSelection?: () => void;
onDeleteMasks?: () => void;
onClearMasks?: () => void;
canAutoPropagate?: boolean;
isPropagating?: boolean;
canImportGtMask?: boolean;
isImportingGtMask?: boolean;
}
export function ToolsPalette({
activeTool,
setActiveTool,
onTriggerAI,
onUndo,
onRedo,
canUndo = false,
canRedo = false,
onAutoPropagate,
onImportGtMask,
onClearSelection,
onDeleteMasks,
onClearMasks,
canAutoPropagate = false,
isPropagating = false,
canImportGtMask = false,
isImportingGtMask = false,
}: 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 = [
{ id: 'move', icon: MousePointer2, label: '拖拽 / 选择 (V)' },
{ id: 'edit_polygon', icon: PencilLine, label: '调整多边形 (E)' },
{ id: 'create_polygon', icon: Hexagon, label: '创建多边形 (P)' },
{ id: 'create_rectangle', icon: Square, label: '创建矩形 (R)' },
{ id: 'create_circle', icon: Circle, label: '创建圆 (O)' },
{ id: 'create_point', icon: Crosshair, label: '创建点 (C)' },
{ id: 'create_line', icon: Minus, label: '创建线段 (L)' },
{ id: 'brush', icon: Brush, label: '画笔 (B)' },
{ id: 'eraser', icon: Eraser, label: '橡皮擦 (X)' },
{ id: 'area_merge', icon: Combine, 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 (
<div className="w-12 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-center py-4 shrink-0 z-10">
<div className="flex flex-col gap-4 w-full px-2">
<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-1.5 w-12 shrink-0 px-1.5">
{tools.map(tool => {
const Icon = tool.icon;
const isActive = activeTool === tool.id;
return (
<button
key={tool.id}
onClick={() => setActiveTool(tool.id)}
title={tool.label}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center transition-all p-2",
isActive
? (tool.id.includes('remove') ? "bg-red-500/10 text-red-500"
: tool.id.includes('merge') ? "bg-green-500/10 text-green-500"
: "bg-white/10 text-white")
: "text-gray-500 hover:bg-white/5 hover:text-white"
<React.Fragment key={tool.id}>
<button
onClick={() => setActiveTool(tool.id)}
title={tool.label}
className={cn(
"w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5",
isActive
? (tool.id.includes('remove') ? "bg-red-500/10 text-red-500"
: tool.id.includes('merge') ? "bg-green-500/10 text-green-500"
: "bg-white/10 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>
)}
>
<Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
</button>
{tool.id === 'eraser' && sizeControl && (
<div className="w-9 rounded-md border border-white/10 bg-white/[0.03] px-1 py-2 text-center">
<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 => {
const Icon = tool.icon;
const isActive = activeTool === tool.id;
return (
<button
key={tool.id}
onClick={() => setActiveTool(tool.id)}
title={tool.label}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center transition-all p-2 border",
isActive
? `${tool.bg} ${tool.color} ${tool.border} shadow-[0_0_10px_rgba(255,255,255,0.05)]`
: "text-gray-500 hover:bg-white/5 hover:text-white border-transparent"
)}
>
<Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
</button>
)
})}
<button
onClick={onClearMasks}
disabled={!onClearMasks}
title="清空遮罩"
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"
>
<Trash2 size={16} strokeWidth={2.1} />
</button>
<div data-testid="tool-group-separator" className="my-1 h-px w-9 bg-white/15" />
<button
onClick={onImportGtMask}
disabled={!canImportGtMask || isImportingGtMask}
title={isImportingGtMask ? '正在导入 GT Mask' : '导入 GT Mask'}
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"
>
<FileUp size={16} strokeWidth={2.2} />
</button>
<button
onClick={() => {
setActiveTool('sam_trigger');
if (onTriggerAI) onTriggerAI();
}}
title="触发 SAM 推理 (Enter)"
title="打开 AI 智能分割"
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'
? "bg-cyan-600 text-white shadow-lg shadow-cyan-900/20"
: "text-gray-500 hover:bg-white/5"
)}
>
<Wand2 size={18} strokeWidth={2} />
</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} />
<AiSegmentationIcon size={17} strokeWidth={2.1} />
</button>
</div>

View 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();
});
});

View 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>
);
}

View 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();
});
});

View 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">
adminLC视频序列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

View File

@@ -8,4 +8,39 @@
-ms-overflow-style: none; /* IE and Edge */
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;
}
}

View File

@@ -53,10 +53,12 @@ describe('api client contracts', () => {
name: 'Demo',
status: 'ready',
frames: 12,
fps: '30FPS',
fps: '10FPS',
thumbnail_url: 'thumb',
video_path: 'uploads/demo.mp4',
source_type: 'video',
original_fps: 29.97,
parse_fps: 10,
createdAt: 'created',
updatedAt: 'updated',
}),
@@ -73,6 +75,19 @@ describe('api client contracts', () => {
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 });
@@ -82,6 +97,134 @@ describe('api client contracts', () => {
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 () => {
const { getProjects } = await import('./api');
axiosMock.client.get.mockResolvedValueOnce({
@@ -121,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 () => {
const { getDashboardOverview } = await import('./api');
const overview = {
@@ -184,7 +354,7 @@ describe('api client contracts', () => {
});
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 = {
id: 1,
project_id: 9,
@@ -231,6 +401,10 @@ describe('api client contracts', () => {
await expect(deleteAnnotation('1')).resolves.toBeUndefined();
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({
data: {
model: 'sam2.1_hiera_tiny',
@@ -267,6 +441,48 @@ describe('api client contracts', () => {
}, {
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 () => {
@@ -275,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 }];
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(
'/api/ai/import-gt-mask',
expect.any(FormData),
@@ -286,6 +502,7 @@ describe('api client contracts', () => {
expect(form.get('project_id')).toBe('9');
expect(form.get('frame_id')).toBe('5');
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 () => {
@@ -300,8 +517,10 @@ describe('api client contracts', () => {
classId: 'c1',
className: '胆囊',
classZIndex: 20,
classMaskId: 7,
segmentation: [[10, 10, 90, 10, 90, 40]],
bbox: [10, 10, 80, 30],
metadata: { geometry_smoothing: { strength: 35, method: 'chaikin' } },
}, frame, '2');
expect(payload).toEqual({
@@ -309,12 +528,15 @@ describe('api client contracts', () => {
frame_id: 5,
template_id: 2,
mask_data: {
instance_id: 'm1',
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
label: '胆囊',
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],
points: [[0.6333333333333333, 0.4]],
});
expect(annotationToMask({
@@ -326,7 +548,12 @@ describe('api client contracts', () => {
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
label: '旧标签',
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]],
bbox: null,
@@ -340,6 +567,7 @@ describe('api client contracts', () => {
classId: 'c1',
className: '胆囊',
classZIndex: 20,
classMaskId: 7,
label: '胆囊',
color: '#ff0000',
saveStatus: 'saved',
@@ -347,6 +575,58 @@ describe('api client contracts', () => {
pathData: 'M 10 10 L 90 10 L 90 40 Z',
points: [[50, 25]],
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' },
}));
});
@@ -367,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 () => {
const { predictMask } = await import('./api');
axiosMock.client.post.mockResolvedValueOnce({
@@ -423,6 +833,48 @@ describe('api client contracts', () => {
});
});
it('sends normalized mask geometry to the backend analyzer', async () => {
const { analyzeMask } = await import('./api');
axiosMock.client.post.mockResolvedValueOnce({
data: {
confidence: 0.87,
confidence_source: 'model_score',
topology_anchor_count: 3,
topology_anchors: [],
area: 0.12,
bbox: [0.1, 0.2, 0.8, 0.6],
source: 'sam2.1_hiera_tiny',
message: 'ok',
},
});
const result = await analyzeMask({
id: 'm1',
frameId: '5',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 90, 10, 90, 40]],
bbox: [10, 10, 80, 30],
metadata: { source: 'sam2.1_hiera_tiny', score: 0.87 },
}, { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 }, { extractSkeleton: true });
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/analyze-mask', {
frame_id: 5,
mask_data: {
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
label: '胆囊',
color: '#ff0000',
source: 'sam2.1_hiera_tiny',
score: 0.87,
},
points: undefined,
bbox: [0.1, 0.2, 0.8, 0.6],
extract_skeleton: true,
});
expect(result.confidence).toBe(0.87);
});
it('normalizes combined box and point prompts for interactive SAM2 refinement', async () => {
const { predictMask } = await import('./api');
axiosMock.client.post.mockResolvedValueOnce({ data: { polygons: [], scores: [] } });

View File

@@ -1,6 +1,7 @@
import axios, { AxiosError } from 'axios';
import { DEFAULT_AI_MODEL_ID, type AiModelId, type Frame, type Mask, type Project, type Template } from '../store/useStore';
import axios, { AxiosError, type AxiosProgressEvent } from 'axios';
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 { normalizeClassMaskIds } from './maskIds';
const apiClient = axios.create({
baseURL: API_BASE_URL,
@@ -28,18 +29,90 @@ apiClient.interceptors.response.use(
(error: AxiosError) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.reload();
if (!error.config?.url?.includes('/api/auth/login')) {
window.location.reload();
}
}
return Promise.reject(error);
}
);
// 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 });
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
function normalizeProjectStatus(status?: string): Project['status'] {
const value = (status || 'pending').toLowerCase();
@@ -49,6 +122,12 @@ function normalizeProjectStatus(status?: string): Project['status'] {
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 {
return {
id: String(p.id),
@@ -56,7 +135,7 @@ function mapProject(p: any): Project {
description: p.description,
status: normalizeProjectStatus(p.status),
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,
video_path: p.video_path,
source_type: p.source_type,
@@ -86,6 +165,14 @@ export async function updateProject(id: string, payload: Partial<Project>): Prom
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> {
await apiClient.delete(`/api/projects/${id}`);
}
@@ -97,7 +184,9 @@ function _mapTemplate(t: any): Template {
id: String(t.id),
name: t.name,
description: t.description,
classes: mapping.classes || [],
color: t.color,
z_index: t.z_index,
classes: normalizeClassMaskIds(mapping.classes || []),
rules: mapping.rules || [],
createdAt: t.created_at,
updatedAt: t.updated_at,
@@ -114,7 +203,7 @@ export async function createTemplate(payload: {
description?: string;
color: string;
z_index: number;
classes?: { name: string; color: string; zIndex: number; category?: string }[];
classes?: Template['classes'];
rules?: any[];
}): Promise<Template> {
const response = await apiClient.post('/api/templates', payload);
@@ -131,7 +220,26 @@ export async function deleteTemplate(id: string): Promise<void> {
}
// 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();
formData.append('file', file);
if (projectId) {
@@ -141,6 +249,7 @@ export async function uploadMedia(file: File, projectId?: string): Promise<{ url
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: options.onProgress ? (event) => options.onProgress?.(toUploadProgress(event)) : undefined,
});
const { file_url, object_name } = response.data;
return { url: file_url, id: object_name };
@@ -160,12 +269,13 @@ export async function getProjectFrames(projectId: string): Promise<Array<{
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();
files.forEach((file) => formData.append('files', file));
if (projectId) formData.append('project_id', projectId);
const response = await apiClient.post('/api/media/upload/dicom', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: options.onProgress ? (event) => options.onProgress?.(toUploadProgress(event)) : undefined,
});
return response.data;
}
@@ -285,6 +395,7 @@ export interface SavedAnnotation {
template_id: number | null;
mask_data: {
polygons?: number[][][];
holes?: number[][][][];
label?: string;
color?: string;
class?: {
@@ -292,8 +403,15 @@ export interface SavedAnnotation {
name?: string;
color?: string;
zIndex?: number;
maskId?: number;
category?: string;
};
source?: string;
propagated_from_frame_id?: number;
propagated_from_frame_index?: number;
geometry_smoothing?: GeometrySmoothingOptions;
score?: number | null;
[key: string]: unknown;
} | null;
points: number[][] | null;
bbox: number[] | null;
@@ -307,6 +425,7 @@ export interface SaveAnnotationPayload {
template_id?: number;
mask_data?: {
polygons: number[][][];
holes?: number[][][][];
label?: string;
color?: string;
class?: {
@@ -314,8 +433,11 @@ export interface SaveAnnotationPayload {
name?: string;
color?: string;
zIndex?: number;
maskId?: number;
category?: string;
};
geometry_smoothing?: GeometrySmoothingOptions;
[key: string]: unknown;
};
points?: number[][];
bbox?: number[];
@@ -329,6 +451,7 @@ export interface PropagateMasksPayload {
model?: AiModelId;
seed: {
polygons?: number[][][];
holes?: number[][][][];
bbox?: number[];
points?: number[][];
label?: string;
@@ -341,6 +464,11 @@ export interface PropagateMasksPayload {
category?: string;
};
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';
max_frames?: number;
@@ -348,6 +476,19 @@ export interface PropagateMasksPayload {
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 {
model: AiModelId;
direction: string;
@@ -357,6 +498,34 @@ export interface PropagateMasksResult {
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 {
id: string;
task_id?: number;
@@ -424,6 +593,82 @@ function polygonToBbox(points: number[][], width: number, height: number): [numb
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(
segmentation: number[][] | undefined,
width: number,
@@ -444,33 +689,104 @@ function pixelSegmentationToNormalizedPolygons(
.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(
projectId: string,
mask: Mask,
frame: Frame,
templateId?: string | 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;
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,
name: mask.className || mask.label,
color: mask.color,
zIndex: mask.classZIndex,
maskId: mask.classMaskId,
}
: 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 = {
project_id: Number(projectId),
frame_id: Number(frame.id),
template_id: effectiveTemplateId ? Number(effectiveTemplateId) : undefined,
mask_data: {
...metadata,
instance_id: instanceId,
polygons,
...(splitGeometry.holes ? { holes: splitGeometry.holes } : {}),
label: mask.label,
color: mask.color,
...(classMetadata ? { class: classMetadata } : {}),
...(geometrySmoothing ? { geometry_smoothing: geometrySmoothing } : {}),
},
bbox: mask.bbox
? [
@@ -482,8 +798,9 @@ export function buildAnnotationPayload(
: undefined,
};
if (mask.points) {
payload.points = mask.points.map(([x, y]) => [
const seedPoints = maskSeedPointsPixels(mask);
if (seedPoints.length > 0) {
payload.points = seedPoints.map(([x, y]) => [
clamp01(x / Math.max(frame.width, 1)),
clamp01(y / Math.max(frame.height, 1)),
]);
@@ -494,10 +811,18 @@ export function buildAnnotationPayload(
export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mask | null {
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;
const bbox = polygonToBbox(firstPolygon, frame.width, frame.height);
const bbox = polygonsToBbox(segmentationPolygons, frame.width, frame.height);
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 {
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
@@ -506,15 +831,88 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
classId: classMetadata?.id,
className: classMetadata?.name,
classZIndex: classMetadata?.zIndex,
classMaskId: classMetadata?.maskId,
saveStatus: 'saved',
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}`,
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]),
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,
};
}
@@ -604,6 +1002,11 @@ export async function propagateMasks(payload: PropagateMasksPayload): Promise<Pr
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> {
const response = await apiClient.post('/api/ai/annotate', payload);
return response.data;
@@ -615,7 +1018,12 @@ export async function updateAnnotation(annotationId: string, payload: UpdateAnno
}
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(
@@ -623,12 +1031,14 @@ export async function importGtMask(
projectId: string,
frameId: string,
templateId?: string | null,
options: { unknownColorPolicy?: 'discard' | 'undefined' } = {},
): Promise<SavedAnnotation[]> {
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', projectId);
formData.append('frame_id', frameId);
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, {
headers: { 'Content-Type': 'multipart/form-data' },
});
@@ -655,4 +1065,37 @@ export async function exportMasks(projectId: string): Promise<Blob> {
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;

View 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();
});
});

View 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
View 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;
}

View File

@@ -94,4 +94,34 @@ describe('progress websocket client', () => {
unsubscribeStatus();
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);
});
});

View File

@@ -30,6 +30,7 @@ class ProgressWebSocket {
private heartbeatInterval = 15000;
private shouldReconnect = false;
private shouldCloseAfterOpen = false;
private manualDisconnect = false;
private currentInterval = 3000;
constructor(url = WS_PROGRESS_URL) {
@@ -43,6 +44,7 @@ class ProgressWebSocket {
this.shouldReconnect = true;
this.shouldCloseAfterOpen = false;
this.manualDisconnect = false;
this.notifyStatus('connecting');
try {
@@ -71,7 +73,9 @@ class ProgressWebSocket {
};
this.ws.onclose = () => {
console.log('[WebSocket] Connection closed');
if (!this.manualDisconnect) {
console.log('[WebSocket] Connection closed');
}
this.stopHeartbeat();
this.ws = null;
this.notifyStatus('disconnected');
@@ -97,6 +101,7 @@ class ProgressWebSocket {
disconnect() {
this.shouldReconnect = false;
this.manualDisconnect = true;
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);

View File

@@ -7,16 +7,22 @@ describe('useStore', () => {
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', () => {
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().token).toBe('token-1');
expect(useStore.getState().currentUser?.username).toBe('admin');
expect(localStorage.getItem('token')).toBe('token-1');
useStore.getState().logout();
expect(useStore.getState().isAuthenticated).toBe(false);
expect(useStore.getState().currentUser).toBeNull();
expect(useStore.getState().projects).toEqual([]);
expect(useStore.getState().frames).toEqual([]);
expect(localStorage.getItem('token')).toBeNull();
@@ -31,6 +37,9 @@ describe('useStore', () => {
useStore.getState().setCurrentFrame(0);
useStore.getState().addMask({ id: 'm1', frameId: 'f1', pathData: 'M 0 0 Z', label: 'mask', color: '#fff' });
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().addAnnotation({ id: 'a1', frameId: 'f1', type: 'mask', points: [], label: 'ann', color: '#fff' });
useStore.getState().addTemplate({ id: 't1', name: 'Template', classes: [], rules: [] });
@@ -42,6 +51,9 @@ describe('useStore', () => {
expect(useStore.getState().frames).toHaveLength(1);
expect(useStore.getState().currentFrameIndex).toBe(0);
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().annotations).toHaveLength(1);
expect(useStore.getState().templates[0].name).toBe('Template 2');

View File

@@ -24,6 +24,8 @@ export type AiModelId =
| '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' },
@@ -64,6 +66,7 @@ export interface Mask {
classId?: string;
className?: string;
classZIndex?: number;
classMaskId?: number;
saveStatus?: 'draft' | 'saved' | 'dirty' | 'saving' | 'error';
saved?: boolean;
pathData: string;
@@ -81,6 +84,8 @@ export interface Template {
id: string;
name: string;
description?: string;
color?: string;
z_index?: number;
classes: TemplateClass[];
rules?: TemplateRule[];
createdAt?: string;
@@ -92,6 +97,7 @@ export interface TemplateClass {
name: string;
color: string;
zIndex: number;
maskId?: number;
category?: string;
description?: string;
}
@@ -104,11 +110,20 @@ export interface TemplateRule {
operation: string;
}
export interface UserProfile {
id: number;
username: string;
role: string;
is_active?: number;
}
export interface AppState {
// Auth
isAuthenticated: boolean;
token: string | null;
login: (token: string) => void;
currentUser: UserProfile | null;
login: (token: string, user?: UserProfile | null) => void;
setCurrentUser: (user: UserProfile | null) => void;
logout: () => void;
// Projects
@@ -128,6 +143,9 @@ export interface AppState {
annotations: Annotation[];
masks: Mask[];
selectedMaskIds: string[];
maskPreviewOpacity: number;
brushSize: number;
eraserSize: number;
maskHistory: Mask[][];
maskFuture: Mask[][];
setActiveModule: (module: string) => void;
@@ -140,6 +158,9 @@ export interface AppState {
updateMask: (id: string, updates: Partial<Mask>) => void;
setMasks: (masks: Mask[]) => void;
setSelectedMaskIds: (ids: string[]) => void;
setMaskPreviewOpacity: (opacity: number) => void;
setBrushSize: (size: number) => void;
setEraserSize: (size: number) => void;
clearMasks: () => void;
undoMasks: () => void;
redoMasks: () => void;
@@ -167,17 +188,20 @@ export interface AppState {
export const useStore = create<AppState>((set) => ({
// Auth
isAuthenticated: false,
token: null,
login: (token: string) => {
isAuthenticated: Boolean(localStorage.getItem('token')),
token: localStorage.getItem('token'),
currentUser: null,
login: (token: string, user: UserProfile | null = null) => {
localStorage.setItem('token', token);
set({ isAuthenticated: true, token });
set({ isAuthenticated: true, token, currentUser: user });
},
setCurrentUser: (currentUser: UserProfile | null) => set({ currentUser }),
logout: () => {
localStorage.removeItem('token');
set({
isAuthenticated: false,
token: null,
currentUser: null,
currentProject: null,
projects: [],
templates: [],
@@ -185,6 +209,9 @@ export const useStore = create<AppState>((set) => ({
annotations: [],
masks: [],
selectedMaskIds: [],
maskPreviewOpacity: 50,
brushSize: DEFAULT_BRUSH_SIZE,
eraserSize: DEFAULT_ERASER_SIZE,
maskHistory: [],
maskFuture: [],
activeTemplateId: null,
@@ -206,7 +233,7 @@ export const useStore = create<AppState>((set) => ({
})),
// Workspace
activeModule: 'workspace',
activeModule: 'dashboard',
activeTool: 'move',
aiModel: DEFAULT_AI_MODEL_ID,
frames: [],
@@ -214,6 +241,9 @@ export const useStore = create<AppState>((set) => ({
annotations: [],
masks: [],
selectedMaskIds: [],
maskPreviewOpacity: 50,
brushSize: DEFAULT_BRUSH_SIZE,
eraserSize: DEFAULT_ERASER_SIZE,
maskHistory: [],
maskFuture: [],
setActiveModule: (activeModule: string) => set({ activeModule }),
@@ -247,6 +277,15 @@ export const useStore = create<AppState>((set) => ({
};
}),
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: () =>
set((state) => ({
masks: [],

View File

@@ -32,7 +32,7 @@ function makeStageEvent(x = 120, y = 80) {
}
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) => ({
x: event.clientX || fallbackX,
y: event.clientY || fallbackY,
@@ -40,6 +40,13 @@ vi.mock('react-konva', () => ({
return (
<div
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) => {
const point = coords(event, 120, 80);
onClick?.(makeStageEvent(point.x, point.y));
@@ -57,6 +64,21 @@ vi.mock('react-konva', () => ({
onMouseMove?.(makeStageEvent(point.x, point.y));
}}
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}
</div>
@@ -80,6 +102,22 @@ vi.mock('react-konva', () => ({
props.onClick?.(konvaEvent);
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?.({
target: {
x: () => event.clientX || props.x || 0,
@@ -100,6 +138,9 @@ vi.mock('react-konva', () => ({
data-testid="konva-path"
data-path={props.data}
data-fill={props.fill}
data-stroke={props.stroke}
data-stroke-width={props.strokeWidth}
data-dash={props.dash?.join(',') || ''}
data-fill-rule={props.fillRule}
onClick={(event) => {
const point = {

View File

@@ -1,12 +1,13 @@
import { DEFAULT_AI_MODEL_ID, useStore } from '../store/useStore';
import { DEFAULT_AI_MODEL_ID, DEFAULT_BRUSH_SIZE, DEFAULT_ERASER_SIZE, useStore } from '../store/useStore';
export function resetStore() {
useStore.setState({
isAuthenticated: false,
token: null,
currentUser: null,
projects: [],
currentProject: null,
activeModule: 'workspace',
activeModule: 'dashboard',
activeTool: 'move',
aiModel: DEFAULT_AI_MODEL_ID,
frames: [],
@@ -14,6 +15,9 @@ export function resetStore() {
annotations: [],
masks: [],
selectedMaskIds: [],
maskPreviewOpacity: 50,
brushSize: DEFAULT_BRUSH_SIZE,
eraserSize: DEFAULT_ERASER_SIZE,
maskHistory: [],
maskFuture: [],
templates: [],

View File

@@ -47,7 +47,7 @@ echo " ✓ FastAPI 已启动 (http://localhost:8000/docs)"
# 5. 启动 Celery Worker
echo "[5/6] 启动 Celery Worker..."
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
echo " ✓ Celery Worker 已启动"

View File

@@ -9,7 +9,7 @@
## 修改文件清单
### 文件 1: 创建 `public/` 目录并放入 logo
- **操作**: `mkdir public && cp logo_square.png public/logo.png`
- **操作**: 将系统 logo 放入 `public/logo.png`
- **说明**: Vite 自动将 `public/` 下文件暴露到根 URL
### 文件 2: `src/components/Sidebar.tsx`(修改)

View File

@@ -282,13 +282,13 @@ AI 助手运行的容器/环境与项目实际开发环境分离,后者才装
2. 浏览器控制台报 `favicon.ico 404`
### B. 产生原因
1. `logo_square.png` 位于项目根目录,但 Vite 不会自动将根目录文件暴露为静态资源
2. 前端代码引用路径为 `/Logo.png`(首字母大写),与实际文件名 `logo_square.png` 不匹配
1. 早期 logo 位于项目根目录,但 Vite 不会自动将根目录文件暴露为静态资源
2. 前端代码引用路径为 `/Logo.png`(首字母大写),与实际静态资源路径不匹配
3. `index.html` 无 favicon 声明,浏览器默认请求 `/favicon.ico`
### C. 解决方案
1. 创建 `public/` 目录Vite 原生支持,自动暴露到根 URL
2. `logo_square.png` 复制`public/logo.png`
2.系统 logo 统一放入并引用`public/logo.png`
3. `Sidebar.tsx` 引用路径改为 `/logo.png`
4. `index.html` 添加 `<link rel="icon" type="image/png" href="/logo.png" />`

View File

@@ -5,13 +5,13 @@
- 需求类型: 缺陷修复 + 资源部署
## 原始需求描述
1.@logo_square.png 移动到特定位置,作为系统 logo
1. 将 logo 静态资源移动到 `public/logo.png`,作为系统 logo
2. 解决 `Failed to load resource: the server responded with a status of 404 (Not Found) 3000/favicon.ico:1`
## 需求拆解
### 需求 1: 部署 logo 静态资源
- **详细描述**: logo_square.png 位于项目根目录,但前端 Sidebar.tsx 引用的是 `/Logo.png`,且 Vite 项目缺少 `public/` 目录导致静态文件无法暴露到根路径
- **详细描述**: 早期 logo 位于项目根目录,但前端 Sidebar.tsx 引用的是 `/Logo.png`,且 Vite 项目缺少 `public/` 目录导致静态文件无法暴露到根路径;当前项目已统一使用 `public/logo.png`
- **优先级**: P0-阻塞
- **影响范围**: `public/` 目录创建、`Sidebar.tsx`
- **验收标准**: 浏览器可正常加载 logoSidebar 正确显示

441
撰写Agent.md Normal file
View File

@@ -0,0 +1,441 @@
# 软著文档撰写 Agent 通用工作流
本文档用于指导 AI 编码助手或文档 Agent 在任意软件项目中完成中国计算机软件著作权申报材料撰写工作。该流程不绑定特定项目,适用于 Web 系统、桌面软件、移动端应用、后端服务、算法平台、数据处理工具等各类软件。
## 1. 角色定位
你是一名经验丰富的软件技术文档工程师和中国计算机软件著作权申报专家。你需要根据用户提供的参考模板、系统基本信息、源代码、运行环境和界面实际表现,生成逻辑严密、表达规范、可用于软著申报的产品技术文档。
工作目标不是复述代码,而是把代码中体现的业务功能、使用流程、技术结构和核心实现提炼为符合软著材料要求的文档。
## 2. 标准目录约定
工作区通常包含三个同级目录:
```text
参考软著构建模板/
待分析代码目录/
新撰写软著文档/
```
各目录职责如下:
- `参考软著构建模板/`:存放用户给出的参考样例,如 `参考-1. 软著说明书.md``参考-2. 软著登记表.md``参考-3. 代码汇总.md`。必须优先学习其目录结构、标题层级、表格字段、文字风格和排版习惯。
- `待分析代码目录/`存放现有系统源码、配置文件、README、部署文档、基本信息说明等。实际目录名由用户指定。
- `新撰写软著文档/`:最终交付目录。生成的正式结果必须保存到该目录,不要散落在源码目录或临时目录中。
标准输出文件名固定为:
```text
新撰写软著文档/1. 软著说明书.md
新撰写软著文档/2. 软著登记表.md
新撰写软著文档/3. 代码汇总.md
```
如需补充验证材料,可另建:
```text
新撰写软著文档/images/
新撰写软著文档/系统使用视频/
新撰写软著文档/功能验证与素材清单.md
```
## 3. 启动工作流
收到任务后,先确认用户是否已经提供以下材料:
- 软件名称、版本号、著作权人、开发完成日期、首次发表日期等基本信息。
- 参考模板目录。
- 源代码目录。
- 系统可运行地址、本地启动方式或可截图的界面。
- 登录账号、测试账号或演示环境说明。
- 是否需要真实截图、录屏和功能验证。
如果用户只要求“工作流已就绪”,只回复该固定短语,等待用户继续提供基本信息和源代码。
如果用户已经明确要求开始生成文档,则不要停留在计划阶段,应直接读取模板和代码,创建目标目录并生成三份文档。
## 4. 第一阶段:学习模板
必须先阅读参考模板,而不是直接套用通用格式。
重点提取:
- 说明书标题层级、章节顺序和图文排版方式。
- 登记表字段名称、表格结构、填写口径和篇幅限制。
- 代码汇总的文件名标注、代码块风格、摘抄长度和顺序。
- 模板中的常用措辞,如“单击”“填写”“系统显示”“确认后”“保存成功”等。
模板学习后,应保持新文档在排版和语言上与模板一致,但内容必须来自当前项目实际功能,不得照搬模板业务内容。
## 5. 第二阶段:理解代码与系统功能
对源码进行结构化分析,优先阅读:
- 项目说明:`README`、部署文档、产品文档、需求文档。
- 前端入口:路由、页面组件、状态管理、接口封装、导航菜单。
- 后端入口:路由、控制器、服务层、数据模型、权限逻辑。
- 配置文件:依赖、运行环境、数据库、中间件、第三方服务。
- 测试文件:可帮助确认实际功能边界。
分析时要整理出:
- 系统用户角色。
- 注册、登录、退出流程。
- 主界面结构。
- 核心业务模块。
- 数据列表、详情查看、新增、编辑、删除、导入、导出等管理功能。
- 算法、AI、图像、音视频、数据处理等特色功能。
- 系统是否支持多种数据类型例如视频、图片、DICOM、PDF、Excel、音频等。
- 权限控制、任务进度、审计日志、模板管理等辅助能力。
注意:说明书面向用户,不面向程序员。代码中的“接口、路由、组件、状态、触发器、数据库字段”等应转化为用户可理解的“页面、按钮、列表、表单、操作结果、提示信息”。
## 6. 任务一:撰写操作说明书
输出路径:
```text
新撰写软著文档/1. 软著说明书.md
```
### 6.1 必备章节
通常至少包含:
- 系统注册。
- 系统登录。
- 系统核心管理界面。
- 数据列表与信息查看。
- 新增、编辑、删除、导入、导出等管理操作。
- 核心业务流程。
- 系统设置、用户管理、权限管理、审计日志等辅助模块。
- 退出系统。
如项目不存在公开注册,而是管理员创建账号,应写成“系统注册由管理员在用户管理界面完成”,不要虚构前台注册页。
### 6.2 行文要求
使用正式、客观、可申报的操作说明语气,例如:
- “用户单击‘登录’按钮,系统显示登录界面。”
- “填写完成后,单击‘保存’按钮,系统更新列表信息。”
- “系统在页面下方显示处理进度,用户可查看当前任务状态。”
严禁在说明书中出现开发黑话,例如:
- “调用 API”
- “进入路由”
- “触发接口”
- “修改 state”
- “发送 payload”
- “执行 hook”
这些内容应改写为:
- “系统提交用户填写的信息”
- “页面切换至对应功能区”
- “系统保存并显示处理结果”
### 6.3 图文要求
初稿可使用统一占位符:
```text
[插入图片:系统登录界面图]
```
如果系统可运行,应尽量用真实截图替换占位符:
```text
![](images/01-login.png)
```
截图安排原则:
- 一个功能区域可以放多张图,每张图分别展示不同动作或状态。
- 不要为了省事让同一张截图在多个段落重复出现。
- 不同截图应对应不同功能点,例如“列表页”“导入弹窗”“编辑表单”“结果预览”“导出配置”。
- 核心功能必须图文并茂,不要只写文字。
- 涉及 AI、自动处理、批量导入、任务进度、导出结果等复杂功能时应至少包含“入口/配置”“执行中/处理中”“结果展示”三类图。
- 涉及多数据类型时,每类数据都应有图。例如系统支持视频和 DICOM应分别展示视频项目和 DICOM 序列的处理界面。
截图命名建议:
```text
images/01-login.png
images/02-dashboard.png
images/03-project-list.png
images/04-import-dialog.png
images/05-workspace-main.png
images/06-core-feature-config.png
images/07-core-feature-result.png
```
完成后必须检查:
- 图片文件是否存在。
- 图片引用是否断链。
- 是否存在同一图片反复引用。
- 图片与文字是否匹配。
- 是否仍有未替换的占位符。
## 7. 任务二:填写软著登记表
输出路径:
```text
新撰写软著文档/2. 软著登记表.md
```
登记表应严格参照模板字段填写。常见字段包括:
- 软件全称。
- 软件简称。
- 版本号。
- 著作权人。
- 开发完成日期。
- 首次发表日期。
- 开发方式。
- 权利取得方式。
- 硬件环境。
- 软件环境。
- 编程语言。
- 源程序量。
- 主要功能。
- 技术特点。
### 7.1 环境填写原则
根据代码和配置合理推断:
- 前端框架React、Vue、Angular、uni-app 等。
- 后端框架Spring Boot、FastAPI、Django、Flask、Express 等。
- 数据库MySQL、PostgreSQL、SQLite、MongoDB 等。
- 中间件Redis、RabbitMQ、MinIO、Nginx、Celery 等。
- AI/算法PyTorch、TensorFlow、OpenCV、ONNX Runtime 等。
- 运行环境Windows、Linux、Docker、Node.js、Python、JDK 等。
不确定的信息标注为“待确认”,不要编造精确版本。
### 7.2 功能与技术特点
主要功能建议控制在 200 字以内,说明系统解决什么业务问题、包含哪些核心模块、能完成哪些操作。
技术特点建议控制在 100 字以内,说明架构、框架、数据处理、权限控制、可扩展性、算法能力等。
登记表语言应精炼,不写长篇说明书式操作步骤。
## 8. 任务三:提取代码汇总
输出路径:
```text
新撰写软著文档/3. 代码汇总.md
```
代码汇总不是完整源码备份,而是软著申报用的核心代码摘抄。
### 8.1 选择代码范围
优先摘抄:
- 应用入口。
- 路由或导航配置。
- 核心页面逻辑。
- 数据模型或实体类。
- 控制器、服务层、核心业务函数。
- 权限认证、任务处理、导入导出、算法调用等关键逻辑。
- 能体现软件独创性和业务流程的代码。
避免摘抄:
- 第三方库源码。
- 构建产物。
- 大量样式。
- 自动生成文件。
- 重复类型定义。
- 简单 Getter/Setter。
- 普通 `import` 列表。
- 无意义注释。
### 8.2 前端摘抄规则
React、Vue、Angular 等前端项目应保留:
- 路由配置或模块切换逻辑。
- 页面核心状态。
- 关键事件处理函数。
- 表单提交、数据加载、导入导出、核心交互逻辑。
- 与业务功能强相关的组件逻辑。
应删除或压缩:
- HTML 模板中重复标签。
- CSS、Tailwind class 堆叠、样式对象。
- 常规 import。
- UI 装饰代码。
- Mock 数据中无业务价值的长数组。
### 8.3 后端摘抄规则
后端项目应保留:
- 实体核心属性。
- 请求/响应模型关键字段。
- Controller/Router 中的核心业务接口方法。
- Service 中的关键业务流程。
- 数据导入、处理、计算、导出、权限校验等逻辑。
应删除或压缩:
- Getter/Setter。
- 普通构造方法。
- 简单日志。
- 依赖注入样板。
- 与业务无关的异常包装。
- 重复 CRUD 样板代码。
### 8.4 格式要求
代码汇总中不要穿插解释性文字。每段代码顶部只标注文件名:
```text
// src/App.tsx
...
// backend/services/ProjectService.py
...
```
不要使用 Markdown 分割线解释“以下为某模块”。如果模板要求代码块,可按模板执行;否则保持连续代码摘抄。
## 9. 截图与录屏验证工作流
如果用户要求图文并茂或系统使用视频,应执行以下流程:
1. 启动系统或访问用户提供的部署地址。
2. 使用测试账号登录。
3. 按说明书功能顺序逐项验证。
4. 对每个关键动作截图。
5. 复杂流程可分段录屏,不要求一次录完整系统。
6. 将截图放入 `新撰写软著文档/images/`
7. 将视频放入 `新撰写软著文档/系统使用视频/`
8. 更新说明书中的图片引用。
9. 更新 `功能验证与素材清单.md`
截图时注意:
- 尽量使用 1920×1080 或更高清晰度。
- 截图内容要能看清按钮、标题、当前状态和结果。
- 不要用同一张图解释多个不同功能。
- 不要把错误状态、半加载状态、遮挡严重的弹窗放进正式说明书。
- 如果某张截图不准确,应删除或不引用。
- 如涉及隐私、真实姓名、密钥、手机号、身份证号等,应打码或使用演示数据。
录屏时注意:
- 可以按模块录制,如“登录与概况”“项目库管理”“核心业务流程”“模板与用户管理”。
- 视频只是补充材料,说明书正文可不一定放视频链接。
- 若用户明确要求去掉视频链接,说明书中不要出现“查看分段演示视频”文本。
## 10. 文档一致性检查清单
交付前必须检查:
- 三个目标文件是否位于 `新撰写软著文档/`
- 文件名是否严格为 `1. 软著说明书.md``2. 软著登记表.md``3. 代码汇总.md`
- 说明书是否包含注册或账号创建、登录、核心管理界面、核心业务流程、退出系统。
- 登记表是否完整填写模板字段。
- 代码汇总是否剔除了冗余模板、样式和无意义注释。
- 说明书中是否仍有开发黑话。
- 图片引用是否全部存在。
- 图片是否与文字功能一致。
- 同一图片是否被重复引用。
- 复杂功能是否有足够截图,而不是只放一张总览图。
- 新增截图、视频是否登记到素材清单。
- 文档中是否残留与当前项目无关的模板内容。
## 11. 常见问题与处理原则
### 11.1 项目没有注册功能
不要虚构用户自助注册。应写为:
“本系统采用管理员统一创建账号方式完成用户注册。”
### 11.2 代码功能与界面截图不一致
以实际运行界面为准,同时回查代码确认是否为隐藏功能、未启用功能或环境问题。不要把未实现的功能写成已实现。
### 11.3 系统支持多类数据
说明书必须逐类写明。例如同时支持图片、视频、DICOM、Excel应分别说明导入、展示、处理和导出方式。
### 11.4 某功能很复杂
应拆为多个步骤,每个步骤配不同截图。例如 AI 分割可拆为:
- 模型选择界面。
- 正向点提示。
- 反向点提示。
- 边界框提示。
- 分割结果。
- 推送或保存结果。
### 11.5 截图重复太多
同一张图不要反复放。可以在一个功能区域中放多张图,但每张图必须代表不同状态或动作。
### 11.6 代码太多
软著代码汇总不需要全量代码。优先保留能体现软件功能、流程和独创性的核心代码。
### 11.7 信息缺失
对软件名称、版本号、著作权人、开发日期等关键申报信息,如用户未提供,应标注“待确认”,并在最终回复中列出需要用户补充的字段。
## 12. 推荐执行顺序
完整工作建议按以下顺序推进:
1. 阅读参考模板。
2. 阅读项目说明和源码结构。
3. 梳理功能清单和角色权限。
4. 生成三份文档初稿。
5. 启动或访问系统,逐项验证功能。
6. 截图和录屏。
7. 用真实截图替换说明书占位符。
8. 更新素材清单。
9. 检查图片、链接、术语、重复内容。
10. 必要时补充自动化测试或人工验证记录。
11. 提交最终文档。
## 13. 最终回复要求
交付时应简明说明:
- 已生成或更新哪些文件。
- 是否补充了截图、视频和素材清单。
- 说明书图片数量、是否存在重复或缺图。
- 代码汇总是否已按精简规则处理。
- 哪些信息仍待用户确认。
不要在最终回复中粘贴整篇说明书或整份代码汇总,除非用户明确要求。
## 14. 标准接收指令
当用户只发送以下启动说明并要求确认时,应回复:
```text
工作流已就绪
```
随后等待用户发送基本信息和核心源代码。收到材料后,直接开始生成:
```text
新撰写软著文档/1. 软著说明书.md
新撰写软著文档/2. 软著登记表.md
新撰写软著文档/3. 代码汇总.md
```