完善项目导入、模板与分割工作区交互
- 增强 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:
128
backend/services/demo_media.py
Normal file
128
backend/services/demo_media.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Helpers for seeding the bundled demo media project."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from minio_client import upload_file
|
||||
from models import Frame, Project, User
|
||||
from services.frame_parser import natural_filename_key, parse_dicom, upload_frames_to_minio
|
||||
from statuses import PROJECT_STATUS_PENDING, PROJECT_STATUS_READY
|
||||
|
||||
DEMO_DICOM_PROJECT_NAME = "演示DICOM序列"
|
||||
DEMO_DICOM_PARSE_FPS = 30.0
|
||||
DEMO_VIDEO_PROJECT_NAME = "Data_MyVideo_1"
|
||||
|
||||
|
||||
def demo_dicom_files(dicom_dir: str) -> list[Path]:
|
||||
"""Return .dcm files in natural file-name order."""
|
||||
root = Path(dicom_dir)
|
||||
if not root.exists() or not root.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
[path for path in root.iterdir() if path.is_file() and path.name.lower().endswith(".dcm")],
|
||||
key=lambda path: natural_filename_key(path.name),
|
||||
)
|
||||
|
||||
|
||||
def create_unparsed_video_demo_project(
|
||||
db: Session,
|
||||
*,
|
||||
owner: User,
|
||||
video_path: str,
|
||||
project_name: str = DEMO_VIDEO_PROJECT_NAME,
|
||||
) -> Project:
|
||||
"""Create the bundled demo video project without extracting frames."""
|
||||
source = Path(video_path)
|
||||
if not source.exists() or not source.is_file():
|
||||
raise FileNotFoundError(f"Demo video not found: {video_path}")
|
||||
|
||||
project = Project(
|
||||
name=project_name,
|
||||
description="默认演示视频,尚未生成帧",
|
||||
status=PROJECT_STATUS_PENDING,
|
||||
source_type="video",
|
||||
parse_fps=30.0,
|
||||
original_fps=None,
|
||||
owner_user_id=owner.id,
|
||||
)
|
||||
db.add(project)
|
||||
db.flush()
|
||||
|
||||
data = source.read_bytes()
|
||||
object_name = f"uploads/{project.id}/{source.name}"
|
||||
upload_file(object_name, data, content_type="video/mp4", length=len(data))
|
||||
project.video_path = object_name
|
||||
project.thumbnail_url = None
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
def create_parsed_dicom_demo_project(
|
||||
db: Session,
|
||||
*,
|
||||
owner: User,
|
||||
dicom_dir: str,
|
||||
project_name: str = DEMO_DICOM_PROJECT_NAME,
|
||||
) -> Project:
|
||||
"""Create the demo DICOM project, upload the series, and register parsed frames."""
|
||||
dcm_files = demo_dicom_files(dicom_dir)
|
||||
if not dcm_files:
|
||||
raise FileNotFoundError(f"Demo DICOM series not found: {dicom_dir}")
|
||||
|
||||
project = Project(
|
||||
name=project_name,
|
||||
description=f"默认演示 DICOM 序列,已按文件名自然顺序生成 {len(dcm_files)} 帧",
|
||||
status=PROJECT_STATUS_PENDING,
|
||||
source_type="dicom",
|
||||
parse_fps=DEMO_DICOM_PARSE_FPS,
|
||||
original_fps=None,
|
||||
owner_user_id=owner.id,
|
||||
)
|
||||
db.add(project)
|
||||
db.flush()
|
||||
|
||||
dicom_prefix = f"uploads/{project.id}/dicom"
|
||||
for dcm_file in dcm_files:
|
||||
data = dcm_file.read_bytes()
|
||||
upload_file(
|
||||
f"{dicom_prefix}/{dcm_file.name}",
|
||||
data,
|
||||
content_type="application/dicom",
|
||||
length=len(data),
|
||||
)
|
||||
project.video_path = dicom_prefix
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix=f"seg_demo_dicom_{project.id}_")
|
||||
try:
|
||||
output_dir = os.path.join(tmp_dir, "frames")
|
||||
frame_files = parse_dicom(dicom_dir, output_dir)
|
||||
object_names = upload_frames_to_minio(frame_files, project.id)
|
||||
|
||||
for idx, obj_name in enumerate(object_names):
|
||||
image = cv2.imread(frame_files[idx])
|
||||
height, width = image.shape[:2] if image is not None else (None, None)
|
||||
db.add(Frame(
|
||||
project_id=project.id,
|
||||
frame_index=idx,
|
||||
image_url=obj_name,
|
||||
width=width,
|
||||
height=height,
|
||||
timestamp_ms=idx * 1000.0 / DEMO_DICOM_PARSE_FPS,
|
||||
source_frame_number=idx,
|
||||
))
|
||||
if object_names:
|
||||
project.thumbnail_url = object_names[0]
|
||||
project.status = PROJECT_STATUS_READY
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -16,6 +17,14 @@ from minio_client import upload_file, BUCKET_NAME
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def natural_filename_key(filename: str) -> Tuple[object, ...]:
|
||||
"""Sort file names by their visible numeric order instead of pure lexicographic order."""
|
||||
return tuple(
|
||||
int(part) if part.isdigit() else part.casefold()
|
||||
for part in re.split(r"(\d+)", Path(filename).name)
|
||||
)
|
||||
|
||||
|
||||
def get_video_fps(video_path: str) -> float:
|
||||
"""Read the original frame rate of a video file."""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
@@ -150,7 +159,8 @@ def parse_dicom(
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
dcm_files = sorted(
|
||||
[f for f in os.listdir(dicom_dir) if f.lower().endswith(".dcm")]
|
||||
[f for f in os.listdir(dicom_dir) if f.lower().endswith(".dcm")],
|
||||
key=natural_filename_key,
|
||||
)
|
||||
|
||||
frame_paths: List[str] = []
|
||||
|
||||
@@ -15,6 +15,7 @@ from models import Frame, ProcessingTask, Project
|
||||
from progress_events import publish_task_progress_event
|
||||
from services.frame_parser import (
|
||||
extract_thumbnail,
|
||||
natural_filename_key,
|
||||
parse_dicom,
|
||||
parse_video,
|
||||
upload_frames_to_minio,
|
||||
@@ -188,7 +189,10 @@ def run_parse_media_task(db: Session, task_id: int) -> dict[str, Any]:
|
||||
os.makedirs(dcm_dir, exist_ok=True)
|
||||
|
||||
client = get_minio_client()
|
||||
objects = list(client.list_objects(BUCKET_NAME, prefix=project.video_path, recursive=True))
|
||||
objects = sorted(
|
||||
list(client.list_objects(BUCKET_NAME, prefix=project.video_path, recursive=True)),
|
||||
key=lambda obj: natural_filename_key(obj.object_name),
|
||||
)
|
||||
for obj in objects:
|
||||
_ensure_not_cancelled(db, task)
|
||||
if obj.object_name.lower().endswith(".dcm"):
|
||||
|
||||
Reference in New Issue
Block a user