From 149cbc95d3404ce88d3377b665465d7f583ba9d4 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 3 May 2026 05:41:22 +0800 Subject: [PATCH] update web workflow and preview behavior --- WebSite/src/App.tsx | 128 ++++++++++++++++++++++++++----- generate_head_extension_video.py | 87 ++++++++++++--------- head_extension_app.py | 75 ++++++++++++++---- video_generator_app.py | 2 +- web_backend.py | 125 ++++++++++++++++++++++++++++-- 5 files changed, 339 insertions(+), 78 deletions(-) diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index 01e25a7..8fb7698 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -86,12 +86,13 @@ const DEFAULT_PACKAGE_OPTIONS: PackageOptions = { }; const VIDEO_SOURCE_OPTIONS = [ - { key: 'original', label: '原始序列' }, - { key: 'hard_boundary', label: '硬边界' }, - { key: 'gaussian_smooth', label: '高斯平滑' }, - { key: 'soft_transition', label: '软过渡重建' }, + { key: 'hard_boundary', label: '硬边界', mode: 'hard_boundary' }, + { key: 'gaussian_smooth', label: '高斯平滑', mode: 'gaussian_smooth' }, + { key: 'soft_transition', label: '软过渡重建', mode: 'soft_transition' }, ]; +const PREVIEW_ALGORITHM_OPTIONS = VIDEO_SOURCE_OPTIONS; + type LibraryItem = { id: string; patientId: string; @@ -284,6 +285,8 @@ export default function App() { // --- Simulation State (Workspace) --- const [cervicalRotation, setCervicalRotation] = useState(14.5); const [transitionWidth, setTransitionWidth] = useState(90); + const [previewGaussianSigma, setPreviewGaussianSigma] = useState(3); + const [previewAlgorithm, setPreviewAlgorithm] = useState('soft_transition'); const [showPreviewCutoffLine, setShowPreviewCutoffLine] = useState(true); const [isSimulating, setIsSimulating] = useState(restoredDeformationJob?.job.status === 'running'); const [progress, setProgress] = useState(restoredDeformationJob ? progressFromJob(restoredDeformationJob.job, restoredDeformationJob.progress) : 0); @@ -299,7 +302,7 @@ export default function App() { const [packageOptions, setPackageOptions] = useState(DEFAULT_PACKAGE_OPTIONS); const [videoMaxAngle, setVideoMaxAngle] = useState(20); const [videoDuration, setVideoDuration] = useState(6); - const [videoSource, setVideoSource] = useState('original'); + const [videoSource, setVideoSource] = useState('hard_boundary'); const [showVideoArrow, setShowVideoArrow] = useState(true); // --- User Management Shared State --- @@ -309,13 +312,12 @@ export default function App() { const [pwChangeInput, setPwChangeInput] = useState(''); const [showAddUser, setShowAddUser] = useState(false); const [activeUserMenu, setActiveUserMenu] = useState(null); + const [isResettingDemo, setIsResettingDemo] = useState(false); const selectedDataset = libraryData.find(item => item.id === selectedLibraryId) || libraryData[0]; const selectedInputDir = selectedDataset?.dicomPath || ''; const selectedVideoSource = VIDEO_SOURCE_OPTIONS.find(option => option.key === videoSource) || VIDEO_SOURCE_OPTIONS[0]; - const videoSourceInputDir = videoSource === 'original' - ? selectedInputDir - : deformationJob?.result?.outputs?.[videoSource] || ''; + const videoSourceInputDir = selectedInputDir; const isVideoSourceReady = Boolean(videoSourceInputDir); useEffect(() => { @@ -364,6 +366,16 @@ export default function App() { link.remove(); }; + const downloadPreviewImage = () => { + if (!previewImage) return; + const link = document.createElement('a'); + link.href = previewImage; + link.download = `quick_2d_preview_${previewAlgorithm}_${cervicalRotation.toFixed(1)}deg.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + }; + const clearDeformationTask = () => { setDeformationJob(null); setProgress(0); @@ -449,6 +461,31 @@ export default function App() { return items; }; + const resetDemoEnvironment = async () => { + if (isResettingDemo) return; + if (!confirm('确定恢复演示环境出厂设置吗?影像数据库将只保留 Ori_Head_CT,并清空当前任务结果。')) return; + + setIsResettingDemo(true); + try { + const data = await apiRequest('/api/demo/reset', { method: 'POST' }) as { items?: LibraryItem[] }; + const items = data.items || []; + setLibraryData(items); + setSelectedLibraryId(items[0]?.id || ''); + setLibraryInfo(null); + setPreviewImage(''); + setVideoJob(null); + setZipJobs({}); + clearDeformationTask(); + setBackendOnline(true); + setBackendMessage('演示环境已恢复出厂设置'); + showToast('已恢复演示环境出厂设置'); + } catch (error) { + showToast((error as Error).message); + } finally { + setIsResettingDemo(false); + } + }; + const refreshBackendDefaults = async () => { try { const data = await apiRequest('/api/defaults'); @@ -755,6 +792,9 @@ export default function App() { body: JSON.stringify({ inputDir: selectedInputDir, angleDegrees: cervicalRotation, + transitionWidth, + gaussianSigma: previewGaussianSigma, + mode: previewAlgorithm, showCutoffLine: showPreviewCutoffLine }), signal: controller.signal @@ -778,7 +818,7 @@ export default function App() { controller.abort(); window.clearTimeout(timer); }; - }, [currentPage, selectedInputDir, cervicalRotation, showPreviewCutoffLine]); + }, [currentPage, selectedInputDir, cervicalRotation, transitionWidth, previewGaussianSigma, previewAlgorithm, showPreviewCutoffLine]); const handleRunSimulation = async () => { if (isSimulating) return; @@ -808,7 +848,7 @@ export default function App() { const handleGenerateVideo = async () => { if (videoJob?.status === 'running') return; if (!videoSourceInputDir) { - showToast(videoSource === 'original' ? '请选择影像库数据源' : '请先完成四状态输出,再生成该状态视频'); + showToast('请选择影像库数据源'); return; } try { @@ -819,6 +859,7 @@ export default function App() { maxAngle: videoMaxAngle, durationSeconds: videoDuration, showArrow: showVideoArrow, + mode: selectedVideoSource.mode, }) }) as BackendJob; setVideoJob(job); @@ -989,13 +1030,33 @@ export default function App() {
-
仰头角度{cervicalRotation.toFixed(1)}°
- setCervicalRotation(parseFloat(e.target.value))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" /> +
快速预览算法{PREVIEW_ALGORITHM_OPTIONS.find(option => option.key === previewAlgorithm)?.label}
+
-
过渡平滑宽度{transitionWidth}
- setTransitionWidth(parseInt(e.target.value, 10))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" /> +
仰头角度{cervicalRotation.toFixed(1)}°
+ setCervicalRotation(parseFloat(e.target.value))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" />
+ {previewAlgorithm === 'gaussian_smooth' && ( +
+
高斯平滑强度{previewGaussianSigma.toFixed(1)}
+ setPreviewGaussianSigma(parseFloat(e.target.value))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" /> +
+ )} + {previewAlgorithm === 'soft_transition' && ( +
+
软过渡宽度{transitionWidth}
+ setTransitionWidth(parseInt(e.target.value, 10))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" /> +
+ )}
- 视频来源序列 - {videoSource !== 'original' && !isVideoSourceReady && 需先生成四状态} + 视频形变方式 + {!isVideoSourceReady && 请选择影像库数据源}
setVideoMaxAngle(parseInt(e.target.value, 10))} className="w-full accent-blue-600" /> + setVideoMaxAngle(parseInt(e.target.value, 10))} className="w-full accent-blue-600" />
时长{videoDuration}s
@@ -1122,9 +1183,19 @@ export default function App() {

快速 2D 预览

对应 head_extension_app.py 的 preview_deform_2d

-
- {cervicalRotation.toFixed(1)} DEG - {isPreviewLoading &&

自动更新中...

} +
+
+ {cervicalRotation.toFixed(1)} DEG + {isPreviewLoading &&

自动更新中...

} +
+
@@ -1399,6 +1470,23 @@ export default function App() {
)} + {currentUser?.role === 'admin' && ( +
+
+

演示环境出厂设置

+

恢复后影像数据库只保留 Ori_Head_CT,并清空当前任务结果。

+
+ +
+ )} + {/* User List Header */}

系统权限生命周期管理

diff --git a/generate_head_extension_video.py b/generate_head_extension_video.py index 9190ae4..a24c8ef 100644 --- a/generate_head_extension_video.py +++ b/generate_head_extension_video.py @@ -5,6 +5,7 @@ from pathlib import Path import imageio.v2 as imageio import numpy as np from PIL import Image, ImageDraw, ImageFont +from scipy.ndimage import gaussian_filter from scipy.ndimage import map_coordinates from head_extension_app import ( @@ -20,9 +21,41 @@ OUTPUT_DIR = Path("ppt_video") FPS = 30 DURATION_SECONDS = 6 END_HOLD_SECONDS = 1 +VIDEO_MODES = {"hard_boundary", "gaussian_smooth", "soft_transition"} +MODE_LABELS = { + "hard_boundary": "Hard boundary", + "gaussian_smooth": "Gaussian smooth", + "soft_transition": "Soft transition", +} -def video_soft_bend_2d(image, angle_degrees): +def normalize_mode(mode): + return mode if mode in VIDEO_MODES else "soft_transition" + + +def video_motion_weight(height, width, mode): + mode = normalize_mode(mode) + yy, xx = np.mgrid[0:height, 0:width] + full_motion_y = height * 0.50 + fixed_y = height * 0.92 + boundary_y = (full_motion_y + fixed_y) * 0.5 + + if mode == "hard_boundary": + return (yy <= boundary_y).astype(np.float32) + + if mode == "gaussian_smooth": + hard = (yy <= boundary_y).astype(np.float32) + return np.clip(gaussian_filter(hard, sigma=height * 0.025), 0, 1).astype(np.float32) + + t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1) + weight = 1 - (t * t * (3 - 2 * t)) + + x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1) + x_soft = x_soft * x_soft * (3 - 2 * x_soft) + return np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1).astype(np.float32) + + +def video_soft_bend_2d(image, angle_degrees, mode="soft_transition"): """Video-only 2D deformation with a broad neck transition. The app's fast preview uses a compact transition, which is useful for @@ -37,19 +70,7 @@ def video_soft_bend_2d(image, angle_degrees): pivot_x = int(width * 0.55) pivot_y = int(height * 0.62) - # Broad transition: nearly full motion for head/upper C-spine, then a long - # smooth blend through the lower C-spine to avoid splitting bone structures. - full_motion_y = height * 0.50 - fixed_y = height * 0.92 - t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1) - weight = 1 - (t * t * (3 - 2 * t)) - - # A small x-dependent term keeps the posterior contour from looking like a - # straight sliced plane while remaining deterministic and smooth. - x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1) - x_soft = x_soft * x_soft * (3 - 2 * x_soft) - weight = np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1) - + weight = video_motion_weight(height, width, mode) theta = np.deg2rad(angle_degrees) * weight cos_t = np.cos(theta) sin_t = np.sin(theta) @@ -74,8 +95,9 @@ def smoothstep(t): return t * t * (3 - 2 * t) -def make_frame(before_image, angle, max_angle, show_arrow=True): - after_image = video_soft_bend_2d(before_image, angle) +def make_frame(before_image, angle, max_angle, show_arrow=True, mode="soft_transition"): + mode = normalize_mode(mode) + after_image = video_soft_bend_2d(before_image, angle, mode) frame = Image.new("RGB", (1920, 1080), (0, 0, 0)) draw = ImageDraw.Draw(frame) @@ -90,7 +112,7 @@ def make_frame(before_image, angle, max_angle, show_arrow=True): draw.text((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255)) draw.text( (1055, 120), - f"Head extension: {angle:04.1f} deg", + f"{MODE_LABELS[mode]}: {angle:04.1f} deg", font=title_font, fill=(255, 255, 255), ) @@ -101,22 +123,6 @@ def make_frame(before_image, angle, max_angle, show_arrow=True): draw.line((x0, y0, x1, y1), fill=arrow, width=8) draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow) - # Minimal progress bar. - bar_x, bar_y, bar_w, bar_h = 1040, 980, 760, 12 - draw.rounded_rectangle( - (bar_x, bar_y, bar_x + bar_w, bar_y + bar_h), - radius=6, - fill=(70, 70, 70), - ) - fill_w = int(bar_w * angle / max_angle) if max_angle else 0 - draw.rounded_rectangle( - (bar_x, bar_y, bar_x + fill_w, bar_y + bar_h), - radius=6, - fill=arrow, - ) - draw.text((1040, 1000), "0 deg", font=get_font(24), fill=(210, 210, 210)) - draw.text((1745, 1000), f"{max_angle:g} deg", font=get_font(24), fill=(210, 210, 210)) - # Invisible-looking spacer: keeps the font imported above alive for some PIL builds. _ = angle_font return frame @@ -153,10 +159,17 @@ def parse_args(): action="store_true", help="Hide the yellow direction arrow.", ) + parser.add_argument( + "--mode", + default="soft_transition", + choices=["hard_boundary", "gaussian_smooth", "soft_transition"], + help="2D video deformation mode. Default: soft_transition", + ) return parser.parse_args() -def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0, show_arrow=True): +def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0, show_arrow=True, mode="soft_transition"): + mode = normalize_mode(mode) output_file = Path(output_path) output_file.parent.mkdir(parents=True, exist_ok=True) @@ -177,17 +190,17 @@ def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0, for index in range(moving_frames): t = index / (moving_frames - 1) angle = max_angle * smoothstep(t) - writer.append_data(np.asarray(make_frame(before_image, angle, max_angle, show_arrow))) + writer.append_data(np.asarray(make_frame(before_image, angle, max_angle, show_arrow, mode))) for _ in range(hold_frames): - writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle, show_arrow))) + writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle, show_arrow, mode))) return output_file.resolve() def main(): args = parse_args() - output_file = generate_video(args.input, args.output, args.max_angle, args.duration, not args.no_arrow) + output_file = generate_video(args.input, args.output, args.max_angle, args.duration, not args.no_arrow, args.mode) print(output_file) diff --git a/head_extension_app.py b/head_extension_app.py index 6f6a6ae..a15b3ec 100644 --- a/head_extension_app.py +++ b/head_extension_app.py @@ -152,7 +152,37 @@ def fit_image(image, width, height): return canvas -def preview_deform_2d(image, angle_degrees): +def preview_motion_weight(height, width, mode="soft_transition", transition_width=90, gaussian_sigma=3): + yy, xx = np.mgrid[0:height, 0:width] + boundary_y = height * 0.71 + transition_span = height * np.clip(float(transition_width) / 90 * 0.42, 0.18, 0.56) + full_motion_y = boundary_y - transition_span * 0.5 + fixed_y = boundary_y + transition_span * 0.5 + + if mode == "hard_boundary": + return (yy <= boundary_y).astype(np.float32) + + if mode == "gaussian_smooth": + try: + from scipy.ndimage import gaussian_filter + except Exception: + gaussian_filter = None + + hard = (yy <= boundary_y).astype(np.float32) + if gaussian_filter is None: + return hard + sigma = float(np.clip(float(gaussian_sigma), 1, 12)) + return np.clip(gaussian_filter(hard, sigma=sigma), 0, 1).astype(np.float32) + + t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1) + weight = 1 - (t * t * (3 - 2 * t)) + + x_soft = np.clip((xx - width * 0.15) / (width * 0.75), 0, 1) + x_soft = x_soft * x_soft * (3 - 2 * x_soft) + return np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1).astype(np.float32) + + +def preview_deform_2d(image, angle_degrees, mode="soft_transition", transition_width=90, gaussian_sigma=3): """Fast visual preview only. The DICOM output uses the real 3D field.""" try: from scipy.ndimage import map_coordinates @@ -166,15 +196,7 @@ def preview_deform_2d(image, angle_degrees): pivot_x = int(w * 0.55) pivot_y = int(h * 0.62) - full_motion_y = h * 0.50 - fixed_y = h * 0.92 - t = np.clip((yy - full_motion_y) / (fixed_y - full_motion_y), 0, 1) - weight = 1 - (t * t * (3 - 2 * t)) - - x_soft = np.clip((xx - w * 0.15) / (w * 0.75), 0, 1) - x_soft = x_soft * x_soft * (3 - 2 * x_soft) - weight = np.clip(weight * (0.90 + 0.10 * x_soft), 0, 1) - + weight = preview_motion_weight(h, w, mode, transition_width, gaussian_sigma) theta = np.deg2rad(angle_degrees) * weight cos_t = np.cos(theta) sin_t = np.sin(theta) @@ -359,7 +381,7 @@ def run_deformation(input_dir, output_dir, angle_degrees, transition_width, prog output_paths["legacy_soft"] = legacy_soft_dir progress("正在生成四状态过程对比图...") - preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees) + preview_paths = make_four_state_preview(state_images, Path(output_dir), angle_degrees, transition_width=transition_width) make_output_preview_from_images( state_images["original"], state_images["soft_transition"], @@ -437,14 +459,32 @@ def make_output_preview_from_images(original_image, deformed_image, output_dir, return preview_path -def make_four_state_preview(state_images, output_dir, angle_degrees, coordinates_cutoff=None): +def make_four_state_preview(state_images, output_dir, angle_degrees, coordinates_cutoff=None, transition_width=90): output_dir = Path(output_dir) screenshot_dir = output_dir / "process_screenshots" reset_folder(screenshot_dir) + original_panel = sitk_sagittal_panel(state_images["original"], coordinates_cutoff) + preview_panels = { + "original": original_panel, + "hard_boundary": preview_deform_2d(original_panel, angle_degrees, "hard_boundary"), + "gaussian_smooth": preview_deform_2d( + original_panel, + angle_degrees, + "gaussian_smooth", + transition_width, + ), + "soft_transition": preview_deform_2d( + original_panel, + angle_degrees, + "soft_transition", + transition_width, + ), + } + panels = [] for state_key, label, _ in STATE_LABELS: - panel = sitk_sagittal_panel(state_images[state_key], coordinates_cutoff) + panel = preview_panels[state_key] panel_path = screenshot_dir / f"{state_key}.png" panel.save(panel_path, quality=95) panels.append((label, panel)) @@ -517,7 +557,7 @@ class HeadExtensionApp: self.angle = Scale( controls, from_=0, - to=20, + to=45, orient=HORIZONTAL, resolution=0.5, length=420, @@ -603,7 +643,12 @@ class HeadExtensionApp: self.cached_volume = load_dicom_volume(self.input_dir.get()) before = crop_head_neck(sagittal_mip(self.cached_volume)) before_with_line = draw_cutoff_line(before, self.cached_volume.shape[0]) - after = preview_deform_2d(before_with_line, float(self.angle.get())) + after = preview_deform_2d( + before_with_line, + float(self.angle.get()), + transition_width=float(self.transition.get()), + gaussian_sigma=3, + ) canvas = Image.new("RGB", (1120, 610), (0, 0, 0)) draw = ImageDraw.Draw(canvas) diff --git a/video_generator_app.py b/video_generator_app.py index fd38a66..f47b720 100644 --- a/video_generator_app.py +++ b/video_generator_app.py @@ -51,7 +51,7 @@ class VideoGeneratorApp: self.max_angle = Scale( controls, from_=5, - to=30, + to=45, orient=HORIZONTAL, resolution=1, length=360, diff --git a/web_backend.py b/web_backend.py index f5ce435..5153140 100644 --- a/web_backend.py +++ b/web_backend.py @@ -360,6 +360,91 @@ def add_dicom_from_zip(target_dir, zip_filename, payload, start_index): return count +def validate_single_dicom_series(dicom_dir): + series_counts = {} + for dicom_path in Path(dicom_dir).glob("*.dcm"): + try: + ds = pydicom.dcmread(str(dicom_path), stop_before_pixels=True, force=True) + except Exception as exc: + raise RuntimeError(f"{dicom_path.name} 不是可读取的 DICOM 文件。") from exc + series_uid = str(getattr(ds, "SeriesInstanceUID", "") or "NO_SERIES_UID") + series_counts[series_uid] = series_counts.get(series_uid, 0) + 1 + + if len(series_counts) > 1: + series_summary = ", ".join(str(count) for count in sorted(series_counts.values(), reverse=True)) + raise RuntimeError( + "上传内容包含多个 DICOM 序列,不能作为一个影像库数据集导入。" + f"检测到 {len(series_counts)} 个序列,分别约 {series_summary} 张。" + "请上传单个序列文件夹/ZIP。" + ) + + +def reset_demo_environment(): + source_dir = APP_DIR / "Ori_Head_CT" + source_zip = APP_DIR / "Ori_Head_CT.zip" + demo_root = LIBRARY_DIR / "demo_ori_head_ct" + demo_dicom_dir = demo_root / "dicom" + + if not source_dir.exists() and not source_zip.exists(): + raise RuntimeError("未找到 Ori_Head_CT 或 Ori_Head_CT.zip,无法恢复演示环境。") + + safe_mkdir(LIBRARY_DIR) + for child in LIBRARY_DIR.iterdir(): + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + + safe_mkdir(demo_dicom_dir) + copied = 0 + if source_dir.exists(): + for dicom_path in sorted(source_dir.glob("*.dcm")): + shutil.copy2(dicom_path, demo_dicom_dir / dicom_path.name) + copied += 1 + else: + with zipfile.ZipFile(source_zip) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + member_name = member.filename.replace("\\", "/") + if Path(member_name).suffix.lower() != ".dcm": + continue + if Path(member_name).is_absolute() or ".." in Path(member_name).parts: + continue + copied += 1 + with archive.open(member) as member_file: + write_dicom_payload(demo_dicom_dir, Path(member_name).name, member_file.read(), copied) + + if copied == 0: + shutil.rmtree(demo_root) + raise RuntimeError("Ori_Head_CT 中没有找到 .dcm 文件。") + + validate_single_dicom_series(demo_dicom_dir) + record = build_library_record( + "demo_ori_head_ct", + "Ori_Head_CT", + demo_dicom_dir, + source="upload", + version="DICOM-DEMO", + ) + write_library_meta([record]) + DICOM_FILE_CACHE.clear() + + if RESULT_DIR.exists(): + shutil.rmtree(RESULT_DIR) + safe_mkdir(RESULT_DIR) + with JOBS_LOCK: + JOBS.clear() + persist_jobs_locked() + write_json_file(USER_TASKS_META, {}) + + return { + "ok": True, + "message": "演示环境已恢复出厂设置。", + "items": list_library(), + } + + def upload_library_item(headers, body): fields, files = parse_multipart(headers, body) dicom_files = [ @@ -398,6 +483,11 @@ def upload_library_item(headers, body): if total_dicom_count == 0: shutil.rmtree(target_dir.parent) raise RuntimeError("压缩包里没有找到 .dcm 文件。") + try: + validate_single_dicom_series(target_dir) + except Exception: + shutil.rmtree(target_dir.parent) + raise record = build_library_record( item_id, @@ -547,11 +637,26 @@ def start_job(kind, worker, owner=None, params=None, remember_user_task=True): return get_job(job_id) -def make_preview(input_dir, angle_degrees, show_cutoff_line=True): +def make_preview( + input_dir, + angle_degrees, + show_cutoff_line=True, + transition_width=90, + mode="soft_transition", + gaussian_sigma=3, +): + if mode not in {"hard_boundary", "gaussian_smooth", "soft_transition"}: + mode = "soft_transition" volume = load_dicom_volume(input_dir) before = crop_head_neck(sagittal_mip(volume)) before_display = draw_cutoff_line(before, volume.shape[0]) if show_cutoff_line else before - after = preview_deform_2d(before_display, float(angle_degrees)) + after = preview_deform_2d( + before_display, + float(angle_degrees), + mode, + transition_width=float(transition_width), + gaussian_sigma=float(gaussian_sigma), + ) canvas_image = Image.new("RGB", (1440, 520), (0, 0, 0)) canvas_image.paste(fit_image(before_display, 700, 520), (0, 0)) @@ -779,12 +884,19 @@ class Handler(BaseHTTPRequestHandler): return body = self.read_json() + if parsed.path == "/api/demo/reset": + self.send_json(reset_demo_environment()) + return + if parsed.path == "/api/preview": self.send_json( make_preview( body["inputDir"], body.get("angleDegrees", 12), bool(body.get("showCutoffLine", True)), + body.get("transitionWidth", 90), + body.get("mode", "soft_transition"), + body.get("gaussianSigma", 3), ) ) return @@ -865,13 +977,16 @@ class Handler(BaseHTTPRequestHandler): max_angle = float(body.get("maxAngle", 20)) duration = float(body.get("durationSeconds", 6)) show_arrow = bool(body.get("showArrow", True)) + mode = body.get("mode", "hard_boundary") + if mode not in {"hard_boundary", "gaussian_smooth", "soft_transition"}: + mode = "hard_boundary" def worker(job_id): job_root = RESULT_DIR / job_id reset_dir(job_root) - output_file = job_root / f"head_extension_{job_id}.mp4" - set_job(job_id, message="正在生成 0° 到目标角度的视频。") - output = generate_video(input_dir, output_file, max_angle, duration, show_arrow) + output_file = job_root / f"head_extension_{mode}_{job_id}.mp4" + set_job(job_id, message=f"正在生成 {mode} 视频。") + output = generate_video(input_dir, output_file, max_angle, duration, show_arrow, mode) output = Path(output).resolve() return { "video": {