playreel/backend/utils/image.py

107 lines
4.3 KiB
Python

import base64
import io
import numpy as np
from PIL import Image, ImageDraw, ImageFont
Image.MAX_IMAGE_PIXELS = None
IMAGE_SIGNATURES = ((b"\xff\xd8\xff", "jpg"), (b"\x89PNG\r\n\x1a\n", "png"),
(b"GIF87a", "gif"), (b"GIF89a", "gif"))
LOGO_BAR_SEARCH_FROM = 0.88 # 하단 12%에서만 밝은 띠를 찾는다
LOGO_BAR_BRIGHTNESS = 200
LOGO_BAR_MIN_ROWS = 20
def sniff_extension(data: bytes) -> str:
"""확장자가 .gif여도 실제 바이트는 JPEG·PNG인 경우가 많아 매직 넘버로 판별한다."""
head = data[:12]
for signature, extension in IMAGE_SIGNATURES:
if head.startswith(signature):
return extension
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
return "webp"
return "bin"
def content_box(frame: Image.Image) -> tuple[int, int, int, int]:
"""레터박스(검정·흰 여백)를 걷어낸 실제 포스터 영역."""
brightness = np.asarray(frame.convert("RGB")).astype(np.float32).mean(2)
live = (brightness > 14) & (brightness < 244)
cols = np.nonzero(live.any(0))[0]
rows = np.nonzero(live.any(1))[0]
return int(cols.min()), int(rows.min()), int(cols.max()) + 1, int(rows.max()) + 1
def vlm_grid_overlay(poster: Image.Image) -> Image.Image:
"""VLM이 눈금을 읽어 좌표를 답하게 하는 격자.
숫자가 없으면 모델이 선을 세야 하는데 그걸 못한다. 축을 구분하려고 색도 둘로 나눈다 —
프롬프트가 '빨간 가로선 = 세로 위치, 하늘색 세로선 = 가로 위치'로 못박고 있다.
"""
overlay = poster.convert("RGB").copy()
width, height = overlay.size
draw = ImageDraw.Draw(overlay, "RGBA")
font = ImageFont.load_default(max(14, width // 55))
for row in range(1, 20):
y = int(height * row * 0.05)
draw.line([(0, y), (width, y)], fill=(255, 0, 80, 150), width=max(1, width // 700))
if row % 2 == 0:
draw.text((4, y + 2), f"{row * 5}", font=font, fill=(255, 255, 0, 255))
for col in range(1, 10):
x = int(width * col * 0.10)
draw.line([(x, 0), (x, height)], fill=(0, 220, 255, 130), width=max(1, width // 700))
draw.text((x + 3, 3), f"{col * 10}", font=font, fill=(0, 255, 255, 255))
return overlay
def review_grid_overlay(poster: Image.Image) -> Image.Image:
"""사람이 검수 화면에서 요소를 지목하는 격자.
읽는 게 아니라 가리키는 용도라 라벨이 필요 없고, 촘촘할수록 좋아서 가로도 5%로 둔다.
"""
overlay = poster.convert("RGB").copy()
width, height = overlay.size
draw = ImageDraw.Draw(overlay)
line_width = max(1, width // 900)
for step in range(1, 20):
x, y = width * step / 20, height * step / 20
draw.line([(x, 0), (x, height)], fill=(255, 0, 128), width=line_width)
draw.line([(0, y), (width, y)], fill=(255, 0, 128), width=line_width)
return overlay
def bar_box(poster: Image.Image) -> tuple[int, int, int, int] | None:
"""하단 주최·주관·후원 로고 띠(밝은 가로 밴드)의 bbox."""
pixels = np.asarray(poster.convert("RGB")).astype(np.float32)
height, width = pixels.shape[:2]
search_from = int(height * LOGO_BAR_SEARCH_FROM)
rows = np.nonzero(pixels[search_from:].mean(axis=(1, 2)) > LOGO_BAR_BRIGHTNESS)[0]
if len(rows) < LOGO_BAR_MIN_ROWS:
return None
return 0, search_from + rows.min() - 4, width, min(height, search_from + rows.max() + 5)
def logo_bar(poster: Image.Image) -> Image.Image | None:
box = bar_box(poster)
return poster.crop(box) if box else None
def round_mask(size: tuple[int, int], radius_ratio: float = 0.08) -> Image.Image:
"""사각 패치를 둥근 카드 위에 얹을 때 모서리가 튀는 것을 막는 알파 마스크."""
width, height = size
mask = Image.new("L", size, 0)
ImageDraw.Draw(mask).rounded_rectangle(
(0, 0, width - 1, height - 1),
radius=max(1, round(min(width, height) * radius_ratio)), fill=255)
return mask
def to_data_uri(image: Image.Image, max_size: tuple[int, int] = (1100, 1600)) -> str:
image = image.copy()
image.thumbnail(max_size, Image.LANCZOS)
buffer = io.BytesIO()
image.save(buffer, "JPEG", quality=92)
return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode()