Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Space filling #4

Merged
merged 13 commits into from
Aug 14, 2024
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ A predictable, interpretable wordcloud library
Run `poetry install`

## Development
Run `poetry run black src` and `poetry run pylint src` for styling and linting.
Run `poetry run black wrdcld` and `poetry run pylint wrdcld` for styling and linting.

## Testing
Run `python -m unittest discover tests/`
Binary file added fonts/OpenSans-Regular.ttf
Binary file not shown.
2,448 changes: 2,354 additions & 94 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ readme = "README.md"
[tool.poetry.dependencies]
python = "^3.9"
pillow = "^10.4.0"
numpy = "^2.0.1"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we talked about already - I think this is too heavyweight :)


[tool.poetry.group.dev.dependencies]
black = "^24.8.0"
pylint = "^3.2.6"
jupyter = "^1.0.0"

[build-system]
requires = ["poetry-core"]
Expand Down
2 changes: 1 addition & 1 deletion test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections import Counter
from src import make_word_cloud
from wrdcld import make_word_cloud

with open("dancingmen.txt") as f:
contents = f.read()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_rectangle.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from src.rectangle import Rectangle
from wrdcld.rectangle import Rectangle
from unittest import TestCase


Expand Down
4 changes: 2 additions & 2 deletions src/__init__.py → wrdcld/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def make_word_cloud(
word, required_font_size, available_rectangles, img, canvas
)

# img.show()
# input()
# for rectangle in available_rectangles:
# canvas.rectangle(rectangle.xyrb)

return img
21 changes: 15 additions & 6 deletions src/font.py → wrdcld/font.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from wrdcld.util import get_repo_root


FONT_PATH = Path(r"C:\\Windows\\Fonts\\Verdanab.ttf")
# FONT_PATH = Path("/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf")
def get_default_font_path():
return get_repo_root() / "fonts" / "OpenSans-Regular.ttf"


def find_fontsize_for_width(width, word):
fontsize = width / 2
step = width / 2

font_path = get_default_font_path()
while step > 0.5:
step /= 2
font = ImageFont.truetype(FONT_PATH, fontsize)

font = ImageFont.truetype(font_path, fontsize)
length = font.getlength(word)

if length < width:
Expand All @@ -24,16 +26,23 @@ def find_fontsize_for_width(width, word):


def draw_text(canvas, img, rectangle, word, font, rotate=False):
"""

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good talk

"""
FONT_COLOR = (255, 255, 0)

# text can sometimes have a negative bounding box, so we need to account for that
text_bbox = font.getbbox(word)

if rotate:
text_image = Image.new("RGB", rectangle.rotated_ccw.wh, (73, 109, 137))
text_draw = ImageDraw.Draw(text_image)
text_draw.text((0, 0), word, font=font, fill=FONT_COLOR)

text_draw.text((-text_bbox[0], -text_bbox[1]), word, font=font, fill=FONT_COLOR)
rotated_text_image = text_image.rotate(90, expand=True)
img.paste(rotated_text_image, rectangle.xy)
# canvas.rectangle(rectangle.xyrb)
Copy link
Collaborator

@naslundx naslundx Aug 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you merge my other PR this will simplify

EDIT: I take that back, I think this just shows up because it's not rebased, so nothing to be doen here.


else:
canvas.text(rectangle.xy, word, font=font, fill=FONT_COLOR)
canvas.text((rectangle.x-text_bbox[0],rectangle.y-text_bbox[1]), word, font=font, fill=FONT_COLOR)
# canvas.rectangle(rectangle.xyrb)
15 changes: 9 additions & 6 deletions src/main.py → wrdcld/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import random
from PIL import ImageFont

from .font import FONT_PATH, draw_text
from .font import draw_text, get_default_font_path
from .rectangle import (
Rectangle,
fill_space_around_word,
fill_remaining_space_horizontal,
fill_remaining_space_vertical,
)


def _fill(
rectangle: Rectangle,
canvas,
Expand Down Expand Up @@ -50,9 +50,9 @@ def _fill(

return text_rectangle


def fill_next_word(word, required_font_size, available_rectangles, img, canvas):
font = ImageFont.truetype(FONT_PATH, required_font_size)
font_path = get_default_font_path()
font = ImageFont.truetype(font_path, required_font_size)
word_length = font.getlength(word)

suitable_horizontal_rectangles = [
Expand All @@ -79,7 +79,6 @@ def fill_next_word(word, required_font_size, available_rectangles, img, canvas):
)

options = []

if horizontal_option is not None:
options.append("horizontal")
if vertical_option is not None:
Expand Down Expand Up @@ -115,10 +114,14 @@ def fill_next_word(word, required_font_size, available_rectangles, img, canvas):
rotate=True,
)

fill_direction = random.choice(["horizontal", "vertical"])

# figure out new available rectangles
fill_func = random.choice(
[fill_remaining_space_horizontal, fill_remaining_space_vertical]
)
new_available_rectangles = fill_func(chosen_rectangle, text_rectangle)

return available_rectangles + new_available_rectangles
available_rectangles_around_word = fill_space_around_word(img, text_rectangle, fill_direction)

return available_rectangles + new_available_rectangles + available_rectangles_around_word
103 changes: 103 additions & 0 deletions src/rectangle.py → wrdcld/rectangle.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from dataclasses import dataclass
import math
import numpy as np


@dataclass(frozen=True)
Expand All @@ -11,10 +12,16 @@ class Rectangle:

@property
def xy(self):
"""
Returns the coordinates of the rectangle as a tuple (x, y).
"""
return (int(self.x + 0.5), int(self.y + 0.5))

@property
def wh(self):
"""
Returns the width and height of the rectangle as a tuple (width, height).
"""
return (math.ceil(self.width), math.ceil(self.height))

@property
Expand All @@ -27,14 +34,23 @@ def bottom(self):

@property
def xyrb(self):
"""
Returns the coordinates of the rectangle as a tuple (x, y, right, bottom).
"""
return (self.x, self.y, self.x + self.width, self.y + self.height)

@property
def area(self):
"""
Returns the area of the rectangle.
"""
return self.width * self.height

@property
def rotated_ccw(self):
"""
Returns a new rectangle that is rotated 90 degrees counter-clockwise.
"""
return Rectangle(
x=self.x,
y=self.y + self.height - self.width,
Expand Down Expand Up @@ -186,3 +202,90 @@ def fill_remaining_space_vertical(
)

return rectangles

def fill_space_around_word(
img,
outer_rect: Rectangle,
fill_direction: str,
) -> list[Rectangle]:
"""
Returns a list of rectangles that fill the remaining space

Args:
img (Image): the overaall wordcloud image.
inner_rect (Rectangle): The rectangle containing the new text.

Returns:
list[Rectangle]: List of rectangles that fill the remaining space.
"""

img_section = img.crop(outer_rect.xyrb)
img_data = np.array(img_section.quantize(2))

if fill_direction == "horizontal":
img_data = img_data.T


base_value = img_data[0,0]
min_rectangle_side_length = 5

rectangles = [Rectangle(x=0,y=0, width=img_data.shape[1], height=0)]
for row_ind, img_row in enumerate(img_data):

if fill_direction == "horizontal":
img_row = img_row[::-1]

# find the gaps between the letters
left_inds = []
right_inds = []
new_rect_active = False
for col_ind, val in enumerate(img_row):
if val == base_value and not new_rect_active:
new_rect_active = True
left_inds.append(col_ind)
elif new_rect_active and val != base_value:
new_rect_active = False
right_inds.append(col_ind)
else:
continue

if new_rect_active:
right_inds.append(img_data.shape[1])

new_rectangles = []
for left_ind, right_ind in zip(left_inds, right_inds, strict=True):
# if the rectangle is too small
if right_ind - left_ind < min_rectangle_side_length:
continue
# if this is a continuation of an existing rectangle
extended = False
for rect_ind, rectangle in enumerate(rectangles):
if rectangle.bottom == row_ind and rectangle.x == left_ind and rectangle.right == right_ind:
rectangles[rect_ind] = Rectangle(x=rectangle.x, y=rectangle.y, width=rectangle.width, height=rectangle.height+1)
extended = True
# otherwise it's a new rectangle
if not extended:
new_rectangles.append(Rectangle(x=left_ind, y=row_ind, width=right_ind-left_ind, height=1))

rectangles += new_rectangles

# if we rotated the image, we need to rotate the rectangles back
if fill_direction == "horizontal":
rotated_rectangles = []
for rectangle in rectangles:
rotated_rectangles.append(Rectangle(
x=rectangle.y,
y=img_data.shape[1] - (rectangle.x + rectangle.width) ,
width=rectangle.height,
height=rectangle.width
))
rectangles = rotated_rectangles

# offset the rectangles to the correct position
rectangles = [
Rectangle(x=rectangle.x + outer_rect.x, y=rectangle.y + outer_rect.y, width=rectangle.width, height=rectangle.height)
for rectangle in rectangles
if rectangle.height >= min_rectangle_side_length and rectangle.width >= min_rectangle_side_length
]

return rectangles
4 changes: 4 additions & 0 deletions wrdcld/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from pathlib import Path

def get_repo_root():
return Path(__file__).parent.parent