This commit is contained in:
mike
2026-06-27 00:39:32 +02:00
parent 61cd2e559b
commit 78ffb029b9
34 changed files with 22267 additions and 17 deletions

433
tour_comfy/orbit_module.py Normal file
View File

@@ -0,0 +1,433 @@
"""
orbit_module.py — 2.5D actor orbit preview via depth-card parallax.
Pipeline:
1. load actor image — use provided path directly (selection is caller's responsibility)
2. create_depth_map — fake depth from alpha mask distance transform
3. find_bg_plate — static background for compositing (original for nobg; blurred for opaque)
4. render_orbit — per-frame: parallax-warp actor, composite over static bg
5. save_orbit_output — write RGBA PNGs + MP4
The "orbit" illusion requires a static reference (background plate).
Without it the viewer has nothing to anchor on and it reads as a side-slide.
Usage:
from orbit_module import run_orbit_pipeline
result = run_orbit_pipeline(image_path, output_dir)
CLI: see orbit_poc.py
"""
import os
import math
import shutil
import subprocess
import tempfile
import cv2
import numpy as np
from scipy.ndimage import distance_transform_edt
__all__ = [
"create_depth_map",
"render_orbit_frame",
"render_orbit",
"save_orbit_output",
"run_orbit_pipeline",
]
# ---------------------------------------------------------------------------
# Image loading helpers
# ---------------------------------------------------------------------------
def _load_rgba(path: str) -> np.ndarray:
"""Load any image as RGBA uint8 H×W×4."""
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
if img is None:
raise FileNotFoundError(f"Cannot read image: {path}")
if img.ndim == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGRA)
elif img.shape[2] == 3:
img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
return cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
def _has_real_alpha(rgba: np.ndarray) -> bool:
"""True if the image contains meaningful transparency (not just all-255)."""
alpha = rgba[:, :, 3]
transparent_pct = float((alpha < 32).mean())
return transparent_pct > 0.05 # >5% transparent pixels = real alpha
# ---------------------------------------------------------------------------
# Background plate
# ---------------------------------------------------------------------------
def _find_original_for_nobg(actor_path: str) -> str | None:
"""
Given a .nobg.png sidecar path, find the original opaque image.
e.g. foo.nobg.png → foo.png or foo.jpg
"""
root, _ = os.path.splitext(actor_path)
if not root.endswith(".nobg"):
return None
base = root[: -len(".nobg")]
for ext in (".png", ".jpg", ".jpeg", ".webp"):
p = base + ext
if os.path.exists(p):
return p
return None
def _make_bg_plate(actor_rgba: np.ndarray) -> np.ndarray:
"""
Build a static background plate for opaque images (no nobg available).
Strategy: blur the source image with a large kernel. The blurred copy stays
fixed while the sharp actor layer shifts — creates subtle depth separation.
"""
H, W = actor_rgba.shape[:2]
# Large blur: simulates out-of-focus background
blurred_rgb = cv2.GaussianBlur(actor_rgba[:, :, :3], (0, 0), max(H, W) * 0.04)
plate = np.dstack([blurred_rgb, np.full((H, W), 255, dtype=np.uint8)])
return plate.astype(np.uint8)
def get_bg_plate(actor_path: str, actor_rgba: np.ndarray) -> np.ndarray:
"""
Return the background plate for the orbit:
- .nobg.png: load the matching original opaque image (best result)
- Opaque image: return blurred copy (subtle but functional)
Plate is resized to match actor_rgba dimensions.
"""
H, W = actor_rgba.shape[:2]
orig = _find_original_for_nobg(actor_path)
if orig:
bg = _load_rgba(orig)
if bg.shape[:2] != (H, W):
bg = cv2.resize(bg, (W, H), interpolation=cv2.INTER_AREA)
bg[:, :, 3] = 255 # ensure fully opaque background
return bg
return _make_bg_plate(actor_rgba)
# ---------------------------------------------------------------------------
# Depth map
# ---------------------------------------------------------------------------
def create_depth_map(image_rgba: np.ndarray) -> np.ndarray:
"""
Float32 H×W depth in [0,1]. 1 = closest (subject centre), 0 = far/background.
Uses the alpha mask distance transform:
- For transparent-bg images: EDT of the foreground mask (subject body)
- For opaque images: EDT from image edges (assumes subject is centred)
Power-law shaping (^0.5) keeps the gradient gradual near the centre.
"""
alpha = image_rgba[:, :, 3]
mask = (alpha > 32).astype(np.uint8)
if mask.sum() == 0:
return np.zeros(alpha.shape, dtype=np.float32)
dist = distance_transform_edt(mask).astype(np.float32)
max_d = dist.max()
if max_d > 0:
dist /= max_d
return np.sqrt(dist)
# ---------------------------------------------------------------------------
# Orbit rendering
# ---------------------------------------------------------------------------
def _alpha_composite(fg: np.ndarray, bg: np.ndarray) -> np.ndarray:
"""Alpha-composite RGBA fg over RGBA bg. Returns RGBA uint8."""
a = fg[:, :, 3:4].astype(np.float32) / 255.0
out_rgb = fg[:, :, :3].astype(np.float32) * a + bg[:, :, :3].astype(np.float32) * (1.0 - a)
out_a = fg[:, :, 3:4].astype(np.float32) + bg[:, :, 3:4].astype(np.float32) * (1.0 - a)
return np.dstack([out_rgb.clip(0, 255), out_a.clip(0, 255)]).astype(np.uint8)
def render_orbit_frame(
actor_rgba: np.ndarray,
depth: np.ndarray,
theta: float,
parallax_strength: float = 0.08,
bg_rgba: np.ndarray | None = None,
) -> np.ndarray:
"""
Swing-mode frame: depth-based parallax shift only.
Closer pixels (depth≈1) shift more than far pixels (depth≈0).
Composited over static bg plate for perceivable depth.
Returns RGBA uint8 H×W×4.
"""
H, W = actor_rgba.shape[:2]
shift_x = depth * (W * parallax_strength * math.sin(theta))
shift_y = depth * (H * parallax_strength * 0.03 * -math.cos(theta))
yc, xc = np.mgrid[0:H, 0:W].astype(np.float32)
map_x = (xc - shift_x).astype(np.float32)
map_y = (yc - shift_y).astype(np.float32)
bgra = cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA)
warped_bgra = cv2.remap(bgra, map_x, map_y,
interpolation=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0, 0))
warped = cv2.cvtColor(warped_bgra, cv2.COLOR_BGRA2RGBA)
if bg_rgba is None:
return warped
return _alpha_composite(warped, bg_rgba)
def _perspective_card_frame(
bgra_src: np.ndarray,
theta: float,
) -> np.ndarray:
"""
Orbit-mode frame: simulate a flat card rotating around its vertical axis.
- Squishes width by |cos(θ)|, centred on canvas
- Mirrors source for the back half (cos < 0) so the "back" is visible
- Returns BGRA with transparent borders (ready for bg composite)
At θ=0° → full width, unmirrored (front face)
At θ=90° → hair-thin line
At θ=180°→ full width, mirrored (back face)
"""
H, W = bgra_src.shape[:2]
cos_t = math.cos(theta)
compress = abs(cos_t)
is_back = cos_t < 0
if compress < 0.025:
# Near-edge-on: return a one-pixel-wide vertical strip to avoid singularity
out = np.zeros_like(bgra_src)
mid = W // 2
out[:, mid:mid+1] = bgra_src[:, mid:mid+1]
return out
new_w = max(int(round(W * compress)), 2)
x0 = (W - new_w) // 2
x1 = x0 + new_w
# Source corners: mirror left↔right for the back face
if is_back:
src = np.float32([[W, 0], [0, 0], [0, H], [W, H]])
else:
src = np.float32([[0, 0], [W, 0], [W, H], [0, H]])
dst = np.float32([[x0, 0], [x1, 0], [x1, H], [x0, H]])
M = cv2.getPerspectiveTransform(src, dst)
return cv2.warpPerspective(bgra_src, M, (W, H),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0, 0))
def render_orbit(
actor_rgba: np.ndarray,
depth: np.ndarray,
n_frames: int = 36,
parallax_strength: float = 0.08,
mode: str = "swing",
max_angle_deg: float = 35.0,
bg_rgba: np.ndarray | None = None,
) -> list:
"""
Render all orbit frames.
mode='swing' — sinusoidal ±max_angle_deg depth-parallax, loops cleanly
mode='orbit' — full 360° perspective card rotation (compress + mirror)
Returns list of RGBA uint8 frames.
"""
if mode == "swing":
max_rad = math.radians(max_angle_deg)
angles = [max_rad * math.sin(2 * math.pi * i / n_frames) for i in range(n_frames)]
return [render_orbit_frame(actor_rgba, depth, theta, parallax_strength, bg_rgba)
for theta in angles]
elif mode == "orbit":
# Don't use the photo bg plate — the transparent areas should stay transparent
# so the perspective compression (card getting thin at 90°, mirrored at 180°)
# is clearly visible. Solid bg is added at MP4 write time.
angles = [2 * math.pi * i / n_frames for i in range(n_frames)]
bgra_src = cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA)
return [
cv2.cvtColor(_perspective_card_frame(bgra_src, theta), cv2.COLOR_BGRA2RGBA)
for theta in angles
]
else:
raise ValueError(f"Unknown mode: {mode!r}")
# ---------------------------------------------------------------------------
# Output saving
# ---------------------------------------------------------------------------
def _composite_over_solid(frame_rgba: np.ndarray, bg: tuple = (18, 18, 18)) -> np.ndarray:
"""Alpha-composite RGBA over a solid colour; return BGR uint8 for ffmpeg."""
rgb = frame_rgba[:, :, :3].astype(np.float32)
a = frame_rgba[:, :, 3:4].astype(np.float32) / 255.0
bg_f = np.array(bg, dtype=np.float32)
out = (rgb * a + bg_f * (1.0 - a)).clip(0, 255).astype(np.uint8)
return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
def save_orbit_output(
frames: list,
output_dir: str,
fps: int = 24,
bg_color: tuple = (18, 18, 18),
) -> dict:
"""
Write orbit_frames/frame_NNN.png (RGBA) and orbit_preview.mp4.
Returns dict with paths.
"""
frames_dir = os.path.join(output_dir, "orbit_frames")
os.makedirs(frames_dir, exist_ok=True)
frame_paths = []
for i, frame in enumerate(frames):
path = os.path.join(frames_dir, f"frame_{i:03d}.png")
cv2.imwrite(path, cv2.cvtColor(frame, cv2.COLOR_RGBA2BGRA))
frame_paths.append(path)
video_path = os.path.join(output_dir, "orbit_preview.mp4")
_frames_to_mp4(frames, video_path, fps=fps, bg_color=bg_color)
return {
"frames_dir": frames_dir,
"n_frames": len(frames),
"video_path": video_path,
"frame_paths": frame_paths,
}
def _frames_to_mp4(
frames: list, output_path: str, fps: int = 24, bg_color: tuple = (18, 18, 18)
) -> None:
"""Composite frames over solid bg, write MP4 via ffmpeg."""
if not frames:
return
with tempfile.TemporaryDirectory(prefix="orbit_mp4_") as tmpdir:
for i, frame in enumerate(frames):
bgr = _composite_over_solid(frame, bg_color)
cv2.imwrite(
os.path.join(tmpdir, f"frame_{i:04d}.jpg"), bgr,
[cv2.IMWRITE_JPEG_QUALITY, 95],
)
H, W = frames[0].shape[:2]
W2, H2 = W - (W % 2), H - (H % 2)
cmd = [
"ffmpeg", "-y",
"-framerate", str(fps),
"-i", os.path.join(tmpdir, "frame_%04d.jpg"),
"-vf", f"crop={W2}:{H2}:0:0",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-crf", "18", "-movflags", "+faststart",
output_path,
]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {r.stderr[-600:]}")
# ---------------------------------------------------------------------------
# Debug helpers
# ---------------------------------------------------------------------------
def _save_debug(actor_rgba, actor_path, bg_rgba, debug_dir):
os.makedirs(debug_dir, exist_ok=True)
if os.path.exists(actor_path):
shutil.copy2(actor_path, os.path.join(debug_dir, "selected_frame.png"))
cv2.imwrite(os.path.join(debug_dir, "actor_rgba.png"),
cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA))
cv2.imwrite(os.path.join(debug_dir, "mask.png"), actor_rgba[:, :, 3])
if bg_rgba is not None:
cv2.imwrite(os.path.join(debug_dir, "bg_plate.png"),
cv2.cvtColor(bg_rgba, cv2.COLOR_RGBA2BGRA))
def _save_depth_debug(depth, debug_dir):
os.makedirs(debug_dir, exist_ok=True)
d8 = (depth * 255).astype(np.uint8)
cv2.imwrite(os.path.join(debug_dir, "depth.png"), d8)
cv2.imwrite(os.path.join(debug_dir, "depth_colorized.png"),
cv2.applyColorMap(d8, cv2.COLORMAP_MAGMA))
# ---------------------------------------------------------------------------
# Full pipeline
# ---------------------------------------------------------------------------
def run_orbit_pipeline(
image_path: str,
output_dir: str,
n_frames: int = 36,
parallax_strength: float = 0.08,
mode: str = "swing",
fps: int = 24,
max_angle_deg: float = 35.0,
debug: bool = True,
) -> dict:
"""
Full pipeline: load → bg-plate → depth → render → save.
image_path: the specific image to orbit (caller selects; no sharpness heuristic)
Returns dict: actor_path, frames_dir, video_path, n_frames, debug_dir, has_alpha, has_bg
"""
os.makedirs(output_dir, exist_ok=True)
debug_dir = os.path.join(output_dir, "debug")
# 1. Load actor — prefer nobg sidecar for cleaner depth
actor_path = image_path
root, _ = os.path.splitext(image_path)
nobg_candidate = root + ".nobg.png"
if not root.endswith(".nobg") and os.path.exists(nobg_candidate):
actor_path = nobg_candidate
actor_rgba = _load_rgba(actor_path)
has_alpha = _has_real_alpha(actor_rgba)
# 2. Background plate (static reference — essential for perceivable depth)
bg_rgba = get_bg_plate(actor_path, actor_rgba)
has_bg = bg_rgba is not None
if debug:
_save_debug(actor_rgba, actor_path, bg_rgba, debug_dir)
# 3. Depth map
depth = create_depth_map(actor_rgba)
if debug:
_save_depth_debug(depth, debug_dir)
# 4. Render
frames = render_orbit(
actor_rgba, depth,
n_frames=n_frames,
parallax_strength=parallax_strength,
mode=mode,
max_angle_deg=max_angle_deg,
bg_rgba=bg_rgba,
)
# 5. Save
result = save_orbit_output(frames, output_dir, fps=fps)
result.update({
"actor_path": actor_path,
"debug_dir": debug_dir,
"has_alpha": has_alpha,
"has_bg": has_bg,
})
return result