From f568faf6ed5358e9ae34ef49a6c94a94bf75cef5 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 3 May 2026 02:59:00 +0800 Subject: [PATCH] generate videos from selected z-axis sequences --- WebSite/src/App.tsx | 5 +- generate_head_extension_video.py | 116 ++++++++++--------------------- web_backend.py | 5 +- 3 files changed, 43 insertions(+), 83 deletions(-) diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index 01e25a7..d269e62 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -819,6 +819,7 @@ export default function App() { maxAngle: videoMaxAngle, durationSeconds: videoDuration, showArrow: showVideoArrow, + sourceLabel: selectedVideoSource.label, }) }) as BackendJob; setVideoJob(job); @@ -1068,8 +1069,8 @@ export default function App() {
-
最大角度{videoMaxAngle}°
- setVideoMaxAngle(parseInt(e.target.value, 10))} className="w-full accent-blue-600" /> +
播放轴向Z
+
逐层播放 DICOM 切片
时长{videoDuration}s
diff --git a/generate_head_extension_video.py b/generate_head_extension_video.py index 9190ae4..7228ed8 100644 --- a/generate_head_extension_video.py +++ b/generate_head_extension_video.py @@ -5,13 +5,11 @@ from pathlib import Path import imageio.v2 as imageio import numpy as np from PIL import Image, ImageDraw, ImageFont -from scipy.ndimage import map_coordinates from head_extension_app import ( - crop_head_neck, + ct_window, fit_image, load_dicom_volume, - sagittal_mip, ) @@ -22,47 +20,6 @@ DURATION_SECONDS = 6 END_HOLD_SECONDS = 1 -def video_soft_bend_2d(image, angle_degrees): - """Video-only 2D deformation with a broad neck transition. - - The app's fast preview uses a compact transition, which is useful for - interactive feedback but can visibly split vertebrae in animation. This - broad ramp keeps the head and upper cervical spine moving together and - blends gradually into the lower neck/shoulder region. - """ - arr = np.asarray(image.convert("L")).astype(np.float32) - height, width = arr.shape - yy, xx = np.mgrid[0:height, 0:width] - - 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) - - theta = np.deg2rad(angle_degrees) * weight - cos_t = np.cos(theta) - sin_t = np.sin(theta) - dx = xx - pivot_x - dy = yy - pivot_y - - src_x = pivot_x + cos_t * dx + sin_t * dy - src_y = pivot_y - sin_t * dx + cos_t * dy - - warped = map_coordinates(arr, [src_y, src_x], order=1, mode="constant", cval=0) - return Image.fromarray(np.clip(warped, 0, 255).astype(np.uint8)).convert("RGB") - - def get_font(size): for path in [r"C:\Windows\Fonts\arial.ttf", r"C:\Windows\Fonts\calibri.ttf"]: if os.path.exists(path): @@ -70,61 +27,48 @@ def get_font(size): return ImageFont.load_default() -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(slice_image, slice_index, slice_count, source_label, show_arrow=True): frame = Image.new("RGB", (1920, 1080), (0, 0, 0)) draw = ImageDraw.Draw(frame) - left = fit_image(before_image, 760, 720) - right = fit_image(after_image, 760, 720) - frame.paste(left, (120, 245)) - frame.paste(right, (1040, 245)) + panel = fit_image(slice_image, 1160, 860) + frame.paste(panel, (380, 145)) title_font = get_font(48) - angle_font = get_font(56) - draw.text((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255)) + small_font = get_font(28) + draw.text((410, 70), f"{source_label} DICOM z-axis sequence", font=title_font, fill=(255, 255, 255)) draw.text( - (1055, 120), - f"Head extension: {angle:04.1f} deg", - font=title_font, - fill=(255, 255, 255), + (410, 1015), + f"Slice {slice_index + 1} / {slice_count}", + font=small_font, + fill=(210, 210, 210), ) arrow = (255, 210, 60) if show_arrow: - x0, y0, x1, y1 = 1390, 405, 1515, 335 + x0, y0, x1, y1 = 1320, 275, 1450, 205 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 + bar_x, bar_y, bar_w, bar_h = 650, 1018, 870, 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 + fill_w = int(bar_w * (slice_index + 1) / slice_count) if slice_count 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 def parse_args(): parser = argparse.ArgumentParser( - description="Generate a 0-degree to target-angle head-extension MP4 video." + description="Generate a z-axis DICOM sequence MP4 video." ) parser.add_argument( "--input", @@ -140,7 +84,7 @@ def parse_args(): "--max-angle", type=float, default=20.0, - help="Target head-extension angle. Default: 20", + help="Kept for compatibility; z-axis sequence videos do not use this value.", ) parser.add_argument( "--duration", @@ -153,17 +97,22 @@ def parse_args(): action="store_true", help="Hide the yellow direction arrow.", ) + parser.add_argument( + "--source-label", + default="DICOM", + help="Label shown in the video title.", + ) 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, source_label="DICOM"): output_file = Path(output_path) output_file.parent.mkdir(parents=True, exist_ok=True) volume = load_dicom_volume(input_dir) - before_image = crop_head_neck(sagittal_mip(volume)) + slice_count = volume.shape[0] - moving_frames = int(FPS * duration_seconds) + moving_frames = max(1, int(FPS * duration_seconds)) hold_frames = FPS * END_HOLD_SECONDS with imageio.get_writer( @@ -175,19 +124,28 @@ def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0, macro_block_size=1, ) as writer: 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))) + t = index / (moving_frames - 1) if moving_frames > 1 else 0 + slice_index = min(slice_count - 1, int(round(t * (slice_count - 1)))) + slice_image = Image.fromarray(ct_window(volume[slice_index])).convert("RGB") + writer.append_data(np.asarray(make_frame(slice_image, slice_index, slice_count, source_label, show_arrow))) for _ in range(hold_frames): - writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle, show_arrow))) + slice_image = Image.fromarray(ct_window(volume[-1])).convert("RGB") + writer.append_data(np.asarray(make_frame(slice_image, slice_count - 1, slice_count, source_label, show_arrow))) 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.source_label, + ) print(output_file) diff --git a/web_backend.py b/web_backend.py index f5ce435..91cb0d6 100644 --- a/web_backend.py +++ b/web_backend.py @@ -865,13 +865,14 @@ class Handler(BaseHTTPRequestHandler): max_angle = float(body.get("maxAngle", 20)) duration = float(body.get("durationSeconds", 6)) show_arrow = bool(body.get("showArrow", True)) + source_label = body.get("sourceLabel", "DICOM") 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) + set_job(job_id, message=f"正在生成 {source_label} 的 z 轴序列视频。") + output = generate_video(input_dir, output_file, max_angle, duration, show_arrow, source_label) output = Path(output).resolve() return { "video": {