修复演示恢复项目帧数据

- 恢复演示出厂设置后直接解析演视LC视频序列并生成可打开帧序列

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

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

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

- 更新 README 和前后端契约/设计/测试文档中的演示恢复说明
This commit is contained in:
2026-05-07 16:17:07 +08:00
parent b1131c9126
commit 620e95ff91
14 changed files with 141 additions and 20 deletions

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

View File

@@ -71,7 +71,7 @@ def _seed_default_project_sync() -> None:
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,
)
@@ -96,7 +96,7 @@ def _seed_default_project_sync() -> None:
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_unparsed_video_demo_project(
video_project = create_parsed_video_demo_project(
db,
owner=admin,
video_path=settings.demo_video_path,

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

@@ -23,7 +23,7 @@ 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
@@ -252,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

@@ -12,12 +12,20 @@ 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_PARSE_FPS = 30.0
DEMO_VIDEO_PROJECT_NAME = "演视LC视频序列"
DEMO_VIDEO_PARSE_FPS = 30.0
DEMO_VIDEO_TARGET_WIDTH = 640
LEGACY_DEMO_VIDEO_PROJECT_NAMES = {"Data_MyVideo_1"}
LEGACY_DEMO_DICOM_PROJECT_NAMES = {"演示DICOM序列"}
@@ -67,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

@@ -132,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({
@@ -140,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",
@@ -195,9 +204,9 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
assert data["project"]["frame_count"] == 3
assert data["project"]["video_path"] == f"uploads/{data['project']['id']}/dicom"
assert [project["name"] for project in data["projects"]] == ["演视LC视频序列", "演视DICOM序列"]
assert data["projects"][0]["status"] == "pending"
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
@@ -211,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