import os import argparse 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 ( crop_head_neck, fit_image, load_dicom_volume, sagittal_mip, ) 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 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 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) weight = video_motion_weight(height, width, mode) 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): return ImageFont.truetype(path, 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, 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) left = fit_image(before_image, 760, 720) right = fit_image(after_image, 760, 720) frame.paste(left, (120, 245)) frame.paste(right, (1040, 245)) title_font = get_font(48) angle_font = get_font(56) draw.text((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255)) draw.text( (1055, 120), f"{MODE_LABELS[mode]}: {angle:04.1f} deg", font=title_font, fill=(255, 255, 255), ) arrow = (255, 210, 60) if show_arrow: x0, y0, x1, y1 = 1390, 405, 1515, 335 draw.line((x0, y0, x1, y1), fill=arrow, width=8) draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow) # 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." ) parser.add_argument( "--input", default="input_ct_2F", help="Input DICOM folder. Default: input_ct_2F", ) parser.add_argument( "--output", default=str(OUTPUT_DIR / "head_extension_0_to_20deg.mp4"), help="Output MP4 file path.", ) parser.add_argument( "--max-angle", type=float, default=20.0, help="Target head-extension angle. Default: 20", ) parser.add_argument( "--duration", type=float, default=DURATION_SECONDS, help="Animation duration in seconds before final hold. Default: 6", ) parser.add_argument( "--no-arrow", 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, mode="soft_transition"): mode = normalize_mode(mode) 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)) moving_frames = int(FPS * duration_seconds) hold_frames = FPS * END_HOLD_SECONDS with imageio.get_writer( output_file, format="FFMPEG", fps=FPS, codec="libx264", quality=8, 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, mode))) for _ in range(hold_frames): 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, args.mode) print(output_file) if __name__ == "__main__": main()