时长{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": {