312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""⑩ hybrid — 생성 클립 아트워크 위에 원본 고정층을 다시 덮는다.
|
|
|
|
Kling 단독은 생동감이 있지만 제목대가 7~17 드리프트하고, 레이어 단독은 글자가 0px이지만
|
|
훅이 약하다. 그래서 Kling을 아트워크 층으로 쓰고 원본에서 오려낸 마스크를 위에 얹는다.
|
|
⑦ render의 QR·로고바 패치를 마스크 전체로 일반화한 것이고 추가 크레딧은 0이다.
|
|
"""
|
|
import io
|
|
import math
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
from models.hybrid import HybridResult, LayerConfig, Rect
|
|
from utils.video import decode_frames, encode_mp4
|
|
|
|
FPS = 24
|
|
DURATION = 8.0
|
|
HOLD_SECONDS = 0.5 # 첫 0.5초는 원본 정지 — 첫 프레임이 곧 썸네일이다
|
|
CRF = 17
|
|
|
|
FEATHER_PX = 30
|
|
SIL_DARK_MAX = 120
|
|
GLYPH_BRIGHT_MIN = 165
|
|
SWEEP_SECONDS = 0.8
|
|
SWEEP_GAIN = 120
|
|
GLOW_SECONDS = 0.9
|
|
GLOW_GAIN = 110
|
|
PROOF_COUNT = 8
|
|
PROOF_SIZE = (300, 400)
|
|
|
|
|
|
def ease_sine(t: float) -> float:
|
|
return 0.5 - 0.5 * math.cos(math.pi * min(max(t, 0.0), 1.0))
|
|
|
|
|
|
def punch_scale(t: float, peak: float) -> float:
|
|
"""1.0 → peak(0.22s) → 유지(0.12s) → 0.96(0.18s, 지나침) → 1.0. 오버슈트가 팝의 정체다."""
|
|
if t < 0 or t > 0.64:
|
|
return 1.0
|
|
if t < 0.22:
|
|
return 1.0 + (peak - 1.0) * (1 - (1 - t / 0.22) ** 3)
|
|
if t < 0.34:
|
|
return peak
|
|
if t < 0.52:
|
|
return peak + (0.96 - peak) * ease_sine((t - 0.34) / 0.18)
|
|
return 0.96 + 0.04 * ease_sine((t - 0.52) / 0.12)
|
|
|
|
|
|
def to_pixels(rect: Rect, size: tuple[int, int]) -> tuple[int, int, int, int]:
|
|
width, height = size
|
|
return (int(rect[0] * width), int(rect[1] * height),
|
|
int(rect[2] * width), int(rect[3] * height))
|
|
|
|
|
|
def feather_rect_mask(size: tuple[int, int], rect: Rect) -> Image.Image:
|
|
mask = Image.new("L", size, 0)
|
|
ImageDraw.Draw(mask).rectangle(to_pixels(rect, size), fill=255)
|
|
return mask.filter(ImageFilter.GaussianBlur(FEATHER_PX / 2))
|
|
|
|
|
|
def glyph_alpha(poster: Image.Image, rect: Rect, dark_ink: bool = True) -> Image.Image:
|
|
"""사각 안의 글자 픽셀만 알파로. 펀치 중 이웃을 원본에서 다시 얹을 때 쓴다.
|
|
|
|
dark_ink는 어두운 베벨·그림자도 글자로 본다(밝은 배경 포스터).
|
|
검정 포스터에서 켜면 배경 전체가 글자가 되어 검은 상자가 된다.
|
|
"""
|
|
width, height = poster.size
|
|
x0, y0, x1, y1 = to_pixels(rect, poster.size)
|
|
luminance = np.asarray(poster.convert("L")).astype(np.float32)
|
|
mask = np.zeros((height, width), np.uint8)
|
|
patch = luminance[y0:y1, x0:x1]
|
|
mask[y0:y1, x0:x1] = (((patch > GLYPH_BRIGHT_MIN) | (patch < 75)) * 255 if dark_ink
|
|
else (patch > 55) * 255)
|
|
return Image.fromarray(mask).filter(ImageFilter.MaxFilter(13)).filter(
|
|
ImageFilter.GaussianBlur(2))
|
|
|
|
|
|
def build_masks(poster: Image.Image, config: LayerConfig) -> dict:
|
|
width, height = poster.size
|
|
pixels = np.asarray(poster).astype(np.float32)
|
|
luminance = pixels.mean(axis=2)
|
|
|
|
fixed = np.zeros((height, width), np.float32)
|
|
for rect in config.fixed_rects:
|
|
fixed = np.maximum(fixed, np.asarray(feather_rect_mask(poster.size, rect)) / 255.0)
|
|
|
|
x0, y0, x1, y1 = to_pixels(config.sil_box, poster.size)
|
|
silhouette = np.zeros((height, width), np.uint8)
|
|
silhouette[y0:y1, x0:x1] = (pixels.max(axis=2) < SIL_DARK_MAX)[y0:y1, x0:x1] * 255
|
|
if config.sil_poly:
|
|
# 밝은 의상은 어두운 픽셀 임계로 안 잡혀서 다각형과 합친다
|
|
polygon = Image.new("L", poster.size, 0)
|
|
ImageDraw.Draw(polygon).polygon(
|
|
[(int(x * width), int(y * height)) for x, y in config.sil_poly], fill=255)
|
|
silhouette = np.maximum(silhouette, np.asarray(polygon))
|
|
silhouette = np.asarray(Image.fromarray(silhouette)
|
|
.filter(ImageFilter.MaxFilter(7))
|
|
.filter(ImageFilter.GaussianBlur(4))) / 255.0
|
|
|
|
glyphs = {}
|
|
for name, rect in config.glyphs.items():
|
|
gx0, gy0, gx1, gy1 = to_pixels(rect, poster.size)
|
|
patch = np.zeros((height, width), np.uint8)
|
|
patch[gy0:gy1, gx0:gx1] = (luminance[gy0:gy1, gx0:gx1] > GLYPH_BRIGHT_MIN) * 255
|
|
glyphs[name] = np.asarray(
|
|
Image.fromarray(patch).filter(ImageFilter.GaussianBlur(1.2))) / 255.0
|
|
|
|
glyph_union = (np.max(np.stack(list(glyphs.values())), axis=0) if glyphs
|
|
else np.zeros((height, width), np.float32))
|
|
hold = np.clip(fixed + silhouette + glyph_union, 0, 1)
|
|
|
|
if config.mask.mode == "glyph":
|
|
hold = glyph_hold_mask(poster, config)
|
|
silhouette = np.zeros_like(silhouette)
|
|
return {"hold": hold, "glyph": glyphs, "sil": silhouette}
|
|
|
|
|
|
def glyph_hold_mask(poster: Image.Image, config: LayerConfig) -> np.ndarray:
|
|
"""검정 포스터용. 사각 고정층이 검은 상자가 되므로 글자 픽셀만 얇게 잡는다."""
|
|
width, height = poster.size
|
|
spec = config.mask
|
|
luminance = np.asarray(poster.convert("L")).astype(np.float32)
|
|
thin = np.zeros((height, width), np.uint8)
|
|
thick = np.zeros((height, width), np.uint8)
|
|
for rect in config.fixed_rects:
|
|
x0, y0, x1, y1 = to_pixels(rect, poster.size)
|
|
target = thick if rect[1] >= spec.thick_from_y else thin
|
|
target[y0:y1, x0:x1] = (luminance[y0:y1, x0:x1] > spec.ink_min) * 255
|
|
thin_mask = Image.fromarray(thin).filter(
|
|
ImageFilter.MaxFilter(spec.thin_dilate)).filter(ImageFilter.GaussianBlur(2))
|
|
thick_mask = Image.fromarray(thick).filter(
|
|
ImageFilter.MaxFilter(spec.thick_dilate)).filter(ImageFilter.GaussianBlur(3))
|
|
return np.maximum(np.asarray(thin_mask), np.asarray(thick_mask)) / 255.0
|
|
|
|
|
|
def pop_rect(frame: Image.Image, poster: Image.Image, rect: Rect, scale: float) -> None:
|
|
"""원본의 사각 영역을 오려 중심 기준으로 배율을 먹여 덮는다."""
|
|
x0, y0, x1, y1 = to_pixels(rect, poster.size)
|
|
crop = poster.crop((x0, y0, x1, y1))
|
|
scaled_w, scaled_h = int(crop.width * scale), int(crop.height * scale)
|
|
crop = crop.resize((scaled_w, scaled_h), Image.LANCZOS)
|
|
mask = Image.new("L", crop.size, 0)
|
|
ImageDraw.Draw(mask).rectangle((6, 6, scaled_w - 7, scaled_h - 7), fill=255)
|
|
mask = mask.filter(ImageFilter.GaussianBlur(4))
|
|
frame.paste(crop, ((x0 + x1) // 2 - scaled_w // 2, (y0 + y1) // 2 - scaled_h // 2), mask)
|
|
|
|
|
|
def register(frame: np.ndarray, base: np.ndarray, weight: np.ndarray,
|
|
previous=(1.0, 0, 0)) -> tuple[np.ndarray, tuple, float]:
|
|
"""생성 클립을 원본에 정합한다.
|
|
|
|
모델이 프레임 전체를 2~3% 드리프트시키므로, 정합 없이 글자만 얇게 고정하면
|
|
클립의 글자가 옆에 비쳐 겹쳐 보인다. 고정층 영역에서 MAE가 최소인 스케일·이동을 찾는다.
|
|
"""
|
|
height, width = base.shape[:2]
|
|
step = 6 # 1/6 축소에서 탐색
|
|
small = (width // step, height // step)
|
|
base_small = np.asarray(Image.fromarray(base.astype(np.uint8)).resize(
|
|
small, Image.BILINEAR), np.float32)
|
|
frame_small = Image.fromarray(frame.astype(np.uint8)).resize(small, Image.BILINEAR)
|
|
weight_small = np.asarray(Image.fromarray((weight * 255).astype(np.uint8)).resize(
|
|
small, Image.BILINEAR), np.float32) / 255.0
|
|
weight_sum = weight_small.sum() + 1e-6
|
|
small_h, small_w = base_small.shape[:2]
|
|
|
|
def cost(scale, dx, dy):
|
|
inverse = 1 / scale
|
|
moved = frame_small.transform(
|
|
frame_small.size, Image.AFFINE,
|
|
(inverse, 0, small_w / 2 * (1 - inverse) - dx / step * inverse,
|
|
0, inverse, small_h / 2 * (1 - inverse) - dy / step * inverse), Image.BILINEAR)
|
|
diff = np.abs(np.asarray(moved, np.float32) - base_small).mean(axis=2)
|
|
return (diff * weight_small).sum() / weight_sum
|
|
|
|
best = (cost(*previous), *previous)
|
|
for scale in (previous[0] - 0.02, previous[0] - 0.01, previous[0],
|
|
previous[0] + 0.01, previous[0] + 0.02):
|
|
for dx in range(previous[1] - 24, previous[1] + 25, 8):
|
|
for dy in range(previous[2] - 24, previous[2] + 25, 8):
|
|
score = cost(scale, dx, dy)
|
|
if score < best[0]:
|
|
best = (score, scale, dx, dy)
|
|
_, scale, dx, dy = best
|
|
for fine_scale in (scale - 0.005, scale, scale + 0.005):
|
|
for fine_dx in range(dx - 4, dx + 5, 2):
|
|
for fine_dy in range(dy - 4, dy + 5, 2):
|
|
score = cost(fine_scale, fine_dx, fine_dy)
|
|
if score < best[0]:
|
|
best = (score, fine_scale, fine_dx, fine_dy)
|
|
_, scale, dx, dy = best
|
|
|
|
inverse = 1 / scale
|
|
warped = Image.fromarray(frame.astype(np.uint8)).transform(
|
|
(width, height), Image.AFFINE,
|
|
(inverse, 0, width / 2 * (1 - inverse) - dx * inverse,
|
|
0, inverse, height / 2 * (1 - inverse) - dy * inverse), Image.BICUBIC)
|
|
return np.asarray(warped, np.float32), (scale, dx, dy), best[0]
|
|
|
|
|
|
def clip_frame_reader(clip: bytes | Path, size: tuple[int, int], crop: str | None):
|
|
"""클립 프레임을 순서대로 하나씩만 메모리에 올린다. 인덱스는 단조 증가한다."""
|
|
frames = decode_frames(clip)
|
|
state = {"index": -1, "image": None}
|
|
|
|
def read(wanted: int) -> np.ndarray:
|
|
while state["index"] < wanted:
|
|
try:
|
|
frame = next(frames)
|
|
except StopIteration:
|
|
break
|
|
state["index"] += 1
|
|
state["image"] = frame
|
|
image = state["image"]
|
|
if crop:
|
|
x0, y0, w, h = (int(v) for v in crop.split(","))
|
|
image = image.crop((x0, y0, x0 + w, y0 + h))
|
|
return np.asarray(image.convert("RGB").resize(size, Image.LANCZOS)).astype(np.float32)
|
|
|
|
return read
|
|
|
|
|
|
def compose_frames(poster: Image.Image, config: LayerConfig, clip: bytes | Path,
|
|
masks: dict, do_register: bool) -> Iterator[Image.Image]:
|
|
width, height = poster.size
|
|
base = np.asarray(poster).astype(np.float32)
|
|
hold = masks["hold"][..., None]
|
|
read_clip = clip_frame_reader(clip, poster.size, config.hybrid.clip_crop)
|
|
|
|
xs = np.arange(width, dtype=np.float32)[None, :]
|
|
ys = np.arange(height, dtype=np.float32)[:, None]
|
|
hold_frames = int(HOLD_SECONDS * FPS)
|
|
total = int(DURATION * FPS)
|
|
glow_masks: dict = {}
|
|
# 펀치 안 하는 이웃도 항상 보호한다 — 펀치된 크롭의 배경이 이웃 글자를 덮는다
|
|
text_elements = [(rect, glyph_alpha(poster, rect, config.glyph_dark_ink))
|
|
for rect, _, _ in config.punches]
|
|
text_elements += [(rect, glyph_alpha(poster, rect, config.glyph_dark_ink))
|
|
for rect in config.protect]
|
|
registration = (1.0, 0, 0)
|
|
|
|
for index in range(total):
|
|
seconds = index / FPS
|
|
if index < hold_frames:
|
|
artwork = base
|
|
else:
|
|
artwork = read_clip(index - hold_frames)
|
|
if do_register:
|
|
artwork, registration, _ = register(artwork, base, masks["hold"], registration)
|
|
|
|
frame = artwork * (1 - hold) + base * hold
|
|
|
|
for name, rect in config.glyphs.items():
|
|
progress = (seconds - config.sweep_at.get(name, 0.0)) / SWEEP_SECONDS
|
|
if 0 <= progress <= 1:
|
|
gx0, gx1 = rect[0] * width, rect[2] * width
|
|
band_x = gx0 - 120 + (gx1 - gx0 + 240) * progress
|
|
band = np.exp(-((xs - 0.35 * (ys - rect[1] * height)) - band_x) ** 2
|
|
/ (2 * 55.0 ** 2))
|
|
frame = frame + SWEEP_GAIN * (band * masks["glyph"][name])[..., None]
|
|
|
|
for rect, at in config.glows:
|
|
progress = (seconds - at) / GLOW_SECONDS
|
|
if 0 <= progress <= 1:
|
|
glow = glow_masks.setdefault(
|
|
rect, np.asarray(glyph_alpha(poster, rect, config.glyph_dark_ink)) / 255.0)
|
|
frame = frame + GLOW_GAIN * math.sin(math.pi * progress) * glow[..., None]
|
|
|
|
image = Image.fromarray(np.clip(frame, 0, 255).astype(np.uint8))
|
|
active = []
|
|
for order, (rect, at, peak) in enumerate(config.punches):
|
|
scale = punch_scale(seconds - at, peak)
|
|
if scale != 1.0:
|
|
pop_rect(image, poster, rect, scale)
|
|
active.append(order)
|
|
if active:
|
|
for order, (_, alpha) in enumerate(text_elements):
|
|
if order not in active:
|
|
image.paste(poster, (0, 0), alpha)
|
|
yield image
|
|
|
|
|
|
def render_hybrid(poster: bytes | Path | Image.Image, clip: bytes | Path,
|
|
config: LayerConfig, *, preview: bool = False) -> HybridResult:
|
|
if isinstance(poster, Image.Image):
|
|
source = poster.convert("RGB")
|
|
else:
|
|
source = Image.open(poster if isinstance(poster, Path)
|
|
else io.BytesIO(poster)).convert("RGB")
|
|
masks = build_masks(source, config)
|
|
do_register = config.hybrid.register or config.mask.mode == "glyph"
|
|
|
|
proof_at = {round(step * DURATION * FPS / PROOF_COUNT) for step in range(PROOF_COUNT)}
|
|
proof_tiles: list[Image.Image] = []
|
|
|
|
def frames() -> Iterator[Image.Image]:
|
|
for index, image in enumerate(compose_frames(source, config, clip, masks, do_register)):
|
|
if preview and index in proof_at:
|
|
proof_tiles.append(image.resize(PROOF_SIZE))
|
|
yield image
|
|
|
|
video = encode_mp4(frames(), source.size, fps=FPS, crf=CRF)
|
|
|
|
sheet = None
|
|
if proof_tiles:
|
|
sheet = Image.new("RGB", ((PROOF_SIZE[0] + 10) * len(proof_tiles), PROOF_SIZE[1]), "black")
|
|
for order, tile in enumerate(proof_tiles):
|
|
sheet.paste(tile, (order * (PROOF_SIZE[0] + 10), 0))
|
|
|
|
return HybridResult(video=video, frames=int(DURATION * FPS), duration=DURATION, proof=sheet)
|