generate videos from selected z-axis sequences

This commit is contained in:
2026-05-03 02:59:00 +08:00
parent 5ba2d48fdb
commit f568faf6ed
3 changed files with 43 additions and 83 deletions

View File

@@ -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() {
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span></span><span>{videoMaxAngle}°</span></div>
<input type="range" min="5" max="30" step="1" value={videoMaxAngle} onChange={e => setVideoMaxAngle(parseInt(e.target.value, 10))} className="w-full accent-blue-600" />
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span></span><span>Z</span></div>
<div className="h-5 flex items-center text-[10px] font-bold text-slate-400"> DICOM </div>
</div>
<div>
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span></span><span>{videoDuration}s</span></div>

View File

@@ -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)

View File

@@ -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": {