127 lines
5.2 KiB
Python
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
|