make text drawing algorithm a util function

This commit is contained in:
LBlend committed 2024-04-05 02:38:48 +02:00
1 parent d7d2c5a804
commit 9a5cfa5b1d
3 files changed
+90 -162

No files matched your search

+4 -78
View File
@@ -1,7 +1,6 @@
import asyncio
import json
import random
import textwrap
from datetime import datetime
from datetime import timedelta
from typing import override
@@ -11,11 +10,9 @@ import discord
import numpy as np
from discord import app_commands
from discord.ext import commands
from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont
from cogs.utils import embed_templates
from cogs.utils import misc_utils
from cogs.utils.discord_utils import Lobby
from cogs.utils.discord_utils import LobbyView
@@ -96,77 +93,6 @@ class BingoGenerator:
with open(f"{BINGO_PATH}/names.json") as f:
NAMES = json.load(f)
@classmethod
async def put_text_in_box(
cls,
image: np.ndarray,
text: str,
top_left: tuple,
bottom_right: tuple,
font_path: str,
font_size: int = 10,
color: tuple = (0, 0, 0),
):
"""
Puts text inside a bounding box on the cv2 image with automatic text wrapping, using PIL for text rendering.
:param image: Source cv2 image (numpy array).
:param text: Text string to be put.
:param top_left: Tuple (x, y) defining the top-left corner of the bounding box.
:param bottom_right: Tuple (x, y) defining the bottom-right corner of the bounding box.
:param font_path: Path to a .ttf font file.
:param font_size: Initial font size.
:param color: Text color in RGB.
:return: Image with text (numpy array).
"""
# TODO: Dry. This algorithm is also seen in the memes cog
# Convert the cv2 image to a PIL image
image_pil = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(image_pil)
# Get coordinates of the bounding box
x1, y1 = top_left
x2, y2 = bottom_right
# Get the width and height of the bounding box
# We multiply by 0.9 to give some padding
w, h = (x2 - x1) * 0.9, (y2 - y1) * 0.9
# Initialize font width/height to 0 in order to run the while loops below at least once
font_width = 0
font_height = 0
# Add newlines to the text. Here we have gone with 15 characters per line
# This is arbitrary, but seems to work well
text = textwrap.fill(text, width=15)
# Increase the font size until the longest line of text does not fit in the bounding box anymore
# We also have to check the height, since the text might be too tall for the box
while font_width < w and font_height < h:
font = ImageFont.truetype(font_path, font_size)
longest_line = max(text.split("\n"), key=lambda x: font.getlength(x))
font_width = font.getlength(longest_line)
# bbox doesn't work with multiline text, so we have to calculate the height manually
# take the height of the bounding box, multiply by 1.5 (our assumed line dividor height)
# after that, we multiply by the number of lines
font_box = font.getbbox(text)
font_height = ((font_box[3] - font_box[1]) * 1.5) * text.count("\n")
font_size += 1
# Actually draw the text
draw.multiline_textbbox((w, h), text=text, font=font, align="center", anchor="mm")
center_of_box = ((x1 + x2) // 2, (y1 + y2) // 2)
draw.multiline_text(center_of_box, text, font=font, fill=color, align="center", anchor="mm")
# Convert back to cv2 image format and update the original image
cv2_image = cv2.cvtColor(np.array(image_pil), cv2.COLOR_RGB2BGR)
np.copyto(image, cv2_image)
@classmethod
async def sample_cells(cls, players: list[str], bot: commands.Bot) -> dict[str, list[str]]:
assert 0 < len(players) <= 10
@@ -231,11 +157,11 @@ class BingoGenerator:
for i in range(0, 5):
for j in range(0, 5):
width = 123
await cls.put_text_in_box(
await misc_utils.put_text_in_box(
sliced,
sample_space.pop(),
(i * width + 1, j * width + 1),
((i + 1) * width, (j + 1) * width),
top_left=(i * width + 1, j * width + 1),
bottom_right=((i + 1) * width, (j + 1) * width),
font_path=cls.FONT_PATH,
)
+10 -84
View File
@@ -1,22 +1,22 @@
import functools
import os
import textwrap
from io import BytesIO
import cv2
import discord
import numpy as np
from discord import app_commands
from discord.ext import commands
from moviepy.editor import CompositeVideoClip
from moviepy.editor import TextClip
from moviepy.editor import VideoFileClip
from PIL import Image
from PIL import ImageDraw
from PIL import ImageEnhance
from PIL import ImageFont
from PIL import UnidentifiedImageError
from cogs.utils import discord_utils
from cogs.utils import embed_templates
from cogs.utils import misc_utils
class Meme(commands.Cog):
@@ -74,82 +74,6 @@ class Meme(commands.Cog):
await interaction.followup.send(file=discord.File(output, "deepfry.jpg"))
# TODO: Move this to utils
@staticmethod
def draw_text(image: Image.Image, text: str, offset: tuple = (0, 0)):
"""
Draws meme text on preferred meme template.
Parameters
----------
image (Image.Image): The meme template.
text (str): The text to be drawn.
font (ImageFont.FreeTypeFont): The font, with its size set, to be used.
offset (tuple): The offset of the text from the top left corner of the image. Defaults to (0, 0).
"""
box_size = (540, 540)
box = Image.new("RGB", box_size, (255, 255, 255))
draw = ImageDraw.Draw(box)
# Using a non-mono font may cause issues but eh
# I don't want to ship a whole ass new font just to cover edge cases for this command
# It's rather a skill issue on my end that I can't get things to stay within bounds
font_path = "./src/assets/fonts/comic.ttf"
# Calculate initial font size
# This is done by increasing the font size until the text is too wide
max_width, max_height = box_size[0] * 0.9, box_size[1] * 0.9 # 90% of the box size
font_size = 20 # Initiale size. I consider this the smallest readable size
font_width = 0 # Initiale width. This is just to make sure the while loop runs at least once
while font_width < max_width:
font = ImageFont.truetype(font_path, font_size)
font_box = font.getbbox(text)
font_width = font_box[2] - font_box[0]
font_size += 2
# Wrap the text based on our current font size
text = textwrap.fill(text, width=font_size)
# We then have to calculate the font size again
#
# The problem is, font.getbbox() doesn't work with multiline text so it returns the size without the linebreaks
# We can get around this by calculating the size of the longest line in the text
# and then increasing the font size until that line is too wide
#
# We also have to make sure it doesn't get too tall
longest_line = max(text.split("\n"), key=len)
longest_line_font_box = font.getbbox(longest_line)
font_width = longest_line_font_box[2] - longest_line_font_box[0]
# Estimate font height
# This is done by multiplying the height of the original non-wrapped text with the number of lines
# and then adding 20 to account for the line spacing.
# This of course is dependant on font size itself, but it's good enough for now
font_height = (font_box[3] - font_box[1] + 20) * text.count("\n")
max_height = box_size[1] * 0.9
while font_width < max_width and font_height < max_height:
font = ImageFont.truetype(font_path, font_size)
longest_line_font_box = font.getbbox(longest_line)
font_width = longest_line_font_box[2] - longest_line_font_box[0]
font_box = font.getbbox(text)
font_height = (font_box[3] - font_box[1] + 20) * text.count("\n")
font_size += 2
# Draw textbox and text
draw.multiline_textbbox(box_size, text=text, font=font, align="center", anchor="mm") # TODO: remove?
text_box = box.size[0] // 2, box.size[1] // 2
draw.multiline_text(text_box, text, font=font, fill=(0, 0, 0, 255), align="center", anchor="mm")
image.paste(box, offset)
@app_commands.checks.bot_has_permissions(attach_files=True)
@app_commands.checks.cooldown(1, 5)
@app_commands.command(name="preferansemem", description="Generer en drake-aktig mem basert på tekst")
@@ -167,15 +91,17 @@ class Meme(commands.Cog):
await interaction.response.defer()
# Fetch meme template
template = Image.open("./src/assets/sivert_goodbad.jpg")
template = cv2.imread("./src/assets/sivert_goodbad.jpg")
# Draw text
Meme.draw_text(template, dårlig_tekst, offset=(540, 0))
Meme.draw_text(template, bra_tekst, offset=(540, 540))
font = "./src/assets/fonts/comic.ttf"
await misc_utils.put_text_in_box(template, dårlig_tekst, (540, 0), (1080, 540), font)
await misc_utils.put_text_in_box(template, bra_tekst, (540, 540), (1080, 1080), font)
# Save image to buffer
output = BytesIO()
template.save(output, format="jpeg")
_, buffer = cv2.imencode(".jpg", template)
output = BytesIO(buffer)
cv2.imdecode(np.frombuffer(output.getbuffer(), np.uint8), -1)
output.seek(0)
# Send image
+76
View File
@@ -1,7 +1,14 @@
import datetime
import textwrap
from math import ceil
from zoneinfo import ZoneInfo
import cv2
import numpy as np
from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont
MIDNIGHT = datetime.time(hour=19, minute=48, tzinfo=ZoneInfo("Europe/Oslo"))
@@ -98,3 +105,72 @@ class Paginator:
self.current_page = self.total_page_count
return self.get_page(self.current_page)
async def put_text_in_box(
image: np.ndarray,
text: str,
top_left: tuple,
bottom_right: tuple,
font_path: str = "./src/assets/fonts/impact.ttf",
font_size: int = 10,
color: tuple = (0, 0, 0),
):
"""
Puts text inside a bounding box on the cv2 image with automatic text wrapping, using PIL for text rendering.
Parameters
----------
image (np.ndarray): Source cv2 image.
text (str): Text to be put on the image.
top_left (tuple): (x, y) Top-left corner of the bounding box.
bottom_right (tuple): (x, y) Bottom-right corner of the bounding box.
font_path (str): Path to the font file. Defaults to Impact font.
font_size (int): Initial font size. Defaults to 10.
color (tuple): Text color in RGB. Defaults to (0, 0, 0).
"""
# Convert the cv2 image to a PIL image
image_pil = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(image_pil)
# Get coordinates of the bounding box
x1, y1 = top_left
x2, y2 = bottom_right
# Get the width and height of the bounding box
# We multiply by 0.9 to give some padding
w, h = (x2 - x1) * 0.9, (y2 - y1) * 0.9
# Initialize font width/height to 0 in order to run the while loops below at least once
font_width = 0
font_height = 0
# Add newlines to the text. Here we have gone with 15 characters per line
# This is arbitrary, but seems to work well
text = textwrap.fill(text, width=15)
# Increase the font size until the longest line of text does not fit in the bounding box anymore
# We also have to check the height, since the text might be too tall for the box
while font_width < w and font_height < h:
font = ImageFont.truetype(font_path, font_size)
longest_line = max(text.split("\n"), key=lambda x: font.getlength(x))
font_width = font.getlength(longest_line)
# bbox doesn't work with multiline text, so we have to calculate the height manually
# take the height of the bounding box, multiply by 1.5 (our assumed line dividor height)
# after that, we multiply by the number of lines
font_box = font.getbbox(text)
font_height = ((font_box[3] - font_box[1]) * 1.5) * text.count("\n")
font_size += 1
# Actually draw the text
draw.multiline_textbbox((w, h), text=text, font=font, align="center", anchor="mm")
center_of_box = ((x1 + x2) // 2, (y1 + y2) // 2)
draw.multiline_text(center_of_box, text, font=font, fill=color, align="center", anchor="mm")
# Convert back to cv2 image format and update the original image
cv2_image = cv2.cvtColor(np.array(image_pil), cv2.COLOR_RGB2BGR)
np.copyto(image, cv2_image)