修复演示恢复默认模板覆盖逻辑
- 新增后端默认模板服务,集中维护腹腔镜胆囊切除术和头颈部CT分割的权威分类树、颜色、maskid 和层级定义。 - 演示恢复出厂设置时强制恢复系统默认模板,缺失模板会重建,已修改或删减的默认语义分类树会覆盖回默认状态。 - 清理 main.py 中重复的默认模板定义,让启动 seed 复用同一套服务逻辑,避免后续默认模板定义漂移。 - 扩展管理员恢复出厂设置测试,覆盖头颈部CT模板被改坏和腹腔镜模板缺失后的恢复结果。 - 更新 AGENTS、README 和需求/API/测试/前端审计文档,明确恢复出厂设置会权威恢复系统默认模板。
This commit is contained in:
@@ -237,7 +237,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
## 主要业务流程
|
||||
|
||||
1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化开发管理员 `admin / 123456`;成功后返回签名 JWT,`GET /api/auth/me` 可读取当前用户;角色包括 `admin`、`annotator`、`viewer`,写入类业务接口要求 `admin/annotator`,用户管理后台要求 `admin`。
|
||||
2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。
|
||||
2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,同时按内置权威定义恢复“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板,缺失的会重建,被修改或删减的语义分类树会覆盖回默认状态。
|
||||
3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、重命名项目、复制项目和删除项目;项目卡片删除按钮旁提供复制入口,复制时可选择“新项目重置”(复制项目媒体和已生成帧序列,但清空标注/mask)或“全内容复制”(复制项目、帧序列、标注和关联 mask 元数据),任务运行历史不复制;删除当前项目后会清空工作区当前项目、帧、mask 和选区。
|
||||
4. 上传资源:视频走 `/api/media/upload`,只上传源文件并关联项目,不自动拆帧;项目库在视频上传期间显示导入进度条、百分比和已上传字节。只有视频项目在尚未生成帧、未处于项目名称编辑状态且未解析中时显示“生成帧”,DICOM 项目不显示生成帧入口;DICOM 批量走 `/api/media/upload/dicom`,前端和后端都会按文件名自然顺序排序 `.dcm` 文件,避免 `10.dcm` 排在 `2.dcm` 前导致切片错位;DICOM 上传期间显示导入进度条、本次有效文件数量和已上传字节,上传完成后轮询解析任务进度直到完成、失败或取消。
|
||||
5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery,接口支持 `parse_fps`、`max_frames` 和 `target_width` 标准帧序列参数;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。
|
||||
@@ -247,7 +247,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
9. AI 分割:侧栏和工作区工具栏的 AI 智能分割入口使用 Bot + Sparkles 组合图标强化 AI 识别;前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;AI 画布会按容器和当前帧尺寸默认居中放大底图并保留边距;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择和当前帧视角。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。
|
||||
10. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed,并用传播起始帧和传播结束帧指定追踪范围;如果当前参考帧没有 mask,点击开始传播会提示“当前参考帧无遮罩”,不会提交任务或保存其它帧标注;用户可直接修改数字框,也可点击“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏有独立“传播权重”选择器,可为本次传播二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,也不影响 AI 单帧分割权重;前端提交传播前只保存当前参考帧中的 draft/dirty mask,使 seed 优先带稳定的后端 `source_annotation_id`,再按传播权重 id、seed mask、seed 来源 id 和前/后方向组装 `steps` 并调用 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/result;Celery worker 顺序执行各 step,避免多个视频 tracker 并发抢占 GPU;每个 step 会根据 seed 来源 id、方向和 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本用前端临时 `source_mask_id` 生成的传播标注会按同一参考帧、方向和语义信息兼容清理;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`;若历史或外部 seed 仍带 `geometry_smoothing`,forward/backward 两个方向的传播结果保存前仍会应用同一参数;当前工作区平滑按钮应用后会直接改写实际 polygon,后续传播以新几何参与签名和追踪。工作区轮询 `GET /api/tasks/{task_id}` 展示进度并刷新标注,Dashboard 也能显示/取消/重试传播任务。
|
||||
11. GT 导入:工作区左侧工具栏“导入 GT Mask”调用 `/api/ai/import-gt-mask`;选择文件后前端会显示导入结果预览,并让用户决定未知 maskid 处理方式,可舍弃未知类别,也可导入为“未定义类别”等待重新命名。后端用 `cv2.IMREAD_UNCHANGED` 读取 mask 并校验 dtype;GT 图片必须是 8-bit 灰度 maskid 图,或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,0 为背景、X 为 1-255 的 maskid,16-bit/uint16 GT_label、普通彩色类别图和全背景 0 图都会返回明确错误;全背景图错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”;灰度/RGB 等通道图按模板 `maskId` 匹配类别,超出现有类别时按 `unknown_color_policy` 处理;如果 mask 图片尺寸和当前帧不同,会按当前帧长宽最近邻拉伸后再提取区域;每个连通域用高精度 contour 生成 polygon 标注,保留更多边界点并设置点数上限避免拖慢前端;导入结果与普通 mask 共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存链路;后端仍可写入 distance transform seed point 供数据兼容,但前端不显示或拖动 seed point。
|
||||
12. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色、maskid 和内部覆盖顺序;所有新建、复制、批量导入和后端返回的模板都会归一化包含黑色 `[0,0,0]`、`maskid: 0` 的“待分类”保留类,该类固定在语义分类树最后,不能删除,也不能拖拽到更高层级;批量导入 JSON 支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式,也兼容带“批量导入分类:”前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容,会先预览分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示;系统默认模板包括“腹腔镜胆囊切除术”和“头颈部CT分割”,恢复演示出厂设置只删除用户私有模板,系统默认模板会保留;模板库“生效中模板架构清单”里的每个模板卡片支持鼠标点击复制,复制会创建当前用户私有副本并保留分类名称、颜色、maskid、内部层级和规则,同时重建类别内部 id;模板库详情页的分类区标题为“语义分类树(拖拽调层级)”,右上角提供“+ 新建分类”,每个分类行右侧用垃圾桶图标删除该 label,不再展示“未分类/批量导入/模板名”等来源标签;如果项目中的已保存 mask 引用了当前模板里已被删除的类别,工作区打开项目回显时会把该 mask 降级为 `maskid: 0` 的“待分类”mask 并标记为待保存;模板库详情页和编辑弹窗都支持拖拽调整语义类别层级顺序,拖拽会重算 `zIndex` 并保存到后端,保存后当前详情页会立刻刷新;`OntologyInspector.tsx` 在工作区显示当前模板分类树,也支持拖拽调整内部覆盖顺序。maskid 只作为 GT_label/类别 ID,不参与排序。
|
||||
12. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色、maskid 和内部覆盖顺序;所有新建、复制、批量导入和后端返回的模板都会归一化包含黑色 `[0,0,0]`、`maskid: 0` 的“待分类”保留类,该类固定在语义分类树最后,不能删除,也不能拖拽到更高层级;批量导入 JSON 支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式,也兼容带“批量导入分类:”前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容,会先预览分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示;系统默认模板包括“腹腔镜胆囊切除术”和“头颈部CT分割”,恢复演示出厂设置只删除用户私有模板,并会重建缺失的系统默认模板、覆盖恢复被修改或删减的默认语义分类树;模板库“生效中模板架构清单”里的每个模板卡片支持鼠标点击复制,复制会创建当前用户私有副本并保留分类名称、颜色、maskid、内部层级和规则,同时重建类别内部 id;模板库详情页的分类区标题为“语义分类树(拖拽调层级)”,右上角提供“+ 新建分类”,每个分类行右侧用垃圾桶图标删除该 label,不再展示“未分类/批量导入/模板名”等来源标签;如果项目中的已保存 mask 引用了当前模板里已被删除的类别,工作区打开项目回显时会把该 mask 降级为 `maskid: 0` 的“待分类”mask 并标记为待保存;模板库详情页和编辑弹窗都支持拖拽调整语义类别层级顺序,拖拽会重算 `zIndex` 并保存到后端,保存后当前详情页会立刻刷新;`OntologyInspector.tsx` 在工作区显示当前模板分类树,也支持拖拽调整内部覆盖顺序。maskid 只作为 GT_label/类别 ID,不参与排序。
|
||||
13. 导出:工作区使用统一“分割结果导出”入口,导出前先保存待归档 mask;用户可选择整体视频、特定范围帧或当前图片,默认导出范围为当前图片,并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择特定范围帧时,可直接修改起止帧输入框,也可在播放进度条或视频处理进度条上点击/拖拽选择导出范围;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览。下载 ZIP 文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`,项目名来自 `Project.name` 并替换文件系统不安全字符,时间戳格式为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 顺序而非原视频帧号。后端保留兼容的 COCO JSON 和 PNG mask ZIP 接口,同时新增统一结果 ZIP;统一 ZIP 固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`;导出时 GT_label 固定写 8-bit uint8 PNG,像素值使用类别真实 `maskid`,其中 `maskid: 0` 的“待分类”与背景同为 0,Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 `maskid` 的旧标注才补下一个可用值,正整数 maskid 超出 1-255 会拒绝导出,保证导出的 GT_label 可按同一模板再导入;选择分开 mask 时输出 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`,同一帧同一类别合并为一张图;选择 GT_label/Pro_label/Mix_label 时分别输出 `GT_label图/{视频名称_时间戳_项目帧序号}.png`、`Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`、`Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。maskid 不参与覆盖排序,GT_label/Pro_label/Mix_label 重叠区域覆盖顺序由内部拖拽排序字段决定,并与未选中状态下的 Canvas 显示顺序一致。
|
||||
|
||||
---
|
||||
|
||||
@@ -419,7 +419,7 @@ cd ~/Desktop/Seg_Server
|
||||
|
||||
当前项目、帧、标注、任务、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` 并修改默认管理员密码。
|
||||
|
||||
系统默认模板会在后端启动时幂等补齐,当前包括“腹腔镜胆囊切除术”和“头颈部CT分割”;所有新建、复制、导入和后端返回的模板都会归一化带上黑色 `maskid: 0` 的“待分类”保留类,并固定在语义分类树最后。恢复演示出厂设置只删除用户私有模板,默认系统模板会继续保留。模板库左侧“生效中模板架构清单”里的复制按钮会把任一模板复制成当前用户私有副本,并保留分类名称、颜色、maskid、内部层级顺序和规则。
|
||||
系统默认模板会在后端启动时幂等补齐,当前包括“腹腔镜胆囊切除术”和“头颈部CT分割”;所有新建、复制、导入和后端返回的模板都会归一化带上黑色 `maskid: 0` 的“待分类”保留类,并固定在语义分类树最后。恢复演示出厂设置只删除用户私有模板,并会按内置权威定义重建缺失的默认系统模板、覆盖恢复被修改或删减的默认语义分类树。模板库左侧“生效中模板架构清单”里的复制按钮会把任一模板复制成当前用户私有副本,并保留分类名称、颜色、maskid、内部层级顺序和规则。
|
||||
|
||||
---
|
||||
|
||||
|
||||
123
backend/main.py
123
backend/main.py
@@ -24,25 +24,8 @@ logging.basicConfig(
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
RESERVED_UNCLASSIFIED_CLASS = {
|
||||
"id": "reserved-unclassified",
|
||||
"name": "待分类",
|
||||
"color": "#000000",
|
||||
"zIndex": 0,
|
||||
"maskId": 0,
|
||||
"category": "系统保留",
|
||||
}
|
||||
|
||||
|
||||
def _with_reserved_unclassified_class(classes: list[dict]) -> list[dict]:
|
||||
filtered = [
|
||||
item for item in classes
|
||||
if item.get("id") != RESERVED_UNCLASSIFIED_CLASS["id"]
|
||||
and item.get("name") != RESERVED_UNCLASSIFIED_CLASS["name"]
|
||||
and item.get("maskId") != 0
|
||||
]
|
||||
return [*filtered, dict(RESERVED_UNCLASSIFIED_CLASS)]
|
||||
|
||||
def _ensure_runtime_schema_columns() -> None:
|
||||
"""Add nullable columns introduced after initial create_all deployments."""
|
||||
try:
|
||||
@@ -137,8 +120,6 @@ def _seed_default_project_sync() -> None:
|
||||
|
||||
def _seed_default_templates_sync() -> None:
|
||||
"""Seed default ontology templates on first startup."""
|
||||
from models import Template
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_default_templates(db)
|
||||
@@ -148,112 +129,12 @@ def _seed_default_templates_sync() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def _template_classes(
|
||||
template_name: str,
|
||||
names: list[str],
|
||||
colors: list[tuple[int, int, int]],
|
||||
*,
|
||||
id_prefix: str,
|
||||
) -> list[dict]:
|
||||
classes = []
|
||||
for idx, (rgb, name) in enumerate(zip(colors, names)):
|
||||
color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
|
||||
classes.append({
|
||||
"id": f"{id_prefix}-{idx}",
|
||||
"name": name,
|
||||
"color": color_hex,
|
||||
"zIndex": (len(names) - idx) * 10,
|
||||
"maskId": idx + 1,
|
||||
"category": template_name,
|
||||
})
|
||||
return classes
|
||||
|
||||
|
||||
def ensure_default_templates(db) -> None:
|
||||
"""Ensure all bundled system templates exist."""
|
||||
from models import Template
|
||||
from services.default_templates import ensure_default_templates as _ensure_default_templates
|
||||
|
||||
default_templates = [
|
||||
{
|
||||
"name": "腹腔镜胆囊切除术",
|
||||
"description": "腹腔镜胆囊切除术(LC)手术器械与解剖结构语义分割模板,共35个分类",
|
||||
"color": "#06b6d4",
|
||||
"z_index": 0,
|
||||
"classes": _with_reserved_unclassified_class(_template_classes(
|
||||
"腹腔镜胆囊切除术",
|
||||
[
|
||||
'针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
|
||||
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
|
||||
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
|
||||
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
|
||||
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
|
||||
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
|
||||
],
|
||||
[
|
||||
(134, 124, 118), (0, 157, 142), (245, 161, 0), (255, 172, 159), (146, 175, 236), (155, 62, 0),
|
||||
(255, 91, 0), (255, 234, 0), (85, 111, 181), (155, 132, 0), (181, 227, 14), (72, 0, 255),
|
||||
(255, 0, 255), (29, 32, 136), (240, 16, 116), (160, 15, 95), (0, 155, 33), (0, 160, 233),
|
||||
(52, 184, 178), (66, 115, 82), (90, 120, 41), (255, 0, 0), (117, 0, 0), (167, 24, 233),
|
||||
(42, 8, 66), (112, 113, 150), (0, 255, 0), (255, 255, 255), (0, 255, 255), (181, 85, 105),
|
||||
(113, 102, 140), (202, 202, 200), (197, 83, 181), (136, 162, 196), (138, 251, 213),
|
||||
],
|
||||
id_prefix="cls-lap",
|
||||
)),
|
||||
},
|
||||
{
|
||||
"name": "头颈部CT分割",
|
||||
"description": "头颈部CT分割",
|
||||
"color": "#ef4444",
|
||||
"z_index": 10,
|
||||
"classes": _with_reserved_unclassified_class(_template_classes(
|
||||
"头颈部CT分割",
|
||||
[
|
||||
"肿瘤/结节 (Tumor/Nodule)",
|
||||
"下颌骨 (Mandible)",
|
||||
"甲状腺 (Thyroid)",
|
||||
"气管 (Trachea)",
|
||||
"颈椎 (Cervical Spine)",
|
||||
"颈动脉 (Carotid Artery)",
|
||||
"颈静脉 (Jugular Vein)",
|
||||
"腮腺 (Parotid Gland)",
|
||||
"下颌下腺 (Submandibular Gland)",
|
||||
"舌骨 (Hyoid Bone)",
|
||||
],
|
||||
[
|
||||
(255, 0, 0),
|
||||
(0, 255, 0),
|
||||
(0, 0, 255),
|
||||
(255, 255, 0),
|
||||
(255, 0, 255),
|
||||
(0, 255, 255),
|
||||
(255, 128, 0),
|
||||
(128, 0, 128),
|
||||
(0, 128, 128),
|
||||
(128, 128, 0),
|
||||
],
|
||||
id_prefix="cls-head-neck-ct",
|
||||
)),
|
||||
},
|
||||
]
|
||||
_ensure_default_templates(db)
|
||||
|
||||
for definition in default_templates:
|
||||
existing = db.query(Template).filter(
|
||||
Template.name == definition["name"],
|
||||
Template.owner_user_id.is_(None),
|
||||
).first()
|
||||
if existing is not None:
|
||||
continue
|
||||
template = Template(
|
||||
name=definition["name"],
|
||||
description=definition["description"],
|
||||
color=definition["color"],
|
||||
z_index=definition["z_index"],
|
||||
mapping_rules={"classes": definition["classes"], "rules": []},
|
||||
owner_user_id=None,
|
||||
)
|
||||
db.add(template)
|
||||
logger.info("Seeded default template '%s' with %d classes", definition["name"], len(definition["classes"]))
|
||||
db.commit()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
@@ -26,6 +26,7 @@ from services.demo_media import (
|
||||
create_unparsed_video_demo_project,
|
||||
demo_dicom_files,
|
||||
)
|
||||
from services.default_templates import restore_default_templates
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||
|
||||
@@ -236,6 +237,8 @@ def reset_demo_factory(
|
||||
if not preserved_admin:
|
||||
raise HTTPException(status_code=500, detail="Default admin was not preserved")
|
||||
|
||||
restored_templates = restore_default_templates(db)
|
||||
|
||||
video_project = create_unparsed_video_demo_project(
|
||||
db,
|
||||
owner=preserved_admin,
|
||||
@@ -270,6 +273,7 @@ def reset_demo_factory(
|
||||
"source_types": [project.source_type for project in projects],
|
||||
"frame_counts": {project.name: len(project.frames) for project in projects},
|
||||
"deleted_counts": deleted_counts,
|
||||
"restored_templates": [template.name for template in restored_templates],
|
||||
"requested_by": requested_by,
|
||||
},
|
||||
)
|
||||
|
||||
151
backend/services/default_templates.py
Normal file
151
backend/services/default_templates.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Bundled system ontology templates and restore helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Template
|
||||
|
||||
RESERVED_UNCLASSIFIED_CLASS = {
|
||||
"id": "reserved-unclassified",
|
||||
"name": "待分类",
|
||||
"color": "#000000",
|
||||
"zIndex": 0,
|
||||
"maskId": 0,
|
||||
"category": "系统保留",
|
||||
}
|
||||
|
||||
|
||||
def _with_reserved_unclassified_class(classes: list[dict]) -> list[dict]:
|
||||
filtered = [
|
||||
item for item in classes
|
||||
if item.get("id") != RESERVED_UNCLASSIFIED_CLASS["id"]
|
||||
and item.get("name") != RESERVED_UNCLASSIFIED_CLASS["name"]
|
||||
and item.get("maskId") != 0
|
||||
]
|
||||
return [*filtered, dict(RESERVED_UNCLASSIFIED_CLASS)]
|
||||
|
||||
|
||||
def _template_classes(
|
||||
template_name: str,
|
||||
names: list[str],
|
||||
colors: list[tuple[int, int, int]],
|
||||
*,
|
||||
id_prefix: str,
|
||||
) -> list[dict]:
|
||||
classes = []
|
||||
for idx, (rgb, name) in enumerate(zip(colors, names)):
|
||||
color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
|
||||
classes.append({
|
||||
"id": f"{id_prefix}-{idx}",
|
||||
"name": name,
|
||||
"color": color_hex,
|
||||
"zIndex": (len(names) - idx) * 10,
|
||||
"maskId": idx + 1,
|
||||
"category": template_name,
|
||||
})
|
||||
return classes
|
||||
|
||||
|
||||
def bundled_default_template_definitions() -> list[dict]:
|
||||
"""Return fresh definitions for all bundled system templates."""
|
||||
return [
|
||||
{
|
||||
"name": "腹腔镜胆囊切除术",
|
||||
"description": "腹腔镜胆囊切除术(LC)手术器械与解剖结构语义分割模板,共35个分类",
|
||||
"color": "#06b6d4",
|
||||
"z_index": 0,
|
||||
"classes": _with_reserved_unclassified_class(_template_classes(
|
||||
"腹腔镜胆囊切除术",
|
||||
[
|
||||
"针", "线", "肿瘤", "血管阻断夹", "棉球", "双极电凝",
|
||||
"肝脏", "胆囊", "分离钳", "脂肪", "止血海绵", "肝总管",
|
||||
"吸引器", "剪刀", "超声刀", "止血纱布", "胆总管", "生物夹",
|
||||
"无损伤钳", "钳夹", "喷洒", "胆囊管", "动脉", "电凝",
|
||||
"静脉", "标本袋", "引流管", "纱布", "金属钛夹", "韧带",
|
||||
"肝蒂", "推结器", "乳胶管-血管阻断", "吻合器", "术中超声",
|
||||
],
|
||||
[
|
||||
(134, 124, 118), (0, 157, 142), (245, 161, 0), (255, 172, 159), (146, 175, 236), (155, 62, 0),
|
||||
(255, 91, 0), (255, 234, 0), (85, 111, 181), (155, 132, 0), (181, 227, 14), (72, 0, 255),
|
||||
(255, 0, 255), (29, 32, 136), (240, 16, 116), (160, 15, 95), (0, 155, 33), (0, 160, 233),
|
||||
(52, 184, 178), (66, 115, 82), (90, 120, 41), (255, 0, 0), (117, 0, 0), (167, 24, 233),
|
||||
(42, 8, 66), (112, 113, 150), (0, 255, 0), (255, 255, 255), (0, 255, 255), (181, 85, 105),
|
||||
(113, 102, 140), (202, 202, 200), (197, 83, 181), (136, 162, 196), (138, 251, 213),
|
||||
],
|
||||
id_prefix="cls-lap",
|
||||
)),
|
||||
},
|
||||
{
|
||||
"name": "头颈部CT分割",
|
||||
"description": "头颈部CT分割",
|
||||
"color": "#ef4444",
|
||||
"z_index": 10,
|
||||
"classes": _with_reserved_unclassified_class(_template_classes(
|
||||
"头颈部CT分割",
|
||||
[
|
||||
"肿瘤/结节 (Tumor/Nodule)",
|
||||
"下颌骨 (Mandible)",
|
||||
"甲状腺 (Thyroid)",
|
||||
"气管 (Trachea)",
|
||||
"颈椎 (Cervical Spine)",
|
||||
"颈动脉 (Carotid Artery)",
|
||||
"颈静脉 (Jugular Vein)",
|
||||
"腮腺 (Parotid Gland)",
|
||||
"下颌下腺 (Submandibular Gland)",
|
||||
"舌骨 (Hyoid Bone)",
|
||||
],
|
||||
[
|
||||
(255, 0, 0),
|
||||
(0, 255, 0),
|
||||
(0, 0, 255),
|
||||
(255, 255, 0),
|
||||
(255, 0, 255),
|
||||
(0, 255, 255),
|
||||
(255, 128, 0),
|
||||
(128, 0, 128),
|
||||
(0, 128, 128),
|
||||
(128, 128, 0),
|
||||
],
|
||||
id_prefix="cls-head-neck-ct",
|
||||
)),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def ensure_default_templates(db: Session, *, restore_existing: bool = False) -> list[Template]:
|
||||
"""Create bundled system templates, optionally restoring existing ones exactly."""
|
||||
templates: list[Template] = []
|
||||
for definition in bundled_default_template_definitions():
|
||||
existing = db.query(Template).filter(
|
||||
Template.name == definition["name"],
|
||||
Template.owner_user_id.is_(None),
|
||||
).first()
|
||||
if existing is None:
|
||||
existing = Template(owner_user_id=None)
|
||||
db.add(existing)
|
||||
elif not restore_existing:
|
||||
templates.append(existing)
|
||||
continue
|
||||
|
||||
existing.name = definition["name"]
|
||||
existing.description = definition["description"]
|
||||
existing.color = definition["color"]
|
||||
existing.z_index = definition["z_index"]
|
||||
existing.owner_user_id = None
|
||||
existing.mapping_rules = {
|
||||
"classes": deepcopy(definition["classes"]),
|
||||
"rules": [],
|
||||
}
|
||||
templates.append(existing)
|
||||
db.commit()
|
||||
for template in templates:
|
||||
db.refresh(template)
|
||||
return templates
|
||||
|
||||
|
||||
def restore_default_templates(db: Session) -> list[Template]:
|
||||
"""Restore bundled system templates after demo factory reset."""
|
||||
return ensure_default_templates(db, restore_existing=True)
|
||||
@@ -184,7 +184,27 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
|
||||
assert db_session.query(ProcessingTask).count() == 0
|
||||
assert db_session.query(Template).filter(Template.owner_user_id.is_not(None)).count() == 0
|
||||
preserved_templates = db_session.query(Template).filter(Template.owner_user_id.is_(None)).all()
|
||||
assert [template.name for template in preserved_templates] == ["头颈部CT分割"]
|
||||
templates_by_name = {template.name: template for template in preserved_templates}
|
||||
assert set(templates_by_name) == {"腹腔镜胆囊切除术", "头颈部CT分割"}
|
||||
head_neck_classes = templates_by_name["头颈部CT分割"].mapping_rules["classes"]
|
||||
lap_classes = templates_by_name["腹腔镜胆囊切除术"].mapping_rules["classes"]
|
||||
assert [item["name"] for item in head_neck_classes] == [
|
||||
"肿瘤/结节 (Tumor/Nodule)",
|
||||
"下颌骨 (Mandible)",
|
||||
"甲状腺 (Thyroid)",
|
||||
"气管 (Trachea)",
|
||||
"颈椎 (Cervical Spine)",
|
||||
"颈动脉 (Carotid Artery)",
|
||||
"颈静脉 (Jugular Vein)",
|
||||
"腮腺 (Parotid Gland)",
|
||||
"下颌下腺 (Submandibular Gland)",
|
||||
"舌骨 (Hyoid Bone)",
|
||||
"待分类",
|
||||
]
|
||||
assert [item["maskId"] for item in head_neck_classes] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0]
|
||||
assert len(lap_classes) == 36
|
||||
assert lap_classes[-1]["name"] == "待分类"
|
||||
assert lap_classes[-1]["maskId"] == 0
|
||||
assert db_session.query(AuditLog).count() == 1
|
||||
assert db_session.query(AuditLog).first().action == "admin.demo_factory_reset"
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
| 修改角色 / 启停用 / 改密码 | 真实可用 | 调用 `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 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据 |
|
||||
| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据;“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |
|
||||
|
||||
## Dashboard 系统概况
|
||||
|
||||
|
||||
@@ -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 项目;响应包含兼容单个 `project` 和完整 `projects` 列表 |
|
||||
| POST | `/api/admin/demo-factory-reset` | 演示部署恢复出厂设置;请求体需 `confirmation=RESET_DEMO_FACTORY`;重置后保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;同时按内置权威定义重建缺失的“腹腔镜胆囊切除术”“头颈部CT分割”系统模板,并覆盖恢复被修改或删减的默认语义分类树;响应包含兼容单个 `project` 和完整 `projects` 列表 |
|
||||
| POST | `/api/projects` | 创建项目 |
|
||||
| GET | `/api/projects` | 项目列表 |
|
||||
| GET | `/api/projects/{project_id}` | 项目详情 |
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- 管理员侧栏显示“用户管理”入口;管理员可以新增用户、修改角色、停用/启用、修改密码、删除无项目用户。
|
||||
- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。
|
||||
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
|
||||
- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”;恢复演示出厂设置不得删除系统默认模板。
|
||||
- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”;恢复演示出厂设置不得删除系统默认模板,并必须重建缺失的默认模板、覆盖恢复被修改或删减的默认语义分类树。
|
||||
|
||||
## R2 项目管理
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `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 保存前对 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、查询、更新、删除标注、工作区回显、清空已保存标注、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、默认模板“腹腔镜胆囊切除术”和“头颈部CT分割”幂等 seed、恢复出厂设置保留系统模板、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、数组/对象/常见粘贴格式导入、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py`, `test_admin.py` | 已覆盖 |
|
||||
| R8 | 模板加载、新建、编辑、删除、删除模板站内确认、鼠标复制模板为私有副本并保留 maskid/颜色/层级/规则、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页标题/新建分类/垃圾桶删 label、默认模板“腹腔镜胆囊切除术”和“头颈部CT分割”幂等 seed、恢复出厂设置保留并权威恢复系统模板、默认模板缺失后重建、默认语义分类树被修改/删减后覆盖恢复、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `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` | 已覆盖 |
|
||||
|
||||
Reference in New Issue
Block a user