6
\$\begingroup\$

I have written a Python script that takes a list of filenames of pictures and generates a photo collage of them. This is my most complex project yet, and I have written it completely by myself (like I always do, but this is way harder than most of my other projects).

It can take images with different aspect ratios and it can take any number of images. It does not crop the images, and it automatically generates a layout so that all of the images can be put into the layout without any of the images overlapping.

It has two modes, with resizing and without resizing.

With resizing, it automatically resizes the image to a certain ratio to a unit area, while maintaining the original aspect ratio. It scales the images according to the position of the image inside the list, namely the lower the index the larger the image will be, it scales the images exponentially.

It also rotates the images randomly, you can control whether it rotates the images or not, the maximum degree of rotation, and a bunch of other parameters as well.

Every time the code is run, the output is completely randomized.

Here is the script:

import cv2
import numpy as np
import random
import rpack
from fractions import Fraction
from math import prod

def resize_guide(image_size, unit_shape, target_ratio):
    aspect_ratio = Fraction(*image_size).limit_denominator()
    horizontal = aspect_ratio.numerator
    vertical = aspect_ratio.denominator
    target_area = prod(unit_shape) * target_ratio
    unit_length = (target_area/(horizontal*vertical))**.5
    return (int(horizontal*unit_length), int(vertical*unit_length))

def make_border(image, value, border=16):
    return cv2.copyMakeBorder(
        image,
        top=border,
        bottom=border,
        left=border,
        right=border,
        borderType=cv2.BORDER_CONSTANT,
        value=value
    )

def rotate_image(image, angle):
    h, w = image.shape[:2]
    cX, cY = (w // 2, h // 2)
    M = cv2.getRotationMatrix2D((cX, cY), -angle, 1.0)
    cos = np.abs(M[0, 0])
    sin = np.abs(M[0, 1])
    nW = int((h * sin) + (w * cos))
    nH = int((h * cos) + (w * sin))
    M[0, 2] += (nW / 2) - cX
    M[1, 2] += (nH / 2) - cY
    return cv2.warpAffine(image, M, (nW, nH))

def make_collage(image_files, output_file,
        exponent=0.8, border=16, max_degree=15, unit_shape=(1280,720),
        resize_images=True, image_border=True, rotate_images=True, limit_shape=False):
    images = [cv2.imread(name) for name in image_files]
    size_hint = [exponent**i for i in range(len(images))]
    
    if resize_images:
        resized_images = []
        for image, hint in zip(images, size_hint):
            height, width = image.shape[:2]
            guide = resize_guide((width, height), unit_shape, hint)
            resized = cv2.resize(image, guide, interpolation = cv2.INTER_AREA)
            if image_border:
                resized = make_border(resized, (255, 255, 255), border)
            resized_images.append(resized)
        images = resized_images
    else:
        sorted_images = []
        for image in sorted(images, key=lambda x: -prod(x.shape[:2])):
            if image_border:
                image = make_border(image, (255, 255, 255), border)
            sorted_images.append(image)
        images = sorted_images
    
    sizes = []
    processed_images = []
    for image in images:
        if rotate_images:
            image = rotate_image(image, random.randrange(-max_degree, max_degree+1))
        processed = make_border(image, (0,0,0), border)
        processed_images.append(processed)
        height, width = processed.shape[:2]
        sizes.append((width, height))
    
    if limit_shape:
        max_side = int((sum([w*h for w, h in sizes])*2)**.5)
        packed = rpack.pack(sizes, max_width=max_side, max_height=max_side)
    else:
        packed = rpack.pack(sizes)
    
    shapes = [(x, y, w, h) for (x, y), (w, h) in zip(packed, sizes)]
    rightmost = sorted(shapes, key=lambda x: -x[0] - x[2])[0]
    bound_width = rightmost[0] + rightmost[2]
    downmost = sorted(shapes, key=lambda x: -x[1] - x[3])[0]
    bound_height = downmost[1] + downmost[3]
    
    collage = np.zeros([bound_height, bound_width, 3],dtype=np.uint8)
    for image, (x, y, w, h) in zip(processed_images, shapes):
        collage[y:y+h, x:x+w] = image
    
    collage = cv2.GaussianBlur(collage, (3,3), cv2.BORDER_DEFAULT)
    cv2.imwrite(output_file, collage)

Example:


                                                                          
from pathlib import Path                                                  
                                                                          
files = [str(i) for i in Path('D:/Selected').glob('*')]                   
                                                                          
make_collage(files[::-1], 'D:/collages/'+random.randbytes(6).hex()+'.png')

enter image description here

0.9 exponent:

enter image description here

The images are a selection of the pictures I have created. They are ordered chronologically.

I don't completely understand how the libraries work, but I know how to put them together, to make them work in concert.

This is the first time I have done something like this, and I really want to know how it can be improved.


Update:

I added some logic that constraints the maximum side the bounding area can have, so that the output will roughly be square-like.

Basically it takes the sum of the areas, multiply the sum by two, and takes the square root as maximum width and height.

It is still experimental so it is disabled by default, but so far I have not encountered any problems.

Below is a selection of my photos edited by me, arranged using the new method, I have resized and compressed the image to make it fit here, but I did not edit the contents.

enter image description here

\$\endgroup\$
1
  • \$\begingroup\$ Why "exponent" and not "scale"? \$\endgroup\$
    – Bharel
    Apr 13, 2022 at 14:08

1 Answer 1

1
\$\begingroup\$

Thank you, this offers some very nice functionality.


This signature is not very informative:

def resize_guide(image_size, unit_shape, target_ratio):

In particular, caller needs some guidance on the type / shape of image_size that should be passed in. You might do this through an optional type annotation or through a """docstring""".


In make_collage the if resize_images: clause might be an Extract Helper candidate.

I wonder if the else clause could become common code for both branches.

Deriving sizes from images similarly might make sense for Extract Helper. The goal is to have the function's source code fit on one screen without scrolling, and to reduce coupling by letting temp vars go out-of-scope when helper exits.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.