playreel/backend/services/styling/info_band.py

127 lines
5.2 KiB
Python

"""스타일링 ③ — 생성된 포스터 하단에 정보 밴드를 그린다
큰 제목은 생성 모델이 대체로 맞추지만 작은 글자에서 자모를 흘린다
(동대문구 → 몽대문구, 체험존 → 채헐존). 프롬프트로는 더 못 밀어낸다.
그래서 날짜·시간·장소는 모델에 맡기지 않고 실제 폰트로 직접 그린다.
밴드 색은 이미지 하단에서 뽑아 얹은 티를 줄인다.
"""
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from answers.poster_text_answer import PosterTextAnswer
from utils.font import font
PAD_RATIO = 0.055
BAND_RATIO_WITH_SLOGAN = 0.165
BAND_RATIO = 0.13
FEATHER_RATIO = 0.42
FEATHER_CURVE = 1.4
FEATHER_BLUR = 3
DARK_LUMINANCE = 118 # 이보다 어두우면 밝은 글자를 얹는다
DARK_BACKGROUND_GAIN = 0.42
LIGHT_BACKGROUND_GAIN, LIGHT_BACKGROUND_LIFT = 1.06, 26
LIGHT_INK = (245, 243, 238)
DARK_INK = (26, 24, 22)
MIN_FONT_SIZE = 12
FONT_STEP = 2
ROW_GAP_RATIO = 0.085
QUOTE_MARKS = '"“”'
def band_palette(image: Image.Image, band_top: int) -> tuple[tuple, tuple]:
"""밴드 배경색과 글자색을 이미지 하단 평균색에서 뽑는다"""
width, height = image.size
strip = image.crop((0, band_top, width, height)).resize((1, 1), Image.LANCZOS)
red, green, blue = strip.getpixel((0, 0))[:3]
luminance = 0.299 * red + 0.587 * green + 0.114 * blue
if luminance < DARK_LUMINANCE:
background = tuple(int(channel * DARK_BACKGROUND_GAIN)
for channel in (red, green, blue))
return background, LIGHT_INK
background = tuple(min(255, int(channel * LIGHT_BACKGROUND_GAIN + LIGHT_BACKGROUND_LIFT))
for channel in (red, green, blue))
return background, DARK_INK
def fit_font(draw: ImageDraw.ImageDraw, text: str, weight: str, max_width: int,
start_size: int) -> ImageFont.FreeTypeFont:
"""max_width 안에 들어가는 가장 큰 크기를 찾는다"""
size = start_size
while size > MIN_FONT_SIZE:
candidate = font(weight, size)
if draw.textlength(text, font=candidate) <= max_width:
return candidate
size -= FONT_STEP
return font(weight, MIN_FONT_SIZE)
def band_rows(text: PosterTextAnswer, band_height: int) -> list[tuple[str, str, int, float]]:
"""슬로건 → 날짜+시간 → 장소 순. 날짜를 가장 크게 잡는다
슬로건은 원래 제목 근처에 놓이는 문구지만 생성 결과의 제목 위치가 매번 달라
상단에 얹으면 아트워크와 부딪힌다. 밴드 첫 줄로 내려 항상 읽히게 한다.
"""
slogan = text.slogan.strip().strip(QUOTE_MARKS)
datetime_line = text.date.strip() + (f" {text.time.strip()}" if text.time.strip() else "")
rows = []
if slogan:
rows.append((f"“{slogan}”", "SemiBold", round(band_height * 0.20), 0.72))
if datetime_line:
rows.append((datetime_line, "Bold", round(band_height * 0.33), 1.0))
if text.place.strip():
rows.append((text.place.strip(), "SemiBold", round(band_height * 0.21), 0.92))
return rows
def paint_scrim(image: Image.Image, band_top: int, band_height: int,
background: tuple) -> None:
"""위쪽 경계가 칼같이 잘리면 얹은 티가 난다. 그라데이션으로 이어 붙인다"""
width = image.width
scrim = Image.new("RGB", (width, band_height), background)
mask = Image.new("L", (width, band_height), 255)
mask_draw = ImageDraw.Draw(mask)
feather = round(band_height * FEATHER_RATIO)
for row in range(feather):
mask_draw.line([(0, row), (width, row)],
fill=int(255 * (row / feather) ** FEATHER_CURVE))
image.paste(scrim, (0, band_top), mask.filter(ImageFilter.GaussianBlur(FEATHER_BLUR)))
def compose_info_band(poster: Image.Image, text: PosterTextAnswer) -> Image.Image | None:
"""밴드를 얹은 이미지를 돌려준다. 그릴 내용이 없으면 None"""
if not (text.date.strip() or text.place.strip()):
return None
image = poster.convert("RGB")
width, height = image.size
band_height = round(height * (BAND_RATIO_WITH_SLOGAN if text.slogan.strip()
else BAND_RATIO))
band_top = height - band_height
background, ink = band_palette(image, band_top)
paint_scrim(image, band_top, band_height, background)
draw = ImageDraw.Draw(image)
inner_width = width - round(width * PAD_RATIO) * 2
rows = band_rows(text, band_height)
fitted = [(line, fit_font(draw, line, weight, inner_width, size), opacity)
for line, weight, size, opacity in rows]
gap = round(band_height * ROW_GAP_RATIO)
heights = [chosen.getbbox(line)[3] - chosen.getbbox(line)[1]
for line, chosen, _ in fitted]
top = band_top + (band_height - (sum(heights) + gap * (len(fitted) - 1))) // 2 \
- round(band_height * 0.03)
for (line, chosen, opacity), line_height in zip(fitted, heights, strict=True):
colour = ink if opacity >= 1.0 else tuple(
round(channel * opacity + background[index] * (1 - opacity))
for index, channel in enumerate(ink))
draw.text((width // 2, top), line, font=chosen, fill=colour, anchor="ma")
top += line_height + gap
return image