ucast/ucast/service/cover.py
Theta-Dev e12c3518d1
All checks were successful
continuous-integration/drone/push Build is passing
update channel after importing
remove emoji from cover titles
2022-06-23 02:43:16 +02:00

451 lines
13 KiB
Python

import math
import random
from importlib import resources
from pathlib import Path
from typing import List, Literal, Optional, Tuple
import wcag_contrast_ratio
from colorthief import ColorThief
from fonts.ttf import SourceSansPro
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
from ucast.service import typ, util
COVER_STYLE_BLUR = "blur"
COVER_STYLE_GRADIENT = "gradient"
CoverStyle = Literal["blur", "gradient"]
CHAR_ELLIPSIS = ""
COVER_WIDTH = 500
MIN_CONTRAST = 4.5
def _split_text(
height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0
) -> List[str]:
"""
Split and trim the input text so it can be printed to a certain
area of an image.
:param height: Image area height [px]
:param width: Image area width [px]
:param text: Input text
:param font: Pillow ImageFont
:param line_spacing: Line spacing [px]
:return: List of lines
"""
if height < font.size:
return []
max_lines = math.floor((height - font.size) / (font.size + line_spacing)) + 1
lines = []
line = ""
for word in text.split(" "):
if len(lines) >= max_lines:
line = word
break
if line == "":
nline = word
else:
nline = line + " " + word
if font.getsize(nline)[0] <= width:
line = nline
elif line != "":
lines.append(line)
line = word
else:
# try to trim current word
while nline:
nline = nline[:-1]
nline_e = nline + CHAR_ELLIPSIS
if font.getsize(nline_e)[0] <= width:
lines.append(nline_e)
break
if line != "":
if len(lines) >= max_lines:
# Drop the last line and add ... to the end
lastline = lines[-1] + CHAR_ELLIPSIS
if font.getsize(lastline)[0] <= width:
lines[-1] = lastline
else:
i_last_space = lines[-1].rfind(" ")
lines[-1] = lines[-1][:i_last_space] + CHAR_ELLIPSIS
else:
lines.append(line)
return lines
def _draw_text_box(
draw: ImageDraw.ImageDraw,
box: Tuple[int, int, int, int],
text: str,
font: ImageFont.FreeTypeFont,
color: typ.Color = (0, 0, 0),
line_spacing=0,
vertical_center=True,
):
"""
Draw a text box to an image. The text gets automatically
wrapped and trimmed to fit.
:param draw: Pillow ImageDraw object
:param box: Coordinates of the text box ``(x_tl, y_tl, x_br, y_br)``
:param text: Text to be printed
:param font: Pillow ImageFont
:param color: Text color
:param line_spacing: Line spacing [px]
:param vertical_center: Center text vertically in the box
"""
x_tl, y_tl, x_br, y_br = box
height = y_br - y_tl
width = x_br - x_tl
sanitized_text = util.strip_emoji(text)
lines = _split_text(height, width, sanitized_text, font, line_spacing)
y_start = y_tl
if vertical_center:
text_height = len(lines) * (font.size + line_spacing) - line_spacing
y_start += int((height - text_height) / 2)
for i, line in enumerate(lines):
y_pos = y_start + i * (font.size + line_spacing)
draw.text((x_tl, y_pos), line, color, font)
def _get_dominant_color(img: Image.Image) -> typ.Color:
"""
Return the dominant color of an image using the ColorThief library.
:param img: Pillow Image object
:return: dominant color
"""
thief = ColorThief.__new__(ColorThief)
thief.image = img
return thief.get_color()
def _interpolate_color(color_from: typ.Color, color_to: typ.Color, steps: int):
"""
Return a generator providing colors within the given range. Useful to create
gradients.
:param color_from: Starting color
:param color_to: Ending color
:param steps: Number of steps
:return: Generator providing the colors
"""
det_co = [(t - f) / steps for f, t in zip(color_from, color_to)]
for i in range(steps):
yield [round(f + det * i) for f, det in zip(color_from, det_co)]
def _color_to_float(color: typ.Color) -> tuple[float, ...]:
return tuple(c / 255 for c in color)
def _get_text_color(bg_color: typ.Color) -> typ.Color:
"""
Return the text color (black or white) with the largest contrast
to a given background color.
:param bg_color: Background color
:return: Text color
"""
color_float = _color_to_float(bg_color)
c_blk = wcag_contrast_ratio.rgb((0, 0, 0), color_float)
c_wht = wcag_contrast_ratio.rgb((1, 1, 1), color_float)
if c_wht > c_blk:
return 255, 255, 255
return 0, 0, 0
def _get_baseimage(thumbnail: Image.Image, style: CoverStyle):
"""
Return the background image for the cover.
:param thumbnail: Thumbnail image object
:param style: Style of the cover image
:return: Base image
"""
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH))
if style == COVER_STYLE_GRADIENT:
# Thumbnail with color gradient background
# Get dominant colors from the top and bottom 20% of the thumbnail image
top_part = thumbnail.crop((0, 0, COVER_WIDTH, int(thumbnail.height * 0.2)))
bottom_part = thumbnail.crop(
(0, int(thumbnail.height * 0.8), COVER_WIDTH, thumbnail.height)
)
top_color = _get_dominant_color(top_part)
bottom_color = _get_dominant_color(bottom_part)
cover_draw = ImageDraw.Draw(cover)
for i, color in enumerate(
_interpolate_color(top_color, bottom_color, cover.height)
):
cover_draw.line(((0, i), (cover.width, i)), tuple(color), 1)
else:
# Thumbnail with blurred background
ctn_width = int(COVER_WIDTH / thumbnail.height * thumbnail.width)
ctn_x_left = int((ctn_width - COVER_WIDTH) / 2)
ctn = thumbnail.resize(
(ctn_width, COVER_WIDTH), Image.Resampling.LANCZOS
).filter(ImageFilter.GaussianBlur(20))
cover.paste(ctn, (-ctn_x_left, 0))
return cover
def _resize_thumbnail(thumbnail: Image.Image) -> Image.Image:
"""
Scale thumbnail image down to cover size and remove black bars
:param thumbnail: Thumbnail image object
:return: Resized thumbnail image object
"""
# Scale the thumbnail image down to cover size
tn_resize_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height)
tn_16_9_height = int(COVER_WIDTH / 16 * 9)
tn_height = min(tn_resize_height, tn_16_9_height)
tn_crop_y_top = int((tn_resize_height - tn_height) / 2)
tn_crop_y_bottom = tn_resize_height - tn_crop_y_top
return thumbnail.resize(
(COVER_WIDTH, tn_resize_height), Image.Resampling.LANCZOS
).crop((0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom))
def _prepare_text_background(
base_img: Image.Image, bboxes: List[Tuple[int, int, int, int]]
) -> Tuple[Image.Image, typ.Color]:
"""
Return the preferred text color (black or white) and darken
the image if necessary
:param base_img: Image object
:param bboxes: Text boxes
:return: Updated image, text color
"""
rng = random.Random()
rng.seed(0x9B38D30461B7F0E6)
min_contrast_bk = 22
min_contrast_wt = 22
worst_color_wt = None
def corr_x(x: int) -> int:
return min(max(0, x), base_img.width)
def corr_y(y: int) -> int:
return min(max(0, y), base_img.height)
for bbox in bboxes:
x_tl, y_tl, x_br, y_br = bbox
x_tl = corr_x(x_tl)
y_tl = corr_y(y_tl)
x_br = corr_x(x_br)
y_br = corr_y(y_br)
height = y_br - y_tl
width = x_br - x_tl
for _ in range(math.ceil(width * height * 0.01)):
target_pos = (rng.randint(x_tl, x_br - 1), rng.randint(y_tl, y_br - 1))
img_color = base_img.getpixel(target_pos)
img_color_float = _color_to_float(img_color)
ct_bk = wcag_contrast_ratio.rgb((0, 0, 0), img_color_float)
ct_wt = wcag_contrast_ratio.rgb((1, 1, 1), img_color_float)
if ct_bk < min_contrast_bk:
min_contrast_bk = ct_bk
if ct_wt < min_contrast_wt:
worst_color_wt = img_color
min_contrast_wt = ct_wt
if min_contrast_bk >= MIN_CONTRAST:
return base_img, (0, 0, 0)
if min_contrast_wt >= MIN_CONTRAST:
return base_img, (255, 255, 255)
pixel = Image.new("RGB", (1, 1), worst_color_wt)
for i in range(1, 100):
brightness_f = 1 - i / 100
contrast_f = 1 - i / 1000
pixel_c = ImageEnhance.Brightness(pixel).enhance(brightness_f)
pixel_c = ImageEnhance.Contrast(pixel_c).enhance(contrast_f)
new_color = pixel_c.getpixel((0, 0))
if (
wcag_contrast_ratio.rgb((1, 1, 1), _color_to_float(new_color))
>= MIN_CONTRAST
):
new_img = ImageEnhance.Brightness(base_img).enhance(brightness_f)
new_img = ImageEnhance.Contrast(new_img).enhance(contrast_f)
return new_img, (255, 255, 255)
return base_img, (255, 255, 255)
def _draw_text_avatar(
cover: Image.Image,
avatar: Optional[Image.Image],
title: str,
channel: str,
) -> Image.Image:
# Add channel avatar
avt_margin = 0
avt_size = 0
tn_16_9_height = int(COVER_WIDTH / 16 * 9) # typical: 281
tn_16_9_margin = int((COVER_WIDTH - tn_16_9_height) / 2) # typical: 110
if avatar:
avt_margin = int(tn_16_9_margin * 0.05) # typical: 14
avt_size = tn_16_9_margin - 2 * avt_margin # typical: 82
# Add text
text_margin_x = 16
text_margin_topleft = avt_margin + avt_size + text_margin_x # typical: 112
text_vertical_offset = -17
text_line_space = -4
fnt = ImageFont.truetype(SourceSansPro, 50)
top_text_box = ( # typical: (112, -17, 484, 110)
text_margin_topleft,
text_vertical_offset,
COVER_WIDTH - text_margin_x,
tn_16_9_margin,
)
bottom_text_box = ( # typical: (16, 373, 484, 500)
text_margin_x,
COVER_WIDTH - tn_16_9_margin + text_vertical_offset,
COVER_WIDTH - text_margin_x,
COVER_WIDTH,
)
cover, text_color = _prepare_text_background(cover, [top_text_box, bottom_text_box])
cover_draw = ImageDraw.Draw(cover)
_draw_text_box(
cover_draw,
top_text_box,
channel,
fnt,
text_color,
text_line_space,
)
_draw_text_box(
cover_draw,
bottom_text_box,
title,
fnt,
text_color,
text_line_space,
)
if avatar:
avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS)
circle_mask = Image.new("L", (avt_size, avt_size))
circle_mask_draw = ImageDraw.Draw(circle_mask)
circle_mask_draw.ellipse((0, 0, avt_size, avt_size), 255)
cover.paste(avt, (avt_margin, avt_margin), circle_mask)
return cover
def _create_cover_image(
thumbnail: Image.Image,
avatar: Optional[Image.Image],
title: str,
channel: str,
style: CoverStyle,
) -> Image.Image:
"""
Create a cover image from video metadata and thumbnail
:param thumbnail: Thumbnail image object
:param avatar: Creator avatar image object
:param title: Video title
:param channel: Channel name
:param style: Style of cover image
:return: Cover image
"""
tn = _resize_thumbnail(thumbnail)
cover = _get_baseimage(tn, style)
cover = _draw_text_avatar(cover, avatar, title, channel)
# Insert thumbnail image in the middle
tn_margin = int((COVER_WIDTH - tn.height) / 2)
cover.paste(tn, (0, tn_margin))
return cover
def _create_blank_cover_image(
avatar: Optional[Image.Image], title: str, channel: str
) -> Image.Image:
bg_color = (16, 16, 16)
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH), bg_color)
yt_icon_path = resources.path("ucast.resources", "yt_icon.png")
yt_icon = Image.open(yt_icon_path)
yt_icon_x_left = int((COVER_WIDTH - yt_icon.width) / 2)
yt_icon_y_top = int((COVER_WIDTH - yt_icon.height) / 2)
cover.paste(yt_icon, (yt_icon_x_left, yt_icon_y_top))
_draw_text_avatar(cover, avatar, title, channel)
return cover
def create_cover_file(
thumbnail_path: Optional[Path],
avatar_path: Optional[Path],
title: str,
channel: str,
style: CoverStyle,
cover_path: Path,
):
"""
Create a cover image from video metadata and thumbnail
and save it to disk.
:param thumbnail_path: Path of thumbnail image
:param avatar_path: Path of avatar image
:param title: Video title
:param channel: Channel name
:param style: Style of cover image
:param cover_path: Save path of cover image
"""
thumbnail = None
if thumbnail_path:
thumbnail = Image.open(thumbnail_path)
avatar = None
if avatar_path:
avatar = Image.open(avatar_path)
if thumbnail:
cvr = _create_cover_image(thumbnail, avatar, title, channel, style)
else:
cvr = _create_blank_cover_image(avatar, title, channel)
cvr.save(cover_path)