Skip to content

Instantly share code, notes, and snippets.

@0xc0392b
Last active July 15, 2022 21:24
Show Gist options
  • Save 0xc0392b/19a572aaaf5890045bd3d2d284c12d69 to your computer and use it in GitHub Desktop.
Save 0xc0392b/19a572aaaf5890045bd3d2d284c12d69 to your computer and use it in GitHub Desktop.
PIL collage script
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
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")
{
"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