41 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
70 changed files with 4610 additions and 1154 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.

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,7 @@
- **多媒体资产管理** — 支持视频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,橡皮擦可从选中 mask 扣除区域;删除传播 seed 或传播结果时会同步删除同一传播链上的自动传播 mask但保留其他帧独立 AI 推理/人工标注 mask未选中特定 mask 时按右侧语义分类树的内部优先级叠放显示AI 智能分割页单独提供正/反点和框选,实时渲染 Mask 遮罩
- **交互式画布标注** — 基于 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 不参与排序
- **项目工作区** — 项目创建、帧浏览、多图层标注、自动传播帧提示、进度追踪
@@ -139,9 +139,8 @@ Seg_Server/
├── uploads/ # 临时上传目录
├── frames/ # 临时帧目录
├── doc/ # 当前实现审计、接口契约与后续实施文档
├── logo_square.png # 侧边栏与 favicon 使用的根目录方形 Logo
├── public/
│ └── logo.png # 旧版保留 Logo 静态资源
│ └── logo.png # 登录页、侧边栏与 favicon 使用的 Logo
├── start_services.sh # 一键启动所有服务脚本
├── server.ts # Express + Vite 前端入口(不再提供旧版 mock API
├── index.html # SPA HTML 入口
@@ -341,7 +340,7 @@ celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency
nohup celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 > /tmp/celery.log 2>&1 &
```
视频导入只创建项目并把源视频保存到 MinIO不会自动拆帧项目库导入面板会用 Axios 上传回调显示上传进度、百分比和字节数。用户在项目库点击“生成帧”后,再选择目标 FPS 并调用 `POST /api/media/parse`。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` 元数据。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: 安装前端依赖并构建
@@ -418,7 +417,7 @@ cd ~/Desktop/Seg_Server
后端启动时会自动种子化默认管理员 `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`,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。生产部署时必须在 `backend/.env` 覆盖 `JWT_SECRET_KEY` 并修改默认管理员密码。
当前项目、帧、标注、任务、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、内部层级顺序和规则。
@@ -513,7 +512,7 @@ pip install -e . --no-build-isolation
- 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict`
- 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。
- AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签;如果换标签的 mask 属于传播链,系统会同步更新前后帧对应传播 mask 的分类元数据;“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择和当前帧视角,不会因工作区重新加载而跳回第一帧。
- 工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed按用户设置的传播起始帧和传播结束帧向前/向后追踪;用户可直接修改数字框,也可先点击“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏可单独选择本次传播使用的 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 标注帧,会显示青色外框加红色内描边,固定为外层当前帧、内层标注框。
- 工作区传播功能会使用当前打开参考帧的全部 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重叠覆盖彩色分割结果/`,重叠区域按右侧语义分类树内部优先级从低到高覆盖。

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
@@ -38,8 +39,8 @@ class Settings(BaseSettings):
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/Data_MyVideo_1.mp4"
demo_dicom_dir: str = "/home/wkmgc/Desktop/Seg_Server/2024_2_5_王芳/※2F458C45CFAA4C7CB76A39AA2BFE436B"
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

@@ -46,22 +46,17 @@ def _ensure_runtime_schema_columns() -> None:
logger.warning("Runtime schema column check failed: %s", exc)
def _seed_default_admin_and_ownership_sync() -> None:
"""Ensure the default admin exists and owns legacy unassigned projects."""
from models import Project
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:
admin = ensure_default_admin(db)
db.query(Project).filter(Project.owner_user_id.is_(None)).update(
{"owner_user_id": admin.id},
synchronize_session=False,
)
db.commit()
logger.info("Default admin ready; legacy projects assigned to user id=%s", admin.id)
logger.info("Default admin ready id=%s", admin.id)
except Exception as exc: # noqa: BLE001
logger.error("Failed to seed default admin or ownership: %s", exc)
logger.error("Failed to seed default admin: %s", exc)
finally:
db.close()
@@ -73,20 +68,35 @@ def _seed_default_project_sync() -> None:
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_unparsed_video_demo_project,
create_parsed_video_demo_project,
demo_dicom_files,
)
db = SessionLocal()
try:
admin = ensure_default_admin(db)
existing_video = db.query(Project).filter(Project.name == DEMO_VIDEO_PROJECT_NAME).first()
if existing_video is not None and existing_video.owner_user_id is None:
existing_video.owner_user_id = admin.id
legacy_video = (
db.query(Project)
.filter(Project.name.in_(LEGACY_DEMO_VIDEO_PROJECT_NAMES))
.first()
)
if legacy_video is not None:
legacy_video.name = DEMO_VIDEO_PROJECT_NAME
db.commit()
elif existing_video is None and os.path.exists(settings.demo_video_path):
video_project = create_unparsed_video_demo_project(
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,
@@ -96,9 +106,6 @@ def _seed_default_project_sync() -> None:
existing_dicom = db.query(Project).filter(Project.name == DEMO_DICOM_PROJECT_NAME).first()
if existing_dicom is not None:
if existing_dicom.owner_user_id is None:
existing_dicom.owner_user_id = admin.id
db.commit()
return
if not demo_dicom_files(settings.demo_dicom_dir):
@@ -147,7 +154,7 @@ async def lifespan(app: FastAPI):
try:
Base.metadata.create_all(bind=engine)
_ensure_runtime_schema_columns()
_seed_default_admin_and_ownership_sync()
_seed_default_admin_sync()
logger.info("Database tables initialized.")
except Exception as exc: # noqa: BLE001
logger.error("Database initialization failed: %s", exc)

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

@@ -25,7 +25,7 @@ class User(Base):
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="admin", 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(

View File

@@ -10,7 +10,7 @@ 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 ensure_default_admin, hash_password, require_admin, write_audit_log
from routers.auth import SUPPORTED_ROLES, ensure_default_admin, hash_password, normalize_user_role, require_admin, write_audit_log
from schemas import (
AdminUserCreate,
AdminUserUpdate,
@@ -23,25 +23,29 @@ from services.demo_media import (
DEMO_DICOM_PROJECT_NAME,
DEMO_VIDEO_PROJECT_NAME,
create_parsed_dicom_demo_project,
create_unparsed_video_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"])
VALID_ROLES = {"admin", "annotator", "viewer"}
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 VALID_ROLES:
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),
@@ -49,7 +53,8 @@ def list_users(
) -> List[User]:
"""Return all users for the administrator console."""
_ = admin_user
return db.query(User).order_by(User.id).all()
users = db.query(User).order_by(User.id).all()
return [normalize_user_role(db, user) for user in users]
@router.post(
@@ -69,10 +74,12 @@ def create_user(
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=_normalize_role(payload.role),
role=role,
is_active=1 if payload.is_active else 0,
)
db.add(user)
@@ -104,6 +111,7 @@ def update_user(
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)}}
@@ -111,6 +119,8 @@ def update_user(
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 ""
@@ -119,8 +129,11 @@ def update_user(
user.password_hash = hash_password(password)
if "role" in updates:
next_role = _normalize_role(updates["role"])
if user.id == admin_user.id and next_role != "admin":
raise HTTPException(status_code=400, detail="Cannot remove your own admin 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"]:
@@ -158,9 +171,9 @@ def delete_user(
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
owned_project_count = db.query(Project).filter(Project.owner_user_id == user_id).count()
if owned_project_count:
raise HTTPException(status_code=409, detail="User owns projects; deactivate or migrate projects first")
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()
@@ -239,13 +252,12 @@ def reset_demo_factory(
restored_templates = restore_default_templates(db)
video_project = create_unparsed_video_demo_project(
video_project = create_parsed_video_demo_project(
db,
owner=preserved_admin,
video_path=settings.demo_video_path,
project_name=DEMO_VIDEO_PROJECT_NAME,
)
video_project.frame_count = 0
dicom_project = create_parsed_dicom_demo_project(
db,

View File

@@ -43,23 +43,30 @@ 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 _owned_project_or_404(project_id: int, db: Session, current_user: User) -> Project:
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
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 _owned_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame:
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, Project.owner_user_id == current_user.id)
.filter(Frame.id == frame_id)
)
if project_id is not None:
query = query.filter(Frame.project_id == project_id)
@@ -108,10 +115,11 @@ def _rgb_tuple_to_hex(rgb: tuple[int, int, int]) -> str:
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]]]:
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
@@ -129,16 +137,13 @@ def _template_class_maps(template: Template | None) -> tuple[dict[int, dict[str,
"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
def _gt_unknown_label(token: int | str) -> str:
if isinstance(token, int):
return f"未定义类别 {token}"
return f"未定义颜色 {token}"
return by_maskid, by_color, unclassified
def _load_frame_image(frame: Frame) -> np.ndarray:
@@ -196,6 +201,17 @@ 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
@@ -469,7 +485,7 @@ def predict(
- **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`.
- **semantic**: disabled in the current SAM 2.1 point/box product flow.
"""
frame = _owned_frame_or_404(payload.image_id, db, current_user)
frame = _shared_frame_or_404(payload.image_id, db, current_user)
image = _load_frame_image(frame)
prompt_type = payload.prompt_type.lower()
@@ -638,7 +654,7 @@ def analyze_mask(
) -> dict:
"""Return backend-computed mask properties for the frontend inspector."""
if payload.frame_id is not None:
_owned_frame_or_404(payload.frame_id, db, current_user)
_shared_frame_or_404(payload.frame_id, db, current_user)
mask_data = payload.mask_data or {}
polygons = mask_data.get("polygons") or []
@@ -694,7 +710,7 @@ def smooth_mask(
to the current mask, then save through the normal annotation endpoint.
"""
if payload.frame_id is not None:
_owned_frame_or_404(payload.frame_id, db, current_user)
_shared_frame_or_404(payload.frame_id, db, current_user)
polygons = payload.mask_data.get("polygons") or []
valid_polygons = _normalize_polygons(polygons)
@@ -740,8 +756,8 @@ def propagate(
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))
_owned_project_or_404(payload.project_id, db, current_user)
source_frame = _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
_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 []
@@ -787,6 +803,13 @@ def propagate(
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"),
@@ -805,32 +828,47 @@ def propagate(
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
polygon_to_save = _smooth_polygon(polygon, smoothing) if smoothing else polygon
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 []
annotation = Annotation(
project_id=payload.project_id,
frame_id=frame.id,
template_id=template_id,
mask_data={
"polygons": [polygon_to_save],
**({"holes": [hole_group], "hasHoles": True} if hole_group 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,
"score": scores[polygon_index] if polygon_index < len(scores) else None,
**({"geometry_smoothing": smoothing} if smoothing else {}),
**({"class": class_metadata} if class_metadata else {}),
},
points=None,
bbox=_polygon_bbox(polygon_to_save),
)
db.add(annotation)
created.append(annotation)
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:
@@ -858,8 +896,8 @@ def queue_propagate_task(
current_user: User = Depends(require_editor),
) -> ProcessingTaskOut:
"""Queue multiple seed/direction propagation steps as one background task."""
_owned_project_or_404(payload.project_id, db, current_user)
source_frame = _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
_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")
@@ -913,7 +951,7 @@ def auto_segment(
current_user: User = Depends(require_editor),
) -> dict:
"""Run automatic mask generation on a frame using a grid of point prompts."""
frame = _owned_frame_or_404(image_id, db, current_user)
frame = _shared_frame_or_404(image_id, db, current_user)
image = _load_frame_image(frame)
try:
@@ -936,10 +974,10 @@ def save_annotation(
current_user: User = Depends(require_editor),
) -> Annotation:
"""Persist an annotation (mask, points, bbox) into the database."""
_owned_project_or_404(payload.project_id, db, current_user)
_shared_project_or_404(payload.project_id, db, current_user)
if payload.frame_id:
_owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
_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)
@@ -975,8 +1013,8 @@ async def import_gt_mask(
the frontend an editable point-region representation instead of a static
bitmap layer.
"""
_owned_project_or_404(project_id, db, current_user)
frame = _owned_frame_or_404(frame_id, db, current_user, project_id)
_shared_project_or_404(project_id, db, current_user)
frame = _shared_frame_or_404(frame_id, db, current_user, project_id)
if unknown_color_policy not in {"discard", "undefined"}:
raise HTTPException(status_code=400, detail="unknown_color_policy must be discard or undefined")
@@ -1016,7 +1054,7 @@ async def import_gt_mask(
if resized_to_frame:
label_image = cv2.resize(label_image, (width, height), interpolation=cv2.INTER_NEAREST)
by_maskid, _by_color = _template_class_maps(template)
by_maskid, _by_color, unclassified_class = _template_class_maps(template)
has_template_classes = bool(by_maskid)
fallback_color = _normalize_hex_color(color) or "#22c55e"
@@ -1033,8 +1071,9 @@ async def import_gt_mask(
annotation_label = class_meta["name"]
annotation_color = class_meta["color"]
elif is_unknown:
annotation_label = _gt_unknown_label(label_value)
annotation_color = fallback_color
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
@@ -1120,11 +1159,11 @@ def list_annotations(
current_user: User = Depends(get_current_user),
) -> List[Annotation]:
"""Return persisted annotations for a project, optionally scoped to one frame."""
_owned_project_or_404(project_id, db, current_user)
_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:
_owned_frame_or_404(frame_id, db, current_user, project_id)
_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()
@@ -1144,7 +1183,7 @@ def update_annotation(
annotation = (
db.query(Annotation)
.join(Project, Project.id == Annotation.project_id)
.filter(Annotation.id == annotation_id, Project.owner_user_id == current_user.id)
.filter(Annotation.id == annotation_id)
.first()
)
if not annotation:
@@ -1177,7 +1216,7 @@ def delete_annotation(
annotation = (
db.query(Annotation)
.join(Project, Project.id == Annotation.project_id)
.filter(Annotation.id == annotation_id, Project.owner_user_id == current_user.id)
.filter(Annotation.id == annotation_id)
.first()
)
if not annotation:

View File

@@ -18,6 +18,7 @@ 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):
@@ -50,9 +51,26 @@ def create_access_token(user: User, expires_delta: timedelta | None = None) -> s
def ensure_default_admin(db: Session) -> User:
"""Create the default development admin if the user table is empty."""
"""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,
@@ -63,6 +81,31 @@ def ensure_default_admin(db: Session) -> User:
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
@@ -92,6 +135,8 @@ def get_current_user(
) 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,
@@ -110,7 +155,7 @@ def require_admin(current_user: User = Depends(get_current_user)) -> 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 {"admin", "annotator"}:
if current_user.role not in SUPPORTED_ROLES:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Edit permission required")
return current_user
@@ -143,6 +188,8 @@ 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,

View File

@@ -58,12 +58,12 @@ def get_dashboard_overview(
current_user: User = Depends(get_current_user),
) -> dict[str, Any]:
"""Return live dashboard data derived from persisted backend records."""
owned_project_ids_query = db.query(Project.id).filter(Project.owner_user_id == current_user.id)
project_count = db.query(func.count(Project.id)).filter(Project.owner_user_id == current_user.id).scalar() or 0
frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(owned_project_ids_query)).scalar() or 0
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)).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_(owned_project_ids_query))
.filter(Annotation.project_id.in_(shared_project_ids_query))
.scalar()
or 0
)
@@ -76,7 +76,6 @@ def get_dashboard_overview(
active_task_count = (
db.query(func.count(ProcessingTask.id))
.outerjoin(Project, Project.id == ProcessingTask.project_id)
.filter((ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id))
.filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES))
.scalar()
or 0
@@ -84,14 +83,12 @@ def get_dashboard_overview(
projects = (
db.query(Project)
.filter(Project.owner_user_id == current_user.id)
.order_by(Project.updated_at.desc())
.all()
)
recent_tasks = (
db.query(ProcessingTask)
.outerjoin(Project, Project.id == ProcessingTask.project_id)
.filter((ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id))
.order_by(ProcessingTask.created_at.desc())
.limit(50)
.all()
@@ -120,7 +117,7 @@ def get_dashboard_overview(
recent_annotations = (
db.query(Annotation)
.filter(Annotation.project_id.in_(owned_project_ids_query))
.filter(Annotation.project_id.in_(shared_project_ids_query))
.order_by(Annotation.updated_at.desc())
.limit(10)
.all()

View File

@@ -206,10 +206,8 @@ def _frame_image_extension(frame: Frame) -> str:
def _project_or_404(project_id: int, db: Session, current_user: User) -> Project:
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
_ = 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

View File

@@ -72,10 +72,7 @@ async def upload_media(
file_url = get_presigned_url(object_name, expires=3600)
if project_id:
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.video_path = object_name
@@ -141,10 +138,7 @@ async def upload_dicom_batch(
uploaded = []
if project_id:
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
else:
@@ -202,10 +196,7 @@ def parse_media(
The Celery worker performs the heavy FFmpeg/OpenCV/pydicom work and
updates the persisted task record as it progresses.
"""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")

View File

@@ -1,7 +1,7 @@
"""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
@@ -16,12 +16,12 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["Projects"])
def _next_project_copy_name(db: Session, owner_user_id: int, source_name: str) -> str:
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.owner_user_id == owner_user_id, Project.name.like(f"{base_name}%"))
.filter(Project.name.like(f"{base_name}%"))
.all()
}
if base_name not in existing_names:
@@ -76,7 +76,6 @@ def list_projects(
"""Retrieve a paginated list of projects."""
projects = (
db.query(Project)
.filter(Project.owner_user_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
@@ -97,10 +96,7 @@ def get_project(
current_user: User = Depends(get_current_user),
) -> Project:
"""Retrieve a project by its ID."""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return _prepare_project_response(project)
@@ -119,16 +115,13 @@ def copy_project(
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,
Project.owner_user_id == current_user.id,
).first()
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, current_user.id, source.name)
next_name = _next_project_copy_name(db, source.name)
copied = Project(
name=next_name,
@@ -196,10 +189,7 @@ def update_project(
current_user: User = Depends(require_editor),
) -> Project:
"""Update project fields partially."""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -227,10 +217,7 @@ def delete_project(
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,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -255,10 +242,7 @@ def create_frame(
current_user: User = Depends(require_editor),
) -> Frame:
"""Register a new frame under a project."""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -277,26 +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,
Project.owner_user_id == current_user.id,
).first()
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
@@ -320,7 +302,6 @@ def get_frame(
.filter(
Frame.project_id == project_id,
Frame.id == frame_id,
Project.owner_user_id == current_user.id,
)
.first()
)

View File

@@ -33,13 +33,11 @@ def _now() -> datetime:
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,
(ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id),
)
.filter(ProcessingTask.id == task_id)
.first()
)
if not task:
@@ -60,9 +58,8 @@ def list_tasks(
current_user: User = Depends(get_current_user),
) -> List[ProcessingTask]:
"""Return recent background processing tasks."""
query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id).filter(
(ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id)
)
_ = 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:
@@ -130,10 +127,7 @@ def retry_task(
if previous.project_id is None:
raise HTTPException(status_code=400, detail="Task has no project_id")
project = db.query(Project).filter(
Project.id == previous.project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == previous.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
is_propagation_task = previous.task_type == "propagate_masks"

View File

@@ -304,6 +304,7 @@ class PropagationSeed(BaseModel):
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

View File

@@ -12,12 +12,22 @@ from sqlalchemy.orm import Session
from minio_client import upload_file
from models import Frame, Project, User
from services.frame_parser import natural_filename_key, parse_dicom, upload_frames_to_minio
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_PROJECT_NAME = "DICOM序列"
DEMO_DICOM_PARSE_FPS = 30.0
DEMO_VIDEO_PROJECT_NAME = "Data_MyVideo_1"
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]:
@@ -65,6 +75,85 @@ def create_unparsed_video_demo_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,
*,

View File

@@ -11,7 +11,7 @@ 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,
@@ -109,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:
@@ -169,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 {}

View File

@@ -83,6 +83,17 @@ 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 _normalize_polygon(polygon: list[list[float]]) -> list[list[float]]:
return [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2]
@@ -222,6 +233,9 @@ def _seed_signature(seed: dict[str, Any]) -> str:
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}"
@@ -273,15 +287,73 @@ def _seed_identity_matches(mask_data: dict[str, Any], seed_key: str, seed: dict[
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 "")
@@ -323,11 +395,25 @@ def _delete_replaced_frame_annotations(
.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)
@@ -446,6 +532,7 @@ def _save_propagated_annotations(
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
@@ -477,36 +564,51 @@ def _save_propagated_annotations(
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 []
annotation = Annotation(
project_id=int(payload["project_id"]),
frame_id=frame.id,
template_id=template_id,
mask_data={
"polygons": [polygon],
**({"holes": [hole_group], "hasHoles": True} if hole_group 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,
"source_annotation_id": source_annotation_id,
"source_mask_id": source_mask_id,
"score": scores[polygon_index] if polygon_index < len(scores) else None,
**({"geometry_smoothing": smoothing} if smoothing else {}),
**({"class": class_metadata} if class_metadata else {}),
},
points=None,
bbox=_polygon_bbox(polygon),
)
db.add(annotation)
created.append(annotation)
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:

View File

@@ -12,14 +12,14 @@ def test_admin_user_management_and_audit_logs(client, db_session):
})
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={
"role": "viewer",
"password": "newsecret",
"is_active": False,
})
assert updated.status_code == 200
assert updated.json()["role"] == "viewer"
assert updated.json()["role"] == "annotator"
assert updated.json()["is_active"] == 0
users = client.get("/api/admin/users")
@@ -37,8 +37,41 @@ def test_admin_user_management_and_audit_logs(client, db_session):
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="viewer", password_hash=hash_password("secret123"), role="viewer", is_active=1)
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)
@@ -51,7 +84,7 @@ def test_admin_routes_require_admin_role(client, db_session):
client.headers.update({"Authorization": original_auth})
def test_viewer_role_is_read_only_for_business_mutations(client, db_session):
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)
@@ -61,14 +94,14 @@ def test_viewer_role_is_read_only_for_business_mutations(client, db_session):
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": "Nope"}).status_code == 403
assert client.patch(f"/api/projects/{project['id']}", json={"name": "Nope"}).status_code == 403
assert client.post("/api/ai/annotate", json={"project_id": project["id"]}).status_code == 403
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_or_user_with_projects(client, db_session):
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
@@ -80,11 +113,12 @@ def test_admin_cannot_delete_self_or_user_with_projects(client, db_session):
db_session.commit()
response = client.delete(f"/api/admin/users/{user.id}")
assert response.status_code == 409
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 / "Data_MyVideo_1.mp4"
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"
@@ -98,6 +132,11 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
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({
@@ -106,6 +145,10 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
"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",
@@ -155,20 +198,20 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
data = response.json()
assert data["message"] == "演示环境已恢复出厂设置"
assert data["admin_user"]["username"] == "admin"
assert data["project"]["name"] == "DICOM序列"
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"]] == ["Data_MyVideo_1", "DICOM序列"]
assert data["projects"][0]["status"] == "pending"
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"] == 0
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']}/Data_MyVideo_1.mp4",
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",
@@ -177,8 +220,18 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
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() == 3
assert [frame.source_frame_number for frame in db_session.query(Frame).order_by(Frame.frame_index).all()] == [0, 1, 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

View File

@@ -393,6 +393,9 @@ def test_propagate_saves_tracked_annotations(client, monkeypatch):
"color": "#ff0000",
"class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20},
"template_id": None,
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
"source_instance_id": "gallbladder-instance-7",
"smoothing": {"strength": 45, "method": "chaikin"},
},
})
@@ -409,6 +412,10 @@ def test_propagate_saves_tracked_annotations(client, monkeypatch):
assert saved["frame_id"] == frames[1]["id"]
assert saved["mask_data"]["source"] == "sam2.1_hiera_tiny_propagation"
assert saved["mask_data"]["class"]["name"] == "胆囊"
assert saved["mask_data"]["source_annotation_id"] == 7
assert saved["mask_data"]["source_mask_id"] == "annotation-7"
assert saved["mask_data"]["source_instance_id"] == "gallbladder-instance-7"
assert saved["mask_data"]["instance_id"] == "gallbladder-instance-7"
assert saved["mask_data"]["score"] == 0.8
assert saved["mask_data"]["geometry_smoothing"] == {"strength": 45.0, "method": "chaikin"}
assert saved["mask_data"]["polygons"][0] != [[0.15, 0.15], [0.25, 0.15], [0.25, 0.25]]
@@ -541,6 +548,7 @@ def test_propagation_task_runner_saves_annotations_and_progress(client, db_sessi
"label": "胆囊",
"color": "#ff0000",
"class_metadata": {"id": "c1", "name": "胆囊"},
"source_instance_id": "worker-instance-1",
"smoothing": {"strength": 40, "method": "chaikin"},
},
}],
@@ -576,12 +584,90 @@ def test_propagation_task_runner_saves_annotations_and_progress(client, db_sessi
listing = client.get(f"/api/ai/annotations?project_id={project['id']}")
assert listing.json()[0]["frame_id"] == frames[1]["id"]
assert listing.json()[0]["mask_data"]["source"] == "sam2.1_hiera_tiny_propagation"
assert listing.json()[0]["mask_data"]["source_instance_id"] == "worker-instance-1"
assert listing.json()[0]["mask_data"]["instance_id"] == "worker-instance-1"
stored_polygon = listing.json()[0]["mask_data"]["polygons"][0]
assert listing.json()[0]["mask_data"]["geometry_smoothing"] == {"strength": 40.0, "method": "chaikin"}
assert stored_polygon != [[0.15, 0.15], [0.25, 0.15], [0.25, 0.25]]
assert len(stored_polygon) > 3
def test_propagation_task_runner_keeps_disconnected_result_polygons_in_one_annotation(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Disconnected Mask"}).json()
frames = [
client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": idx,
"image_url": f"frames/{idx}.jpg",
"width": 640,
"height": 360,
}).json()
for idx in range(2)
]
first_piece = [[0.15, 0.15], [0.25, 0.15], [0.25, 0.25], [0.15, 0.25]]
second_piece = [[0.70, 0.70], [0.90, 0.70], [0.90, 0.90], [0.70, 0.90]]
second_hole = [[[0.76, 0.76], [0.82, 0.76], [0.82, 0.82], [0.76, 0.82]]]
task = ProcessingTask(
task_type="propagate_masks",
status="queued",
progress=0,
project_id=project["id"],
payload={
"project_id": project["id"],
"frame_id": frames[0]["id"],
"model": "sam2.1_hiera_tiny",
"include_source": False,
"save_annotations": True,
"steps": [{
"direction": "forward",
"max_frames": 2,
"seed": {
"polygons": [
[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]],
[[0.6, 0.6], [0.8, 0.6], [0.8, 0.8]],
],
"label": "多区域",
"color": "#ff0000",
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
"source_instance_id": "multi-region-instance-7",
},
}],
},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
monkeypatch.setattr("services.propagation_task_runner.download_file", lambda object_name: b"jpeg")
monkeypatch.setattr("services.propagation_task_runner.publish_task_progress_event", lambda event_task: None)
monkeypatch.setattr("services.propagation_task_runner.sam_registry.propagate_video", lambda *args, **kwargs: [
{"frame_index": 0, "polygons": [], "scores": []},
{
"frame_index": 1,
"polygons": [first_piece, second_piece],
"holes": [[], second_hole],
"scores": [0.72, 0.93],
},
])
result = run_propagate_project_task(db_session, task.id)
assert result["created_annotation_count"] == 1
annotations = db_session.query(Annotation).filter(Annotation.project_id == project["id"]).all()
assert len(annotations) == 1
annotation = annotations[0]
assert annotation.frame_id == frames[1]["id"]
assert annotation.bbox == [0.15, 0.15, 0.75, 0.75]
assert annotation.mask_data["polygons"] == [first_piece, second_piece]
assert annotation.mask_data["holes"] == [[], second_hole]
assert annotation.mask_data["hasHoles"] is True
assert annotation.mask_data["source_instance_id"] == "multi-region-instance-7"
assert annotation.mask_data["instance_id"] == "multi-region-instance-7"
assert annotation.mask_data["score"] == 0.93
assert annotation.mask_data["scores"] == [0.72, 0.93]
def test_propagation_task_runner_skips_unchanged_seed_and_replaces_changed_seed(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Dedupe"}).json()
frames = [
@@ -742,6 +828,174 @@ def test_propagation_task_runner_replaces_legacy_or_different_weight_results(cli
assert annotations[0].mask_data["polygons"] == [output_polygon]
def test_propagation_task_runner_keeps_same_class_seeds_separate(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Multi Instance"}).json()
frames = [
client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": idx,
"image_url": f"frames/{idx}.jpg",
"width": 640,
"height": 360,
}).json()
for idx in range(2)
]
output_by_source = {
"instance-7": [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]],
"instance-8": [[0.62, 0.62], [0.82, 0.62], [0.82, 0.82], [0.62, 0.82]],
}
task = ProcessingTask(
task_type="propagate_masks",
status="queued",
progress=0,
project_id=project["id"],
payload={
"project_id": project["id"],
"frame_id": frames[0]["id"],
"model": "sam2.1_hiera_tiny",
"include_source": False,
"save_annotations": True,
"steps": [
{
"direction": "forward",
"max_frames": 2,
"seed": {
"polygons": [[[0.05, 0.05], [0.15, 0.05], [0.15, 0.15]]],
"label": "胆囊",
"color": "#ff0000",
"source_annotation_id": 7,
"source_mask_id": "annotation-7",
"source_instance_id": "instance-7",
"class_metadata": {"id": "gallbladder", "name": "胆囊"},
},
},
{
"direction": "forward",
"max_frames": 2,
"seed": {
"polygons": [[[0.65, 0.65], [0.75, 0.65], [0.75, 0.75]]],
"label": "胆囊",
"color": "#ff0000",
"source_annotation_id": 8,
"source_mask_id": "annotation-8",
"source_instance_id": "instance-8",
"class_metadata": {"id": "gallbladder", "name": "胆囊"},
},
},
],
},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
monkeypatch.setattr("services.propagation_task_runner.download_file", lambda object_name: b"jpeg")
monkeypatch.setattr("services.propagation_task_runner.publish_task_progress_event", lambda event_task: None)
def fake_propagate_video(model, frame_paths, source_frame_index, seed, direction, max_frames):
output_polygon = output_by_source[seed["source_instance_id"]]
return [
{"frame_index": 0, "polygons": [seed["polygons"][0]], "scores": [0.9]},
{"frame_index": 1, "polygons": [output_polygon], "scores": [0.8]},
]
monkeypatch.setattr("services.propagation_task_runner.sam_registry.propagate_video", fake_propagate_video)
result = run_propagate_project_task(db_session, task.id)
assert result["created_annotation_count"] == 2
assert result["deleted_annotation_count"] == 0
annotations = db_session.query(Annotation).filter(Annotation.project_id == project["id"]).order_by(Annotation.id).all()
assert [annotation.mask_data["source_annotation_id"] for annotation in annotations] == [7, 8]
assert [annotation.mask_data["source_instance_id"] for annotation in annotations] == ["instance-7", "instance-8"]
assert [annotation.mask_data["instance_id"] for annotation in annotations] == ["instance-7", "instance-8"]
assert [annotation.mask_data["polygons"][0] for annotation in annotations] == [output_by_source["instance-7"], output_by_source["instance-8"]]
def test_propagation_task_runner_replaces_only_matching_instance_id(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Instance Replacement"}).json()
frames = [
client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": idx,
"image_url": f"frames/{idx}.jpg",
"width": 640,
"height": 360,
}).json()
for idx in range(2)
]
old_target_polygon = [[0.12, 0.12], [0.24, 0.12], [0.24, 0.24], [0.12, 0.24]]
sibling_polygon = [[0.16, 0.16], [0.30, 0.16], [0.30, 0.30], [0.16, 0.30]]
new_target_polygon = [[0.14, 0.14], [0.28, 0.14], [0.28, 0.28], [0.14, 0.28]]
for polygon, instance_id in [(old_target_polygon, "tracked-instance-a"), (sibling_polygon, "tracked-instance-b")]:
db_session.add(Annotation(
project_id=project["id"],
frame_id=frames[1]["id"],
mask_data={
"polygons": [polygon],
"label": "胆囊",
"color": "#ff0000",
"source": "sam2.1_hiera_tiny_propagation",
"propagated_from_frame_id": frames[0]["id"],
"propagation_seed_key": f"instance:{instance_id}",
"propagation_seed_signature": "old-signature",
"propagation_direction": "forward",
"source_instance_id": instance_id,
"instance_id": instance_id,
"class": {"id": "gallbladder", "name": "胆囊"},
},
bbox=[polygon[0][0], polygon[0][1], polygon[1][0] - polygon[0][0], polygon[2][1] - polygon[1][1]],
))
db_session.commit()
task = ProcessingTask(
task_type="propagate_masks",
status="queued",
progress=0,
project_id=project["id"],
payload={
"project_id": project["id"],
"frame_id": frames[0]["id"],
"model": "sam2.1_hiera_tiny",
"include_source": False,
"save_annotations": True,
"steps": [{
"direction": "forward",
"max_frames": 2,
"seed": {
"polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]],
"label": "胆囊",
"color": "#ff0000",
"source_instance_id": "tracked-instance-a",
"class_metadata": {"id": "gallbladder", "name": "胆囊"},
},
}],
},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
monkeypatch.setattr("services.propagation_task_runner.download_file", lambda object_name: b"jpeg")
monkeypatch.setattr("services.propagation_task_runner.publish_task_progress_event", lambda event_task: None)
monkeypatch.setattr("services.propagation_task_runner.sam_registry.propagate_video", lambda model, frame_paths, source_frame_index, seed, direction, max_frames: [
{"frame_index": 0, "polygons": [seed["polygons"][0]], "scores": [0.9]},
{"frame_index": 1, "polygons": [new_target_polygon], "scores": [0.8]},
])
result = run_propagate_project_task(db_session, task.id)
assert result["created_annotation_count"] == 1
assert result["deleted_annotation_count"] == 1
annotations = db_session.query(Annotation).filter(Annotation.project_id == project["id"]).order_by(Annotation.id).all()
assert len(annotations) == 2
assert [annotation.mask_data["source_instance_id"] for annotation in annotations] == ["tracked-instance-b", "tracked-instance-a"]
assert [annotation.mask_data["polygons"][0] for annotation in annotations] == [sibling_polygon, new_target_polygon]
def test_propagation_task_runner_replaces_downstream_result_from_middle_frame_manual_seed(client, db_session, monkeypatch):
project = client.post("/api/projects", json={"name": "Propagation Middle Frame Replacement"}).json()
frames = [
@@ -1410,8 +1664,17 @@ def test_import_gt_mask_handles_unknown_maskid_policy_and_resizes_to_frame(clien
assert undefined_response.status_code == 201
labels = {item["mask_data"]["label"] for item in undefined_response.json()}
assert labels == {"已定义", "未定义类别 2"}
unknown = next(item for item in undefined_response.json() if item["mask_data"]["label"].startswith("未定义"))
assert labels == {"已定义", "待分类"}
unknown = next(item for item in undefined_response.json() if item["mask_data"]["label"] == "待分类")
assert unknown["mask_data"]["color"] == "#000000"
assert unknown["mask_data"]["class"] == {
"id": "reserved-unclassified",
"name": "待分类",
"color": "#000000",
"zIndex": 0,
"maskId": 0,
"category": "系统保留",
}
assert unknown["mask_data"]["gt_unknown_class"] is True
assert unknown["mask_data"]["gt_label_value"] == 2
assert unknown["mask_data"]["gt_resized_to_frame"] is True

View File

@@ -30,3 +30,37 @@ def test_business_routes_require_auth(app):
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

@@ -216,6 +216,60 @@ 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

View File

@@ -50,6 +50,29 @@ def test_project_crud_and_frames(client, monkeypatch):
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
@@ -186,7 +209,7 @@ def test_project_and_frame_404s(client):
assert client.get("/api/projects/999/frames/1").status_code == 404
def test_projects_are_scoped_to_authenticated_owner(client, db_session):
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",
@@ -203,14 +226,19 @@ def test_projects_are_scoped_to_authenticated_owner(client, db_session):
db_session.refresh(other_project)
listing = client.get("/api/projects")
assert [project["id"] for project in listing.json()] == [owner_project["id"]]
assert client.get(f"/api/projects/{other_project.id}").status_code == 404
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()] == [other_project.id]
assert client.get(f"/api/projects/{owner_project['id']}").status_code == 404
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

@@ -46,7 +46,7 @@ Word 方案描述的理想系统包含:
## 当前代码尚未落地的目标
- 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。
- GT mask 导入:当前仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图导入,后端会按 maskid 拆分区域,生成高精度 polygon 标注;超出现有类别的 maskid 可舍弃或导入为未定义类别16-bit/uint16 GT_label 和普通彩色类别图会被拒绝尺寸不一致会自动最近邻拉伸到当前帧骨架提取、HDBSCAN 和更复杂的模板自动映射尚未实现。
- 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 历史栈。

View File

@@ -24,11 +24,12 @@
- 检查 MinIO bucket。
- 测试 Redis。
- Seed 默认模板。
- 如果存在 `demo_video_path`配置的 `demo_dicom_dir` DICOM 序列,创建默认演示视频项目和演示 DICOM 项目DICOM 按文件名自然顺序生成帧
- 如果存在 `demo/演视LC视频序列.mp4``demo/演视DICOM序列/`创建名为“演视LC视频序列”的默认演示视频项目和名为“演视DICOM序列”的演示 DICOM 项目,视频和 DICOM 都会生成帧DICOM 按文件名自然顺序读取;启动时会把旧显示名 `Data_MyVideo_1` / `演示DICOM序列` 迁移为新显示名
## 前端模块切换
`App.tsx` 使用 Zustand 中的 `activeModule` 做模块切换,没有使用路由库。
`useStore` 默认 `activeModule``dashboard`,因此用户登录后默认进入“总体概况”页。
| activeModule | 组件 | 页面 |
|--------------|------|------|
@@ -63,25 +64,25 @@
3. FastAPI `backend/routers/auth.py` 查询 `users` 表并校验密码哈希。
4. 前端把返回 JWT 写入 localStorage并把用户资料写入 store。
5. 后续业务请求带 `Authorization: Bearer <token>`,后端按当前用户过滤项目资源。
6. `admin/annotator` 可调用写入类业务接口`viewer` 只能读取`/api/admin/*` 仅允许 `admin`
6. 系统只支持唯一默认 `admin``annotator``admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`
### 管理员用户管理
1. `Sidebar.tsx` 仅对 `currentUser.role === 'admin'` 显示“用户管理”。
2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成用户新增、停用/启用、角色修改、改密码和删除无项目用户。
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` 会阻止管理员删除、停用或降级自己,并阻止删除仍拥有项目的用户;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,重新创建演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。
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}` 查看失败详情。
### 工作区浏览
@@ -111,6 +112,6 @@
- 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。
- AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。
- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别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 文件夹;清空当前帧遮罩会删除对应后端标注。手工绘制、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`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。
- 后端已接入 Bearer JWT 鉴权、共享项目库和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求默认 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。

View File

@@ -13,7 +13,7 @@
|------|------|------|------|
| 登录拦截 | `App.tsx` | 真实可用 | 未登录显示 `Login`,登录后显示主界面 |
| 模块切换 | `Sidebar.tsx` + `App.tsx` | 真实可用 | 切换 `dashboard/projects/workspace/ai/templates`“AI智能分割”入口使用 Bot + Sparkles 组合图标,强化 AI 语义 |
| Logo | `Sidebar.tsx` | 真实可用 | 侧边栏通过 Vite 资源导入使用根目录 `logo_square.png`favicon 使用 `/logo_square.png`,前端服务会从项目根目录提供该文件 |
| Logo | `Login.tsx` / `Sidebar.tsx` | 真实可用 | 登录页、侧边栏和 favicon 都使用 `public/logo.png`,运行时访问路径为 `/logo.png` |
| GPU 状态圆标 | `Sidebar.tsx` | 真实可用 | 通过 `GET /api/ai/models/status` 显示 GPU/CPU 和当前模型可用性 |
## 登录页
@@ -23,7 +23,7 @@
| 用户名/密码输入 | 真实可用 | 默认填入 `admin / 123456`,用户名使用 `autocomplete=username`,密码使用 `autocomplete=current-password` |
| 安全登录按钮 | 真实可用 | 调用 `POST /api/auth/login`,后端校验 `users` 表密码哈希并返回签名 JWT |
| 错误提示 | 真实可用 | 捕获后端错误并显示 |
| 登录态恢复 / 退出 | 真实可用 | 页面刷新后用 `/api/auth/me` 恢复当前用户;侧栏底部显示当前用户名并可退出登录 |
| 登录态恢复 / 退出 | 真实可用 | 页面刷新后用 `/api/auth/me` 恢复当前用户;侧栏底部使用退出图标显示当前用户名并可退出登录,退出提示不接收鼠标事件,避免悬浮到工作区按钮时误弹出 |
| 安全审计说明文字 | 部分可用 | 登录和用户管理操作已有 `audit_logs` 记录;登录页“端到端加密”等安全文案仍是展示性说明,不代表已接入完整企业级安全审计 |
## 管理员用户后台
@@ -32,11 +32,11 @@
|------|------|------|
| 侧栏“用户管理”入口 | 真实可用 | 仅当前用户 `role=admin` 时显示;非管理员无法看到入口,后端 `/api/admin/*` 也会返回 403 |
| 用户列表 | 真实可用 | 调用 `GET /api/admin/users`,展示用户 id、用户名、角色、启停用状态和创建时间 |
| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名初始密码`admin/annotator/viewer` 角色;后端校验用户名唯一密码长度 |
| 修改角色 / 启停用 / 改密码 | 真实可用 | 调用 `PATCH /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`,后端只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目并清空用户、项目帧、标注、任务和私有模板等演示数据“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |
| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端直接从 `demo/` 读取“演视LC视频序列”和“演视DICOM序列”只保留默认 admin、已生成帧的演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目并清空用户、项目帧、标注、任务和私有模板等演示数据“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |
## Dashboard 系统概况
@@ -58,9 +58,9 @@
| 项目列表 | 真实可用 | 调用 `GET /api/projects` |
| 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 |
| 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` |
| 新建项目 | 真实可用 | 调用 `POST /api/projects` |
| 新建项目 | 已移除入口 | 项目库不再展示独立“新建项目”按钮;导入视频/DICOM 时自动创建项目,后端 `POST /api/projects` 保留给导入流程和兼容调用 |
| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧;上传期间显示项目库导入进度条、百分比和已上传字节 |
| 生成帧按钮 | 真实可用 | 对已导入源视频且尚无帧、非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })` |
| 生成帧/重新生成帧按钮 | 真实可用 | 对已导入源视频且非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })`;已有帧时显示“重新生成帧”,后端会先清空旧帧、标注和 mask任务入队后项目库继续轮询 `GET /api/tasks/{task_id}`,解析成功后立即重新拉取项目列表,使后端新写入的 `thumbnail_url` 自动刷新到项目封面 |
| 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS |
| 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 |
| 导入 DICOM 序列 | 真实可用 | 可上传 `.dcm` 并触发解析;上传前按文件名自然顺序排序,后端解析也保持同一顺序;上传期间显示导入进度条、有效 DICOM 文件数量和已上传字节,上传完成后继续显示解析任务进度直到完成、失败或取消 |
@@ -76,12 +76,12 @@
| 顶栏操作提示 | 真实可用 | 保存、导出、传播范围选择等短反馈会自动消失;保存/导出/传播进行中和无帧项目提示会保留到状态变化 |
| 自动加载项目帧 | 真实可用 | 调用 `GET /api/projects/{id}/frames` |
| 无帧项目提示 | 真实可用 | 如果 `video_path` 存在但无帧,只提示回到项目库生成帧,不自动创建拆帧任务 |
| SAM 模型状态徽标 | 真实可用 | 左侧 Sidebar 底部保留紧凑 GPU/CPU 状态徽标;工作区顶栏不再重复显示该徽标,只保留“传播权重下拉和自动传播范围摘要 |
| SAM 模型状态徽标 | 真实可用 | 左侧 Sidebar 底部保留紧凑 GPU/CPU 状态徽标;工作区顶栏不再重复显示该徽标,传播权重下拉和自动传播范围摘要只在进入自动传播后显示 |
| 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask回显时保留当前项目帧里尚未保存的 AI/手工 draft mask避免从 AI 页推送的候选被覆盖 |
| “分割结果导出”按钮 | 真实可用 | 原“导出 JSON 标注集”和“导出 PNG Mask ZIP”已合并为一个入口点击后可选择整体视频、特定范围帧或当前图片默认导出范围为当前图片并勾选导出分开二值 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 策略选择,可舍弃未知类别或导入为未定义类别;随后调用 `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并共用拓扑锚点统计、边缘平滑、编辑、分类和保存链路 |
| 参考帧/起止帧/传播权重/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed工作区顶栏有独立“传播权重”下拉,可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;如果用户尚未显式设置范围,点击“自动传播”会先进入时间轴范围选择模式,播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 来源 id、未编辑传播结果的原始 seed 签名和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictorworker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon因此传播以新几何参与签名中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数,前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask |
| 清空片段遮罩 | 真实可用 | 点击“清空片段遮罩”后会进入和自动传播一致的时间轴范围选择模式,用户可在播放进度条或视频处理进度条上点击/拖拽选择起止帧;顶栏提供“清空全部”和“保留人工/AI”两种模式默认清空全部以保持旧行为“清空全部”会删除该帧段内所有本地 draft mask并对已保存 mask 调用 `DELETE /api/ai/annotations/{annotation_id}`,若范围内存在人工绘制或 AI 智能分割生成的红色“人工/AI 标注帧”会先弹出确认;“保留人工/AI”只删除自动传播/推理 mask不弹出人工帧确认人工/AI 标注帧、范围外 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 画布
@@ -95,14 +95,14 @@
| 正向/反向选点 | 真实可用 | UI 能加点,并按当前帧 `frame.id` 调用 `/api/ai/predict`;结果需点击归档保存才持久化 |
| 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 |
| AI 推理中提示 | 真实可用 | 请求期间会显示 |
| 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆拖拽生成 polygon画笔按当前语义分类生成连续圆形笔触并在松开时 union 成 mask若与选中 mask 连通则自动合并;橡皮擦从选中 mask 中扣除笔触区域;均写入 `Mask.segmentation`,可归档保存 |
| 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆拖拽生成 polygon切换到多边形/矩形/圆会保留当前 mask 选区,有选中 mask 时新创建的多边形/矩形/圆会通过 polygon union 并入该 mask即使两块区域不重叠也合并为同一个多 polygon mask没有选中 mask 时才创建新 mask未选语义分类时自动归入黑色 `maskid:0` 的“待分类”;画笔按当前语义分类或当前选中 mask 生成连续圆形笔触,松开后有选中 mask 则并入选中 mask没有选中 mask 才创建新的当前类别 mask画笔闭合形成中空区域时保留内洞 ring使用 even-odd 渲染并显示内外圈顶点;按 `Esc` 或点击左侧“取消选中”按钮可清空选区和临时绘制状态;橡皮擦从选中 mask 中扣除笔触区域;均写入 `Mask.segmentation`,可归档保存 |
| 画布上下文提示 | 真实可用 | 切换到多边形、矩形、圆、画笔、橡皮擦、区域合并/去除、调整多边形等隐性操作工具时,画布左上角显示当前工具的完成/取消/选择顺序提示;提示会在数秒后自动隐藏,避免长期遮挡待编辑图像,工具或操作状态变化时会重新出现 |
| Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染;普通 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并通过 `source_annotation_id``source_mask_id``propagation_seed_key` 同步更新同一传播链上的前后传播 mask同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty归档保存时更新后端 |
| 清空遮罩 | 真实可用 | 工作区只通过左侧工具栏触发清空;当前帧有选中 mask 时清选中 mask没有选中时清当前帧全部 mask无传播链结果时直接执行存在传播链结果时弹窗选择只清当前帧、清空所有传播帧、按帧范围选择或取消;按帧范围选择进入和清空片段遮罩一致的时间轴范围选择,并提供清空全部/保留人工AI |
| 应用分类 | 真实可用 | 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 标注帧,会二次确认是否删除,选择否会整帧保留 |
| 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 |
| 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id未保存 mask 显示“未保存”,未选中时显示“未选择” |
@@ -110,11 +110,12 @@
| 元素 | 状态 | 说明 |
|------|------|------|
| 工具分组分隔线 | 真实可用 | 拖拽/选择到创建圆为绘制/基础编辑组,画笔/橡皮擦为局部笔触组,区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask 和 AI 智能分割为外部动作组;组间使用浅灰横线分隔,`data-testid="tool-group-separator"` 位于清空遮罩下方的外部动作组分隔线 |
| 工具分组分隔线 | 真实可用 | 拖拽/选择到创建圆为绘制/基础编辑组,画笔/橡皮擦/AI自动推理为局部与追踪组,区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask 和 AI 智能分割为外部动作组;组间使用浅灰横线分隔,`data-testid="tool-group-separator"` 位于清空遮罩下方的外部动作组分隔线 |
| 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 |
| 取消选中 | 真实可用 | 位于拖拽/选择按钮下方,实体按钮等同 `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 规则渲染 |
| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask右下角显示已选数量和操作按钮合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;布尔选择态中第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,避免主区域和扣除区域看起来像随机阴影差异;使用 `polygon-clipping` 做 union / difference若选中的主区域和参与区域存在传播帧对应 mask会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择按帧范围选择会进入和传播一致的时间轴范围选择点击顶栏确认后再弹最终确认只处理范围内存在对应传播链的帧合并会保留主 mask 并移除被合并 mask且移除次级 mask 时会同步删除其同链自动传播结果;去除会从主 mask 扣除后续选中 mask内含扣除会保留 hole ring 并用 even-odd 规则渲染 |
| 导入 GT Mask | 真实可用 | 位于“重叠区域去除”之后,点击后打开文件选择器,并在上传前选择未知类别处理策略;该入口不切换 activeTool |
| AI 智能分割跳转入口 | 真实可用 | 切到 AI 智能分割页;不是直接执行推理 |
| AI 正向选点/反向选点/框选 | 不在工作区工具栏显示 | 这些是 AI 智能分割页功能,工作区左侧工具栏不再提供正向选点、反向选点和边界框选按钮 |
@@ -130,7 +131,7 @@
| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 |
| 顶部 range 拖动 | 真实可用 | 改变当前帧 |
| 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` |
| 播放进度条 / 视频处理进度条 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;当前帧位置用一条白色竖线贯穿两条进度条,避免和青色播放进度、红/蓝处理状态混淆;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `mask_data.source``propagated_from_frame_id``source_annotation_id``source_mask_id``propagation_seed_key` 识别自动传播生成的帧并显示蓝色区段,人工绘制或 AI 智能分割生成的帧显示红色竖线,红/蓝标识也可点击跳转到对应帧;每次自动传播成功处理帧后,工作区会在当前会话记录最近传播范围,并在视频处理进度条上叠加同一蓝色系的纯色片段,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,辅助识别第一次、第二次、第 N 次传播;传播历史片段会按当前仍存在的自动传播 mask 自动裁剪或拆分,清空片段遮罩或单独删除传播 mask 后,无任何 mask 的帧不会继续显示红/蓝颜色;未处理背景使用中性灰以和红/蓝/传播历史标记区分;工作区进入自动传播或清空片段遮罩的范围选择模式时,两条进度条显示 amber 选区,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表示待处理起止帧,颜色避开附近的青色、红色、蓝色和 amber 元素 |
| 播放进度条 / 视频处理进度条 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;当前帧位置用一条白色竖线贯穿两条进度条,避免和青色播放进度、红/蓝处理状态混淆;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `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 内时不会拦截 |
@@ -141,7 +142,7 @@
| 模板选择 | 真实可用 | 读取全局 templates可切换 activeTemplateId并会驱动分类树、mask 分类和导出类别信息 |
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`默认低对比融入深色侧栏hover/focus 时才增强显示 |
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes点击分类会设为后续新 mask 的 activeClass如果 Canvas 已选 mask则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class并滚动/聚焦到对应分类按钮 |
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 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属于低价值信息已从实例属性面板删除 |
@@ -152,7 +153,7 @@
| 元素 | 状态 | 说明 |
|------|------|------|
| 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 画布上下文提示 | 真实可用 | 选择正向点、反向点、边界框选或视口控制时,画布左上角提示点击/拖拽、删除提示点和执行推理的操作方式 |
@@ -184,6 +185,6 @@
## 总体结论
当前前端真实可用的主链路是JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。
当前前端真实可用的主链路是JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、上传视频/DICOM、显式生成帧/重新生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。
当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看。

View File

@@ -15,7 +15,7 @@ timeout: 30000
Authorization: Bearer <token>
```
当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401项目、帧、标注、任务、Dashboard 和导出会按当前用户拥有的项目过滤
当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,所有登录用户可读取,`admin/annotator` 可写入
## 前端封装的 API
@@ -33,21 +33,21 @@ Authorization: Bearer <token>
| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 |
| `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由项目库“生成帧”显式调用,支持 `parse_fps``max_frames``target_width` |
| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task由项目库“生成帧/重新生成帧”显式调用,已有帧时 worker 会先清空旧帧、标注和 mask支持 `parse_fps``max_frames``target_width` |
| `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 |
| `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 同步传播接口,供后端兼容和测试使用 |
| `queuePropagationTask(payload)` | `POST /api/ai/propagate/task` | 对齐 | 工作区自动传播入口;创建 Celery 后台任务并由任务表/进度流追踪 |
| `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保存链路会先预检后端标注 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 可舍弃或导入为未定义类别;尺寸不同会最近邻拉伸到当前帧,连通域会生成高精度 polygon 标注;导入标注可直接用于 `/api/ai/analyze-mask``/api/ai/smooth-mask`,前端不显示或拖动 seed point |
| `importGtMask(file, projectId, frameId, templateId?, options?)` | `POST /api/ai/import-gt-mask` | 对齐 | multipart 上传 GT mask支持 `unknown_color_policy=discard/undefined`;后端仅接受 8-bit 灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景、X 为 1-255 的 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 |
@@ -63,7 +63,7 @@ Authorization: Bearer <token>
| 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、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目同时按内置权威定义重建缺失的“腹腔镜胆囊切除术”“头颈部CT分割”系统模板并覆盖恢复被修改或删减的默认语义分类树响应包含兼容单个 `project` 和完整 `projects` 列表 |
| 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}` | 项目详情 |
@@ -259,7 +259,8 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask避免同
"label": "胆囊",
"color": "#ff0000",
"class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20, "maskId": 1},
"template_id": 2
"template_id": 2,
"source_instance_id": "instance-123"
}
}
```
@@ -280,7 +281,8 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask避免同
"seed": {
"polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
"label": "胆囊",
"color": "#ff0000"
"color": "#ff0000",
"source_instance_id": "instance-123"
}
}
]
@@ -288,7 +290,7 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask避免同
```
SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source``<model_id>_propagation`,前端回显时会把该字段保留到 `Mask.metadata`,用于在视频处理进度条上把自动传播帧显示为蓝色区段。
后台任务入队接口会先规范化/校验 `model` 字段中的 SAM 2.1 权重 id再把规范化后的权重 id 写入 `processing_tasks.payload.model`;前端提交传播前会先保存当前项目中的 draft/dirty mask使 seed 尽量携带稳定的 `source_annotation_id`,同时仍会携带 `source_mask_id`。如果参考 mask 本身来自自动传播且未被编辑,前端会继承其 `propagation_seed_signature`,让后端识别它仍是原始 seed 的同一条传播链;如果该 mask 被编辑,保存时只保留 `source_annotation_id/source_mask_id` lineage不继承旧签名从而触发旧结果清理和重传。worker 保存传播结果时会写入 `propagation_seed_key``propagation_seed_signature``propagation_direction`。同一目标帧段内,同一 seed、同一权重、同一方向再次传播时如果所有目标帧已有同签名结果worker 会跳过该 seed如果签名变化、目标帧段只部分覆盖或本次改用其他 SAM 2.1 权重worker 会先删除本次目标帧段内的旧自动传播标注再保存新结果。对于旧版本只记录前端临时 `source_mask_id` 的传播结果worker 会按方向和 label/color/class 兼容清理,避免保存后的 `source_annotation_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 进度流显示该任务。
后台任务入队接口会先规范化/校验 `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 进度流显示该任务。
## 已完成的接口对齐
@@ -301,10 +303,10 @@ SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2`
- `getProjectAnnotations()` 已接入 `GET /api/ai/annotations`
- `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}`
- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`;工作区批量删除前会先用 `GET /api/ai/annotations` 预检存在的 id跳过本地陈旧 id。
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图,并按模板 `maskId` 匹配类别16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为 `gt_unknown_class` 未定义类别。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,中空导入结果通过 `mask_data.holes``metadata.polygonRingCounts` 回显为可编辑内洞,前端不显示黄色 seed point。
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图,并按模板 `maskId` 匹配类别16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为黑色 `maskid:0` 的“待分类”,并保留 `gt_unknown_class` 和原始 `gt_label_value`。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,中空导入结果通过 `mask_data.holes``metadata.polygonRingCounts` 回显为可编辑内洞,前端不显示黄色 seed point。
- `exportMasks()` 已接入 `GET /api/export/{projectId}/masks`
- `parseMedia()` 已改为创建 Celery 后台任务,并返回 `ProcessingTask`
- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求;传播 seed 可携带与 `polygons` 对齐的 `holes`,后端 seed 签名、SAM 2 seed mask 栅格化和传播结果保存都会保留内洞。
- `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`

View File

@@ -12,20 +12,20 @@
- 页面刷新后前端会用已有 token 调用 `/api/auth/me` 恢复当前用户。
- 登录失败时显示错误信息。
- 业务接口必须校验 Bearer token缺失或无效 token 返回 401。
- 项目、帧、标注、任务、Dashboard 和导出必须按当前用户的项目隔离;用户不能读取、修改或删除其他用户项目资源
- 角色包括 `admin``annotator``viewer``admin/annotator` 可写入业务数据和触发 AI/传播,`viewer` 只能访问读接口,用户管理后台仅 `admin` 可用。
- 管理员侧栏显示“用户管理”入口;管理员可以新增用户、修改角色、停用/启用、修改密码、删除无项目用户。
- 项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库;所有登录用户读取同一项目库,`admin/annotator` 可创建、导入、解析、标注、AI 推理、导出、复制、重命名和删除项目
- 角色包括唯一默认 `admin``annotator`;历史 `viewer` 或额外管理员会归一为标注员;用户管理、审计日志和演示环境出厂设置后台仅默认 `admin` 可用。
- 管理员侧栏显示“用户管理”入口;管理员可以新增标注员、停用/启用、修改密码、删除用户。
- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `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 后创建拆帧任务;项目名称编辑状态下不能显示/触发生成帧入口DICOM 项目不能显示生成帧入口。
- 用户可以对已导入源视频的视频项目点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;如果项目已有帧,重新生成前必须清空该项目现有帧、标注和 mask避免重复帧序列项目名称编辑状态下不能显示/触发生成帧入口DICOM 项目不能显示生成帧入口。
- 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。
- 用户可以在项目库项目卡片上修改项目名称,名称不能为空。
- 用户可以在项目卡片删除按钮旁复制项目;复制时可选择“新项目重置”或“全内容复制”。新项目重置必须复制项目媒体字段和已生成帧序列,但不复制标注或 mask 元数据;全内容复制必须额外复制标注和关联 mask 元数据,并将复制标注重新指向新项目中的对应帧。任务运行历史不复制。
@@ -57,11 +57,12 @@
- 工作区根据当前项目加载帧列表。
- 若项目有媒体但无帧,工作区只提示需要先在项目库生成帧,不再自动触发拆帧。
- 工作区加载帧时必须获取项目完整帧序列,不能被接口默认分页截断到 1000 帧。
- Canvas 显示当前帧图片。
- Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。
- Canvas 未选中特定 mask 时mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。
- 时间轴支持缩略图点击切帧、range 拖动切帧、视频处理进度条点击切帧、人工/AI 标注帧和自动传播帧标识点击切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。
- 清空片段遮罩进入范围选择后必须提供两种模式:`清空全部` 会清空范围内所有 mask包含人工绘制或 AI 智能分割生成的“人工/AI 标注帧”必须弹出“是否清除“人工/AI标注帧”?”确认;`保留人工/AI` 只清空范围内自动传播/推理 mask人工/AI 标注帧必须保留且不弹出人工帧确认;用户取消确认时不能删除本地 mask、后端标注或传播历史条。
- 顶栏旧“清空片段遮罩”入口已移除;当前清空/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`
@@ -75,14 +76,14 @@
- 侧栏“AI智能分割”和工作区工具栏 AI 跳转入口必须使用带明确 AI 语义的图标,而不是普通魔法棒等泛化工具图标。
- 工作区 AI 智能分割入口切换到 AI 页面。
- 多边形、矩形、圆、画笔、橡皮擦工具会在 Canvas 上生成或编辑可保存的 polygon mask左侧工具栏不再提供创建点和创建线段入口。
- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆通过拖拽生成;画笔和橡皮擦支持调整大小。
- 画笔工具在语义分类树有选中类别时可用,按住拖动时以圆形笔触采样,鼠标松开后一次性 union 成连续区域;如果笔触与当前选中 mask 连通,默认合并到该 mask否则生成新的当前类别 mask
- 橡皮擦工具只在当前帧已选中 mask 时可用,按住拖动时以圆形笔触采样,鼠标松开后从选中 mask 中 difference 扣除;扣空时删除该 mask已保存 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 选择逻辑吞掉。
- 绘制工具点击已有 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"` 位于清空遮罩下方并分隔外部动作组。工作区在“清空遮罩”上方提供 `DEL` 按钮,语义等同键盘 Delete/Backspace“导入 GT Mask”入口使用区别于普通编辑工具的紫色底色不切换 activeTool。
- 工具栏按浅灰分隔线分组:拖拽/选择、取消选中到创建圆为基础绘制组,画笔/橡皮擦为局部笔触组,区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask/AI 智能分割为外部动作组;`data-testid="tool-group-separator"` 位于清空遮罩下方并分隔外部动作组。工作区在拖拽/选择下方提供“取消选中”按钮,语义等同 `Esc`;在“清空遮罩”上方提供 `DEL` 按钮,语义等同键盘 Delete/Backspace“导入 GT Mask”入口使用区别于普通编辑工具的紫色底色不切换 activeTool。
- 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。
- “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。
- 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。
@@ -92,7 +93,7 @@
- 撤销、重做绑定全局 `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` 等 lineage metadata这些帧可以进入 dirty 待保存状态,但不能因为几何同步在时间轴上从自动传播帧变成人工/AI 标注帧。
- 区域合并/去除同步到传播帧时必须保留传播 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 回显。
@@ -152,10 +153,10 @@
- 当前前端保存状态按钮会保存当前项目未保存 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 存在传播链上的其它帧结果,则弹出范围确认,用户可选择“只清当前帧”、“清空所有传播帧”、“按帧范围选择”或取消;按帧范围选择进入和清空片段遮罩一致的时间轴范围选择模式,并提供“清空全部”和“保留人工/AI”。清空所有传播帧只同步清空同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。
- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask不弹确认若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。
- 工作区加载项目帧后会查询已保存标注并回显。
- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`
- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。
- 导入 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 可适配当前图片。
@@ -177,7 +178,7 @@
- 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes`
- 右侧面板修改激活模板时,如果当前项目已有任意 mask必须弹窗提示用户确认确认后清空当前项目所有本地 mask 和已保存后端标注,再切换激活模板。当前项目没有任何 mask 时切换激活模板不需要提示。
- 用户可以选择具体分类;新 AI mask 会记录 `classId``className``classZIndex`,并在保存时写入 `mask_data.class`
- 如果 Canvas 当前已经选中一个或多个 mask点击语义分类树会把这些 mask 的 `label``color` 和 class 元数据改为该分类;如果这些 mask 属于自动传播链,还必须通过 `source_annotation_id``source_mask_id``propagation_seed_key` 同步更新同一传播链前后帧的对应 mask已保存 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 的旧值。

View File

@@ -23,15 +23,15 @@
| API 封装 | `src/lib/api.ts` | Axios 客户端、字段映射、AI 响应转换 |
| 配置 | `src/lib/config.ts` | 推导 API 和 WebSocket 地址 |
| WebSocket | `src/lib/websocket.ts` | 进度流连接、订阅、连接状态通知、心跳和重连 |
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;左侧 Sidebar 底部使用 compact 形态显示 GPU/CPU 状态,工作区顶栏不再重复显示,具体传播权重由顶栏下拉负责 |
| 登录页 | `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` | 切换工作区编辑工具、在“重叠区域去除”后触发当前帧/传播链清空、GT Mask 导入和 AI 页面跳转AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、清空遮罩/导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px按钮列固定 48px滚动条使用右侧外扩空间和低对比 `seg-scrollbar` |
| 工具栏 | `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/FrameTimeline.tsx` | 帧导航、播放进度、视频处理进度条、自动传播历史片段、自动传播/布尔操作/导出范围选择、左右方向键切帧、播放和当前/总时长显示 |
| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、工作区 mask 透明度、分类树、后端自定义分类、mask 后端属性分析;内容过长时自身滚动,滚动条使用低对比 `seg-scrollbar` |
| AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 |
| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、详情页和编辑弹窗拖拽排序 |
@@ -84,25 +84,25 @@
### 用户隔离
1. `Project.owner_user_id` 指向 `users.id`;启动时默认 admin 用户会被创建,历史 `owner_user_id IS NULL` 的项目会迁移归属到 admin
1. `Project.owner_user_id` 指向 `users.id` 作为创建者/历史元数据保留;项目库访问不再按该字段隔离,所有登录用户共享同一项目库
2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。
3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。
4. 角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口`viewer` 只能调用读接口`/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、任务、用户模板和旧审计重新创建 `settings.demo_video_path` 指向的演示视频项目,以及 `settings.demo_dicom_dir` 指向的演示 DICOM 项目;DICOM 会按文件名自然顺序上传和生成帧;系统模板保留以保证重置后仍可标注。
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。
3. 用户在视频项目卡片点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS。已有帧的视频重新生成时会提示该操作会清空现有帧序列、标注和 mask后端任务开始时删除旧帧和旧标注再写入新的标准帧序列。任务入队后项目库会继续轮询任务进度解析成功后自动重新拉取项目列表和当前项目对象使后端生成的 `thumbnail_url` 立即显示为项目封面,无需刷新页面或重新进入项目库。
4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps``max_frames``target_width` 指定标准帧序列参数。
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. 刷新项目列表;项目卡片右上角 FPS 徽标显示生成关键帧序列时选择的 `parse_fps`,原始视频 FPS 仅作为底部“原 xx fps”辅助信息显示。
9. 导入视频、生成帧、上传 DICOM 和失败反馈使用 `TransientNotice`,不再使用浏览器 `alert()` 阻塞操作;提示默认数秒后自动消失。视频和 DICOM 上传阶段额外显示项目库内的导入进度面板DICOM 面板显示有效文件数量,并在上传完成后切换为解析任务进度轮询 `GET /api/tasks/{task_id}` 直到成功、失败或取消。
9. 导入视频、生成帧、上传 DICOM 和失败反馈使用 `TransientNotice`,不再使用浏览器 `alert()` 阻塞操作;提示默认数秒后自动消失。视频和 DICOM 上传阶段额外显示项目库内的导入进度面板DICOM 面板显示有效文件数量,并在上传完成后切换为解析任务进度;视频生成帧也会显示解析进度并轮询 `GET /api/tasks/{task_id}` 直到成功、失败或取消。
10. DICOM 和视频帧序列写入同一 `frames` 表并共用工作区、时间轴、AI 传播、标注保存、GT 导入和导出链路,差异只存在于项目库导入入口和后端解析器。
### 任务控制
@@ -117,21 +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. `VideoWorkspace` 加载项目帧时会优先按当前选中 mask 的 `frameId` 和当前打开帧 id 恢复 `currentFrameIndex`;只有没有可恢复帧时才回到第一帧,避免 AI 页在非第一帧推送回工作区时视角被重置。
6. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态;如果切换到另一帧时原 id 不存在,但目标帧存在同一自动传播链的结果,前端会用 `source_annotation_id``source_mask_id``propagation_seed_key` 匹配对应传播 mask 并自动选中。
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 覆盖层,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表达待处理或待导出范围边界,可点击/拖拽设置起止帧。
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”两段式模式选择默认“清空全部”。“清空全部”会对范围内已保存 mask 调用 `DELETE /api/ai/annotations/{id}`,同时移除范围内本地 draft mask、被清空的选区和与清空范围重叠的本地传播历史条若范围内存在非自动传播来源的 mask也就是时间轴红色“人工/AI 标注帧”,执行前会显示站内确认弹窗,取消则不删除任何 mask。“保留人工/AI”只删除范围内自动传播/推理 mask不删除人工绘制或 AI 智能分割生成的红色标注帧,不弹出人工帧确认;范围外 mask 和传播历史片段保持不变。自动传播范围选择,传播权重下拉旁显示当前权重和相对参考帧的向前/向后帧数布尔操作范围选择时,顶栏按钮变为“确认区域合并”或“确认重叠区域去除”点击后弹出最终确认,再只对范围内存在对应传播链的帧执行。
15. 工作区顶栏只在进入自动传播传播链布尔操作或传播链清空时显示对应范围控制;自动传播由左侧工具栏按钮进入范围选择,传播权重下拉旁显示当前权重和相对参考帧的向前/向后帧数,点击“开始传播”后提交后台任务。布尔操作范围选择时,顶栏按钮变为“确认区域合并”或“确认重叠区域去除”;清空范围选择时顶栏按钮变为“确认清空”;点击后弹出最终确认,再只对范围内存在对应传播链的帧执行。顶栏不再提供重复的“清空片段遮罩”。
### AI 点/框推理
@@ -158,22 +158,22 @@
21. 新 mask 会带上当前选择的模板分类元数据,包括 `classId``className``classZIndex``metadata.source=ai_segmentation` 和保存状态 `draft`
20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;用户点击保存后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`dirty mask 会先读取当前后端标注 id 列表,已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`,已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建;如果预检后发生并发删除导致 `PATCH` 返回 404也会降级为重新创建并在随后回显时排除本地旧 mask id保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。
21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。
22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask不弹确认如果存在传播链结果`VideoWorkspace` 弹出范围选择,用户可选择只清当前帧、清空当前帧及同传播链所有自动传播帧、进入按帧范围选择,或取消。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id只对存在的 id 发送 `DELETE`
22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask不弹确认如果存在传播链结果`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时,如果目标帧范围包含人工/AI 标注帧,会二次询问是否删除;其中清空所有传播帧会用传播链 seed 与传播结果跨越的完整帧段检查人工/AI 帧,避免从传播结果帧触发时漏掉中间独立 AI/人工帧;选择是会删除这些人工/AI 标注帧中的全部 mask选择否时这些帧整帧保留只清其它自动传播帧。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认,但 DEL/键盘删除在人工/AI 帧确认选择“是”时只删除本次选中或同传播链对应 mask不会清掉同帧其它 mask;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id只对存在的 id 发送 `DELETE`
### 视频片段传播
1. 用户在工作区打开一帧作为参考帧;该帧全部 mask 都会作为传播 seed不再提供传播对象下拉。
2. 用户可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重;该入口不提供 SAM2/SAM3 家族切换,默认跟随全局 AI 权重,用户手动选择后不再被 AI 页权重切换覆盖。
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 元数据、`source_mask_id` 和可用时的 `source_annotation_id`;中空 mask 会按 `metadata.polygonRingCounts` 将外圈写入 `mask_data.polygons`,把与外圈对齐的内洞写入 `mask_data.holes`,传播 seed 同步携带 `holes`;如果 seed mask 是未编辑的自动传播结果,会沿用其原始 `source_annotation_id/source_mask_id/propagation_seed_signature`,让后端把它识别为原传播链的同一个 seed如果该传播结果被编辑并保存更新 payload 只保留 lineage不保留旧签名使后端按“已修改”路径清理旧结果并重传。对历史或外部写入的 `geometry_smoothing` metadatapayload 仍可透传给后端兼容处理;当前前端平滑应用会直接改写 polygon 几何并移除该参数。
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、步骤进度、已处理帧次和已保存区域数任务运行期间提供取消传播按钮调用通用 `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`,仍按完整签名参与兼容去重。对旧版本只记录前端临时 `source_mask_id` 的传播标注worker 会按 label/color/class 做兼容匹配,确保可被后续稳定 `source_annotation_id` 的传播替换;对中间帧人工新增的替代 seed若缺少旧 source idworker 仍会用语义信息识别候选旧传播结果,并在写入目标帧新 polygon 前用目标帧 bbox 重叠做二次确认和清理。写入前这层清理不限制旧结果方向,确保 backward 传播可覆盖早先 forward 传播留下的同物体旧 mask。
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`,跳过源帧;传播 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` 记录权重传播来源,同时写入 `propagation_seed_key``propagation_seed_signature``propagation_direction``source_annotation_id``source_mask_id` 供后续幂等传播判断;历史 `geometry_smoothing` 仅在存在时保留用于兼容判断。
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供时间轴视频处理进度条显示蓝色传播区段。
### 手工绘制与历史栈
@@ -181,10 +181,10 @@
1. 用户在 `ToolsPalette` 选择多边形、矩形、圆、画笔或橡皮擦工具;创建点和创建线段入口不在工作区左侧工具栏中提供。
2. `CanvasArea` 将交互坐标转换成像素 polygon。
3. 多边形工具逐次记录节点,三点后点击首节点或按 Enter 时生成闭合 polygon。
4. Canvas 左上角根据当前工具和操作阶段显示上下文短提示;多边形提示会随已放置点数切换,明确 Enter 完成、Esc 取消和点击首节点闭合。提示会在工具或操作状态变化时出现,并在数秒后自动隐藏,避免长期遮挡底图。
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` 执行 union/difference,避免拖动中反复重算复杂 polygon。
7. 新 mask 写入 `pathData`、像素 `segmentation``bbox``area` 和当前模板分类元数据
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`;输入框、下拉框和可编辑文本聚焦时跳过快捷键,避免影响帧范围输入。
@@ -207,7 +207,7 @@
3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除。
4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常。
5. `CanvasArea``Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。
6. `area_merge` 使用 union更新第一个选中的主 mask并从前端 store 移除后续被合并 mask如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。执行前会按 `source_annotation_id``source_mask_id``propagation_seed_key` 计算可同步的传播帧;若存在其它传播帧,先弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择。按帧范围选择会把本次布尔操作交给 `VideoWorkspace`,复用底部时间轴范围选择和最终确认弹窗;确认后只在范围内且具备对应关系的帧上执行同一次 union只删除该帧参与合并的次级 mask避免把同链但未参与同步或范围外的区域整链误删。
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 都会显示顶点和边中点插入手柄,内洞拖动、插点、保存与回显继续保持中空结构。
@@ -230,15 +230,15 @@
4. 后端把 `classes``rules` 打包进 `mapping_rules`
5. 返回时再解包给前端。
6. 模板详情页和编辑弹窗都支持拖拽调整语义类别层级顺序;拖拽后重算 `zIndex`,保存到后端模板并刷新当前详情页,`maskId` 保持不变。所有模板都会归一化包含黑色 `maskId: 0` 的“待分类”保留类,该类固定在语义分类树最后,不参与删除和拖拽上移。编辑弹窗点击分类后只编辑分类名称,不展示或编辑旧 `category` 来源元信息。编辑弹窗中的 JSON 批量导入支持 `[[colors], [names]]``{colors, names}` 两种格式,并兼容带前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容导入前会先显示分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示,确认导入后进入编辑态,保存模板时落库。
7. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`;切换工具、切换帧或卸载 Canvas 时清空选择。
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`
15. 对属于自动传播链的 mask分类更新会复用 `source_annotation_id``source_mask_id``propagation_seed_key` 查找同一目标实例在前后帧中的传播结果,并同步更新这些传播 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` 非阻塞提示,默认数秒后自动消失。
@@ -267,7 +267,7 @@
- `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并传入 `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 区域。”;未知类别按策略舍弃或保存为 `gt_unknown_class` 未定义类别。若 GT mask 尺寸和当前帧不同,后端用最近邻插值拉伸到当前帧尺寸后再生成高精度 polygon。
- `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}`;工作区批量删除前会先用 `getProjectAnnotations()` 预检当前项目存在的 id跳过本地陈旧 id避免已被撤销/清空流程删除过的 annotation 再次发起 DELETE 产生 404。
@@ -297,7 +297,7 @@
以下能力属于当前冻结版本的占位或半可用功能:
- Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。
- 已保存标注支持通过右侧语义分类树换标签、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新;多 polygon/分离区域选中后所有子区域都显示编辑手柄,同帧同传播链的分散 mask 会联动高亮Canvas 右下角不再提供旧的“应用分类”按钮,避免没选区时误改整帧;区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择,范围选择复用时间轴和确认弹窗,并保留传播帧来源 metadata选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,同步后端前会预检 id同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留。
- 已保存标注支持通过右侧语义分类树换标签、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、状态接口和测试。
- 自定义分类通过 `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。前端不再展示“后端模型置信度”。

View File

@@ -14,42 +14,42 @@
| 需求 | 测试文件 | 覆盖点 |
|------|----------|--------|
| 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` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、角色权限、审计日志、viewer 读写权限边界、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 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 选择、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选只清当前帧/清空所有传播帧/按帧范围选择/取消、清空片段遮罩进入时间轴范围选择并按选区批量清空、传播链布尔操作按帧范围选择并二次确认、清空全部模式、保留人工/AI 模式只清传播 mask、清空人工/AI 标注帧二次确认、取消确认不删除、仅自动传播帧不确认、清空/删除前预检后端 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` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、橡皮擦后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 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 变体选择、点/框/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 和起止帧范围自动传播、当前参考帧无遮罩提示、传播前只保存参考帧 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 |
| 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 回显为 `maskid:0` 待分类、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、分类变更同步同一传播链前后帧对应 mask、自定义分类 PATCH 后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、选中 mask 后端拓扑属性分析、拓扑锚点数量按真实 polygon 顶点数显示、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览不标 dirty、应用边缘平滑后将 mask 标记为 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑保存时保留传播 lineage 而不把传播帧变成人工/AI 标注帧、平滑撤销/重做、平滑应用后强度归零 |
| 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 再导入保持类别一致 |
| 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 | 登录页、默认开发管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、演示视频项目和已生成帧自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
| R2 | 项目列表/创建/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、项目卡片 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、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、清空后裁剪/移除重叠传播历史条、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/清空范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播/清空范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
| 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 内洞 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 变体选择、点/框/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 和范围自动传播、当前参考帧无遮罩提示、传播前只保存参考帧 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` | 已覆盖 |
| 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 | 统一“分割结果导出”下拉、整体视频/特定范围帧/当前图片导出、特定范围帧时间轴拖拽选择、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、选中状态和草稿状态补充 `ToolsPalette.test.tsx` 中画笔/橡皮擦尺寸控制测试,并验证创建点、创建线段入口不再显示。
- 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。
@@ -58,15 +58,14 @@
- R6补充 `POST /api/ai/propagate` 后端测试,验证 seed mask 传播结果会保存为后续帧标注并保留 class 元数据。
- R6补充 `propagateMasks()` 同步兼容接口和 `queuePropagationTask()` 任务接口测试,验证当前参考帧全部 mask 会按范围组装为后台传播 steps。
- R6补充 `VideoWorkspace` 自动传播进度测试,验证传播任务运行中显示进度,后端返回 0 个新区域时给出明确反馈。
- R4/R6补充时间轴传播范围选择测试验证点击“自动传播”后可在播放进度条或视频处理进度条上拖拽回填起止帧,再提交后台传播任务。
- R4/R6补充时间轴传播范围选择测试验证点击“AI自动推理”后可在播放进度条或视频处理进度条上拖拽回填起止帧,再提交后台传播任务。
- R4/R6补充视频处理进度条传播历史测试验证多次自动传播后会按同一蓝色系显示最近处理范围最新最亮、旧记录逐次变暗且第 5 次后统一阈值色,单个片段不使用渐变。
- R4补充清空片段遮罩后移除重叠传播历史条测试避免已清空视频范围继续显示最近传播进度。
- R4补充清空片段遮罩模式测试覆盖“清空全部”确认删除、“保留人工/AI”只清传播 mask、取消不删除、仅自动传播帧不弹确认。
- 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 切换模板需确认清空,模板详情按钮改为“编辑模板”,当前帧清空会在传播链存在时提供只清当前帧/清空所有传播帧/按帧范围选择/取消,区域合并/去除会在存在传播帧时先选择当前帧/所有传播帧/按帧范围选择并保留传播 metadata。
- 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 文件数量和解析任务轮询进度。
@@ -75,6 +74,10 @@
- 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 渲染且内外圈顶点可显示。
## 运行命令

View File

@@ -27,6 +27,14 @@
| 视频处理 | 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
@@ -68,6 +76,48 @@ 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`
@@ -154,6 +204,26 @@ 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
@@ -184,6 +254,7 @@ 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"]
@@ -191,11 +262,13 @@ 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/Data_MyVideo_1.mp4
demo_dicom_dir=/home/wkmgc/Desktop/Seg_Server/2024_2_5_王芳/※2F458C45CFAA4C7CB76A39AA2BFE436B
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 地址。
---
@@ -314,7 +387,7 @@ admin / 123456
首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT业务接口会校验 `Authorization: Bearer <token>`。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。
默认管理员登录后会看到“用户管理”后台,可新增用户、停用/启用用户、修改角色、重置密码、删除无项目用户并查看登录与用户管理审计日志。角色分为 `admin``annotator``viewer``admin/annotator` 可以执行写入类业务操作,`viewer` 只读。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`DICOM 序列来自 `demo_dicom_dir`
默认管理员登录后会看到“用户管理”后台,可新增标注员、停用/启用用户、重置密码、删除用户并查看登录与用户管理审计日志。系统只支持唯一默认 `admin``annotator` 两类角色标注员不能新增用户、查看审计日志或恢复演示出厂设置但可以和管理员共享同一项目库并执行项目管理、标注、AI 推理、任务和导出等业务操作。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`DICOM 序列来自 `demo_dicom_dir`
---

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

@@ -21,6 +21,7 @@
| [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

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo_square.png" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>语义分割系统 SegServer</title>
</head>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -7,9 +7,6 @@ async function startServer() {
const PORT = 3000;
app.use(express.json());
app.get("/logo_square.png", (_req, res) => {
res.sendFile(path.join(process.cwd(), "logo_square.png"));
});
// Vite middleware for development
if (process.env.NODE_ENV !== "production") {

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 },
],
});
});
@@ -137,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()} />);

View File

@@ -65,7 +65,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
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;
@@ -245,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;
@@ -327,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([]);
@@ -474,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>
@@ -584,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">

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

@@ -90,6 +90,67 @@ describe('CanvasArea', () => {
expect(maskGroup()).toHaveAttribute('data-opacity', '0.3');
});
it('clears only the selected mask state with Escape', async () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z',
label: '胆囊',
color: '#ff0000',
classId: 'c1',
segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]],
},
],
});
render(<CanvasArea activeTool="move" frame={frame} />);
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
id: 'm1',
label: '胆囊',
color: '#ff0000',
}));
expect(useStore.getState().activeClassId).toBe('c1');
});
it('clears selected masks when the toolbar clear-selection signal changes', async () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z',
label: '胆囊',
color: '#ff0000',
classId: 'c1',
segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]],
},
],
});
const { rerender } = render(<CanvasArea activeTool="move" frame={frame} clearSelectionSignal={0} />);
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
rerender(<CanvasArea activeTool="move" frame={frame} clearSelectionSignal={1} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().activeClassId).toBe('c1');
});
it('refines one SAM2 candidate mask from an initial box with positive and negative points', async () => {
apiMock.predictMask
.mockResolvedValueOnce({
@@ -466,6 +527,101 @@ describe('CanvasArea', () => {
expect(screen.getByText('当前图层: 胆囊 #21')).toBeInTheDocument();
});
it('selects all separated propagated pieces with the same seed signature on the frame', () => {
useStore.setState({
maskPreviewOpacity: 35,
masks: [
{
id: 'annotation-20',
annotationId: '20',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: 'Merged object',
color: '#facc15',
segmentation: [[10, 10, 30, 10, 30, 30]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 'seed-frame',
propagation_seed_signature: 'merged-seed-signature',
},
},
{
id: 'annotation-21',
annotationId: '21',
frameId: 'frame-1',
pathData: 'M 80 80 L 100 80 L 100 100 Z',
label: 'Merged object',
color: '#facc15',
segmentation: [[80, 80, 100, 80, 100, 100]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 'seed-frame',
propagation_seed_signature: 'merged-seed-signature',
},
},
],
});
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
fireEvent.click(screen.getAllByTestId('konva-path')[0]);
expect(useStore.getState().selectedMaskIds).toEqual(['annotation-20', 'annotation-21']);
expect(screen.getAllByTestId('konva-group').map((group) => group.getAttribute('data-opacity'))).toEqual(['0.5', '0.5']);
});
it('selects legacy separated propagated pieces without stable seed ids on the frame', () => {
useStore.setState({
maskPreviewOpacity: 35,
masks: [
{
id: 'annotation-30',
annotationId: '30',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: '胆囊',
color: '#facc15',
segmentation: [[10, 10, 30, 10, 30, 30]],
metadata: {
source: 'sam2_propagation',
propagated_from_frame_id: 'seed-frame',
},
},
{
id: 'annotation-31',
annotationId: '31',
frameId: 'frame-1',
pathData: 'M 80 80 L 100 80 L 100 100 Z',
label: '胆囊',
color: '#facc15',
segmentation: [[80, 80, 100, 80, 100, 100]],
metadata: {
source: 'sam2_propagation',
propagated_from_frame_id: 'seed-frame',
},
},
{
id: 'annotation-40',
annotationId: '40',
frameId: 'frame-1',
pathData: 'M 130 130 L 150 130 L 150 150 Z',
label: '肝脏',
color: '#22c55e',
segmentation: [[130, 130, 150, 130, 150, 150]],
metadata: {
source: 'sam2_propagation',
propagated_from_frame_id: 'seed-frame',
},
},
],
});
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
fireEvent.click(screen.getAllByTestId('konva-path')[0]);
expect(useStore.getState().selectedMaskIds).toEqual(['annotation-30', 'annotation-31']);
expect(screen.getAllByTestId('konva-group').map((group) => group.getAttribute('data-opacity'))).toEqual(['0.5', '0.5', '0.35']);
});
it('does not render stored GT seed points as visible editable handles', () => {
useStore.setState({
masks: [
@@ -993,6 +1149,443 @@ describe('CanvasArea', () => {
}));
});
it('uses propagation instance ids to merge only the intended same-class propagated masks', async () => {
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: 'frame-1',
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
saved: true,
saveStatus: 'saved',
metadata: { instance_id: 'same-class-primary' },
},
{
id: 'annotation-2',
annotationId: '2',
frameId: 'frame-1',
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
saved: true,
saveStatus: 'saved',
metadata: { instance_id: 'same-class-secondary' },
},
{
id: 'annotation-10',
annotationId: '10',
frameId: 'frame-2',
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
saved: true,
saveStatus: 'saved',
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 101,
source_instance_id: 'same-class-primary',
instance_id: 'same-class-primary',
},
},
{
id: 'annotation-20',
annotationId: '20',
frameId: 'frame-2',
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
saved: true,
saveStatus: 'saved',
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 202,
source_instance_id: 'same-class-secondary',
instance_id: 'same-class-secondary',
},
},
{
id: 'annotation-30',
annotationId: '30',
frameId: 'frame-2',
pathData: 'M 180 180 L 230 180 L 230 230 L 180 230 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[180, 180, 230, 180, 230, 230, 180, 230]],
saved: true,
saveStatus: 'saved',
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 303,
source_instance_id: 'same-class-unselected',
instance_id: 'same-class-unselected',
},
},
],
});
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]);
fireEvent.click(paths[1]);
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20'])));
expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['30']));
const masks = useStore.getState().masks;
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-30']);
expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
saveStatus: 'dirty',
saved: false,
metadata: expect.objectContaining({ source_instance_id: 'same-class-primary' }),
}));
expect(masks.find((mask) => mask.id === 'annotation-30')).toEqual(expect.objectContaining({
saveStatus: 'saved',
}));
});
it('merges legacy propagated masks by nearest same-label result when stable lineage is missing', async () => {
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: 'frame-1',
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-2',
annotationId: '2',
frameId: 'frame-1',
pathData: 'M 70 70 L 120 70 L 120 120 L 70 120 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[70, 70, 120, 70, 120, 120, 70, 120]],
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-10',
annotationId: '10',
frameId: 'frame-2',
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', propagated_from_frame_id: 'frame-1' },
},
{
id: 'annotation-20',
annotationId: '20',
frameId: 'frame-2',
pathData: 'M 72 72 L 122 72 L 122 122 L 72 122 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[72, 72, 122, 72, 122, 122, 72, 122]],
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', propagated_from_frame_id: 'frame-1' },
},
],
});
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]);
fireEvent.click(paths[1]);
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20'])));
const masks = useStore.getState().masks;
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10']);
expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
saveStatus: 'dirty',
saved: false,
metadata: expect.objectContaining({ source: 'sam2_propagation' }),
}));
});
it('does not merge every same-class legacy propagation instance on later frames', async () => {
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
const frame2 = { ...frame, id: 'frame-2', index: 1 };
const legacyMetadata = {
source: 'sam2_propagation',
propagated_from_frame_id: 'frame-1',
propagation_seed_key: '{"label":"A","color":"#06b6d4"}',
};
useStore.setState({
masks: [
{
id: 'annotation-10',
annotationId: '10',
frameId: 'frame-2',
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
{
id: 'annotation-20',
annotationId: '20',
frameId: 'frame-2',
pathData: 'M 72 72 L 122 72 L 122 122 L 72 122 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[72, 72, 122, 72, 122, 122, 72, 122]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
{
id: 'annotation-30',
annotationId: '30',
frameId: 'frame-2',
pathData: 'M 180 180 L 230 180 L 230 230 L 180 230 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[180, 180, 230, 180, 230, 230, 180, 230]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
{
id: 'annotation-110',
annotationId: '110',
frameId: 'frame-3',
pathData: 'M 14 14 L 64 14 L 64 64 L 14 64 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[14, 14, 64, 14, 64, 64, 14, 64]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
{
id: 'annotation-120',
annotationId: '120',
frameId: 'frame-3',
pathData: 'M 74 74 L 124 74 L 124 124 L 74 124 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[74, 74, 124, 74, 124, 124, 74, 124]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
{
id: 'annotation-130',
annotationId: '130',
frameId: 'frame-3',
pathData: 'M 182 182 L 232 182 L 232 232 L 182 232 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[182, 182, 232, 182, 232, 232, 182, 232]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
],
});
render(<CanvasArea activeTool="area_merge" frame={frame2} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]);
fireEvent.click(paths[1]);
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['20', '120'])));
const masks = useStore.getState().masks;
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-10', 'annotation-110', 'annotation-130', 'annotation-30']);
expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['30', '130']));
expect(masks.find((mask) => mask.id === 'annotation-130')).toEqual(expect.objectContaining({
saveStatus: 'saved',
}));
expect(masks.find((mask) => mask.id === 'annotation-110')).toEqual(expect.objectContaining({
saveStatus: 'dirty',
saved: false,
bbox: [14, 14, 110, 110],
}));
});
it('merges propagated A masks into propagated B masks when merging A into B on the reference frame', async () => {
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: 'frame-1',
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
label: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-2',
annotationId: '2',
frameId: 'frame-1',
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
label: 'A',
color: '#dc2626',
className: 'A',
classMaskId: 1,
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-10',
annotationId: '10',
frameId: 'frame-2',
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
label: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
{
id: 'annotation-20',
annotationId: '20',
frameId: 'frame-2',
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
label: 'A',
color: '#dc2626',
className: 'A',
classMaskId: 1,
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
},
],
});
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]);
fireEvent.click(paths[1]);
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20'])));
const masks = useStore.getState().masks;
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10']);
const propagatedB = masks.find((mask) => mask.id === 'annotation-10');
expect(propagatedB).toEqual(expect.objectContaining({
label: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
saveStatus: 'dirty',
saved: false,
}));
expect(propagatedB?.bbox).toEqual([12, 12, 90, 90]);
expect(propagatedB?.area).toBe(4900);
expect(propagatedB?.segmentation?.flat()).toEqual(expect.arrayContaining([12, 12, 102, 102]));
});
it('turns propagated A masks into B masks when merging A into B and no propagated B exists on that frame', async () => {
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: 'frame-1',
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
label: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-2',
annotationId: '2',
frameId: 'frame-1',
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
label: 'A',
color: '#dc2626',
className: 'A',
classMaskId: 1,
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-20',
annotationId: '20',
frameId: 'frame-2',
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
label: 'A',
color: '#dc2626',
className: 'A',
classMaskId: 1,
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
},
],
});
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]);
fireEvent.click(paths[1]);
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
const masks = useStore.getState().masks;
expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['20']));
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-20']);
expect(masks.find((mask) => mask.id === 'annotation-20')).toEqual(expect.objectContaining({
label: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
saveStatus: 'dirty',
saved: false,
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2 }),
}));
});
it('can hand propagated boolean operations to the workspace frame range selector', () => {
const onRequestBooleanFrameRange = vi.fn();
useStore.setState({
@@ -1043,7 +1636,12 @@ describe('CanvasArea', () => {
fireEvent.click(paths[0]);
fireEvent.click(paths[1]);
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
const rangeButton = screen.getByRole('button', { name: '按帧范围选择' });
expect(rangeButton.parentElement).toHaveClass('grid-cols-4');
expect(screen.getByRole('button', { name: '取消' }).parentElement).toBe(rangeButton.parentElement);
expect(screen.getByRole('button', { name: '只处理当前帧' }).parentElement).toBe(rangeButton.parentElement);
expect(screen.getByRole('button', { name: '处理所有传播帧' }).parentElement).toBe(rangeButton.parentElement);
fireEvent.click(rangeButton);
expect(onRequestBooleanFrameRange).toHaveBeenCalledWith(expect.objectContaining({
operation: 'area_merge',
@@ -1236,6 +1834,9 @@ describe('CanvasArea', () => {
segmentation: [[120, 80, 260, 80, 260, 200, 120, 200]],
bbox: [120, 80, 140, 120],
}));
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
expect(screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(4);
useStore.getState().undoMasks();
expect(useStore.getState().masks).toEqual([]);
@@ -1253,8 +1854,11 @@ describe('CanvasArea', () => {
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
frameId: 'frame-1',
label: '手工圆形',
color: '#06b6d4',
label: '待分类',
color: '#000000',
classId: 'reserved-unclassified',
className: '待分类',
classMaskId: 0,
saveStatus: 'draft',
bbox: [120, 80, 140, 120],
metadata: expect.objectContaining({
@@ -1263,6 +1867,73 @@ describe('CanvasArea', () => {
}),
}));
expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(64);
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
expect(screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(32);
});
it('keeps the selected mask when switching to manual creation tools', async () => {
useStore.setState({
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z',
label: 'Existing',
color: '#06b6d4',
segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]],
},
],
});
const { rerender } = render(<CanvasArea activeTool="create_polygon" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
act(() => useStore.getState().setSelectedMaskIds(['m1']));
rerender(<CanvasArea activeTool="create_rectangle" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
act(() => useStore.getState().setSelectedMaskIds(['m1']));
rerender(<CanvasArea activeTool="create_circle" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
});
it('merges new manual shapes into the selected mask even when separated', () => {
useStore.setState({
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 20 20 L 80 20 L 80 80 L 20 80 Z',
label: 'Existing',
color: '#06b6d4',
segmentation: [[20, 20, 80, 20, 80, 80, 20, 80]],
area: 3600,
saveStatus: 'saved',
annotationId: '7',
saved: true,
},
],
});
render(<CanvasArea activeTool="create_rectangle" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 180, clientY: 120 });
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
id: 'm1',
saveStatus: 'dirty',
saved: false,
metadata: expect.objectContaining({ manualMergeShapes: ['矩形'] }),
}));
expect(useStore.getState().masks[0].segmentation).toHaveLength(2);
expect(useStore.getState().masks[0].area).toBeGreaterThan(3600);
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
});
it('creates a brush mask when a semantic class is selected', () => {
@@ -1295,11 +1966,109 @@ describe('CanvasArea', () => {
expect(useStore.getState().masks[0].area).toBeGreaterThan(1000);
});
it('merges a connected brush stroke into the selected mask', () => {
it('preserves hollow brush masks as editable inner rings', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
});
render(<CanvasArea activeTool="brush" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 100, clientY: 100 });
fireEvent.mouseMove(stage, { clientX: 240, clientY: 100 });
fireEvent.mouseMove(stage, { clientX: 240, clientY: 240 });
fireEvent.mouseMove(stage, { clientX: 100, clientY: 240 });
fireEvent.mouseMove(stage, { clientX: 100, clientY: 100 });
fireEvent.mouseUp(stage, { clientX: 100, clientY: 100 });
const brushMask = useStore.getState().masks[0];
expect(brushMask.metadata).toEqual(expect.objectContaining({
hasHoles: true,
shape: '画笔',
}));
expect(brushMask.metadata?.polygonRingCounts).toEqual([2]);
expect(brushMask.segmentation).toHaveLength(2);
expect(screen.getByTestId('konva-path')).toHaveAttribute('data-fill-rule', 'evenodd');
expect(screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff').length).toBeGreaterThan(8);
});
it('keeps selected mask vertex markers visible while using brush and eraser', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 100 70 L 150 70 L 150 120 L 100 120 Z',
label: '胆囊',
color: '#ff0000',
classId: 'c1',
segmentation: [[100, 70, 150, 70, 150, 120, 100, 120]],
area: 2500,
},
],
});
const selectedVertexHandles = () => screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
const { rerender } = render(<CanvasArea activeTool="brush" frame={frame} />);
expect(selectedVertexHandles()).toHaveLength(4);
rerender(<CanvasArea activeTool="eraser" frame={frame} />);
expect(selectedVertexHandles()).toHaveLength(4);
});
it('does not start brush strokes outside the image bounds', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
});
render(<CanvasArea activeTool="brush" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 700, clientY: 400 });
fireEvent.mouseMove(stage, { clientX: 720, clientY: 420 });
fireEvent.mouseUp(stage, { clientX: 720, clientY: 420 });
expect(useStore.getState().masks).toHaveLength(0);
});
it('clips brush stroke geometry to the current image bounds', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
brushSize: 40,
});
render(<CanvasArea activeTool="brush" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 630, clientY: 350 });
fireEvent.mouseMove(stage, { clientX: 700, clientY: 420 });
fireEvent.mouseUp(stage, { clientX: 700, clientY: 420 });
expect(useStore.getState().masks).toHaveLength(1);
const coordinates = useStore.getState().masks[0].segmentation?.flat() || [];
for (let index = 0; index < coordinates.length; index += 2) {
expect(coordinates[index]).toBeGreaterThanOrEqual(0);
expect(coordinates[index]).toBeLessThanOrEqual(frame.width);
expect(coordinates[index + 1]).toBeGreaterThanOrEqual(0);
expect(coordinates[index + 1]).toBeLessThanOrEqual(frame.height);
}
});
it('merges brush strokes into the selected mask even without an active semantic class', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: null,
activeClassId: null,
selectedMaskIds: ['m1'],
masks: [
{
@@ -1326,7 +2095,9 @@ describe('CanvasArea', () => {
id: 'm1',
label: '胆囊',
color: '#ff0000',
classId: 'c1',
saveStatus: 'draft',
metadata: expect.objectContaining({ manualMergeShapes: ['画笔'] }),
}));
expect(useStore.getState().masks[0].area).toBeGreaterThan(2500);
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
@@ -1378,6 +2149,22 @@ describe('CanvasArea', () => {
source: 'manual',
shape: '多边形',
}));
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
});
it('cancels in-progress polygon creation with Escape', () => {
render(<CanvasArea activeTool="create_polygon" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.click(stage, { clientX: 120, clientY: 80 });
fireEvent.click(stage, { clientX: 220, clientY: 80 });
expect(screen.getAllByTestId('konva-circle')).toHaveLength(2);
fireEvent.keyDown(window, { key: 'Escape' });
expect(useStore.getState().masks).toEqual([]);
expect(useStore.getState().selectedMaskIds).toEqual([]);
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
expect(screen.getByText(/点击画布添加顶点/)).toBeInTheDocument();
});
it('closes a clicked polygon by clicking the first node again', () => {
@@ -1402,6 +2189,53 @@ describe('CanvasArea', () => {
}));
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(3);
});
it('clears selected masks with Escape so brush can create a new unmerged mask', async () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 30, maskId: 2 },
activeClassId: 'c2',
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 100 70 L 150 70 L 150 120 L 100 120 Z',
label: '旧标签',
color: '#ff0000',
classId: 'c1',
segmentation: [[100, 70, 150, 70, 150, 120, 100, 120]],
area: 2500,
},
],
});
const { rerender } = render(<CanvasArea activeTool="move" frame={frame} />);
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
rerender(<CanvasArea activeTool="brush" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 300, clientY: 220 });
fireEvent.mouseMove(stage, { clientX: 330, clientY: 240 });
fireEvent.mouseUp(stage, { clientX: 360, clientY: 260 });
expect(useStore.getState().masks).toHaveLength(2);
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
id: 'm1',
label: '旧标签',
color: '#ff0000',
}));
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
label: '肝脏',
color: '#00ff00',
classId: 'c2',
classMaskId: 2,
}));
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[1].id]);
});
it('shows contextual guidance for boolean selection ordering', () => {

View File

@@ -5,6 +5,7 @@ import useImage from 'use-image';
import { useStore } from '../store/useStore';
import { predictMask } from '../lib/api';
import type { Frame, Mask } from '../store/useStore';
import { RESERVED_UNCLASSIFIED_CLASS } from '../lib/maskIds';
type BooleanOperationTool = 'area_merge' | 'area_remove';
@@ -19,8 +20,12 @@ export interface BooleanFrameRangeRequest {
interface CanvasAreaProps {
activeTool: string;
frame: Frame | null;
currentFrameNumber?: number;
totalFrames?: number;
clearSelectionSignal?: number;
onRequestDeleteMasks?: (maskIds: string[]) => void;
onRequestBooleanFrameRange?: (request: BooleanFrameRangeRequest) => void;
onBooleanOperationStart?: () => void;
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
}
@@ -61,14 +66,42 @@ function propagationSourceMaskTokens(value: unknown): string[] {
return tokens;
}
function propagationInstanceTokens(value: unknown): string[] {
return typeof value === 'string' && value.length > 0 ? [`instance:${value}`] : [];
}
function reliablePropagationLineageTokens(mask: Mask): Set<string> {
const metadata = mask.metadata || {};
const tokens = new Set<string>([`mask:${mask.id}`]);
if (mask.annotationId) {
tokens.add(`annotation:${mask.annotationId}`);
}
propagationInstanceTokens(metadata.source_instance_id).forEach((token) => tokens.add(token));
propagationInstanceTokens(metadata.instance_id).forEach((token) => tokens.add(token));
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') {
const seedKey = metadata.propagation_seed_key.trim();
if (/^(annotation|mask):/.test(seedKey)) {
tokens.add(`seed-key:${seedKey}`);
tokens.add(seedKey);
}
}
return tokens;
}
function isPropagationMask(mask: Mask): boolean {
const metadata = mask.metadata || {};
const source = typeof metadata.source === 'string' ? metadata.source : '';
return source.includes('_propagation')
const source = typeof metadata.source === 'string' ? metadata.source.toLowerCase() : '';
return source.includes('propagat')
|| metadata.propagated_from_frame_id !== undefined
|| metadata.propagation_seed_key !== undefined
|| metadata.source_annotation_id !== undefined
|| metadata.source_mask_id !== undefined;
|| metadata.source_mask_id !== undefined
|| metadata.propagation_seed_signature !== undefined;
}
function propagationLineageTokens(mask: Mask): Set<string> {
@@ -77,39 +110,205 @@ function propagationLineageTokens(mask: Mask): Set<string> {
if (mask.annotationId) {
tokens.add(`annotation:${mask.annotationId}`);
}
let hasStablePropagationToken = false;
const instanceTokens = [
...propagationInstanceTokens(metadata.source_instance_id),
...propagationInstanceTokens(metadata.instance_id),
];
if (instanceTokens.length > 0) {
instanceTokens.forEach((token) => tokens.add(token));
hasStablePropagationToken = true;
}
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
if (sourceAnnotationId !== null) {
tokens.add(`annotation:${sourceAnnotationId}`);
hasStablePropagationToken = true;
}
const sourceMaskTokens = propagationSourceMaskTokens(metadata.source_mask_id);
if (sourceMaskTokens.length > 0) {
sourceMaskTokens.forEach((token) => tokens.add(token));
hasStablePropagationToken = true;
}
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}`);
hasStablePropagationToken = true;
}
if (typeof metadata.propagation_seed_signature === 'string' && metadata.propagation_seed_signature.length > 0) {
tokens.add(`seed-signature:${metadata.propagation_seed_signature}`);
hasStablePropagationToken = true;
}
if (isPropagationMask(mask) && !hasStablePropagationToken) {
const source = typeof metadata.source === 'string' ? metadata.source : '';
const classKey = mask.classId || mask.className || '';
tokens.add([
'legacy-propagation',
source,
metadata.propagated_from_frame_id ?? '',
metadata.propagated_from_frame_index ?? '',
metadata.propagation_direction ?? '',
classKey,
mask.label || '',
mask.color || '',
].join(':'));
}
return tokens;
}
function findLinkedMasksOnFrame(selectedIds: string[], allMasks: Mask[], targetFrameId?: string): string[] {
function maskSemanticKey(mask: Mask): string {
return [
mask.classMaskId ?? '',
mask.classId ?? '',
mask.className ?? '',
mask.label ?? '',
mask.color ?? '',
].join(':').toLowerCase();
}
function maskBboxCenter(mask: Mask): CanvasPoint | null {
const bbox = mask.bbox || segmentationBbox(mask.segmentation);
if (!bbox) return null;
return { x: bbox[0] + bbox[2] / 2, y: bbox[1] + bbox[3] / 2 };
}
function propagatedFromFrame(mask: Mask, sourceFrameId: string): boolean {
const metadata = mask.metadata || {};
return metadata.propagated_from_frame_id !== undefined
&& String(metadata.propagated_from_frame_id) === String(sourceFrameId);
}
function propagationFallbackCompatible(selectedMask: Mask, candidate: Mask): boolean {
if (!isPropagationMask(candidate) || maskSemanticKey(candidate) !== maskSemanticKey(selectedMask)) return false;
const selectedMetadata = selectedMask.metadata || {};
const candidateMetadata = candidate.metadata || {};
if (!isPropagationMask(selectedMask)) {
return propagatedFromFrame(candidate, String(selectedMask.frameId));
}
const selectedOriginFrameId = selectedMetadata.propagated_from_frame_id;
const candidateOriginFrameId = candidateMetadata.propagated_from_frame_id;
if (
selectedOriginFrameId !== undefined
&& candidateOriginFrameId !== undefined
&& String(selectedOriginFrameId) !== String(candidateOriginFrameId)
) {
return false;
}
const selectedOriginFrameIndex = selectedMetadata.propagated_from_frame_index;
const candidateOriginFrameIndex = candidateMetadata.propagated_from_frame_index;
if (
selectedOriginFrameIndex !== undefined
&& candidateOriginFrameIndex !== undefined
&& String(selectedOriginFrameIndex) !== String(candidateOriginFrameIndex)
) {
return false;
}
const selectedSource = typeof selectedMetadata.source === 'string' ? selectedMetadata.source : '';
const candidateSource = typeof candidateMetadata.source === 'string' ? candidateMetadata.source : '';
if (selectedSource && candidateSource && selectedSource !== candidateSource) return false;
const selectedDirection = selectedMetadata.propagation_direction;
const candidateDirection = candidateMetadata.propagation_direction;
if (
selectedDirection !== undefined
&& candidateDirection !== undefined
&& String(selectedDirection) !== String(candidateDirection)
) {
return false;
}
return true;
}
function findLinkedMasksOnFrame(
selectedIds: string[],
allMasks: Mask[],
targetFrameId?: string,
options: { strictInstanceMatch?: boolean } = {},
): string[] {
if (!targetFrameId || selectedIds.length === 0) return [];
const selectedMasks = selectedIds
.map((id) => allMasks.find((mask) => mask.id === id))
.filter((mask): mask is Mask => Boolean(mask));
if (selectedMasks.length === 0) return [];
const selectedTokens = new Set<string>();
const selectedHasPropagation = selectedMasks.some(isPropagationMask);
selectedMasks.forEach((mask) => {
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
if (!options.strictInstanceMatch) {
const selectedTokens = new Set<string>();
const selectedHasPropagation = selectedMasks.some(isPropagationMask);
selectedMasks.forEach((mask) => {
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
});
const linkedIds = allMasks
.filter((mask) => String(mask.frameId) === String(targetFrameId))
.filter((mask) => {
const candidateHasPropagation = isPropagationMask(mask);
if (!selectedHasPropagation && !candidateHasPropagation) return false;
const candidateTokens = propagationLineageTokens(mask);
return [...candidateTokens].some((token) => selectedTokens.has(token));
})
.map((mask) => mask.id);
const linkedIdSet = new Set(linkedIds);
selectedMasks.forEach((selectedMask) => {
if (isPropagationMask(selectedMask)) return;
const selectedCenter = maskBboxCenter(selectedMask);
const selectedSemanticKey = maskSemanticKey(selectedMask);
const candidates = allMasks
.filter((mask) => String(mask.frameId) === String(targetFrameId))
.filter((mask) => !linkedIdSet.has(mask.id))
.filter((mask) => isPropagationMask(mask))
.filter((mask) => propagatedFromFrame(mask, String(selectedMask.frameId)))
.filter((mask) => maskSemanticKey(mask) === selectedSemanticKey);
if (candidates.length === 0) return;
const best = candidates.reduce<{ mask: Mask; distance: number } | null>((currentBest, candidate) => {
const candidateCenter = maskBboxCenter(candidate);
const distance = selectedCenter && candidateCenter ? pointDistance(selectedCenter, candidateCenter) : 0;
if (!currentBest || distance < currentBest.distance) return { mask: candidate, distance };
return currentBest;
}, null);
if (best) {
linkedIds.push(best.mask.id);
linkedIdSet.add(best.mask.id);
}
});
return linkedIds;
}
const targetMasks = allMasks.filter((mask) => String(mask.frameId) === String(targetFrameId));
const linkedIds: string[] = [];
const linkedIdSet = new Set<string>();
selectedMasks.forEach((selectedMask) => {
const selectedTokens = reliablePropagationLineageTokens(selectedMask);
const exactMatches = targetMasks.filter((mask) => {
if (!isPropagationMask(selectedMask) && !isPropagationMask(mask)) return false;
const candidateTokens = reliablePropagationLineageTokens(mask);
return [...candidateTokens].some((token) => selectedTokens.has(token));
});
exactMatches.forEach((mask) => {
if (!linkedIdSet.has(mask.id)) {
linkedIds.push(mask.id);
linkedIdSet.add(mask.id);
}
});
if (exactMatches.length > 0) return;
const selectedCenter = maskBboxCenter(selectedMask);
const candidates = targetMasks
.filter((mask) => !linkedIdSet.has(mask.id))
.filter((mask) => propagationFallbackCompatible(selectedMask, mask));
if (candidates.length === 0) return;
const best = candidates.reduce<{ mask: Mask; distance: number } | null>((currentBest, candidate) => {
const candidateCenter = maskBboxCenter(candidate);
const distance = selectedCenter && candidateCenter ? pointDistance(selectedCenter, candidateCenter) : 0;
if (!currentBest || distance < currentBest.distance) return { mask: candidate, distance };
return currentBest;
}, null);
if (best) {
linkedIds.push(best.mask.id);
linkedIdSet.add(best.mask.id);
}
});
return allMasks
.filter((mask) => String(mask.frameId) === String(targetFrameId))
.filter((mask) => {
const candidateHasPropagation = isPropagationMask(mask);
if (!selectedHasPropagation && !candidateHasPropagation) return false;
const candidateTokens = propagationLineageTokens(mask);
return [...candidateTokens].some((token) => selectedTokens.has(token));
})
.map((mask) => mask.id);
return linkedIds;
}
function findPropagationChainMaskIds(selectedIds: string[], allMasks: Mask[]): Set<string> {
@@ -424,11 +623,27 @@ function paintStrokeToGeometry(strokePoints: CanvasPoint[], radius: number): Mul
: polygonClipping.union(firstGeometry, ...restGeometries);
}
function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
return polygonClipping.intersection(first, second).length > 0;
function imageBoundsGeometry(width: number, height: number): MultiPolygon | null {
if (width <= 0 || height <= 0) return null;
return polygonsToMultiPolygon([[
{ x: 0, y: 0 },
{ x: width, y: 0 },
{ x: width, y: height },
{ x: 0, y: height },
]]);
}
export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestBooleanFrameRange, onDeleteMaskAnnotations }: CanvasAreaProps) {
export function CanvasArea({
activeTool,
frame,
currentFrameNumber,
totalFrames,
clearSelectionSignal,
onRequestDeleteMasks,
onRequestBooleanFrameRange,
onBooleanOperationStart,
onDeleteMaskAnnotations,
}: CanvasAreaProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [scale, setScale] = useState(1);
@@ -456,6 +671,8 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
const paintStrokeRef = useRef<CanvasPoint[]>([]);
const paintToolRef = useRef<string | null>(null);
const lastPaintPointRef = useRef<CanvasPoint | null>(null);
const lastClearSelectionSignalRef = useRef<number | undefined>(clearSelectionSignal);
const clearSelectionInProgressRef = useRef(false);
const masks = useStore((state) => state.masks);
const addMask = useStore((state) => state.addMask);
@@ -505,12 +722,29 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool);
const isPaintTool = PAINT_TOOLS.has(effectiveTool);
const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL;
const isManualCreateTool = effectiveTool === POLYGON_TOOL || DRAG_MANUAL_TOOLS.has(effectiveTool);
const canKeepMaskSelection = isPolygonEditTool || isBooleanTool || isPaintTool || isManualCreateTool;
const showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool || isManualCreateTool));
const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize;
const activePaintRadius = Math.max(2, activePaintSize / 2);
const setPaintStrokePoints = useCallback((nextPoints: CanvasPoint[]) => {
paintStrokeRef.current = nextPoints;
setPaintStrokePointsState(nextPoints);
}, []);
const clearSelectionState = useCallback(() => {
clearSelectionInProgressRef.current = true;
setPolygonPoints([]);
setManualStart(null);
setManualCurrent(null);
setPaintStrokePoints([]);
paintToolRef.current = null;
lastPaintPointRef.current = null;
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}, [setGlobalSelectedMaskIds, setPaintStrokePoints]);
const currentLayerLabel = selectedMask
? `${selectedMask.className || selectedMask.label}${selectedMask.annotationId ? ` #${selectedMask.annotationId}` : ' (未保存)'}`
: '未选择';
@@ -544,8 +778,10 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
return {
title: '画笔',
body: activeClass
? '按住并拖动画出连续区域;若与当前选中 mask 连通,会自动合并到该 mask。'
: '先在右侧语义分类树选择类别,然后按住并拖动画出连续区域。',
? '按住并拖动画出连续区域;已有选中 mask 时会并入选中区域,未选中时生成新 mask。'
: selectedMask
? '按住并拖动画出连续区域,松开后并入当前选中 mask。'
: '先在右侧语义分类树选择类别,然后按住并拖动画出连续区域。',
};
}
if (effectiveTool === ERASER_TOOL) {
@@ -645,6 +881,13 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
});
}, [frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]);
useEffect(() => {
if (clearSelectionSignal === undefined) return;
if (lastClearSelectionSignalRef.current === clearSelectionSignal) return;
lastClearSelectionSignalRef.current = clearSelectionSignal;
clearSelectionState();
}, [clearSelectionSignal, clearSelectionState]);
useEffect(() => {
setManualStart(null);
setManualCurrent(null);
@@ -653,13 +896,14 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
lastPaintPointRef.current = null;
setPolygonPoints([]);
setSelectedVertexIndex(null);
if (!isPolygonEditTool && !isBooleanTool && !isPaintTool) {
if (!canKeepMaskSelection) {
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
setSelectedPolygonIndex(0);
}
if (!isBooleanTool) setPendingBooleanFrameIds(null);
}, [effectiveTool, isBooleanTool, isPaintTool, isPolygonEditTool, setPaintStrokePoints]);
}, [canKeepMaskSelection, effectiveTool, isBooleanTool, setGlobalSelectedMaskIds, setPaintStrokePoints]);
useEffect(() => {
if (previousFrameIdRef.current === frame?.id) return;
@@ -686,9 +930,23 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
useEffect(() => {
const currentGlobalSelectedIds = useStore.getState().selectedMaskIds;
if (!canKeepMaskSelection) {
return;
}
const validLocalSelectedIds = selectedMaskIds.filter((id) => (
frameMasks.some((mask) => mask.id === id)
));
if (clearSelectionInProgressRef.current) {
if (selectedMaskIds.length === 0) {
clearSelectionInProgressRef.current = false;
return;
}
setSelectedMaskId(null);
setSelectedMaskIds([]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
return;
}
if (selectedMaskIds.length > 0 && validLocalSelectedIds.length === 0) {
return;
}
@@ -704,11 +962,14 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
if (!isSameSelection) {
setGlobalSelectedMaskIds(nextSelectedMaskIds);
}
}, [frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]);
}, [canKeepMaskSelection, frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]);
useEffect(() => () => setGlobalSelectedMaskIds([]), [setGlobalSelectedMaskIds]);
useEffect(() => {
if (!canKeepMaskSelection) {
return;
}
if (!selectedMaskId) {
const validGlobalSelectedIds = useStore.getState().selectedMaskIds.filter((id) => (
frameMasks.some((mask) => mask.id === id)
@@ -735,7 +996,7 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}
}, [frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]);
}, [canKeepMaskSelection, frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]);
const handleWheel = (e: any) => {
e.evt.preventDefault();
@@ -765,47 +1026,96 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
});
};
const stagePoint = (e: any): CanvasPoint | null => {
const stagePoint = (e: any, options: { clampToImage?: boolean } = {}): CanvasPoint | null => {
const stage = e.target.getStage();
const relPos = stage?.getRelativePointerPosition();
if (!relPos) return null;
const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width;
const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height;
const shouldClamp = options.clampToImage ?? true;
if (!shouldClamp && (relPos.x < 0 || relPos.y < 0 || relPos.x > imageWidth || relPos.y > imageHeight)) {
return null;
}
return {
x: clamp(relPos.x, 0, imageWidth),
y: clamp(relPos.y, 0, imageHeight),
x: shouldClamp ? clamp(relPos.x, 0, imageWidth) : relPos.x,
y: shouldClamp ? clamp(relPos.y, 0, imageHeight) : relPos.y,
};
};
const mergeGeometryIntoSelectedMask = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
if (!selectedMask) return null;
const currentSelectedMask = masks.find((mask) => mask.id === selectedMask.id) || selectedMask;
const targetGeometry = maskToMultiPolygon(currentSelectedMask);
if (!targetGeometry) return null;
const resultGeometry = polygonClipping.union(targetGeometry, geometry);
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) return null;
const metadata = {
...(currentSelectedMask.metadata || {}),
manualMergeShapes: [
...(
Array.isArray(currentSelectedMask.metadata?.manualMergeShapes)
? currentSelectedMask.metadata.manualMergeShapes.filter((item): item is string => typeof item === 'string')
: []
),
shape,
].slice(-12),
};
const nextMask = maskWithSegmentation({
...currentSelectedMask,
metadata,
}, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
});
setMasks(masks.map((mask) => (mask.id === currentSelectedMask.id ? nextMask : mask)));
setSelectedMaskId(nextMask.id);
setSelectedMaskIds([nextMask.id]);
setGlobalSelectedMaskIds([nextMask.id]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
return nextMask;
}, [masks, selectedMask, setGlobalSelectedMaskIds, setMasks]);
const createManualMask = useCallback((shape: string, polygon: CanvasPoint[]) => {
if (!frame?.id || polygon.length < 3) return;
const area = polygonArea(polygon);
if (area <= 1) return;
const color = activeClass?.color || '#06b6d4';
const label = activeClass?.name || `手工${shape}`;
const geometry = polygonsToMultiPolygon([polygon]);
if (geometry && mergeGeometryIntoSelectedMask(shape, geometry)) return;
const templateClass = activeClass || RESERVED_UNCLASSIFIED_CLASS;
const mask: Mask = {
id: `manual-${frame.id}-${shape}-${Date.now()}`,
frameId: frame.id,
templateId: activeTemplateId || undefined,
classId: activeClass?.id,
className: activeClass?.name,
classZIndex: activeClass?.zIndex,
classMaskId: activeClass?.maskId,
classId: templateClass.id,
className: templateClass.name,
classZIndex: templateClass.zIndex,
classMaskId: templateClass.maskId,
saveStatus: 'draft',
saved: false,
pathData: polygonPath(polygon),
label,
color,
label: templateClass.name,
color: templateClass.color,
segmentation: polygonSegmentation(polygon),
bbox: polygonBbox(polygon),
area,
metadata: { source: 'manual', shape },
};
addMask(mask);
}, [activeClass, activeTemplateId, addMask, frame?.id]);
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setGlobalSelectedMaskIds([mask.id]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}, [activeClass, activeTemplateId, addMask, frame?.id, mergeGeometryIntoSelectedMask, setGlobalSelectedMaskIds]);
const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
if (!frame?.id || !activeClass) return null;
if (!frame?.id) return null;
const mergedMask = mergeGeometryIntoSelectedMask(shape, geometry);
if (mergedMask) return mergedMask;
if (!activeClass) return null;
const segmentation = multiPolygonToSegmentation(geometry);
const polygonRingCounts = multiPolygonRingCounts(geometry);
if (segmentation.length === 0) return null;
@@ -836,7 +1146,7 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
};
addMask(mask);
return mask;
}, [activeClass, activeTemplateId, addMask, frame?.id]);
}, [activeClass, activeTemplateId, addMask, frame?.id, mergeGeometryIntoSelectedMask]);
const finishPolygon = useCallback(() => {
if (polygonPoints.length < 3) return;
@@ -869,13 +1179,23 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
}
if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) {
const pos = stagePoint(e);
const pos = stagePoint(e, { clampToImage: false });
const currentStroke = paintStrokeRef.current;
if (!pos) {
lastPaintPointRef.current = null;
return;
}
const previous = lastPaintPointRef.current;
if (!pos || !previous) return;
const radius = Math.max(2, (paintToolRef.current === ERASER_TOOL ? eraserSize : brushSize) / 2);
const minDistance = Math.max(3, radius * 0.55);
if (!previous) {
if (currentStroke.length >= MAX_PAINT_STROKE_POINTS) return;
const nextStroke = [...currentStroke, pos].slice(0, MAX_PAINT_STROKE_POINTS);
lastPaintPointRef.current = pos;
setPaintStrokePoints(nextStroke);
return;
}
if (pointDistance(previous, pos) < minDistance) return;
const currentStroke = paintStrokeRef.current;
if (currentStroke.length >= MAX_PAINT_STROKE_POINTS) return;
const nextStroke = extendStrokePoints(currentStroke, pos, minDistance);
lastPaintPointRef.current = nextStroke[nextStroke.length - 1] || pos;
@@ -1002,53 +1322,35 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
}
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setMasks]);
}, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setGlobalSelectedMaskIds, setMasks]);
const applyPaintStroke = useCallback((tool: string | null, strokePoints: CanvasPoint[]) => {
if (!frame?.id || strokePoints.length === 0) return;
const radius = Math.max(2, (tool === ERASER_TOOL ? eraserSize : brushSize) / 2);
const strokeGeometry = paintStrokeToGeometry(strokePoints, radius);
if (!strokeGeometry) return;
const rawStrokeGeometry = paintStrokeToGeometry(strokePoints, radius);
if (!rawStrokeGeometry) return;
const imageWidth = frame.width || image?.naturalWidth || image?.width || stageSize.width;
const imageHeight = frame.height || image?.naturalHeight || image?.height || stageSize.height;
const imageBounds = imageBoundsGeometry(imageWidth, imageHeight);
const strokeGeometry = imageBounds
? polygonClipping.intersection(rawStrokeGeometry, imageBounds)
: rawStrokeGeometry;
if (!strokeGeometry || strokeGeometry.length === 0) return;
if (tool === BRUSH_TOOL) {
if (!activeClass) {
if (!activeClass && !selectedMask) {
setInferenceMessage('请先在右侧语义分类树选择类别,再使用画笔。');
return;
}
const targetGeometry = selectedMask ? maskToMultiPolygon(selectedMask) : null;
const shouldMerge = Boolean(targetGeometry && geometriesOverlap(targetGeometry, strokeGeometry));
if (selectedMask && targetGeometry && shouldMerge) {
const resultGeometry = polygonClipping.union(targetGeometry, strokeGeometry);
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) return;
const nextMask = {
...maskWithSegmentation(selectedMask, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
}),
templateId: activeTemplateId || selectedMask.templateId,
classId: activeClass.id,
className: activeClass.name,
classZIndex: activeClass.zIndex,
classMaskId: activeClass.maskId,
label: activeClass.name,
color: activeClass.color,
};
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
setSelectedMaskId(selectedMask.id);
setSelectedMaskIds([selectedMask.id]);
setSelectedVertexIndex(null);
return;
}
const nextMask = createManualMaskFromGeometry('画笔', strokeGeometry);
if (nextMask) {
setSelectedMaskId(nextMask.id);
setSelectedMaskIds([nextMask.id]);
setGlobalSelectedMaskIds([nextMask.id]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}
@@ -1076,6 +1378,7 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
setSelectedMaskId(selectedMask.id);
setSelectedMaskIds([selectedMask.id]);
setGlobalSelectedMaskIds([selectedMask.id]);
setSelectedVertexIndex(null);
}
}, [
@@ -1086,16 +1389,25 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
deleteMasksById,
eraserSize,
frame?.id,
frame?.height,
frame?.width,
image?.height,
image?.naturalHeight,
image?.naturalWidth,
image?.width,
masks,
selectedMask,
setGlobalSelectedMaskIds,
setMasks,
stageSize.height,
stageSize.width,
]);
const handleStageMouseDown = (e: any) => {
if (PAINT_TOOLS.has(effectiveTool)) {
const canStart = effectiveTool === BRUSH_TOOL ? Boolean(activeClass) : Boolean(selectedMask);
const canStart = effectiveTool === BRUSH_TOOL ? Boolean(activeClass || selectedMask) : Boolean(selectedMask);
if (!canStart) return;
const pos = stagePoint(e);
const pos = stagePoint(e, { clampToImage: false });
if (pos) {
paintToolRef.current = effectiveTool;
lastPaintPointRef.current = pos;
@@ -1125,7 +1437,7 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
const handleStageMouseUp = (e: any) => {
if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) {
const finalPoint = stagePoint(e);
const finalPoint = stagePoint(e, { clampToImage: false });
const currentStroke = paintStrokeRef.current;
const spacing = Math.max(3, activePaintRadius * 0.55);
const nextStroke = finalPoint
@@ -1266,6 +1578,11 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
clearSelectionState();
return;
}
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask && selectedVertexIndex !== null) {
const currentPoints = segmentationToPoints(selectedMask.segmentation, selectedPolygonIndex);
if (currentPoints.length > 3) {
@@ -1291,15 +1608,11 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
event.preventDefault();
finishPolygon();
}
if (event.key === 'Escape') {
event.preventDefault();
setPolygonPoints([]);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]);
}, [clearSelectionState, deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]);
const boxRect = React.useMemo(() => {
if (!boxStart || !boxCurrent) return null;
@@ -1429,15 +1742,14 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
masks.forEach((mask) => {
const targetFrameId = String(mask.frameId);
if (targetFrameId === currentFrameId) return;
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId).length > 0;
if (!hasPrimary) return;
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId, { strictInstanceMatch: true }).length > 0;
const hasSecondary = secondaryMasks.some((secondary) => (
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId).length > 0
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId, { strictInstanceMatch: true }).length > 0
));
if (hasSecondary) targetFrameIds.add(targetFrameId);
if (hasSecondary && (hasPrimary || effectiveTool === 'area_merge')) targetFrameIds.add(targetFrameId);
});
return { currentFrameId, targetFrameIds };
}, [booleanSelectedMasks, frame, masks]);
}, [booleanSelectedMasks, effectiveTool, frame, masks]);
const runBooleanOperation = useCallback(async (targetFrameIds: Set<string>) => {
if (!frame || booleanSelectedMasks.length < 2) return;
@@ -1448,28 +1760,43 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
const deletedMaskIds = new Set<string>();
const applyOperationForFrame = (targetFrameId: string) => {
const primaryTargetId = targetFrameId === currentFrameId
const linkedPrimaryTargetId = targetFrameId === currentFrameId
? primary.id
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId)[0];
const primaryTarget = masks.find((mask) => mask.id === primaryTargetId);
if (!primaryTarget || deletedMaskIds.has(primaryTarget.id)) return;
const primaryGeometry = maskToMultiPolygon(primaryTarget);
if (!primaryGeometry) return;
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId, { strictInstanceMatch: true })[0];
const secondaryTargetIds = Array.from(new Set(
secondaryMasks.flatMap((secondary) => (
targetFrameId === currentFrameId
? [secondary.id]
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId)
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId, { strictInstanceMatch: true })
)),
)).filter((maskId) => maskId !== primaryTarget.id && !deletedMaskIds.has(maskId));
)).filter((maskId) => maskId !== linkedPrimaryTargetId && !deletedMaskIds.has(maskId));
const secondaryTargets = secondaryTargetIds
.map((maskId) => masks.find((mask) => mask.id === maskId))
.filter((mask): mask is Mask => Boolean(mask));
const fallbackPrimaryTarget = effectiveTool === 'area_merge' ? secondaryTargets[0] : undefined;
const rawPrimaryTarget = masks.find((mask) => mask.id === linkedPrimaryTargetId) || fallbackPrimaryTarget;
if (!rawPrimaryTarget || deletedMaskIds.has(rawPrimaryTarget.id)) return;
const usingSecondaryAsPrimary = !linkedPrimaryTargetId && rawPrimaryTarget.id === fallbackPrimaryTarget?.id;
const primaryTarget = usingSecondaryAsPrimary
? {
...rawPrimaryTarget,
templateId: primary.templateId ?? rawPrimaryTarget.templateId,
classId: primary.classId,
className: primary.className,
classZIndex: primary.classZIndex,
classMaskId: primary.classMaskId,
label: primary.label,
color: primary.color,
}
: rawPrimaryTarget;
const primaryGeometry = maskToMultiPolygon(primaryTarget);
if (!primaryGeometry) return;
const clipGeometries = secondaryTargets
.filter((mask) => mask.id !== primaryTarget.id)
.map(maskToMultiPolygon)
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
if (clipGeometries.length === 0) return;
if (clipGeometries.length === 0 && !usingSecondaryAsPrimary) return;
const resultGeometry = effectiveTool === 'area_merge'
? polygonClipping.union(primaryGeometry, ...clipGeometries)
@@ -1487,7 +1814,9 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
}
if (effectiveTool === 'area_merge') {
secondaryTargets.forEach((mask) => deletedMaskIds.add(mask.id));
secondaryTargets.forEach((mask) => {
if (mask.id !== primaryTarget.id) deletedMaskIds.add(mask.id);
});
}
};
@@ -1518,6 +1847,7 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
const handleBooleanOperation = async () => {
const frameSelection = collectBooleanOperationFrameIds();
if (!frameSelection) return;
onBooleanOperationStart?.();
const { currentFrameId, targetFrameIds } = frameSelection;
if (targetFrameIds.size > 1) {
setPendingBooleanFrameIds(Array.from(targetFrameIds));
@@ -1710,7 +2040,7 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
))}
{/* Polygon vertex editor */}
{isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
{showSelectedMaskVertices && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
ringPoints.map((point, index) => {
const isActiveVertex = selectedPolygonIndex === polygonIndex && selectedVertexIndex === index;
return (
@@ -1722,22 +2052,24 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
fill={isActiveVertex ? '#22d3ee' : '#ffffff'}
stroke={selectedMask.color}
strokeWidth={2 / scale}
draggable
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
draggable={isPolygonEditTool}
onMouseDown={isPolygonEditTool ? ((event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined}
onTouchStart={isPolygonEditTool ? ((event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined}
onDragStart={isPolygonEditTool ? ((event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined}
onClick={(event: any) => {
event.cancelBubble = true;
if (!isPolygonEditTool) return;
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(index);
}}
onTap={(event: any) => {
event.cancelBubble = true;
if (!isPolygonEditTool) return;
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(index);
}}
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
onDragMove={isPolygonEditTool ? ((event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)) : undefined}
onDragEnd={isPolygonEditTool ? ((event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)) : undefined}
/>
);
})
@@ -1768,17 +2100,23 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
</Stage>
<div className="absolute bottom-4 left-4 flex gap-4 text-[10px] font-mono text-gray-500 pointer-events-none">
<span>: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
<span>: {currentLayerLabel}</span>
<span>: {(scale * 100).toFixed(0)}%</span>
<span>: {frameMasks.length}</span>
<span>: {savedMaskCount}</span>
<span>: {draftMaskCount}</span>
<span>: {dirtyMaskCount}</span>
<span>: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
<span>: {currentLayerLabel}</span>
<span>: {(scale * 100).toFixed(0)}%</span>
<span>: {frameMasks.length}</span>
<span>: {savedMaskCount}</span>
<span>: {draftMaskCount}</span>
<span>: {dirtyMaskCount}</span>
</div>
{currentFrameNumber !== undefined && totalFrames !== undefined && (
<div className="absolute bottom-4 right-4 rounded-md border border-white/10 bg-black/60 px-2.5 py-1.5 text-xs font-mono text-gray-200 shadow-lg">
{currentFrameNumber}/{totalFrames}
</div>
)}
{frameMasks.length > 0 && isBooleanTool && (
<div className="absolute bottom-4 right-4 flex gap-2">
<div className="absolute bottom-14 right-4 flex gap-2">
<div className="flex items-center gap-2">
<span className="text-xs bg-white/5 text-gray-300 border border-white/10 px-2.5 py-1.5 rounded">
{booleanSelectedMasks.length}
@@ -1796,7 +2134,7 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
{pendingBooleanFrameIds && frame && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/55 px-4">
<div className="w-full max-w-sm rounded-lg border border-emerald-400/25 bg-[#151515] p-4 shadow-2xl">
<div className="w-full max-w-lg rounded-lg border border-emerald-400/25 bg-[#151515] p-4 shadow-2xl">
<h2 className="text-sm font-semibold text-white"></h2>
<p className="mt-2 text-xs leading-relaxed text-gray-300">
@@ -1804,11 +2142,11 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
<div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-2 text-[11px] text-gray-400">
{pendingBooleanFrameIds.length}
</div>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<div className="mt-4 grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => setPendingBooleanFrameIds(null)}
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:bg-white/5"
className="rounded border border-white/10 px-2 py-1.5 text-xs text-gray-300 hover:bg-white/5"
>
</button>
@@ -1825,7 +2163,7 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
});
setPendingBooleanFrameIds(null);
}}
className="rounded border border-amber-400/30 bg-amber-500/10 px-3 py-1.5 text-xs font-semibold text-amber-100 hover:bg-amber-500/20"
className="rounded border border-amber-400/30 bg-amber-500/10 px-2 py-1.5 text-xs font-semibold text-amber-100 hover:bg-amber-500/20"
>
</button>
@@ -1833,14 +2171,14 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
<button
type="button"
onClick={() => void runBooleanOperation(new Set([String(frame.id)]))}
className="rounded border border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-100 hover:bg-emerald-500/20"
className="rounded border border-emerald-400/30 bg-emerald-500/10 px-2 py-1.5 text-xs font-semibold text-emerald-100 hover:bg-emerald-500/20"
>
</button>
<button
type="button"
onClick={() => void runBooleanOperation(new Set(pendingBooleanFrameIds))}
className="rounded bg-emerald-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-400"
className="rounded bg-emerald-500 px-2 py-1.5 text-xs font-semibold text-white hover:bg-emerald-400"
>
</button>

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

@@ -289,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,6 +49,7 @@ 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('renders a processing progress bar with red annotation markers and blue propagation segments', () => {

View File

@@ -48,12 +48,13 @@ export function FrameTimeline({
const currentSeconds = totalFrames > 0 ? currentFrameIndex / timeBaseFps : 0;
const totalSeconds = totalFrames > 0 ? Math.max(totalFrames - 1, 0) / timeBaseFps : 0;
const isPropagatedMask = (mask: (typeof masks)[number]) => {
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : '';
return source.includes('_propagation')
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_key !== undefined
|| mask.metadata?.propagation_seed_signature !== undefined;
};
const propagatedFrameMarkers = useMemo(() => {
const frameIds = new Set(frames.map((frame) => frame.id));
@@ -533,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

@@ -51,4 +51,13 @@ describe('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';
@@ -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">

View File

@@ -160,6 +160,39 @@ 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'],

View File

@@ -46,6 +46,7 @@ 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('shows the generated frame sequence FPS on project cards instead of source FPS', async () => {
@@ -69,20 +70,11 @@ describe('ProjectLibrary', () => {
expect(screen.queryByText('30FPS')).not.toBeInTheDocument();
});
it('creates a new project from the modal', async () => {
apiMock.createProject.mockResolvedValueOnce({ id: 'p2', name: 'New Project', status: 'pending' });
it('does not expose manual project creation from the project library header', async () => {
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' }));
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 () => {
@@ -134,8 +126,10 @@ describe('ProjectLibrary', () => {
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()} />);
@@ -146,6 +140,35 @@ describe('ProjectLibrary', () => {
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 () => {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } 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, updateProject, copyProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject, getTask } from '../lib/api';
@@ -31,15 +31,10 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
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);
@@ -96,6 +91,18 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
}
};
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'
@@ -175,11 +182,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const canGenerateFrames = (project: Project) => (
project.source_type !== 'dicom'
&& Boolean(project.video_path)
&& (project.frames ?? 0) === 0
&& project.status !== 'parsing'
&& editingProjectId !== project.id
);
const frameGenerationLabel = (project: Project) => (
(project.frames ?? 0) > 0 ? '重新生成帧' : '生成帧'
);
useEffect(() => {
setIsLoading(true);
getProjects()
@@ -188,22 +198,6 @@ 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();
@@ -378,14 +372,80 @@ 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 });
const task = await parseMedia(targetProject.id, { parseFps: frameParseFps });
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
const data = await getProjects();
setProjects(data);
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);
showNotice('生成帧失败,请检查后端服务或项目源文件', 'error');
@@ -503,16 +563,9 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
<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)}
@@ -689,7 +742,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
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>
@@ -815,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">
@@ -853,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

@@ -19,7 +19,9 @@ describe('Sidebar', () => {
render(<Sidebar activeModule="dashboard" setActiveModule={setActiveModule} />);
fireEvent.click(screen.getByTitle('用户管理'));
const adminButton = screen.getByTitle('用户管理');
expect(adminButton.querySelector('.lucide-circle-user')).toBeInTheDocument();
fireEvent.click(adminButton);
expect(setActiveModule).toHaveBeenCalledWith('admin');
});
@@ -39,9 +41,20 @@ describe('Sidebar', () => {
expect(screen.getByTitle('AI智能分割').querySelector('[data-testid="ai-segmentation-icon"]')).toBeInTheDocument();
});
it('uses the root logo_square asset for the sidebar logo', () => {
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_square'));
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,5 +1,5 @@
import React from 'react';
import { Home, FolderOpen, Edit3, LayoutTemplate, LogOut, UserCircle, ShieldCheck } 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';
@@ -20,13 +20,13 @@ export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
{ id: 'workspace', icon: Edit3, label: '分割工作区' },
{ id: 'ai', icon: AiSegmentationIcon, label: 'AI智能分割' },
{ id: 'templates', icon: LayoutTemplate, label: '模板库' },
...(currentUser?.role === 'admin' ? [{ id: 'admin', icon: ShieldCheck, 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_square.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) => {
@@ -60,8 +60,8 @@ export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
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"
>
{currentUser ? <UserCircle size={20} /> : <LogOut size={20} />}
<span className="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:opacity-100">
<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>

View File

@@ -81,14 +81,54 @@ describe('ToolsPalette', () => {
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)');
@@ -97,9 +137,11 @@ describe('ToolsPalette', () => {
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(separators[1]) & 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();

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { MousePointer2, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp, Trash2 } 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';
@@ -8,9 +9,13 @@ interface ToolsPaletteProps {
activeTool: string;
setActiveTool: (tool: string) => void;
onTriggerAI?: () => void;
onAutoPropagate?: () => void;
onImportGtMask?: () => void;
onClearSelection?: () => void;
onDeleteMasks?: () => void;
onClearMasks?: () => void;
canAutoPropagate?: boolean;
isPropagating?: boolean;
canImportGtMask?: boolean;
isImportingGtMask?: boolean;
}
@@ -19,9 +24,13 @@ export function ToolsPalette({
activeTool,
setActiveTool,
onTriggerAI,
onAutoPropagate,
onImportGtMask,
onClearSelection,
onDeleteMasks,
onClearMasks,
canAutoPropagate = false,
isPropagating = false,
canImportGtMask = false,
isImportingGtMask = false,
}: ToolsPaletteProps) {
@@ -80,6 +89,18 @@ export function ToolsPalette({
>
<Icon size={16} strokeWidth={isActive ? 2.5 : 2} />
</button>
{tool.id === 'move' && (
<button
type="button"
onClick={onClearSelection}
disabled={!onClearSelection}
title="取消选中 (Esc)"
aria-label="取消选中"
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-gray-400 hover:bg-white/5 hover:text-white disabled:opacity-35 disabled:cursor-not-allowed"
>
<CircleOff size={16} strokeWidth={2.1} />
</button>
)}
{tool.id === 'eraser' && sizeControl && (
<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>
@@ -96,7 +117,31 @@ export function ToolsPalette({
<div className="mt-1 text-[10px] leading-none text-gray-400">{sizeControl.value}</div>
</div>
)}
{(tool.id === 'create_circle' || tool.id === 'eraser') && (
{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>

View File

@@ -52,11 +52,11 @@ describe('UserAdmin', () => {
expect(screen.getByText('当前管理员admin')).toBeInTheDocument();
});
it('creates a user with role and password', async () => {
it('creates new users as annotators', async () => {
apiMock.createAdminUser.mockResolvedValueOnce({
id: 3,
username: 'nurse',
role: 'viewer',
role: 'annotator',
is_active: 1,
});
@@ -64,30 +64,26 @@ describe('UserAdmin', () => {
await screen.findByText('doctor');
fireEvent.change(screen.getByPlaceholderText('用户名'), { target: { value: 'nurse' } });
fireEvent.change(screen.getByPlaceholderText('初始密码'), { target: { value: 'secret123' } });
fireEvent.change(screen.getAllByDisplayValue('标注员')[0], { target: { value: 'viewer' } });
fireEvent.click(screen.getByRole('button', { name: /新增用户/ }));
await waitFor(() => expect(apiMock.createAdminUser).toHaveBeenCalledWith({
username: 'nurse',
password: 'secret123',
role: 'viewer',
role: 'annotator',
is_active: true,
}));
expect(await screen.findByText('用户已创建')).toBeInTheDocument();
});
it('updates role, status and password, and deletes users', async () => {
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 1 });
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
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');
const roleSelects = screen.getAllByDisplayValue('标注员');
fireEvent.change(roleSelects[1], { target: { value: 'viewer' } });
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { role: 'viewer' }));
expect(screen.queryByText('观察员')).not.toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: '启用' })[1]);
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false }));
@@ -107,7 +103,7 @@ describe('UserAdmin', () => {
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
project: {
id: '8',
name: '演DICOM序列',
name: '演DICOM序列',
status: 'ready',
frames: 300,
fps: '30FPS',
@@ -117,16 +113,16 @@ describe('UserAdmin', () => {
projects: [
{
id: '7',
name: 'Data_MyVideo_1',
status: 'pending',
frames: 0,
name: '演视LC视频序列',
status: 'ready',
frames: 750,
fps: '30FPS',
source_type: 'video',
video_path: 'uploads/7/Data_MyVideo_1.mp4',
video_path: 'uploads/7/演视LC视频序列.mp4',
},
{
id: '8',
name: '演DICOM序列',
name: '演DICOM序列',
status: 'ready',
frames: 300,
fps: '30FPS',
@@ -160,8 +156,8 @@ describe('UserAdmin', () => {
await waitFor(() => expect(apiMock.resetDemoFactory).toHaveBeenCalledWith('RESET_DEMO_FACTORY'));
expect(await screen.findByText('演示环境已恢复出厂设置')).toBeInTheDocument();
expect(useStore.getState().projects).toEqual([
expect.objectContaining({ name: 'Data_MyVideo_1', source_type: 'video' }),
expect.objectContaining({ name: '演DICOM序列', source_type: 'dicom' }),
expect.objectContaining({ name: '演视LC视频序列', source_type: 'video' }),
expect.objectContaining({ name: '演DICOM序列', source_type: 'dicom' }),
]);
expect(useStore.getState().frames).toEqual([]);
expect(useStore.getState().masks).toEqual([]);

View File

@@ -17,7 +17,6 @@ import { TransientNotice, type NoticeState, type NoticeTone } from './TransientN
const roleLabels: Record<string, string> = {
admin: '管理员',
annotator: '标注员',
viewer: '观察员',
};
function formatTime(value: string): string {
@@ -47,7 +46,6 @@ export function UserAdmin() {
const [notice, setNotice] = useState<NoticeState | null>(null);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('annotator');
const [passwordTarget, setPasswordTarget] = useState<AdminUser | null>(null);
const [nextPassword, setNextPassword] = useState('');
const [deleteUserTarget, setDeleteUserTarget] = useState<AdminUser | null>(null);
@@ -88,13 +86,12 @@ export function UserAdmin() {
const created = await createAdminUser({
username: newUsername.trim(),
password: newPassword,
role: newRole,
role: 'annotator',
is_active: true,
});
setUsers((prev) => [...prev, created]);
setNewUsername('');
setNewPassword('');
setNewRole('annotator');
showNotice('用户已创建', 'success');
setAuditLogs(await getAuditLogs(100));
} catch (err: any) {
@@ -188,7 +185,7 @@ export function UserAdmin() {
<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>
<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">
@@ -209,7 +206,7 @@ export function UserAdmin() {
{isLoading && <Loader2 size={16} className="animate-spin text-cyan-300" />}
</div>
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_150px_auto] gap-2 border-b border-white/10 p-4">
<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)}
@@ -225,15 +222,6 @@ export function UserAdmin() {
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"
/>
<select
value={newRole}
onChange={(event) => setNewRole(event.target.value)}
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
>
<option value="annotator"></option>
<option value="viewer"></option>
<option value="admin"></option>
</select>
<button
type="submit"
disabled={isSaving}
@@ -262,16 +250,16 @@ export function UserAdmin() {
<div className="text-xs text-gray-500">ID {user.id}</div>
</td>
<td className="px-4 py-3">
<select
value={user.role}
onChange={(event) => void handlePatchUser(user, { role: event.target.value })}
disabled={isSaving}
className="rounded border border-white/10 bg-[#181818] px-2 py-1 text-xs text-cyan-100"
<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',
)}
>
<option value="admin"></option>
<option value="annotator"></option>
<option value="viewer"></option>
</select>
{roleLabels[user.role] || '标注员'}
</span>
</td>
<td className="px-4 py-3">
<button
@@ -349,7 +337,7 @@ export function UserAdmin() {
<div>
<div className="text-sm font-semibold text-red-100"></div>
<p className="mt-1 text-xs leading-relaxed text-red-200/70">
admin DICOM
adminLC视频序列DICOM序列
</p>
</div>
<button
@@ -444,7 +432,7 @@ export function UserAdmin() {
<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 DICOM
admin LC视频序列DICOM序列
</p>
<label className="mt-4 block text-xs text-gray-400" htmlFor="factory-reset-confirm">
RESET_DEMO_FACTORY

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -180,12 +180,12 @@ describe('api client contracts', () => {
.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: 'viewer', 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, { role: 'viewer' });
await updateAdminUser(2, { is_active: false });
await deleteAdminUser(2);
await expect(getAuditLogs(50)).resolves.toEqual([expect.objectContaining({ action: 'admin.user_created' })]);
@@ -196,17 +196,17 @@ describe('api client contracts', () => {
role: 'annotator',
is_active: true,
});
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { role: 'viewer' });
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' },
project: { id: 8, name: '演DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
projects: [
{ id: 7, name: 'Data_MyVideo_1', status: 'pending', source_type: 'video', frame_count: 0, video_path: 'uploads/7/Data_MyVideo_1.mp4' },
{ id: 8, name: '演DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
{ 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: '演示环境已恢复出厂设置',
@@ -214,10 +214,10 @@ describe('api client contracts', () => {
});
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' }),
project: expect.objectContaining({ id: '8', name: '演DICOM序列', frames: 300, source_type: 'dicom' }),
projects: [
expect.objectContaining({ id: '7', name: 'Data_MyVideo_1', frames: 0, source_type: 'video' }),
expect.objectContaining({ id: '8', name: '演DICOM序列', frames: 300, source_type: 'dicom' }),
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', {
@@ -528,6 +528,7 @@ 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',
@@ -550,6 +551,8 @@ describe('api client contracts', () => {
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]],
@@ -575,6 +578,8 @@ describe('api client contracts', () => {
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' },
},
}));
@@ -693,6 +698,42 @@ describe('api client contracts', () => {
}));
});
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 };
@@ -709,6 +750,8 @@ describe('api client contracts', () => {
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' },
},
@@ -718,6 +761,8 @@ describe('api client contracts', () => {
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',
}),
}));

View File

@@ -466,6 +466,7 @@ export interface PropagateMasksPayload {
template_id?: number;
source_mask_id?: string;
source_annotation_id?: number;
source_instance_id?: string;
propagation_seed_signature?: string;
smoothing?: GeometrySmoothingOptions;
};
@@ -592,6 +593,12 @@ 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;
@@ -762,6 +769,10 @@ export function buildAnnotationPayload(
: 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),
@@ -769,6 +780,7 @@ export function buildAnnotationPayload(
template_id: effectiveTemplateId ? Number(effectiveTemplateId) : undefined,
mask_data: {
...metadata,
instance_id: instanceId,
polygons,
...(splitGeometry.holes ? { holes: splitGeometry.holes } : {}),
label: mask.label,
@@ -803,7 +815,7 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
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 = {
@@ -828,7 +840,7 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
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,
};
}

View File

@@ -7,6 +7,10 @@ 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', { id: 1, username: 'admin', role: 'admin' });

View File

@@ -233,7 +233,7 @@ export const useStore = create<AppState>((set) => ({
})),
// Workspace
activeModule: 'workspace',
activeModule: 'dashboard',
activeTool: 'move',
aiModel: DEFAULT_AI_MODEL_ID,
frames: [],

View File

@@ -7,7 +7,7 @@ export function resetStore() {
currentUser: null,
projects: [],
currentProject: null,
activeModule: 'workspace',
activeModule: 'dashboard',
activeTool: 'move',
aiModel: DEFAULT_AI_MODEL_ID,
frames: [],

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
```