update web workflow and preview behavior
This commit is contained in:
@@ -5,6 +5,7 @@ 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 (
|
||||
@@ -20,9 +21,41 @@ 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 video_soft_bend_2d(image, angle_degrees):
|
||||
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
|
||||
@@ -37,19 +70,7 @@ def video_soft_bend_2d(image, angle_degrees):
|
||||
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)
|
||||
|
||||
weight = video_motion_weight(height, width, mode)
|
||||
theta = np.deg2rad(angle_degrees) * weight
|
||||
cos_t = np.cos(theta)
|
||||
sin_t = np.sin(theta)
|
||||
@@ -74,8 +95,9 @@ 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(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)
|
||||
@@ -90,7 +112,7 @@ def make_frame(before_image, angle, max_angle, show_arrow=True):
|
||||
draw.text((135, 120), "Original: 0 deg", font=title_font, fill=(255, 255, 255))
|
||||
draw.text(
|
||||
(1055, 120),
|
||||
f"Head extension: {angle:04.1f} deg",
|
||||
f"{MODE_LABELS[mode]}: {angle:04.1f} deg",
|
||||
font=title_font,
|
||||
fill=(255, 255, 255),
|
||||
)
|
||||
@@ -101,22 +123,6 @@ def make_frame(before_image, angle, max_angle, show_arrow=True):
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -153,10 +159,17 @@ def parse_args():
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -177,17 +190,17 @@ def generate_video(input_dir, output_path, max_angle=20.0, duration_seconds=6.0,
|
||||
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)))
|
||||
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)))
|
||||
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)
|
||||
output_file = generate_video(args.input, args.output, args.max_angle, args.duration, not args.no_arrow, args.mode)
|
||||
print(output_file)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user