完善项目导入、模板与分割工作区交互

- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。

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

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

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

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

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

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

- 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
This commit is contained in:
2026-05-03 17:11:59 +08:00
parent afcddfaeb9
commit 481ffa5b67
47 changed files with 3650 additions and 676 deletions

View File

@@ -4,8 +4,6 @@ import asyncio
import json
import logging
import os
import shutil
import tempfile
from contextlib import asynccontextmanager, suppress
from datetime import datetime, timezone
@@ -15,10 +13,9 @@ from sqlalchemy import inspect, text
from config import settings
from database import Base, engine, SessionLocal
from minio_client import ensure_bucket_exists, upload_file
from minio_client import ensure_bucket_exists
from progress_events import PROGRESS_CHANNEL
from redis_client import get_redis_client, ping as redis_ping
from statuses import PROJECT_STATUS_PENDING, PROJECT_STATUS_READY
from routers import projects, templates, media, ai, export, auth, dashboard, tasks, admin
@@ -27,9 +24,24 @@ 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": "系统保留",
}
DEFAULT_VIDEO_PATH = settings.demo_video_path
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."""
@@ -72,92 +84,51 @@ def _seed_default_admin_and_ownership_sync() -> None:
def _seed_default_project_sync() -> None:
"""Synchronously seed the default video project on first startup."""
import cv2
from models import Project, Frame
"""Synchronously seed the bundled demo video and DICOM projects on first startup."""
from models import Project
from routers.auth import ensure_default_admin
from services.frame_parser import parse_video, upload_frames_to_minio, extract_thumbnail
from services.demo_media import (
DEMO_DICOM_PROJECT_NAME,
DEMO_VIDEO_PROJECT_NAME,
create_parsed_dicom_demo_project,
create_unparsed_video_demo_project,
demo_dicom_files,
)
db = SessionLocal()
try:
admin = ensure_default_admin(db)
existing = db.query(Project).filter(Project.name == "Data_MyVideo_1").first()
if existing is not None:
if existing.owner_user_id is None:
existing.owner_user_id = admin.id
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
db.commit()
elif existing_video is None and os.path.exists(settings.demo_video_path):
video_project = create_unparsed_video_demo_project(
db,
owner=admin,
video_path=settings.demo_video_path,
project_name=DEMO_VIDEO_PROJECT_NAME,
)
logger.info("Seeded default video project id=%s", video_project.id)
existing_dicom = db.query(Project).filter(Project.name == DEMO_DICOM_PROJECT_NAME).first()
if existing_dicom is not None:
if existing_dicom.owner_user_id is None:
existing_dicom.owner_user_id = admin.id
db.commit()
return
if not os.path.exists(DEFAULT_VIDEO_PATH):
logger.warning("Default video not found at %s", DEFAULT_VIDEO_PATH)
if not demo_dicom_files(settings.demo_dicom_dir):
logger.warning("Default DICOM series not found at %s", settings.demo_dicom_dir)
return
project = Project(
name="Data_MyVideo_1",
description="默认演示视频",
status=PROJECT_STATUS_PENDING,
source_type="video",
parse_fps=30.0,
owner_user_id=admin.id,
project = create_parsed_dicom_demo_project(
db,
owner=admin,
dicom_dir=settings.demo_dicom_dir,
project_name=DEMO_DICOM_PROJECT_NAME,
)
db.add(project)
db.commit()
db.refresh(project)
with open(DEFAULT_VIDEO_PATH, "rb") as f:
data = f.read()
object_name = f"uploads/{project.id}/Data_MyVideo_1.mp4"
upload_file(object_name, data, content_type="video/mp4", length=len(data))
project.video_path = object_name
db.commit()
# Parse frames
tmp_dir = tempfile.mkdtemp(prefix=f"seg_seed_{project.id}_")
try:
local_path = os.path.join(tmp_dir, "video.mp4")
with open(local_path, "wb") as f:
f.write(data)
output_dir = os.path.join(tmp_dir, "frames")
os.makedirs(output_dir, exist_ok=True)
frame_files, original_fps = parse_video(local_path, output_dir, fps=30, max_frames=100)
project.original_fps = original_fps
# Extract thumbnail
thumbnail_path = os.path.join(tmp_dir, "thumbnail.jpg")
try:
extract_thumbnail(local_path, thumbnail_path)
with open(thumbnail_path, "rb") as f:
thumb_data = f.read()
thumb_object = f"projects/{project.id}/thumbnail.jpg"
upload_file(thumb_object, thumb_data, content_type="image/jpeg", length=len(thumb_data))
project.thumbnail_url = thumb_object
except Exception as exc: # noqa: BLE001
logger.warning("Thumbnail extraction failed: %s", exc)
object_names = upload_frames_to_minio(frame_files, project.id)
for idx, obj_name in enumerate(object_names):
img = cv2.imread(frame_files[idx])
h, w = img.shape[:2] if img is not None else (None, None)
timestamp_ms = idx * 1000.0 / 30.0
source_frame_number = int(round(idx * original_fps / 30.0)) if original_fps else None
frame = Frame(
project_id=project.id,
frame_index=idx,
image_url=obj_name,
width=w,
height=h,
timestamp_ms=timestamp_ms,
source_frame_number=source_frame_number,
)
db.add(frame)
project.status = PROJECT_STATUS_READY
db.commit()
logger.info("Seeded default project id=%s with %d frames", project.id, len(object_names))
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
logger.info("Seeded default DICOM project id=%s with %d frames", project.id, len(project.frames))
except Exception as exc:
logger.error("Failed to seed default project: %s", exc)
finally:
@@ -170,53 +141,120 @@ def _seed_default_templates_sync() -> None:
db = SessionLocal()
try:
if db.query(Template).first() is not None:
return
# Laparoscopic cholecystectomy template (35 classes)
colors = [
(134, 124, 118), (0, 157, 142), (245, 161, 0), (255, 172, 159), (146, 175, 236), (155, 62, 0),
(255, 91, 0), (255, 234, 0), (85, 111, 181), (155, 132, 0), (181, 227, 14), (72, 0, 255),
(255, 0, 255), (29, 32, 136), (240, 16, 116), (160, 15, 95), (0, 155, 33), (0, 160, 233),
(52, 184, 178), (66, 115, 82), (90, 120, 41), (255, 0, 0), (117, 0, 0), (167, 24, 233),
(42, 8, 66), (112, 113, 150), (0, 255, 0), (255, 255, 255), (0, 255, 255), (181, 85, 105),
(113, 102, 140), (202, 202, 200), (197, 83, 181), (136, 162, 196), (138, 251, 213),
]
names = [
'', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
]
classes = []
for idx, (rgb, name) in enumerate(zip(colors, names)):
color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
classes.append({
"id": f"cls-lap-{idx}",
"name": name,
"color": color_hex,
"zIndex": (len(names) - idx) * 10,
"category": "腹腔镜胆囊切除术",
})
template = Template(
name="腹腔镜胆囊切除术",
description="腹腔镜胆囊切除术LC手术器械与解剖结构语义分割模板共35个分类",
color="#06b6d4",
z_index=0,
mapping_rules={"classes": classes, "rules": []},
)
db.add(template)
db.commit()
logger.info("Seeded default template '腹腔镜胆囊切除术' with %d classes", len(classes))
ensure_default_templates(db)
except Exception as exc:
logger.error("Failed to seed default templates: %s", exc)
finally:
db.close()
def _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
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",
)),
},
]
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):
"""Application lifespan: startup and shutdown hooks."""