669 lines
19 KiB
Python
669 lines
19 KiB
Python
import io
|
|
import itertools
|
|
import random
|
|
import textwrap
|
|
from functools import partial, lru_cache
|
|
from itertools import repeat
|
|
from typing import Tuple, Union, Iterable, List
|
|
|
|
import albumentations as A
|
|
import cv2 as cv
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image, ImageOps, ImageFont, ImageDraw
|
|
from PIL.Image import Transpose
|
|
from faker import Faker
|
|
from matplotlib import pyplot as plt
|
|
|
|
from cv_analysis.utils import star, rconj
|
|
from cv_analysis.utils.merging import merge_related_rectangles
|
|
|
|
Image_t = Union[Image.Image, np.ndarray]
|
|
#
|
|
# transform = A.Compose(
|
|
# [
|
|
# # geometric transforms
|
|
# A.HorizontalFlip(p=0.2),
|
|
# A.RandomRotate90(p=0.2),
|
|
# A.VerticalFlip(p=0.2),
|
|
# # brightness and contrast transforms
|
|
# A.OneOf(
|
|
# [
|
|
# A.RandomGamma(p=0.5),
|
|
# A.RandomBrightnessContrast(p=0.5),
|
|
# ],
|
|
# p=0.5,
|
|
# ),
|
|
# # noise transforms
|
|
# A.SomeOf(
|
|
# [
|
|
# A.Emboss(p=0.05),
|
|
# A.ImageCompression(p=0.05),
|
|
# A.PixelDropout(p=0.05),
|
|
# ],
|
|
# p=0.5,
|
|
# n=2,
|
|
# ),
|
|
# # color transforms
|
|
# A.SomeOf(
|
|
# [
|
|
# A.ColorJitter(p=1),
|
|
# A.RGBShift(p=1, r_shift_limit=0.1, g_shift_limit=0.1, b_shift_limit=0.1),
|
|
# A.ChannelShuffle(p=1),
|
|
# ],
|
|
# p=0.5,
|
|
# n=3, # 3 => all
|
|
# ),
|
|
# # blurring and sharpening transforms
|
|
# A.OneOf(
|
|
# [
|
|
# A.GaussianBlur(p=0.05),
|
|
# A.MotionBlur(p=0.05, blur_limit=21),
|
|
# A.Sharpen(p=0.05),
|
|
# ],
|
|
# p=0.5,
|
|
# ),
|
|
# # environmental transforms
|
|
# A.OneOf(
|
|
# [
|
|
# A.RandomRain(p=0.2, rain_type="drizzle"),
|
|
# A.RandomFog(p=0.2, fog_coef_upper=0.4),
|
|
# A.RandomSnow(p=0.2),
|
|
# ],
|
|
# p=0.5,
|
|
# ),
|
|
# ],
|
|
# p=0.5,
|
|
# )
|
|
from funcy import (
|
|
juxt,
|
|
compose,
|
|
identity,
|
|
lflatten,
|
|
lmap,
|
|
first,
|
|
iterate,
|
|
take,
|
|
last,
|
|
rest,
|
|
rcompose,
|
|
pairwise,
|
|
interleave,
|
|
keep,
|
|
)
|
|
|
|
from cv_analysis.locations import TEST_PAGE_TEXTURES_DIR
|
|
|
|
# transform = A.Compose(
|
|
# [
|
|
# # brightness and contrast transforms
|
|
# A.OneOf(
|
|
# [
|
|
# A.RandomGamma(p=0.2),
|
|
# A.RandomBrightnessContrast(p=0.2, brightness_limit=0.05, contrast_limit=0.05),
|
|
# ],
|
|
# p=0.5,
|
|
# ),
|
|
# # color transforms
|
|
# A.SomeOf(
|
|
# [
|
|
# A.ColorJitter(p=1),
|
|
# A.RGBShift(p=1, r_shift_limit=0.3, g_shift_limit=0.3, b_shift_limit=0.3),
|
|
# A.ChannelShuffle(p=1),
|
|
# ],
|
|
# p=1.0,
|
|
# n=3, # 3 => all
|
|
# ),
|
|
# # # blurring and sharpening transforms
|
|
# # A.OneOf(
|
|
# # [
|
|
# # A.GaussianBlur(p=0.05),
|
|
# # A.MotionBlur(p=0.05, blur_limit=21),
|
|
# # A.Sharpen(p=0.05),
|
|
# # ],
|
|
# # p=0.0,
|
|
# # ),
|
|
# ]
|
|
# )
|
|
from cv_analysis.utils.display import show_image
|
|
from cv_analysis.utils.drawing import draw_rectangles
|
|
from cv_analysis.utils.rectangle import Rectangle
|
|
|
|
transform = A.Compose(
|
|
[
|
|
# A.ColorJitter(p=1),
|
|
]
|
|
)
|
|
|
|
|
|
Color = Tuple[int, int, int]
|
|
|
|
|
|
@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)
|
|
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 random.choice([True, False]):
|
|
image = image.transpose(Transpose.FLIP_LEFT_RIGHT)
|
|
if random.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 blur(image: np.ndarray):
|
|
return cv.blur(image, (3, 3))
|
|
|
|
|
|
def normalize_image_format_to_array(image: Image_t):
|
|
if isinstance(image, Image.Image):
|
|
return np.array(image)
|
|
return image
|
|
|
|
|
|
def normalize_image_format_to_pil(image: Image_t):
|
|
if isinstance(image, np.ndarray):
|
|
return Image.fromarray(image)
|
|
return image
|
|
|
|
|
|
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
|
|
|
|
|
|
def sharpen(image: np.ndarray):
|
|
return cv.filter2D(image, -1, np.array([[-1, -1, -1], [-1, 6, -1], [-1, -1, -1]]))
|
|
|
|
|
|
def overlay(images, mode=np.sum):
|
|
assert mode in [np.sum, np.max]
|
|
images = np.stack(list(images))
|
|
image = mode(images, axis=0)
|
|
image = (image / image.max() * 255).astype(np.uint8)
|
|
return image
|
|
|
|
|
|
@pytest.fixture
|
|
def texture(base_texture, color, color_intensity):
|
|
color_image = Image.new("RGBA", base_texture.size, color)
|
|
color_image.putalpha(color_intensity)
|
|
texture = superimpose_texture_with_transparency(base_texture, color_image)
|
|
return texture
|
|
|
|
|
|
def tint_image(src, color="#FFFFFF"):
|
|
src.load()
|
|
r, g, b, alpha = src.split()
|
|
gray = ImageOps.grayscale(src)
|
|
result = ImageOps.colorize(gray, (0, 0, 0), color)
|
|
result.putalpha(alpha)
|
|
return result
|
|
|
|
|
|
def color_shift_array(image: np.ndarray, color: Color):
|
|
"""Creates a 3-tensor from a 2-tensor by stacking the 2-tensor three times weighted by the color tuple."""
|
|
assert image.ndim == 3
|
|
assert image.shape[-1] == 3
|
|
assert isinstance(color, tuple)
|
|
assert max(color) <= 255
|
|
assert image.max() <= 255
|
|
|
|
color = np.array(color)
|
|
weights = color / color.sum() / 10
|
|
assert max(weights) <= 1
|
|
|
|
colored = (image * weights).astype(np.uint8)
|
|
|
|
assert colored.shape == image.shape
|
|
|
|
return colored
|
|
|
|
|
|
@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
|
|
|
|
|
|
def superimpose_texture_with_transparency(page: Image, texture: Image) -> Image:
|
|
"""Superimposes a noise image with transparency onto a page image."""
|
|
assert page.mode == "RGB"
|
|
assert texture.mode == "RGBA"
|
|
assert page.size == texture.size
|
|
page.paste(texture, (0, 0), texture)
|
|
return page
|
|
|
|
|
|
@pytest.fixture
|
|
def blank_page(texture, texture_fn) -> np.ndarray:
|
|
"""Creates a blank page with a given orientation and dpi."""
|
|
page = random_flip(texture)
|
|
page = texture_fn(page)
|
|
page_partitioner = PagePartitioner()
|
|
boxes = page_partitioner(page)
|
|
content_generator = ContentGenerator()
|
|
boxes = content_generator(boxes)
|
|
page = paste_contents(page, boxes)
|
|
page_partitioner.draw_boxes(page, boxes)
|
|
|
|
page = np.array(page)
|
|
return page
|
|
|
|
|
|
class ContentRectangle(Rectangle):
|
|
def __init__(self, x1, y1, x2, y2, content=None):
|
|
super().__init__(x1, y1, x2, y2)
|
|
self.content = content
|
|
|
|
|
|
class ContentGenerator:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def __call__(self, boxes: List[Rectangle]) -> Image:
|
|
random.shuffle(boxes)
|
|
|
|
text_boxes = lmap(generate_random_text_block, every_nth(boxes, 2))
|
|
plots = lmap(generate_random_plot, every_nth(boxes[1:], 2))
|
|
|
|
return text_boxes + plots
|
|
|
|
|
|
def every_nth(iterable, n):
|
|
return itertools.islice(iterable, 0, None, n)
|
|
|
|
|
|
def generate_random_plot(rectangle: Rectangle) -> ContentRectangle:
|
|
block = RandomPlot(*rectangle.coords)
|
|
block.generate_random_plot(rectangle)
|
|
return block
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def get_random_seed():
|
|
return random.randint(0, 2**32 - 1)
|
|
|
|
|
|
class RandomContentRectangle(ContentRectangle):
|
|
def __init__(self, x1, y1, x2, y2, content=None, seed=None):
|
|
super().__init__(x1, y1, x2, y2, content)
|
|
self.seed = seed or get_random_seed()
|
|
self.random = random.Random(self.seed)
|
|
|
|
|
|
class RandomPlot(RandomContentRectangle):
|
|
def __init__(self, x1, y1, x2, y2, seed=None):
|
|
super().__init__(x1, y1, x2, y2, seed=seed)
|
|
|
|
cmap_name = self.random.choice(
|
|
[
|
|
"viridis",
|
|
"plasma",
|
|
"inferno",
|
|
"magma",
|
|
"cividis",
|
|
],
|
|
)
|
|
self.cmap = plt.get_cmap(cmap_name)
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def generate_random_plot(self, rectangle: Rectangle):
|
|
# noinspection PyArgumentList
|
|
random.choice(
|
|
[
|
|
self.generate_random_line_plot,
|
|
self.generate_random_bar_plot,
|
|
self.generate_random_scatter_plot,
|
|
self.generate_random_histogram,
|
|
self.generate_random_pie_chart,
|
|
]
|
|
)(rectangle)
|
|
|
|
def generate_random_bar_plot(self, rectangle: Rectangle):
|
|
x = sorted(np.random.randint(low=1, high=11, size=5))
|
|
y = np.random.randint(low=1, high=11, size=5)
|
|
image = self.__generate_random_plot(plt.bar, rectangle, x, y)
|
|
self.content = image
|
|
|
|
def generate_random_line_plot(self, rectangle: Rectangle):
|
|
f = random.choice([np.sin, np.cos, np.tan, np.exp, np.log, np.sqrt, np.square])
|
|
|
|
x = np.linspace(0, 10, 100)
|
|
y = f(x)
|
|
|
|
image = self.__generate_random_plot(plt.plot, rectangle, x, y)
|
|
self.content = image
|
|
|
|
def generate_random_scatter_plot(self, rectangle: Rectangle):
|
|
x = np.random.normal(size=100)
|
|
y = np.random.normal(size=100)
|
|
image = self.__generate_random_plot(plt.scatter, rectangle, x, y)
|
|
self.content = image
|
|
|
|
def generate_random_histogram(self, rectangle: Rectangle):
|
|
x = np.random.normal(size=100)
|
|
image = self.__generate_random_plot(plt.hist, rectangle, x, 10)
|
|
self.content = image
|
|
|
|
def generate_random_pie_chart(self, rectangle: Rectangle):
|
|
x = np.random.uniform(size=10)
|
|
print(x)
|
|
image = self.__generate_random_plot(
|
|
plt.pie, rectangle, x, None, plot_kwargs=self.generate_plot_kwargs(keywords=["a"])
|
|
)
|
|
self.content = image
|
|
|
|
def generate_plot_kwargs(self, keywords=None):
|
|
|
|
kwargs = {
|
|
"color": random.choice(self.cmap.colors),
|
|
"linestyle": random.choice(["-", "--", "-.", ":"]),
|
|
"linewidth": random.uniform(0.5, 2),
|
|
}
|
|
|
|
return kwargs if not keywords else {k: v for k, v in kwargs.items() if k in keywords}
|
|
|
|
def __generate_random_plot(self, plot_fn, rectangle: Rectangle, x, y, plot_kwargs=None):
|
|
|
|
plot_kwargs = self.generate_plot_kwargs() if plot_kwargs is None else plot_kwargs
|
|
print(plot_kwargs)
|
|
|
|
fig, ax = plt.subplots()
|
|
fig.set_size_inches(rectangle.width / 100, rectangle.height / 100)
|
|
fig.tight_layout(pad=0)
|
|
|
|
plot_fn(
|
|
x,
|
|
y,
|
|
**plot_kwargs,
|
|
)
|
|
ax.set_facecolor("none")
|
|
|
|
# disable axes at random
|
|
maybe() and ax.set_xticks([])
|
|
maybe() and ax.set_yticks([])
|
|
maybe() and ax.set_xticklabels([])
|
|
maybe() and ax.set_yticklabels([])
|
|
maybe() and ax.set_xlabel("")
|
|
maybe() and ax.set_ylabel("")
|
|
maybe() and ax.set_title("")
|
|
maybe() and ax.set_frame_on(False)
|
|
|
|
# remove spines at random
|
|
maybe() and (ax.spines["top"].set_visible(False) or ax.spines["right"].set_visible(False))
|
|
|
|
buf = io.BytesIO()
|
|
plt.savefig(buf, format="png", transparent=True)
|
|
buf.seek(0)
|
|
image = Image.open(buf)
|
|
image = image.resize((rectangle.width, rectangle.height))
|
|
buf.close()
|
|
plt.close()
|
|
return image
|
|
|
|
|
|
def maybe():
|
|
return random.random() > 0.9
|
|
|
|
|
|
def generate_random_text_block(rectangle: Rectangle) -> ContentRectangle:
|
|
block = RandomTextBlock(*rectangle.coords)
|
|
block.generate_random_text(rectangle)
|
|
return block
|
|
|
|
|
|
class RandomTextBlock(ContentRectangle):
|
|
def __init__(self, x1, y1, x2, y2):
|
|
super().__init__(x1, y1, x2, y2)
|
|
self.blank_line_percentage = random.uniform(0, 0.5)
|
|
self.font = ImageFont.load_default()
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def generate_random_text(self, rectangle: Rectangle):
|
|
def write_line(line, line_number):
|
|
draw.text((0, line_number * text_size), line, font=self.font, fill=(0, 0, 0, 200))
|
|
|
|
image = Image.new("RGBA", (rectangle.width, rectangle.height), (0, 255, 255, 0))
|
|
draw = ImageDraw.Draw(image)
|
|
text = Faker().paragraph(nb_sentences=1000, variable_nb_sentences=False, ext_word_list=None)
|
|
|
|
wrapped_text = textwrap.wrap(text, width=image.width, break_long_words=False)
|
|
text_size = draw.textsize(first(wrapped_text), font=self.font)[1]
|
|
|
|
lines = last(take(len(wrapped_text), iterate(star(self.format_lines), (True, wrapped_text))))[1]
|
|
|
|
for line_number, line in enumerate(lines):
|
|
write_line(line, line_number)
|
|
|
|
self.content = image
|
|
|
|
def format_lines(self, last_full, lines):
|
|
def truncate_current_line():
|
|
return random.random() < self.blank_line_percentage and last_full
|
|
|
|
# This is meant to be read from the bottom up.
|
|
current_line_shall_not_be_a_full_line = truncate_current_line()
|
|
line_formatter = self.truncate_line if current_line_shall_not_be_a_full_line else identity
|
|
format_current_line = compose(line_formatter, first)
|
|
move_current_line_to_back = star(rconj)
|
|
split_first_line_from_lines_and_format_the_former = juxt(rest, format_current_line)
|
|
split_off_current_line_then_format_it_then_move_it_to_the_back = rcompose(
|
|
split_first_line_from_lines_and_format_the_former,
|
|
move_current_line_to_back,
|
|
)
|
|
current_line_is_a_full_line = not current_line_shall_not_be_a_full_line
|
|
# Start reading here and move up.
|
|
return current_line_is_a_full_line, split_off_current_line_then_format_it_then_move_it_to_the_back(lines)
|
|
|
|
def format_line(self, line, full=True):
|
|
line = self.truncate_line(line) if not full else line
|
|
return line, full
|
|
|
|
def truncate_line(self, line: str):
|
|
n_trailing_words = random.randint(0, 4)
|
|
line = " ".join(line.split()[-n_trailing_words - 1 : -1]).replace(".", "")
|
|
line = line + ".\n" if line else line
|
|
return line
|
|
|
|
|
|
def paste_content(page, content_box: ContentRectangle):
|
|
assert page.mode == "RGB"
|
|
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]):
|
|
for content in contents:
|
|
paste_content(page, content)
|
|
return page
|
|
|
|
|
|
class PagePartitioner:
|
|
def __init__(self):
|
|
self.left_margin_percentage = 0.05
|
|
self.right_margin_percentage = 0.05
|
|
self.top_margin_percentage = 0.1
|
|
self.bottom_margin_percentage = 0.1
|
|
|
|
self.margin_percentage = 0.005
|
|
self.max_depth = 3
|
|
self.initial_recursion_probability = 1
|
|
self.recursion_probability_decay = 0.1
|
|
|
|
def __call__(self, page: Image.Image) -> List[Rectangle]:
|
|
left_margin = int(page.width * self.left_margin_percentage)
|
|
right_margin = int(page.width * self.right_margin_percentage)
|
|
top_margin = int(page.height * self.top_margin_percentage)
|
|
bottom_margin = int(page.height * self.bottom_margin_percentage)
|
|
|
|
box = Rectangle(left_margin, top_margin, page.width - right_margin, page.height - bottom_margin)
|
|
boxes = lflatten(self.generate_content_boxes(box))
|
|
# boxes = self.drop_small_boxes(boxes, *page.size)
|
|
# boxes = merge_related_rectangles(boxes)
|
|
boxes = list(boxes)
|
|
return boxes
|
|
|
|
def draw_boxes(self, page: Image, boxes: Iterable[Rectangle]):
|
|
image = draw_rectangles(page, boxes, filled=False, annotate=True)
|
|
show_image(image)
|
|
|
|
def generate_content_boxes(self, box: Rectangle, depth=0):
|
|
if depth >= self.max_depth:
|
|
yield box
|
|
else:
|
|
child_boxes = self.generate_random_child_boxes(box)
|
|
if self.recurse(depth):
|
|
yield from (self.generate_content_boxes(b, depth + 1) for b in child_boxes)
|
|
else:
|
|
yield child_boxes
|
|
|
|
def generate_random_child_boxes(self, box: Rectangle) -> Tuple[Rectangle, Rectangle]:
|
|
axis = random.choice(["x", "y"])
|
|
|
|
edge_anchor_point, edge_length = (box.x1, box.width) if axis == "x" else (box.y1, box.height)
|
|
split_coordinate = random.uniform(0.3, 0.7) * edge_length + edge_anchor_point
|
|
child_boxes = self.get_child_boxes(box, split_coordinate, axis)
|
|
return child_boxes
|
|
|
|
def get_child_boxes(self, box: Rectangle, split_coordinate, axis) -> Tuple[Rectangle, Rectangle]:
|
|
def low(p):
|
|
return p * (1 + self.margin_percentage)
|
|
|
|
def high(p):
|
|
return p * (1 - self.margin_percentage)
|
|
|
|
if axis == "x":
|
|
return (
|
|
Rectangle(low(box.x1), low(box.y1), high(split_coordinate), high(box.y2)),
|
|
Rectangle(low(split_coordinate), low(box.y1), high(box.x2), high(box.y2)),
|
|
)
|
|
else:
|
|
return (
|
|
Rectangle(low(box.x1), low(box.y1), high(box.x2), high(split_coordinate)),
|
|
Rectangle(low(box.x1), low(split_coordinate), high(box.x2), high(box.y2)),
|
|
)
|
|
|
|
def recurse(self, depth):
|
|
return random.random() <= self.recursion_probability(depth)
|
|
|
|
def recursion_probability(self, depth):
|
|
return self.initial_recursion_probability * (1 - self.recursion_probability_decay) ** depth
|
|
|
|
def drop_small_boxes(
|
|
self,
|
|
boxes: Iterable[Rectangle],
|
|
page_width,
|
|
page_height,
|
|
min_percentage=0.13,
|
|
) -> List[Rectangle]:
|
|
min_width = page_width * min_percentage
|
|
min_height = page_height * min_percentage
|
|
return [b for b in boxes if b.width > min_width and b.height > min_height]
|