DEV Community

Cover image for Photo Collage Generator in Python: Step-by-Step Guide
Alexandr
Alexandr

Posted on • Edited on

Photo Collage Generator in Python: Step-by-Step Guide

Recently, I've tried to make a simple collage with ChatGPT. Surprisingly, it wasn't able to help me, so I ended up using an online collage maker from Pixlr. It is very cool on its own, but I started to think about making my own app. And here it is.

Let's start with source images, I found some across the internet:

Links

Photo by Abet Llacer: https://www.pexels.com/photo/cute-dog-wearing-a-reindeer-headband-14720760/

Photo by Anna-Louise: https://www.pexels.com/photo/674723/

Photo by Studio Naae: https://www.pexels.com/photo/studio-shoot-of-silver-and-golden-christmas-baubles-against-white-background-19507868/

Photo by William Warby: https://www.pexels.com/photo/19561041/

Photo by Em Hopper: https://www.pexels.com/photo/14769816/

https://pixabay.com/illustrations/christmas-bauble-christmas-1079926/

https://pixabay.com/vectors/christmas-pattern-design-wallpaper-6809681/

https://pixabay.com/illustrations/red-christmas-tree-christmas-2892235/

https://pixabay.com/illustrations/christmas-tree-lights-stars-glowing-2928142/

https://pixabay.com/photos/snowman-town-urban-321034/

Photo by Monika Grabkowska on Unsplash

Photo by Annie Spratt on Unsplash

Photo by Brooke Lark on Unsplash

Photo by Nathan Anderson on Unsplash

Photo by Element5 Digital on Unsplash

As you can see here, I've selected quite a few images in different orientations for different layouts—square, vertical (portrait), and horizontal (album).

Let's start with squares. the most logical (and the most straightforward one) way is to make a grid out of images: X by X for square canvas, n+X by X for horizontal, and X by n+X for vertical.

canvas_width, canvas_height = canvas_size

    images_num = len(images)

    # Create the canvas
    collage = Image.new("RGB", canvas_size, bg_color)

    if canvas_width == canvas_height:
        # Square canvas: Determine grid dimensions for a square layout
        grid_size = math.ceil(math.sqrt(images_num))
        cell_size = (canvas_width - (grid_size + 1) * padding) // grid_size

        for idx, img_path in enumerate(images):
            img = Image.open(img_path)
            img = ImageOps.fit(img, (cell_size, cell_size), method=Image.Resampling.LANCZOS)

            # Calculate position in the grid
            x = (idx % grid_size) * (cell_size + padding) + padding
            y = (idx // grid_size) * (cell_size + padding) + padding

            collage.paste(img, (x, y))

    else:
        # Non-square canvas: Determine grid dimensions for the closest layout
        cols = math.ceil(math.sqrt(images_num))
        rows = math.ceil(images_num / cols)
        offset_x, offset_y = 0, 0

        # Adjust grid dimensions and offsets based on canvas proportions
        if canvas_width > canvas_height:
            if cols < rows:
                cols, rows = rows, cols
            if centered:
                offset_x = (canvas_width - (rows * (canvas_height // rows))) // 2
        elif canvas_width < canvas_height:
            if cols > rows:
                cols, rows = rows, cols
            if centered:
                offset_y = (canvas_height - (cols * (canvas_width // cols))) // 2

        # Calculate cell dimensions
        cell_width = (canvas_width - (cols + 1) * padding) // cols
        cell_height = (canvas_height - (rows + 1) * padding) // rows

        # Use the smaller dimension if centering is enabled
        if centered:
            cell_width = cell_height = min(cell_width, cell_height)

        for idx, img_path in enumerate(images):
            img = Image.open(img_path)
            img = ImageOps.fit(img, (cell_width, cell_height), method=Image.Resampling.LANCZOS)

            # Calculate position in the grid
            x = (idx % cols) * (cell_width + padding) + padding + offset_x
            y = (idx // cols) * (cell_height + padding) + padding + offset_y

            collage.paste(img, (x, y))
Enter fullscreen mode Exit fullscreen mode
Square grid collage
Square grid collage

Working with rectangles is even simpler, now we only need to deal with a single row or column:

    canvas_width, canvas_height = canvas_size

    images_num = len(images)

    # Create the canvas
    collage = Image.new("RGB", canvas_size, bg_color)

    # Determine block size based on orientation
    if orientation == "horizontal":
        block_height = (canvas_height - (images_num + 1) * padding) // images_num
        block_width = canvas_width
    elif orientation == "vertical":
        block_width = (canvas_width - (images_num + 1) * padding) // images_num
        block_height = canvas_height
    else:
        raise ValueError("Orientation must be 'horizontal' or 'vertical'.")

    # Centering offsets
    offset_x, offset_y = 0, 0
    if centered:
        if orientation == "horizontal" and images_num * (block_height + padding) < canvas_height:
            offset_y = (canvas_height - (images_num * (block_height + padding))) // 2
        if orientation == "vertical" and images_num * (block_width + padding) < canvas_width:
            offset_x = (canvas_width - (images_num * (block_width + padding))) // 2

    # Resize and paste images
    for idx, img_path in enumerate(images):
        img = Image.open(img_path)
        img = ImageOps.fit(img, (block_width, block_height), method=Image.Resampling.LANCZOS)

        # Calculate position
        x, y = 0, 0
        if orientation == "horizontal":
            y = idx * (block_height + padding) + padding + offset_y
        elif orientation == "vertical":
            x = idx * (block_width + padding) + padding + offset_x

        collage.paste(img, (x, y))
Enter fullscreen mode Exit fullscreen mode
Strip collage
Strip collage
Stack collage
Stack collage

But the mixed input - that's the place where fun begins! Strictly speaking, the problem of packing is a serious mathematical task, no jokes, look in Wiki.

Packing problems are a class of optimization problems in mathematics that involve attempting to pack objects together into containers. The goal is to either pack a single container as densely as possible or pack all objects using as few containers as possible. Many of these problems can be related to real-life packaging, storage and transportation issues. Each packing problem has a dual covering problem, which asks how many of the same objects are required to completely cover every region of the container, where objects are allowed to overlap.

Anyway, trying to implement all of them, could be tiresome, let's add some creativity! One of the most interesting ways to put images on collage is using golden ratio.

And how can we implement the golden ratio in collage? It's actually not that complicated: we divide free space by 1+52 \frac{1 + \sqrt{5}}{2} and reverse direction from the previous (eg. first top, second bottom, etc).

Golden ratio animated
Golden ratio animated
canvas_width, canvas_height = canvas_size
    # Initialize the working area for placing images
    working_area = {
        "x": 0,
        "y": 0,
        "width": canvas_width,
        "height": canvas_height
    }

    # Initialize the canvas (background image or color)
    collage = None
    if bg_image:
        collage = Image.open(bg_image)
    if bg_color:
        collage = Image.new("RGB", canvas_size, bg_color)

    # Determine the initial layout orders
    horizontal_order = "right-to-left"
    vertical_order = "bottom-to-top"

    # Determine the initial layout orders
    if randomization:
        horizontal_order = random.choice(["right-to-left", "left-to-right"])
        vertical_order = random.choice(["bottom-to-top", "top-to-bottom"])
        random.shuffle(images)

    x, y = 0, 0  # Starting position
    for idx, img_path in enumerate(images):
        img = Image.open(img_path)
        # Decide whether to split horizontally or vertically based on working area dimensions
        if working_area["width"] > working_area["height"]:  # Horizontal split
            img = ImageOps.fit(img, (int(working_area["width"] / GOLDEN_RATIO), working_area["height"]), method=Image.Resampling.LANCZOS)
            # Adjust position and update working area
            if horizontal_order == "right-to-left":
                horizontal_order = "left-to-right"
                working_area["x"] += img.width + padding
            else:
                x = x + working_area["width"] - img.width
                horizontal_order = "right-to-left"
            working_area["width"] -= img.width + padding
        else:  # Vertical split
            img = ImageOps.fit(img, (working_area["width"], int(working_area["height"] / GOLDEN_RATIO)), method=Image.Resampling.LANCZOS)
            # Adjust position and update working area
            if vertical_order == "bottom-to-top":
                working_area["y"] += img.height + padding
                vertical_order = "top-to-bottom"
            else:
                y = y + working_area["height"] - img.height
                vertical_order = "bottom-to-top"
            working_area["height"] -= img.height + padding

        # Paste the image onto the canvas
        collage.paste(img, (x, y))
        x, y = working_area["x"], working_area["y"]  # Update position for the next image

        # Stop if the working area becomes too small
        if working_area["width"] <= 0 or working_area["height"] <= 0:
            print("Working area exhausted. Stopping collage creation.")
            break
Enter fullscreen mode Exit fullscreen mode
Golden ratio collage
Golden ratio collage

Huh, so we finally have the script. Now we need GUI.

I have never worked with Streamlit before, so this is a nice opportunity to learn something new. While you can argue that Streamlit is not a "real" GUI (since we can safely assume it's a web server with an HTML template engine), it's a very useful instrument, especially handy when you don't need something complex.

Anyway, let's proceed.

First of all, constants.

GOLDEN_RATIO = (1 + math.sqrt(5)) / 2  # Define the golden ratio

MIN_IMAGES = 4
MAX_IMAGES = 20

MIN_PADDING = 0
MAX_PADDING = 50

# Provided data for social media sizes
SOCIAL_MEDIA_IMAGE_SIZES = {
    "Instagram Feed Square": (1080, 1080),
    "Instagram Feed Portrait": (1080, 1350),
    "Instagram Feed Landscape": (1080, 608),
    "Instagram Stories/Reels": (1080, 1920),
    "Facebook Feed Post": (1200, 630),
    "Facebook Stories": (1080, 1920),
    "Facebook Event Cover": (1920, 1005),
    "YouTube Thumbnail": (1280, 720),
    "YouTube Channel Art": (2560, 1440),
    "X Single Image": (1200, 675),
    "X Multi-Image": (1200, 600),
    "LinkedIn Feed Post": (1200, 627),
    "LinkedIn Company Cover": (1128, 191),
}

# Icons for each platform
SOCIAL_MEDIA_ICONS = {
    "Instagram": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Instagram_icon.png/240px-Instagram_icon.png",
    "Facebook": "https://upload.wikimedia.org/wikipedia/commons/5/51/Facebook_f_logo_%282019%29.svg",
    "YouTube": "https://upload.wikimedia.org/wikipedia/commons/0/09/YouTube_full-color_icon_%282017%29.svg",
    "X": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/28/X_icon_black.svg/240px-X_icon_black.svg.png",
    "LinkedIn": "https://upload.wikimedia.org/wikipedia/commons/c/ca/LinkedIn_logo_initials.png",
}
Enter fullscreen mode Exit fullscreen mode

As you can see here, it's self-explanatory. I've created several pre-defined media templates for a better user expirience.

Next, the page config. For the background, I've used Hero Pattern

    st.set_page_config(
        page_title='Photo Collage Maker',
        page_icon="🧊",
        layout="centered",
    )
    st.title("Photo Collage Maker")
    st.markdown(
        f"""
             <style>
                 .stApp {{
                     background-color: #fefae0;
                     background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='152' height='152' viewBox='0 0 152 152'%3E%3Cg fill-rule='evenodd'%3E%3Cg id='temple' fill='%23283618' fill-opacity='0.1'%3E%3Cpath d='M152 150v2H0v-2h28v-8H8v-20H0v-2h8V80h42v20h20v42H30v8h90v-8H80v-42h20V80h42v40h8V30h-8v40h-42V50H80V8h40V0h2v8h20v20h8V0h2v150zm-2 0v-28h-8v20h-20v8h28zM82 30v18h18V30H82zm20 18h20v20h18V30h-20V10H82v18h20v20zm0 2v18h18V50h-18zm20-22h18V10h-18v18zm-54 92v-18H50v18h18zm-20-18H28V82H10v38h20v20h38v-18H48v-20zm0-2V82H30v18h18zm-20 22H10v18h18v-18zm54 0v18h38v-20h20V82h-18v20h-20v20H82zm18-20H82v18h18v-18zm2-2h18V82h-18v18zm20 40v-18h18v18h-18zM30 0h-2v8H8v20H0v2h8v40h42V50h20V8H30V0zm20 48h18V30H50v18zm18-20H48v20H28v20H10V30h20V10h38v18zM30 50h18v18H30V50zm-2-40H10v18h18V10z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");                 
                 }}
             </style>
        """,
        unsafe_allow_html=True
    )
Enter fullscreen mode Exit fullscreen mode

After that, state and reducers (😉).

The simple and most obvious way

    # pre-define session state variables
    if 'images' not in st.session_state:
        st.session_state.images = None
    if 'platform' not in st.session_state:
        st.session_state.platform = None
    if 'background' not in st.session_state:
        st.session_state.background = None
    if 'layout' not in st.session_state:
        st.session_state.layout = None
    if 'randomization' not in st.session_state:
        st.session_state.randomization = False
    if 'centering' not in st.session_state:
        st.session_state.centering = False
    if 'padding' not in st.session_state:
        st.session_state.padding = MIN_PADDING
    if 'collage' not in st.session_state:
        st.session_state.collage = None
Enter fullscreen mode Exit fullscreen mode

or a more elegant one

# Define default session state values
default_values = {
    "images": None,
    "platform": None,
    "background": None,
    "layout": None,
    "randomization": False,
    "centering": False,
    "padding": MIN_PADDING,
    "collage": None,
}

# Initialize session state
for key, value in default_values.items():
    if key not in st.session_state:
        st.session_state[key] = value
Enter fullscreen mode Exit fullscreen mode

and state management

    def set_active_platform(active_platform):
        st.session_state.platform = active_platform


    def set_active_images(active_images):
        st.session_state.images = active_images


    def set_active_background(category, active_background):
        if category is None and active_background is None:
            st.session_state.background = None
        else:
            st.session_state.background = {
                "category": category,
                "value": active_background
            }


    def set_active_layout(active_layout):
        st.session_state.layout = active_layout


    def handle_create_collage_button_click():
        # Initialize the canvas (background image or color)
        new_collage = None
        if st.session_state.background["category"] == "image":
            new_collage = Image.open(st.session_state.background["value"])
        else:
            new_collage = Image.new("RGB", SOCIAL_MEDIA_IMAGE_SIZES[st.session_state.platform],
                                    st.session_state.background["value"])

        match st.session_state.layout:
            case "golden_ratio":
                new_collage = golden_ratio_collage(st.session_state.images, new_collage, st.session_state.padding,
                                                   st.session_state.randomization)
            case "grid":
                new_collage = grid_collage(st.session_state.images, new_collage, st.session_state.padding,
                                           st.session_state.randomization, st.session_state.centering)
            case "strip":
                new_collage = lane_collage(st.session_state.images, new_collage, st.session_state.padding,
                                           st.session_state.randomization, st.session_state.centering,
                                           orientation="vertical")
            case "stack":
                new_collage = lane_collage(st.session_state.images, new_collage, st.session_state.padding,
                                           st.session_state.randomization, st.session_state.centering,
                                           orientation="horizontal")
            case "auto":
                new_collage = auto_layout(st.session_state.images, new_collage, st.session_state.padding,
                                          st.session_state.randomization, st.session_state.centering)

        st.session_state.collage = new_collage
Enter fullscreen mode Exit fullscreen mode

I prefer to keep it simple, but you can always modify it to be a more centralized state-reducer thing, though.

I've also added the auto layout, so when a user is in doubt, we can do our best automatically.

    canvas_width, canvas_height = collage.size
    images_num = len(images)
    image_objects_list = []
    squares = 0
    horizontal_rectangles = 0
    vertical_rectangles = 0
    total_area = 0
    aspect_ratio_sum_width = aspect_ratio_sum_height = 0
    for idx, img_path in enumerate(images):
        img = Image.open(img_path)
        image_objects_list.append({
            "image": img
        })
        total_area += img.width * img.height
        if img.height == img.width:
            squares += 1
            aspect_ratio_sum_width += 1
            aspect_ratio_sum_height += 1

        elif img.height > img.width:
            vertical_rectangles += 1
            aspect_ratio_sum_height += img.height / img.width
            aspect_ratio_sum_width += 1

        else:
            horizontal_rectangles += 1
            aspect_ratio_sum_width += img.width / img.height
            aspect_ratio_sum_height += 1

    if squares == images_num:
        return grid_collage(images, collage, padding, randomization, centered)

    elif horizontal_rectangles == images_num:
        return lane_collage(images, collage, padding, randomization, centered, orientation="horizontal")

    elif vertical_rectangles == images_num:
        return lane_collage(images, collage, padding, randomization, centered, orientation="vertical")

    else:
        canvas_area = canvas_width * canvas_height
        scaling_factor = math.sqrt(canvas_area / total_area)
        rows = 0
        cols = 0
        aspect_sum_diff = abs(aspect_ratio_sum_width - aspect_ratio_sum_height)
        return golden_ratio_collage(images, collage, padding, randomization)
Enter fullscreen mode Exit fullscreen mode

And the final part - the rest of the structure, plus randomization and centralization for the reusability

st.header("Step 1. Upload images")

    uploaded_files = st.file_uploader("Choose images", type=["jpg", "jpeg", "png"], accept_multiple_files=True)

    if uploaded_files:
        images = [Image.open(file) for file in uploaded_files]
        st.write(f"Selected {len(images)} images")

        if len(images) < MIN_IMAGES:
            st.warning(f"Please select at least {MIN_IMAGES} images for a collage")
        elif len(images) > MAX_IMAGES:
            st.warning(f"Please select at most {MAX_IMAGES} images for a collage")
        else:
            st.success("Images uploaded successfully!")
            show_images_toggle = st.toggle("Show images")
            if show_images_toggle:
                st.image(images, use_container_width=True)
            set_active_images(uploaded_files)

    if st.session_state.images is not None:
        st.header("Step 2. Customize size")
        st.subheader("Select appropriate media size")

        if st.session_state.platform is None:
            # Render buttons dynamically
            for platform, size in SOCIAL_MEDIA_IMAGE_SIZES.items():

                # Extract the base name (e.g., "Instagram" from "Instagram Feed Square")
                platform_base = platform.split()[0]
                icon_url = SOCIAL_MEDIA_ICONS.get(platform_base, None)

                # Display the button with an icon and label
                col1, col2 = st.columns([0.2, 0.8])  # For icon and label alignment
                with col1:
                    if icon_url:
                        st.image(icon_url, width=30)
                    else:
                        st.write("")  # Placeholder for missing icons
                with col2:
                    st.button(label=f"{platform}: {size[0]}x{size[1]}", key=platform, help=platform,
                              use_container_width=True, args=(platform,), on_click=set_active_platform)

        else:
            # Display active platform info
            platform_base = st.session_state.platform.split()[0]
            icon_url = SOCIAL_MEDIA_ICONS.get(platform_base, None)

            # Display the button with an icon and label
            col1, col2 = st.columns([0.2, 0.8])  # For icon and label alignment
            with col1:
                if icon_url:
                    st.image(icon_url, width=30)
                else:
                    st.write("")  # Placeholder for missing icons
            with col2:
                platform = st.session_state.platform
                st.write(
                    f"Selected: {platform} {SOCIAL_MEDIA_IMAGE_SIZES[platform][0]}x{SOCIAL_MEDIA_IMAGE_SIZES[platform][1]}")

            st.button('Re-select size', args=(None,), on_click=set_active_platform, help="Re-select size",
                      use_container_width=True, icon=":material/photo_size_select_actual:")

            st.header("Step 3. Customize background")
            st.subheader("Choose color or image")

            if st.session_state.background is None:
                with st.container(border=True):
                    column1, column2 = st.columns([1, 1])
                    with column1:
                        color_image_radio = st.radio(
                            "Background color or image",
                            ["color", "image"],
                            captions=[
                                "Solid color",
                                "Image",
                            ],
                        )
                    with column2:
                        if color_image_radio == "color":
                            bg_color = st.color_picker("Pick A Color", None)
                            if bg_color:
                                st.write(f"Selected color: {bg_color}")
                                st.button("Set background color", on_click=set_active_background, args=("color", bg_color),
                                          use_container_width=True, icon=":material/water_drop:")
                        else:
                            bg_file = st.file_uploader("Choose image for background", type=["jpg", "jpeg", "png"],
                                                       accept_multiple_files=False)
                            if bg_file:
                                bg_img = Image.open(bg_file)
                                st.image(bg_img, caption="Selected image", use_container_width=True)
                                st.button("Set background image", on_click=set_active_background, args=("image", bg_file),
                                          use_container_width=True, icon=":material/image:")
            else:
                bg_category = st.session_state.background["category"]
                bg_value = st.session_state.background["value"]

                if bg_category == "color":
                    st.write(f"Selected color: {bg_value}")
                else:
                    st.image(bg_value, caption="Selected image", use_container_width=True)

                st.button("Re-select background", use_container_width=True, on_click=set_active_background,
                          args=(None, None), icon=":material/format_paint:", help="Re-select background")

                st.header("Step 4. Customize arrangement of images")
                st.subheader("Choose layout")

                if st.session_state.layout is None:
                    layout_options = ["grid", "strip", "stack", "golden_ratio", "auto"]
                    layout_icons = ["grid_on", "table_rows", "view_column", "grid_goldenratio", "auto_awesome"]

                    with st.container(border=True):
                        columns = st.columns(len(layout_options))  # Create dynamic columns

                        for col, layout, icon in zip(columns, layout_options, layout_icons):
                            with col:
                                st.button(layout.replace("_", " "), use_container_width=True,
                                          on_click=set_active_layout, args=(layout,), icon=f":material/{icon}:")

                else:
                    st.write(f"Selected layout: {st.session_state.layout}")
                    st.button("Re-select layout", use_container_width=True, on_click=set_active_layout,
                              args=(None,), icon=":material/grid_off:", help="Re-select layout")

                    st.header("Step 5. Final arrangement")
                    st.subheader("Set up padding, centering, and randomization")
                    with st.container(border=True):
                        column1, column2 = st.columns([1, 1])
                        with column1:
                            st.number_input("Padding", min_value=MIN_PADDING, max_value=MAX_PADDING, key='padding')
                        with column2:
                            st.checkbox("Randomize image order", key='randomization')
                            st.checkbox("Center images (if possible)", key="centering")
                        st.button("Create collage", use_container_width=True,
                                  on_click=handle_create_collage_button_click, icon=":material/auto_awesome_mosaic:")

                    if st.session_state.collage is not None:
                        st.header("Step 6. Collage preview")
                        st.image(np.array(st.session_state.collage), caption="Collage Preview",
                                 use_container_width=True)
                        # Convert the image to binary format
                        image_bytes = io.BytesIO()
                        st.session_state.collage.save(image_bytes, format="PNG")  # Save as PNG or change to "JPEG"
                        image_bytes.seek(0)  # Move cursor to the beginning

                        # Create a download button
                        st.download_button(
                            label="Download Image",
                            data=image_bytes,
                            file_name=f"{str(uuid.uuid4())}-{st.session_state.layout}-collage.png",
                            mime="image/png",
                            use_container_width=True,
                        )
Enter fullscreen mode Exit fullscreen mode

Me and the keyboard

Sheesh, that was a long one. Anyway, there is still enough room for updates and improvement, adding more variants of the layout, and movable images on collage, etc.

Few of my thoughts:

  1. PIL is a very good library for working with images, however, it has limitations (no SVG support)
  2. Streamlit is fast and useful for small or not very complex (in UI/UX sense) projects, but the very moment you need something more complex, its constraint will start to drag you down - use JS instead.

The full (and optimized) code can be seen at my GitHub repo.
The deployed version can be accessed here.
Video of me using it

Thanks for your attention! Feel free to write your thoughts below.

Speedy emails, satisfied customers

Postmark Image

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more