Revert "generate videos from selected z-axis sequences"
This reverts commit f568faf6ed.
This commit is contained in:
@@ -819,7 +819,6 @@ export default function App() {
|
|||||||
maxAngle: videoMaxAngle,
|
maxAngle: videoMaxAngle,
|
||||||
durationSeconds: videoDuration,
|
durationSeconds: videoDuration,
|
||||||
showArrow: showVideoArrow,
|
showArrow: showVideoArrow,
|
||||||
sourceLabel: selectedVideoSource.label,
|
|
||||||
})
|
})
|
||||||
}) as BackendJob;
|
}) as BackendJob;
|
||||||
setVideoJob(job);
|
setVideoJob(job);
|
||||||
@@ -1069,8 +1068,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span>播放轴向</span><span>Z</span></div>
|
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span>最大角度</span><span>{videoMaxAngle}°</span></div>
|
||||||
<div className="h-5 flex items-center text-[10px] font-bold text-slate-400">逐层播放 DICOM 切片</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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span>时长</span><span>{videoDuration}s</span></div>
|
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span>时长</span><span>{videoDuration}s</span></div>
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ from pathlib import Path
|
|||||||
import imageio.v2 as imageio
|
import imageio.v2 as imageio
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
||||||
from head_extension_app import (
|
from head_extension_app import (
|
||||||
ct_window,
|
crop_head_neck,
|
||||||
fit_image,
|
fit_image,
|
||||||
load_dicom_volume,
|
load_dicom_volume,
|
||||||
|
sagittal_mip,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +22,47 @@ DURATION_SECONDS = 6
|
|||||||
END_HOLD_SECONDS = 1
|
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):
|
def get_font(size):
|
||||||
for path in [r"C:\Windows\Fonts\arial.ttf", r"C:\Windows\Fonts\calibri.ttf"]:
|
for path in [r"C:\Windows\Fonts\arial.ttf", r"C:\Windows\Fonts\calibri.ttf"]:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
@@ -27,48 +70,61 @@ def get_font(size):
|
|||||||
return ImageFont.load_default()
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
def make_frame(slice_image, slice_index, slice_count, source_label, show_arrow=True):
|
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)
|
||||||
|
|
||||||
frame = Image.new("RGB", (1920, 1080), (0, 0, 0))
|
frame = Image.new("RGB", (1920, 1080), (0, 0, 0))
|
||||||
draw = ImageDraw.Draw(frame)
|
draw = ImageDraw.Draw(frame)
|
||||||
|
|
||||||
panel = fit_image(slice_image, 1160, 860)
|
left = fit_image(before_image, 760, 720)
|
||||||
frame.paste(panel, (380, 145))
|
right = fit_image(after_image, 760, 720)
|
||||||
|
frame.paste(left, (120, 245))
|
||||||
|
frame.paste(right, (1040, 245))
|
||||||
|
|
||||||
title_font = get_font(48)
|
title_font = get_font(48)
|
||||||
small_font = get_font(28)
|
angle_font = get_font(56)
|
||||||
draw.text((410, 70), f"{source_label} DICOM z-axis sequence", font=title_font, fill=(255, 255, 255))
|
draw.text((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255))
|
||||||
draw.text(
|
draw.text(
|
||||||
(410, 1015),
|
(1055, 120),
|
||||||
f"Slice {slice_index + 1} / {slice_count}",
|
f"Head extension: {angle:04.1f} deg",
|
||||||
font=small_font,
|
font=title_font,
|
||||||
fill=(210, 210, 210),
|
fill=(255, 255, 255),
|
||||||
)
|
)
|
||||||
|
|
||||||
arrow = (255, 210, 60)
|
arrow = (255, 210, 60)
|
||||||
if show_arrow:
|
if show_arrow:
|
||||||
x0, y0, x1, y1 = 1320, 275, 1450, 205
|
x0, y0, x1, y1 = 1390, 405, 1515, 335
|
||||||
draw.line((x0, y0, x1, y1), fill=arrow, width=8)
|
draw.line((x0, y0, x1, y1), fill=arrow, width=8)
|
||||||
draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow)
|
draw.polygon([(x1, y1), (x1 - 34, y1 + 3), (x1 - 12, y1 + 29)], fill=arrow)
|
||||||
|
|
||||||
bar_x, bar_y, bar_w, bar_h = 650, 1018, 870, 12
|
# Minimal progress bar.
|
||||||
|
bar_x, bar_y, bar_w, bar_h = 1040, 980, 760, 12
|
||||||
draw.rounded_rectangle(
|
draw.rounded_rectangle(
|
||||||
(bar_x, bar_y, bar_x + bar_w, bar_y + bar_h),
|
(bar_x, bar_y, bar_x + bar_w, bar_y + bar_h),
|
||||||
radius=6,
|
radius=6,
|
||||||
fill=(70, 70, 70),
|
fill=(70, 70, 70),
|
||||||
)
|
)
|
||||||
fill_w = int(bar_w * (slice_index + 1) / slice_count) if slice_count else 0
|
fill_w = int(bar_w * angle / max_angle) if max_angle else 0
|
||||||
draw.rounded_rectangle(
|
draw.rounded_rectangle(
|
||||||
(bar_x, bar_y, bar_x + fill_w, bar_y + bar_h),
|
(bar_x, bar_y, bar_x + fill_w, bar_y + bar_h),
|
||||||
radius=6,
|
radius=6,
|
||||||
fill=arrow,
|
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
|
return frame
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Generate a z-axis DICOM sequence MP4 video."
|
description="Generate a 0-degree to target-angle head-extension MP4 video."
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--input",
|
"--input",
|
||||||
@@ -84,7 +140,7 @@ def parse_args():
|
|||||||
"--max-angle",
|
"--max-angle",
|
||||||
type=float,
|
type=float,
|
||||||
default=20.0,
|
default=20.0,
|
||||||
help="Kept for compatibility; z-axis sequence videos do not use this value.",
|
help="Target head-extension angle. Default: 20",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--duration",
|
"--duration",
|
||||||
@@ -97,22 +153,17 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Hide the yellow direction arrow.",
|
help="Hide the yellow direction arrow.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--source-label",
|
|
||||||
default="DICOM",
|
|
||||||
help="Label shown in the video title.",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0, show_arrow=True, source_label="DICOM"):
|
def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0, show_arrow=True):
|
||||||
output_file = Path(output_path)
|
output_file = Path(output_path)
|
||||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
volume = load_dicom_volume(input_dir)
|
volume = load_dicom_volume(input_dir)
|
||||||
slice_count = volume.shape[0]
|
before_image = crop_head_neck(sagittal_mip(volume))
|
||||||
|
|
||||||
moving_frames = max(1, int(FPS * duration_seconds))
|
moving_frames = int(FPS * duration_seconds)
|
||||||
hold_frames = FPS * END_HOLD_SECONDS
|
hold_frames = FPS * END_HOLD_SECONDS
|
||||||
|
|
||||||
with imageio.get_writer(
|
with imageio.get_writer(
|
||||||
@@ -124,28 +175,19 @@ def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0,
|
|||||||
macro_block_size=1,
|
macro_block_size=1,
|
||||||
) as writer:
|
) as writer:
|
||||||
for index in range(moving_frames):
|
for index in range(moving_frames):
|
||||||
t = index / (moving_frames - 1) if moving_frames > 1 else 0
|
t = index / (moving_frames - 1)
|
||||||
slice_index = min(slice_count - 1, int(round(t * (slice_count - 1))))
|
angle = max_angle * smoothstep(t)
|
||||||
slice_image = Image.fromarray(ct_window(volume[slice_index])).convert("RGB")
|
writer.append_data(np.asarray(make_frame(before_image, angle, max_angle, show_arrow)))
|
||||||
writer.append_data(np.asarray(make_frame(slice_image, slice_index, slice_count, source_label, show_arrow)))
|
|
||||||
|
|
||||||
for _ in range(hold_frames):
|
for _ in range(hold_frames):
|
||||||
slice_image = Image.fromarray(ct_window(volume[-1])).convert("RGB")
|
writer.append_data(np.asarray(make_frame(before_image, max_angle, max_angle, show_arrow)))
|
||||||
writer.append_data(np.asarray(make_frame(slice_image, slice_count - 1, slice_count, source_label, show_arrow)))
|
|
||||||
|
|
||||||
return output_file.resolve()
|
return output_file.resolve()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
output_file = generate_video(
|
output_file = generate_video(args.input, args.output, args.max_angle, args.duration, not args.no_arrow)
|
||||||
args.input,
|
|
||||||
args.output,
|
|
||||||
args.max_angle,
|
|
||||||
args.duration,
|
|
||||||
not args.no_arrow,
|
|
||||||
args.source_label,
|
|
||||||
)
|
|
||||||
print(output_file)
|
print(output_file)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -865,14 +865,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
max_angle = float(body.get("maxAngle", 20))
|
max_angle = float(body.get("maxAngle", 20))
|
||||||
duration = float(body.get("durationSeconds", 6))
|
duration = float(body.get("durationSeconds", 6))
|
||||||
show_arrow = bool(body.get("showArrow", True))
|
show_arrow = bool(body.get("showArrow", True))
|
||||||
source_label = body.get("sourceLabel", "DICOM")
|
|
||||||
|
|
||||||
def worker(job_id):
|
def worker(job_id):
|
||||||
job_root = RESULT_DIR / job_id
|
job_root = RESULT_DIR / job_id
|
||||||
reset_dir(job_root)
|
reset_dir(job_root)
|
||||||
output_file = job_root / f"head_extension_{job_id}.mp4"
|
output_file = job_root / f"head_extension_{job_id}.mp4"
|
||||||
set_job(job_id, message=f"正在生成 {source_label} 的 z 轴序列视频。")
|
set_job(job_id, message="正在生成 0° 到目标角度的视频。")
|
||||||
output = generate_video(input_dir, output_file, max_angle, duration, show_arrow, source_label)
|
output = generate_video(input_dir, output_file, max_angle, duration, show_arrow)
|
||||||
output = Path(output).resolve()
|
output = Path(output).resolve()
|
||||||
return {
|
return {
|
||||||
"video": {
|
"video": {
|
||||||
|
|||||||
Reference in New Issue
Block a user