Last active
July 15, 2022 21:24
-
-
Save 0xc0392b/19a572aaaf5890045bd3d2d284c12d69 to your computer and use it in GitHub Desktop.
PIL collage script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from time import time | |
from math import ceil | |
from json import loads | |
from PIL.Image import new as new_image | |
from PIL.Image import open as open_image | |
from PIL.ImageDraw import Draw | |
from PIL.ImageFont import truetype | |
class Metadata: | |
""" schema metadata. | |
""" | |
def __init__(self, title, images_per_row, image_width, image_height, | |
image_x_padding, image_y_padding, vertical_margin, | |
horizontal_margin, title_font_path, title_font_size, | |
caption_font_path, caption_font_size, bg_colour, | |
title_colour, caption_colour): | |
self.title = title | |
self.images_per_row = images_per_row | |
self.image_width = image_width | |
self.image_height = image_height | |
self.image_x_padding = image_x_padding | |
self.image_y_padding = image_y_padding | |
self.vertical_margin = vertical_margin | |
self.title_font_path = title_font_path | |
self.title_font_size = title_font_size | |
self.horizontal_margin = horizontal_margin | |
self.caption_font_path = caption_font_path | |
self.caption_font_size = caption_font_size | |
self.bg_colour = bg_colour | |
self.title_colour = title_colour | |
self.caption_colour = caption_colour | |
@staticmethod | |
def from_dict(d): | |
return Metadata( | |
d["title"], | |
d["images_per_row"], | |
d["image_width"], | |
d["image_height"], | |
d["image_x_padding"], | |
d["image_y_padding"], | |
d["vertical_margin"], | |
d["horizontal_margin"], | |
d["title_font_path"], | |
d["title_font_size"], | |
d["caption_font_path"], | |
d["caption_font_size"], | |
d["bg_colour"], | |
d["title_colour"], | |
d["caption_colour"]) | |
class Schema: | |
""" schema contains metadata and a list of images to include in | |
the collage. | |
""" | |
def __init__(self, meta, images): | |
self.meta = meta | |
self.images = images | |
def __str__(self): | |
return f"schema with {len(self.images)} images" | |
@staticmethod | |
def from_json_file(path): | |
with open(path) as f: | |
text = f.read() | |
json = loads(text) | |
meta = Metadata.from_dict(json["meta"]) | |
images = Images.from_list(json["images"], | |
meta.image_width, | |
meta.image_height) | |
return Schema(meta, images) | |
class Images: | |
""" list of image objects with convenient iterator methods. | |
""" | |
def __init__(self, images): | |
self._images = images | |
def __len__(self): | |
return len(self._images) | |
def __iter__(self): | |
self._current_image_index = 0 | |
return self | |
def __next__(self): | |
if self._current_image_index < len(self._images): | |
next_image = self._images[self._current_image_index] | |
self._current_image_index += 1 | |
# modify each image object on iteration | |
# if necessary | |
# ... | |
return next_image | |
else: | |
raise StopIteration | |
@staticmethod | |
def from_list(l, width, height): | |
make_image = lambda x: Image.from_dict(x, width, height) | |
return Images(list(map(make_image, l))) | |
class Image: | |
""" wraps data for a single image. does not load the image from | |
disk in the constructor. calling load_file will read and return | |
the image file as a PIL image object, with appropriate transformations | |
already applied. | |
""" | |
def __init__(self, file_path, p1, p2, width, height, caption): | |
self._file_path = file_path | |
self._p1 = p1 | |
self._p2 = p2 | |
self.width = width | |
self.height = height | |
self.caption = caption | |
def __str__(self): | |
return f"[{self._file_path}] {self._p1} to {self._p2}, caption={self.caption}" | |
def load_file(self): | |
image = open_image(self._file_path) | |
image = image.crop(box=(self._p1.x, self._p1.y, | |
self._p2.x, self._p2.y)) | |
image = image.resize(size=(self.width, self.height)) | |
return image | |
@staticmethod | |
def from_dict(d, width, height): | |
return Image( | |
d["file_path"], | |
Point(d["p1"]["x"], d["p1"]["y"]), | |
Point(d["p2"]["x"], d["p2"]["y"]), | |
width, height, | |
d["caption"]) | |
class Point: | |
""" x,y point in 2d cartesian space. | |
""" | |
def __init__(self, x, y): | |
self.x = x | |
self.y = y | |
def __str__(self): | |
return f"point ({self.x}, {self.y})" | |
class Collage: | |
""" a collage defined in terms of a schema object. nothing happens in the | |
constructor. calling "save" with a file path will generate the collage image | |
and write to the path. a series of functions with no return values (defined | |
as staticmethods, used only for their side-effects) construct various parts of | |
the collage. | |
""" | |
def __init__(self, schema): | |
self._schema = schema | |
def __str__(self): | |
width = Collage.calculate_width(self._schema) | |
height = Collage.calculate_height(self._schema) | |
return f"{width}x{height}px collage" | |
@staticmethod | |
def make_base_image(schema, width, height): | |
return new_image( | |
mode="RGBA", | |
size=(width, height), | |
color=( | |
schema.meta.bg_colour["r"], | |
schema.meta.bg_colour["g"], | |
schema.meta.bg_colour["b"], | |
schema.meta.bg_colour["a"])) | |
@staticmethod | |
def calculate_width(schema): | |
return (schema.meta.images_per_row * schema.meta.image_width) + \ | |
(schema.meta.vertical_margin * 2) + \ | |
(schema.meta.image_x_padding * (schema.meta.images_per_row - 1)) | |
@staticmethod | |
def calculate_height(schema): | |
image_count = len(schema.images) | |
rows = ceil(image_count / schema.meta.images_per_row) | |
return (rows * schema.meta.image_height) + \ | |
(schema.meta.horizontal_margin * 2) + \ | |
(schema.meta.image_y_padding * (rows - 1)) | |
@staticmethod | |
def calculate_x_offset(schema): | |
return Offset( | |
schema.meta.vertical_margin, | |
schema.meta.image_width + schema.meta.image_x_padding) | |
@staticmethod | |
def calculate_y_offset(schema): | |
return Offset( | |
schema.meta.horizontal_margin, | |
schema.meta.image_width + schema.meta.image_y_padding) | |
@staticmethod | |
def add_title(schema, width, height, base): | |
title_draw = Draw(base) | |
title_font = truetype( | |
schema.meta.title_font_path, | |
schema.meta.title_font_size) | |
title_text_size = title_draw.textsize( | |
schema.meta.title, font=title_font) | |
x = (width // 2) - (title_text_size[0] // 2) | |
y = (schema.meta.horizontal_margin // 2) - (title_text_size[1] // 2) | |
xy = (x, y) | |
fill = (schema.meta.title_colour["r"], | |
schema.meta.title_colour["g"], | |
schema.meta.title_colour["b"]) | |
title_draw.text( | |
xy=xy, | |
text=schema.meta.title, | |
fill=fill, | |
font=title_font) | |
@staticmethod | |
def add_caption(schema, x_offset, y_offset, base, image): | |
cap_draw = Draw(base) | |
cap_font = truetype( | |
schema.meta.caption_font_path, | |
schema.meta.caption_font_size) | |
cap_text_size = cap_draw.textsize(image.caption, | |
font=cap_font) | |
x = x_offset.value + ((schema.meta.image_width // 2) - (cap_text_size[0]) // 2) | |
y = y_offset.value + schema.meta.image_height | |
xy = (x, y) | |
fill = ( | |
schema.meta.caption_colour["r"], | |
schema.meta.caption_colour["g"], | |
schema.meta.caption_colour["b"]) | |
cap_draw.text( | |
xy=xy, | |
text=image.caption, | |
fill=fill, | |
font=cap_font) | |
@staticmethod | |
def add_images(schema, x_offset, y_offset, base): | |
for idx, image in enumerate(schema.images): | |
if idx > 0 and idx % schema.meta.images_per_row == 0: | |
x_offset.reset() | |
y_offset.step() | |
base.paste(im=image.load_file(), | |
box=(x_offset.value, y_offset.value)) | |
Collage.add_caption(schema, x_offset, y_offset, | |
base, image) | |
x_offset.step() | |
def save(self, file_path): | |
# collage dimensions | |
width = Collage.calculate_width(self._schema) | |
height = Collage.calculate_height(self._schema) | |
# offsets | |
x_offset = Collage.calculate_x_offset(self._schema) | |
y_offset = Collage.calculate_y_offset(self._schema) | |
# base image | |
base = Collage.make_base_image(self._schema, width, height) | |
# add the title | |
Collage.add_title(self._schema, width, height, base) | |
# add the images | |
Collage.add_images(self._schema, x_offset, y_offset, base) | |
# save to disk | |
base.save(fp=file_path, mode="PNG") | |
class Offset: | |
""" offset is a wrapper around a counter, which steps in | |
increments of "step". | |
""" | |
def __init__(self, initial, step): | |
self._initial = initial | |
self._step = step | |
self.value = initial | |
def step(self): | |
self.value += self._step | |
def reset(self): | |
self.value = self._initial |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from collage import Schema, Collage | |
if __name__ == "__main__": | |
schema = Schema.from_json_file("./schema.json") | |
print(schema) | |
collage = Collage(schema) | |
print(collage) | |
collage.save("./collage.png") | |
print("done") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"meta": { | |
"title": "Nice Dog Collage 2022", | |
"images_per_row": 10, | |
"image_width": 250, | |
"image_height": 300, | |
"image_x_padding": 30, | |
"image_y_padding": 100, | |
"vertical_margin": 300, | |
"horizontal_margin": 500, | |
"title_font_path": "./Mali-Light.ttf", | |
"title_font_size": 140, | |
"caption_font_path": "./Roboto-Light.ttf", | |
"caption_font_size": 35, | |
"bg_colour": { | |
"r": 255, | |
"g": 255, | |
"b": 255, | |
"a": 255 | |
}, | |
"title_colour": { | |
"r": 0, | |
"g": 148, | |
"b": 50 | |
}, | |
"caption_colour": { | |
"r": 0, | |
"g": 0, | |
"b": 0 | |
} | |
}, | |
"images": [ | |
{ | |
"file_path": "./images/dogs/1.jpg", | |
"caption": "Michael", | |
"p1": { | |
"x": 124, | |
"y": 24 | |
}, | |
"p2": { | |
"x": 489, | |
"y": 319 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/2.jpg", | |
"caption": "Christopher", | |
"p1": { | |
"x": 108, | |
"y": 41 | |
}, | |
"p2": { | |
"x": 401, | |
"y": 321 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/3.jpg", | |
"caption": "Jessica", | |
"p1": { | |
"x": 131, | |
"y": 22 | |
}, | |
"p2": { | |
"x": 279, | |
"y": 190 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/4.jpg", | |
"caption": "Matthew", | |
"p1": { | |
"x": 91, | |
"y": 39 | |
}, | |
"p2": { | |
"x": 443, | |
"y": 314 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/5.jpg", | |
"caption": "David", | |
"p1": { | |
"x": 63, | |
"y": 36 | |
}, | |
"p2": { | |
"x": 336, | |
"y": 281 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/6.jpg", | |
"caption": "James", | |
"p1": { | |
"x": 202, | |
"y": 27 | |
}, | |
"p2": { | |
"x": 429, | |
"y": 273 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/7.jpg", | |
"caption": "Robert", | |
"p1": { | |
"x": 165, | |
"y": 22 | |
}, | |
"p2": { | |
"x": 410, | |
"y": 293 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/8.jpg", | |
"caption": "Justin", | |
"p1": { | |
"x": 92, | |
"y": 22 | |
}, | |
"p2": { | |
"x": 373, | |
"y": 323 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/9.jpg", | |
"caption": "Sarah", | |
"p1": { | |
"x": 21, | |
"y": 21 | |
}, | |
"p2": { | |
"x": 312, | |
"y": 351 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/10.jpg", | |
"caption": "Jonathan", | |
"p1": { | |
"x": 124, | |
"y": 8 | |
}, | |
"p2": { | |
"x": 453, | |
"y": 333 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/11.jpg", | |
"caption": "Stephanie", | |
"p1": { | |
"x": 125, | |
"y": 43 | |
}, | |
"p2": { | |
"x": 310, | |
"y": 296 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/12.jpg", | |
"caption": "Eric", | |
"p1": { | |
"x": 68, | |
"y": 50 | |
}, | |
"p2": { | |
"x": 325, | |
"y": 332 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/13.jpg", | |
"caption": "Elizabeth", | |
"p1": { | |
"x": 31, | |
"y": 23 | |
}, | |
"p2": { | |
"x": 335, | |
"y": 312 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/14.jpg", | |
"caption": "Timothy", | |
"p1": { | |
"x": 91, | |
"y": 22 | |
}, | |
"p2": { | |
"x": 314, | |
"y": 294 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/15.jpg", | |
"caption": "Charles", | |
"p1": { | |
"x": 61, | |
"y": 22 | |
}, | |
"p2": { | |
"x": 326, | |
"y": 288 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/16.jpg", | |
"caption": "Rebecca", | |
"p1": { | |
"x": 34, | |
"y": 17 | |
}, | |
"p2": { | |
"x": 616, | |
"y": 488 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/17.jpg", | |
"caption": "Patrick", | |
"p1": { | |
"x": 178, | |
"y": 18 | |
}, | |
"p2": { | |
"x": 434, | |
"y": 303 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/18.jpg", | |
"caption": "Alexander", | |
"p1": { | |
"x": 39, | |
"y": 6 | |
}, | |
"p2": { | |
"x": 460, | |
"y": 391 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/19.jpg", | |
"caption": "Victoria", | |
"p1": { | |
"x": 125, | |
"y": 30 | |
}, | |
"p2": { | |
"x": 453, | |
"y": 317 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/20.jpg", | |
"caption": "Jacqueline", | |
"p1": { | |
"x": 94, | |
"y": 7 | |
}, | |
"p2": { | |
"x": 374, | |
"y": 267 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/21.jpg", | |
"caption": "Phillip", | |
"p1": { | |
"x": 189, | |
"y": 11 | |
}, | |
"p2": { | |
"x": 397, | |
"y": 223 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/22.jpg", | |
"caption": "Douglas", | |
"p1": { | |
"x": 186, | |
"y": 62 | |
}, | |
"p2": { | |
"x": 356, | |
"y": 228 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/23.jpg", | |
"caption": "Craig", | |
"p1": { | |
"x": 139, | |
"y": 72 | |
}, | |
"p2": { | |
"x": 411, | |
"y": 325 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/24.jpg", | |
"caption": "Nathaniel", | |
"p1": { | |
"x": 92, | |
"y": 13 | |
}, | |
"p2": { | |
"x": 388, | |
"y": 234 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/25.jpg", | |
"caption": "Candice", | |
"p1": { | |
"x": 34, | |
"y": 18 | |
}, | |
"p2": { | |
"x": 319, | |
"y": 271 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/26.jpg", | |
"caption": "Trevor", | |
"p1": { | |
"x": 69, | |
"y": 33 | |
}, | |
"p2": { | |
"x": 297, | |
"y": 240 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/27.jpg", | |
"caption": "Austin", | |
"p1": { | |
"x": 31, | |
"y": 16 | |
}, | |
"p2": { | |
"x": 356, | |
"y": 297 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/28.jpg", | |
"caption": "Johnathan", | |
"p1": { | |
"x": 21, | |
"y": 8 | |
}, | |
"p2": { | |
"x": 231, | |
"y": 217 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/29.jpg", | |
"caption": "Miranda", | |
"p1": { | |
"x": 189, | |
"y": 197 | |
}, | |
"p2": { | |
"x": 829, | |
"y": 756 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/30.jpg", | |
"caption": "Tabitha", | |
"p1": { | |
"x": 63, | |
"y": 133 | |
}, | |
"p2": { | |
"x": 269, | |
"y": 358 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/31.jpg", | |
"caption": "Jorge", | |
"p1": { | |
"x": 111, | |
"y": 19 | |
}, | |
"p2": { | |
"x": 487, | |
"y": 318 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/32.jpg", | |
"caption": "Francisco", | |
"p1": { | |
"x": 64, | |
"y": 79 | |
}, | |
"p2": { | |
"x": 624, | |
"y": 516 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/33.jpg", | |
"caption": "Robin", | |
"p1": { | |
"x": 112, | |
"y": 26 | |
}, | |
"p2": { | |
"x": 376, | |
"y": 269 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/34.jpg", | |
"caption": "Roger", | |
"p1": { | |
"x": 27, | |
"y": 6 | |
}, | |
"p2": { | |
"x": 211, | |
"y": 140 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/35.jpg", | |
"caption": "Alejandro", | |
"p1": { | |
"x": 46, | |
"y": 4 | |
}, | |
"p2": { | |
"x": 238, | |
"y": 185 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/36.jpg", | |
"caption": "Dylan", | |
"p1": { | |
"x": 75, | |
"y": 21 | |
}, | |
"p2": { | |
"x": 485, | |
"y": 410 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/37.jpg", | |
"caption": "Frederick", | |
"p1": { | |
"x": 152, | |
"y": 39 | |
}, | |
"p2": { | |
"x": 409, | |
"y": 289 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/38.jpg", | |
"caption": "Ronnie", | |
"p1": { | |
"x": 65, | |
"y": 163 | |
}, | |
"p2": { | |
"x": 312, | |
"y": 457 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/39.jpg", | |
"caption": "Maggie", | |
"p1": { | |
"x": 8, | |
"y": 60 | |
}, | |
"p2": { | |
"x": 228, | |
"y": 310 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/40.jpg", | |
"caption": "Cesar", | |
"p1": { | |
"x": 26, | |
"y": 67 | |
}, | |
"p2": { | |
"x": 294, | |
"y": 339 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/41.jpg", | |
"caption": "Howard", | |
"p1": { | |
"x": 144, | |
"y": 28 | |
}, | |
"p2": { | |
"x": 440, | |
"y": 263 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/42.jpg", | |
"caption": "Alvin", | |
"p1": { | |
"x": 78, | |
"y": 190 | |
}, | |
"p2": { | |
"x": 244, | |
"y": 374 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/43.jpg", | |
"caption": "Guadalupe", | |
"p1": { | |
"x": 24, | |
"y": 5 | |
}, | |
"p2": { | |
"x": 288, | |
"y": 245 | |
} | |
}, | |
{ | |
"file_path": "./images/dogs/44.jpg", | |
"caption": "Graham", | |
"p1": { | |
"x": 140, | |
"y": 71 | |
}, | |
"p2": { | |
"x": 390, | |
"y": 292 | |
} | |
} | |
] | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment