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()