Matthias Bisping 9fd87aff8e Refactoring: Move
- Move random plot into its own module
- Move geometric predicates into their own module
2023-02-01 18:08:45 +01:00

788 lines
24 KiB
Python

import itertools
import random
import sys
from copy import deepcopy
from enum import Enum
from functools import lru_cache, partial
from math import sqrt
from typing import Tuple, Iterable, List
import blend_modes
import numpy as np
import pytest
from PIL import Image, ImageDraw, ImageEnhance
from PIL.Image import Transpose
from loguru import logger
from cv_analysis.utils.conversion import normalize_image_format_to_array, normalize_image_format_to_pil
from cv_analysis.utils.image_operations import blur, sharpen, overlay, superimpose, compute_pasting_coordinates
from cv_analysis.utils.merging import merge_related_rectangles
from cv_analysis.utils.postprocessing import remove_overlapping, remove_included
from cv_analysis.utils.spacial import area
from synthesis.partitioner.two_column import TwoColumnPagePartitioner
from synthesis.random import rnd, possibly, probably
from synthesis.segment.content_rectangle import ContentRectangle
from synthesis.segment.plot import pick_colormap, RandomPlot
from cv_analysis.utils.geometric import is_square_like
from synthesis.segment.random_content_rectangle import RandomContentRectangle
from synthesis.segment.text_block import TextBlock
from synthesis.text.text_block_generator.caption import CaptionGenerator
from synthesis.text.font import pick_random_font_available_on_system
from synthesis.text.text import generate_random_words, generate_random_number
logger.remove()
logger.add(sys.stderr, level="INFO")
from funcy import (
juxt,
compose,
identity,
lmap,
lsplit,
lfilter,
repeatedly,
mapcat,
chunks,
)
from cv_analysis.locations import TEST_PAGE_TEXTURES_DIR
from cv_analysis.utils.display import show_image
from cv_analysis.utils.rectangle import Rectangle
@pytest.fixture(
params=[
# "rough_grain",
# "plain",
# "digital",
"crumpled",
]
)
def base_texture(request, size):
texture = Image.open(TEST_PAGE_TEXTURES_DIR / (request.param + ".jpg"))
texture = texture.resize(size)
# texture.putalpha(255) # ISSUE 1!!!
return texture
@pytest.fixture(
params=[
"portrait",
# "landscape",
]
)
def orientation(request):
return request.param
@pytest.fixture(
params=[
# 30,
100,
]
)
def dpi(request):
return request.param
@pytest.fixture(
params=[
# "brown",
# "sepia",
# "gray",
"white",
# "light_red",
# "light_blue",
]
)
def color_name(request):
return request.param
@pytest.fixture(
params=[
# "smooth",
# "coarse",
"neutral",
]
)
def texture_name(request):
return request.param
@pytest.fixture(
params=[
# 30,
70,
# 150,
]
)
def color_intensity(request):
return request.param
def random_flip(image):
if rnd.choice([True, False]):
image = image.transpose(Transpose.FLIP_LEFT_RIGHT)
if rnd.choice([True, False]):
image = image.transpose(Transpose.FLIP_TOP_BOTTOM)
return image
@pytest.fixture
def color(color_name):
return {
"brown": "#7d6c5b",
"sepia": "#b8af88",
"gray": "#9c9c9c",
"white": "#ffffff",
"light_red": "#d68c8b",
"light_blue": "#8bd6d6",
}[color_name]
@pytest.fixture
def texture_fn(texture_name, size):
if texture_name == "smooth":
fn = blur
elif texture_name == "coarse":
fn = compose(overlay, juxt(blur, sharpen))
else:
fn = identity
return normalize_image_function(fn)
def normalize_image_function(func):
def inner(image):
image = normalize_image_format_to_array(image)
image = func(image)
image = normalize_image_format_to_pil(image)
return image
return inner
@pytest.fixture
def texture(tinted_blank_page, base_texture):
texture = superimpose(base_texture, tinted_blank_page)
return texture
@pytest.fixture
def tinted_blank_page(size, color, color_intensity):
tinted_page = Image.new("RGBA", size, color)
tinted_page.putalpha(color_intensity)
return tinted_page
@pytest.fixture
def blank_page(size, color, color_intensity):
page = Image.new("RGBA", size, color=(255, 255, 255, 0))
return page
@pytest.fixture
def size(dpi, orientation):
if orientation == "portrait":
size = (8.5 * dpi, 11 * dpi)
elif orientation == "landscape":
size = (11 * dpi, 8.5 * dpi)
else:
raise ValueError(f"Unknown orientation: {orientation}")
size = tuple(map(int, size))
return size
class ContentGenerator:
def __init__(self):
self.constrain_layouts = True
def __call__(self, boxes: List[Rectangle]) -> Image:
rnd.shuffle(boxes)
figure_boxes, text_boxes = lsplit(is_square_like, boxes)
if self.constrain_layouts:
figure_boxes = merge_related_rectangles(figure_boxes)
figure_boxes = lfilter(is_square_like, figure_boxes)
text_boxes = merge_related_rectangles(text_boxes)
boxes = list(
itertools.chain(
map(generate_random_text_block, every_nth(2, text_boxes)),
*zipmap(generate_recursive_random_table_with_caption, every_nth(2, text_boxes[1:])),
*zipmap(generate_recursive_random_table_with_caption, every_nth(2, figure_boxes)),
*zipmap(generate_random_plot_with_caption, every_nth(2, figure_boxes[1:])),
)
)
if self.constrain_layouts:
boxes = remove_included(boxes)
boxes = remove_overlapping(boxes)
return boxes
def zipmap(fn, boxes, n=2):
rets = lmap(list, zip(*map(fn, boxes)))
yield from repeatedly(lambda: [], n) if len(rets) < n else rets
def every_nth(n, iterable):
return itertools.islice(iterable, 0, None, n)
def generate_random_plot_with_caption(rectangle: Rectangle):
plot_box, caption_box = split_into_figure_and_caption(rectangle)
plot_box = generate_random_plot(plot_box)
caption_box = generate_random_image_caption(caption_box)
return plot_box, caption_box
# TODO: deduplicate with generate_random_table_with_caption
def generate_recursive_random_table_with_caption(rectangle: Rectangle):
table_box, caption_box = split_into_figure_and_caption(rectangle)
table_box = generate_recursive_random_table(table_box, double_rule=probably())
caption_box = generate_random_table_caption(caption_box)
return table_box, caption_box
def split_into_figure_and_caption(rectangle: Rectangle):
gap_percentage = rnd.uniform(0, 0.03)
split_point = rnd.uniform(0.5, 0.9)
figure_box = Rectangle(
rectangle.x1, rectangle.y1, rectangle.x2, rectangle.y1 + rectangle.height * (split_point - gap_percentage / 2)
)
caption_box = Rectangle(
rectangle.x1, rectangle.y1 + rectangle.height * (split_point + gap_percentage / 2), rectangle.x2, rectangle.y2
)
return figure_box, caption_box
def generate_random_plot(rectangle: Rectangle) -> ContentRectangle:
block = RandomPlot(*rectangle.coords)
block.content = rectangle.content if isinstance(rectangle, ContentRectangle) else None # TODO: Refactor
block.generate_random_plot(rectangle)
return block
def generate_recursive_random_table(rectangle: Rectangle, **kwargs) -> ContentRectangle:
block = RecursiveRandomTable(*rectangle.coords, **kwargs)
if isinstance(rectangle, RecursiveRandomTable):
block.content = rectangle.content if rectangle.content else None # TODO: Refactor
block.generate_random_table()
return block
class Size(Enum):
SMALL = 120
MEDIUM = 180
LARGE = 300
def get_size_class(rectangle: Rectangle):
size = get_size(rectangle)
if size < Size.SMALL.value:
return Size.SMALL
elif size < Size.LARGE.value:
return Size.MEDIUM
else:
return Size.LARGE
def get_size(rectangle: Rectangle):
size = sqrt(area(rectangle))
return size
def get_random_color_complementing_color_map(colormap):
def color_complement(r, g, b):
"""Reference: https://stackoverflow.com/a/40234924"""
def hilo(a, b, c):
if c < b:
b, c = c, b
if b < a:
a, b = b, a
if c < b:
b, c = c, b
return a + c
k = hilo(r, g, b)
return tuple(k - u for u in (r, g, b))
color = colormap(0.2)[:3]
color = [int(255 * v) for v in color]
color = color_complement(*color)
return color
@lru_cache(maxsize=None)
def get_random_background_color():
return tuple([*get_random_color_complementing_color_map(pick_colormap()), rnd.randint(100, 210)])
class RecursiveRandomTable(RandomContentRectangle):
def __init__(self, x1, y1, x2, y2, border_width=1, layout: str = None, double_rule=False):
"""A table with a random number of rows and columns, and random content in each cell.
Args:
x1: x-coordinate of the top-left corner
y1: y-coordinate of the top-left corner
x2: x-coordinate of the bottom-right corner
y2: y-coordinate of the bottom-right corner
border_width: width of the table border
layout: layout of the table, either "horizontal", "vertical", "closed", or "open"
double_rule: whether to use double rules as the top and bottom rules
"""
assert layout in [None, "horizontal", "vertical", "closed", "open"]
super().__init__(x1, y1, x2, y2)
self.double_rule = double_rule
self.double_rule_width = (3 * border_width) if self.double_rule else 0
self.n_columns = rnd.randint(1, max(self.width // 100, 1))
self.n_rows = rnd.randint(1, max((self.height - 2 * self.double_rule_width) // rnd.randint(17, 100), 1))
self.cell_size = (self.width / self.n_columns, (self.height - 2 * self.double_rule_width) / self.n_rows)
self.content = Image.new("RGBA", (self.width, self.height), (255, 255, 255, 0))
self.background_color = get_random_background_color()
logger.info(f"Background color: {self.background_color}")
self.layout = layout or self.pick_random_layout()
logger.debug(f"Layout: {self.layout}")
def pick_random_layout(self):
if self.n_columns == 1 and self.n_rows == 1:
layout = "closed"
elif self.n_columns == 1:
layout = rnd.choice(["vertical", "closed"])
elif self.n_rows == 1:
layout = rnd.choice(["horizontal", "closed"])
else:
layout = rnd.choice(["closed", "horizontal", "vertical", "open"])
return layout
def generate_random_table(self):
cells = self.generate_table()
cells = list(self.fill_cells_with_content(cells))
# FIXME: There is a bug here: Table rule is not drawn correctly, actually we want to do cells = ...
list(self.draw_cell_borders(cells))
self.content = paste_contents(self.content, cells)
assert self.content.mode == "RGBA"
def fill_cells_with_content(self, cells):
yield from map(self.build_cell, cells)
def build_cell(self, cell):
if self.__is_a_small_cell(cell):
cell = self.build_small_cell(cell)
elif self.__is_a_medium_sized_cell(cell):
cell = self.build_medium_sized_cell(cell)
elif self.__is_a_large_cell(cell):
cell = self.build_large_cell(cell)
else:
raise ValueError(f"Invalid cell size: {get_size(cell)}")
assert cell.content.mode == "RGBA"
return cell
def __is_a_small_cell(self, cell):
return get_size(cell) <= Size.SMALL.value
def __is_a_medium_sized_cell(self, cell):
return get_size(cell) <= Size.MEDIUM.value
def __is_a_large_cell(self, cell):
return get_size(cell) > Size.MEDIUM.value
def build_small_cell(self, cell):
content = (possibly() and generate_random_words(1, 3)) or (
generate_random_number()
+ ((possibly() and " " + rnd.choice(["$", "£", "%", "EUR", "USD", "CAD", "ADA"])) or "")
)
return generate_text_block(cell, content)
def build_medium_sized_cell(self, cell):
choice = rnd.choice(["plot", "recurse"])
if choice == "plot":
return generate_random_plot(cell)
elif choice == "recurse":
return generate_recursive_random_table(
cell,
border_width=1,
layout=random.choice(["open", "horizontal", "vertical"]),
double_rule=False,
)
else:
return generate_text_block(cell, f"{choice} {get_size(cell):.0f} {get_size_class(cell).name}")
def build_large_cell(self, cell):
choice = rnd.choice(["plot", "recurse"])
logger.debug(f"Generating {choice} {get_size(cell):.0f} {get_size_class(cell).name}")
if choice == "plot" and is_square_like(cell):
return generate_random_plot(cell)
else:
logger.debug(f"recurse {get_size(cell):.0f} {get_size_class(cell).name}")
return generate_recursive_random_table(
cell,
border_width=1,
layout=random.choice(["open", "horizontal", "vertical"]),
double_rule=False,
)
def draw_cell_borders(self, cells: List[ContentRectangle]):
def draw_edges_based_on_position(cell: Cell, col_idx, row_index):
# Draw the borders of the cell based on its position in the table
if col_idx < self.n_columns - 1:
cell.draw_right_border()
if row_index < self.n_rows - 1:
cell.draw_bottom_border()
columns = chunks(self.n_rows, cells)
for col_idx, column in enumerate(columns):
for row_index, cell in enumerate(column):
# TODO: Refactor
c = Cell(*cell.coords, self.background_color)
c.content = cell.content
draw_edges_based_on_position(c, col_idx, row_index)
yield cell
if self.layout == "closed":
# TODO: Refactor
c = Cell(*self.coords, self.background_color)
c.content = self.content
c.draw()
yield self
# TODO: Refactor
if self.double_rule:
c1 = Cell(*self.coords)
c1.draw_top_border(width=1)
c1.draw_bottom_border(width=1)
x1, y1, x2, y2 = self.coords
c2 = Cell(x1, y1 + self.double_rule_width, x2, y2 - self.double_rule_width)
c2.draw_top_border(width=1)
c2.draw_bottom_border(width=1)
c = superimpose(c1.content, c2.content)
self.content = superimpose(c, self.content)
yield self
def generate_table(self) -> Iterable[ContentRectangle]:
yield from mapcat(self.generate_column, range(self.n_columns))
def generate_column(self, column_index) -> Iterable[ContentRectangle]:
logger.trace(f"Generating column {column_index}.")
generate_cell_for_row_index = partial(self.generate_cell, column_index)
yield from map(generate_cell_for_row_index, range(self.n_rows))
def generate_cell(self, column_index, row_index) -> ContentRectangle:
w, h = self.cell_size
x1, y1 = (column_index * w), (row_index * h) + self.double_rule_width
x2, y2 = x1 + w, y1 + h
logger.trace(f"Generating cell ({row_index}, {column_index}) at ({x1}, {y1}, {x2}, {y2}).")
return Cell(x1, y1, x2, y2, self.background_color)
def generate_column_names(self):
column_names = repeatedly(self.generate_column_name, self.n_columns)
return column_names
def generate_column_name(self):
column_name = generate_random_words(1, 3)
return column_name
class Cell(ContentRectangle):
def __init__(self, x1, y1, x2, y2, color=None):
super().__init__(x1, y1, x2, y2)
self.background_color = color or (255, 255, 255, 0)
# to debug use random border color: tuple([random.randint(100, 200) for _ in range(3)] + [255])
self.cell_border_color = (0, 0, 0, 255)
self.border_width = 1
self.inset = 1
self.content = Image.new("RGBA", (self.width, self.height))
self.fill()
def draw_top_border(self, width=None):
self.draw_line((0, 0, self.width - self.inset, 0), width=width)
return self
def draw_bottom_border(self, width=None):
self.draw_line((0, self.height - self.inset, self.width - self.inset, self.height - self.inset), width=width)
return self
def draw_left_border(self, width=None):
self.draw_line((0, 0, 0, self.height), width=width)
return self
def draw_right_border(self, width=None):
self.draw_line((self.width - self.inset, 0, self.width - self.inset, self.height), width=width)
return self
def draw_line(self, points, width=None):
width = width or self.border_width
draw = ImageDraw.Draw(self.content)
draw.line(points, width=width, fill=self.cell_border_color)
return self
def draw(self, width=None):
self.draw_top_border(width=width)
self.draw_bottom_border(width=width)
self.draw_left_border(width=width)
self.draw_right_border(width=width)
return self
def draw_top_left_corner(self, width=None):
self.draw_line((0, 0, 0, 0), width=width)
self.draw_line((0, 0, 0, 0), width=width)
return self
def draw_top_right_corner(self, width=None):
self.draw_line((self.width - self.inset, 0, self.width - self.inset, 0), width=width)
self.draw_line((self.width - self.inset, 0, self.width - self.inset, 0), width=width)
return self
def draw_bottom_left_corner(self, width=None):
self.draw_line((0, self.height - self.inset, 0, self.height - self.inset), width=width)
self.draw_line((0, self.height - self.inset, 0, self.height - self.inset), width=width)
return self
def draw_bottom_right_corner(self, width=None):
self.draw_line(
(self.width - self.inset, self.height - self.inset, self.width - self.inset, self.height - self.inset),
width=width,
)
self.draw_line(
(self.width - self.inset, self.height - self.inset, self.width - self.inset, self.height - self.inset),
width=width,
)
return self
def fill(self, color=None):
color = color or self.background_color
image = Image.new("RGBA", (self.width, self.height), color=color)
self.content = superimpose(image, self.content)
return self
def shrink_rectangle(rectangle: Rectangle, factor: float) -> Rectangle:
x1, y1, x2, y2 = compute_scaled_coordinates(rectangle, (1 - factor))
logger.trace(f"Shrinking {rectangle} by {factor} to ({x1}, {y1}, {x2}, {y2}).")
assert x1 >= rectangle.x1
assert y1 >= rectangle.y1
assert x2 <= rectangle.x2
assert y2 <= rectangle.y2
shrunk_rectangle = Rectangle(x1, y1, x2, y2)
if isinstance(rectangle, ContentRectangle): # TODO: Refactor
shrunk_rectangle = ContentRectangle(*shrunk_rectangle.coords, rectangle.content)
return shrunk_rectangle
def compute_scaled_coordinates(rectangle: Rectangle, factor: float) -> Tuple[int, int, int, int]:
# TODO: Refactor: Using image to compute coordinates is not clean
image = Image.new("RGBA", (rectangle.width, rectangle.height))
scaled = image.resize((int(rectangle.width * factor), int(rectangle.height * factor)))
x1, y1 = compute_pasting_coordinates(scaled, image)
x1 = rectangle.x1 + x1
y1 = rectangle.y1 + y1
x2, y2 = x1 + scaled.width, y1 + scaled.height
return x1, y1, x2, y2
def generate_random_text_block(rectangle: Rectangle, n_sentences=3000) -> ContentRectangle:
block = TextBlock(
*rectangle.coords,
font=pick_random_font_available_on_system(
includes=("serif", "sans-serif"),
excludes=("bold", "mono", "italic", "oblique", "cursive"),
),
font_size=30, # TODO: De-hardcode font size... Seems to have no effect on top of that
)
block.content = rectangle.content if isinstance(rectangle, ContentRectangle) else None # TODO: Refactor
block.generate_random_text(rectangle, n_sentences)
return block
def generate_random_image_caption(rectangle: Rectangle) -> ContentRectangle:
return generate_random_caption(rectangle, f"Fig {rnd.randint(1, 20)}")
def generate_random_table_caption(rectangle: Rectangle) -> ContentRectangle:
return generate_random_caption(rectangle, f"Tabl {rnd.randint(1, 20)}")
def generate_random_caption(rectangle: Rectangle, caption_start, n_sentences=1000) -> ContentRectangle:
block = TextBlock(
*rectangle.coords,
text_generator=CaptionGenerator(caption_start=caption_start),
font=pick_random_font_available_on_system(
includes=("italic",),
excludes=("bold", "mono"),
),
font_size=100, # TODO: De-hardcode font size... Seems to have no effect on top of that
)
block.content = rectangle.content if isinstance(rectangle, ContentRectangle) else None # TODO: Refactor
block.generate_random_text(rectangle, n_sentences)
return block
def generate_text_block(rectangle: Rectangle, text) -> ContentRectangle:
block = TextBlock(
*rectangle.coords,
font=pick_random_font_available_on_system(
includes=("serif", "sans-serif", "bold"),
excludes=("mono", "italic", "oblique", "cursive"),
),
font_size=30, # TODO: De-hardcode font size... Seems to have no effect on top of that
)
block.content = rectangle.content if isinstance(rectangle, ContentRectangle) else None # TODO: Refactor
block.put_text(text, rectangle)
return block
def paste_content(page, content_box: ContentRectangle):
assert content_box.content.mode == "RGBA"
page.paste(content_box.content, (content_box.x1, content_box.y1), content_box.content)
return page
def paste_contents(page, contents: Iterable[ContentRectangle]):
page = deepcopy(page)
for content in contents:
paste_content(page, content)
return page
@pytest.fixture(
params=[
TwoColumnPagePartitioner,
# RandomPagePartitioner
]
)
def page_partitioner(request):
return request.param()
@pytest.fixture
def boxes(page_partitioner, blank_page):
boxes = page_partitioner(blank_page)
return boxes
@pytest.fixture
def prepared_texture(texture, texture_fn):
texture = random_flip(texture)
texture = texture_fn(texture)
return texture
@pytest.fixture
def content_boxes(boxes):
content_generator = ContentGenerator()
content_boxes = content_generator(boxes)
return content_boxes
@pytest.fixture
def page_with_opaque_content(
blank_page, tinted_blank_page, prepared_texture, content_boxes
) -> Tuple[np.ndarray, Iterable[Rectangle]]:
"""Creates a page with content"""
page = paste_contents(prepared_texture, content_boxes)
return page, content_boxes
@pytest.fixture
def page_with_translucent_content(
blank_page, tinted_blank_page, prepared_texture, content_boxes
) -> Tuple[np.ndarray, List[Rectangle]]:
"""Creates a page with content"""
page_content = paste_contents(blank_page, content_boxes)
page = blend_by_multiply(page_content, prepared_texture)
return page, content_boxes
def blend_by_multiply(page_content, texture):
def to_array(image: Image) -> np.ndarray:
return np.array(image).astype(np.float32)
texture.putalpha(255)
page_content.putalpha(255)
factor = 1.2
enhancer = ImageEnhance.Contrast(texture)
texture = enhancer.enhance(factor)
page = blend_modes.multiply(
*map(
to_array,
(
page_content,
texture,
),
),
opacity=1,
).astype(np.uint8)
return page
@pytest.fixture
def page_with_content(
page_with_translucent_content,
# page_with_opaque_content,
) -> np.ndarray:
page, boxes = page_with_translucent_content
# page, boxes = page_with_opaque_content
draw_boxes(page, boxes)
return page
def draw_boxes(page: Image, boxes: Iterable[Rectangle]):
from cv_analysis.utils.drawing import draw_rectangles
page = draw_rectangles(page, boxes, filled=False, annotate=True)
show_image(page, backend="pil")