205

I am trying to horizontally combine some JPEG images in Python.

Problem

I have 3 images - each is 148 x 95 - see attached. I just made 3 copies of the same image - that is why they are the same.

enter image description hereenter image description hereenter image description here

My attempt

I am trying to horizontally join them using the following code:

import sys
from PIL import Image

list_im = ['Test1.jpg','Test2.jpg','Test3.jpg']

# creates a new empty image, RGB mode, and size 444 by 95
new_im = Image.new('RGB', (444,95))

for elem in list_im:
    for i in xrange(0,444,95):
        im=Image.open(elem)
        new_im.paste(im, (i,0))
new_im.save('test.jpg')

However, this is producing the output attached as test.jpg.

enter image description here

Question

Is there a way to horizontally concatenate these images such that the sub-images in test.jpg do not have an extra partial image showing?

Additional Information

I am looking for a way to horizontally concatenate n images. I would like to use this code generally so I would prefer to:

  • not to hard-code image dimensions, if possible
  • specify dimensions in one line so that they can be easily changed
SuperStormer
  • 4,997
  • 5
  • 25
  • 35
edesz
  • 11,756
  • 22
  • 75
  • 123
  • 3
    Why is there a `for i in xrange(...)` in your code? Shouldn't `paste` take care of the three image files you specify? – msw May 14 '15 at 02:26
  • question, will your images always be the same size ? – dermen May 14 '15 at 02:59
  • possible duplicate of [Python Image Library: How to combine 4 images into a 2 x 2 grid?](http://stackoverflow.com/questions/4567409/python-image-library-how-to-combine-4-images-into-a-2-x-2-grid) – jsbueno May 14 '15 at 20:44
  • dermen: yes, images will always be the same size. msw: I wasn't sure how to loop through the images, without leaving a blank space in between - my approach is probably not the best to use. – edesz May 15 '15 at 00:22
  • 2
    The only reason why this doesn't work is because of your `xrange(0,444,95)`. If you change this to `xrange(0,444,148)` everything should be fine. This is because you split the images horizontally, and the width of one image is 148. (Also, you want to combine 3 images, so it is logical that your range object should contain 3 values.) – Jonas De Schouwer Aug 18 '21 at 08:35
  • Thanks! You are right about the width part. I did make use of this in the question, but my reasoning about the step size was wrong - see `Image.new('RGB', (444,95))`...here, I specified 444 since, as you also quite correctly pointed out, there were three images and each has a width of 148 pixels so the concatenated image width should be 148 X 3 = 444. Nonetheless, you are correct - `xrange` was incorrectly used. I thought 95 would be the height of the final image, which was a wrong assumption since that is not how [`xrange` worked](https://docs.python.org/2/library/functions.html#xrange). – edesz Aug 18 '21 at 15:15

13 Answers13

272

You can do something like this:

import sys
from PIL import Image

images = [Image.open(x) for x in ['Test1.jpg', 'Test2.jpg', 'Test3.jpg']]
widths, heights = zip(*(i.size for i in images))

total_width = sum(widths)
max_height = max(heights)

new_im = Image.new('RGB', (total_width, max_height))

x_offset = 0
for im in images:
  new_im.paste(im, (x_offset,0))
  x_offset += im.size[0]

new_im.save('test.jpg')

Test1.jpg

Test1.jpg

Test2.jpg

Test2.jpg

Test3.jpg

Test3.jpg

test.jpg

enter image description here


The nested for for i in xrange(0,444,95): is pasting each image 5 times, staggered 95 pixels apart. Each outer loop iteration pasting over the previous.

for elem in list_im:
  for i in xrange(0,444,95):
    im=Image.open(elem)
    new_im.paste(im, (i,0))
  new_im.save('new_' + elem + '.jpg')

enter image description here enter image description here enter image description here

Jean-François Fabre
  • 137,073
  • 23
  • 153
  • 219
dting
  • 38,604
  • 10
  • 95
  • 114
  • Two questions: 1. `x_offset = 0` - is this is the stagger between image centers? 2. For a vertical concatenation, how does your approach change? – edesz May 14 '15 at 03:55
  • 2
    The second argument of paste is a box. "The box argument is either a 2-tuple giving the upper left corner, a 4-tuple defining the left, upper, right, and lower pixel coordinate, or None (same as (0, 0))." So in the 2-tuple we are using `x_offset` as `left`. For vertical concat, keep track of the `y-offset`, or `top`. Instead of `sum(widths)` and `max(height)`, do `sum(heights)` and `max(widths)` and use the second argument of the 2-tuple box. increment `y_offset` by `im.size[1]`. – dting May 14 '15 at 07:30
  • 24
    Nice solution. Note in python3 that maps can only be iterated over once, so you'd have to do images = map(Image.open, image_files) again before iterating through the images the second time. – Naijaba Jan 11 '17 at 15:27
  • 1
    Jaijaba I also ran into the problem you describe, so I edited DTing's solution to use a list comprehension instead of a map. – Ben Quigley Jun 29 '18 at 19:43
  • 1
    I had to use list comprehension instead of `map` in python3.6 – ClementWalter Nov 30 '18 at 11:11
  • Noting the same thing as Naijaba: in python3 `map` returns an iterator, however instead of calling `map` twice, you could could create a list from the iterator `list(map(Image.open, infile_paths))` or use a list comprehension `[Image.open(path) for path in infile_paths]`. – cheshirekow Jul 19 '19 at 22:15
  • May I ask why are you importing `sys`? – Jakub Bláha Dec 04 '19 at 11:27
127

I would try this:

import numpy as np
import PIL
from PIL import Image

list_im = ['Test1.jpg', 'Test2.jpg', 'Test3.jpg']
imgs    = [ Image.open(i) for i in list_im ]
# pick the image which is the smallest, and resize the others to match it (can be arbitrary image shape here)
min_shape = sorted( [(np.sum(i.size), i.size ) for i in imgs])[0][1]
imgs_comb = np.hstack([i.resize(min_shape) for i in imgs])

# save that beautiful picture
imgs_comb = Image.fromarray( imgs_comb)
imgs_comb.save( 'Trifecta.jpg' )    

# for a vertical stacking it is simple: use vstack
imgs_comb = np.vstack([i.resize(min_shape) for i in imgs])
imgs_comb = Image.fromarray( imgs_comb)
imgs_comb.save( 'Trifecta_vertical.jpg' )

It should work as long as all images are of the same variety (all RGB, all RGBA, or all grayscale). It shouldn't be difficult to ensure this is the case with a few more lines of code. Here are my example images, and the result:

Test1.jpg

Test1.jpg

Test2.jpg

Test2.jpg

Test3.jpg

Test3.jpg

Trifecta.jpg:

combined images

Trifecta_vertical.jpg

enter image description here

AlienKevin
  • 2,691
  • 2
  • 17
  • 19
dermen
  • 5,252
  • 4
  • 23
  • 34
  • Thanks a lot. Another good answer. How would `min_shape =....` and `imgs_comb....` change for a vertical concatenation? Could you post that here as a comment, or in your reply? – edesz May 14 '15 at 03:59
  • 4
    For vertical , change ```hstack``` to ```vstack```. – dermen May 14 '15 at 04:02
  • One more question: Your first image (**Test1.jpg**) is larger than the other images. In your final (horizontal or vertical) concatenated image, all the images are the same size. Could you explain how you were able to shrink the first image before concatenating it? – edesz May 14 '15 at 19:08
  • I used ```Image.resize``` from PIL. ```min_shape``` is a tuple of (min_width, min_height) and then ```(np.asarray( i.resize(min_shape) ) for i in imgs )``` will shrink all images to that size. In fact, ```min_shape``` can be any ```(width,height)``` you desire, just keep in mind that enlarging low-res images will make them blurry! – dermen May 14 '15 at 19:29
  • 4
    If you are looking to just combine images together without any specifics, this is probably the most simple and most flexible answer here. It accounts for differing image size, any # of images, and varying picture formats. This was a very well thought out answer and EXTREMELY useful. Would have never thought of using numpy. Thank you. – Noctsol Jan 11 '19 at 14:44
29

Edit: DTing's answer is more applicable to your question since it uses PIL, but I'll leave this up in case you want to know how to do it in numpy.

Here is a numpy/matplotlib solution that should work for N images (only color images) of any size/shape.

import numpy as np
import matplotlib.pyplot as plt

def concat_images(imga, imgb):
    """
    Combines two color image ndarrays side-by-side.
    """
    ha,wa = imga.shape[:2]
    hb,wb = imgb.shape[:2]
    max_height = np.max([ha, hb])
    total_width = wa+wb
    new_img = np.zeros(shape=(max_height, total_width, 3))
    new_img[:ha,:wa]=imga
    new_img[:hb,wa:wa+wb]=imgb
    return new_img

def concat_n_images(image_path_list):
    """
    Combines N color images from a list of image paths.
    """
    output = None
    for i, img_path in enumerate(image_path_list):
        img = plt.imread(img_path)[:,:,:3]
        if i==0:
            output = img
        else:
            output = concat_images(output, img)
    return output

Here is example use:

>>> images = ["ronda.jpeg", "rhod.jpeg", "ronda.jpeg", "rhod.jpeg"]
>>> output = concat_n_images(images)
>>> import matplotlib.pyplot as plt
>>> plt.imshow(output)
>>> plt.show()

enter image description here

Lena
  • 125
  • 1
  • 1
  • 10
derricw
  • 6,757
  • 3
  • 30
  • 34
  • Your `output = concat_images(output, ...` is what I was looking for when I started searching for a way to do this. Thanks. – edesz May 14 '15 at 03:56
  • Hi ballsatballsdotballs, I have one question regarding your answer. If I want to add the sub-title for each sub-images, how to do that? Thanks. – user297850 Dec 11 '16 at 05:06
20

Here is a function generalizing previous approaches, creating a grid of images in PIL:

from PIL import Image
import numpy as np

def pil_grid(images, max_horiz=np.iinfo(int).max):
    n_images = len(images)
    n_horiz = min(n_images, max_horiz)
    h_sizes, v_sizes = [0] * n_horiz, [0] * (n_images // n_horiz)
    for i, im in enumerate(images):
        h, v = i % n_horiz, i // n_horiz
        h_sizes[h] = max(h_sizes[h], im.size[0])
        v_sizes[v] = max(v_sizes[v], im.size[1])
    h_sizes, v_sizes = np.cumsum([0] + h_sizes), np.cumsum([0] + v_sizes)
    im_grid = Image.new('RGB', (h_sizes[-1], v_sizes[-1]), color='white')
    for i, im in enumerate(images):
        im_grid.paste(im, (h_sizes[i % n_horiz], v_sizes[i // n_horiz]))
    return im_grid

It will shrink each row and columns of the grid to the minimum. You can have only a row by using pil_grid(images), or only a column by using pil_grid(images, 1).

One benefit of using PIL over numpy-array based solutions is that you can deal with images structured differently (like grayscale or palette-based images).

Example outputs

def dummy(w, h):
    "Produces a dummy PIL image of given dimensions"
    from PIL import ImageDraw
    im = Image.new('RGB', (w, h), color=tuple((np.random.rand(3) * 255).astype(np.uint8)))
    draw = ImageDraw.Draw(im)
    points = [(i, j) for i in (0, im.size[0]) for j in (0, im.size[1])]
    for i in range(len(points) - 1):
        for j in range(i+1, len(points)):
            draw.line(points[i] + points[j], fill='black', width=2)
    return im

dummy_images = [dummy(20 + np.random.randint(30), 20 + np.random.randint(30)) for _ in range(10)]

pil_grid(dummy_images):

line.png

pil_grid(dummy_images, 3):

enter image description here

pil_grid(dummy_images, 1):

enter image description here

Maxim
  • 7,207
  • 1
  • 30
  • 29
  • 3
    This line in pil_grid: `h_sizes, v_sizes = [0] * n_horiz, [0] * (n_images // n_horiz) ` should read: `h_sizes, v_sizes = [0] * n_horiz, [0] * ((n_images // n_horiz) + (1 if n_images % n_horiz > 0 else 0))` Reason: If the horizontal width does not divide the number of images in integers, you need to accomodate for the additional if incomplete line. – Bernhard Wagner May 30 '19 at 10:27
13

Based on DTing's answer I created a function that is easier to use:

from PIL import Image


def append_images(images, direction='horizontal',
                  bg_color=(255,255,255), aligment='center'):
    """
    Appends images in horizontal/vertical direction.

    Args:
        images: List of PIL images
        direction: direction of concatenation, 'horizontal' or 'vertical'
        bg_color: Background color (default: white)
        aligment: alignment mode if images need padding;
           'left', 'right', 'top', 'bottom', or 'center'

    Returns:
        Concatenated image as a new PIL image object.
    """
    widths, heights = zip(*(i.size for i in images))

    if direction=='horizontal':
        new_width = sum(widths)
        new_height = max(heights)
    else:
        new_width = max(widths)
        new_height = sum(heights)

    new_im = Image.new('RGB', (new_width, new_height), color=bg_color)


    offset = 0
    for im in images:
        if direction=='horizontal':
            y = 0
            if aligment == 'center':
                y = int((new_height - im.size[1])/2)
            elif aligment == 'bottom':
                y = new_height - im.size[1]
            new_im.paste(im, (offset, y))
            offset += im.size[0]
        else:
            x = 0
            if aligment == 'center':
                x = int((new_width - im.size[0])/2)
            elif aligment == 'right':
                x = new_width - im.size[0]
            new_im.paste(im, (x, offset))
            offset += im.size[1]

    return new_im

It allows choosing a background color and image alignment. It's also easy to do recursion:

images = map(Image.open, ['hummingbird.jpg', 'tiger.jpg', 'monarch.png'])

combo_1 = append_images(images, direction='horizontal')
combo_2 = append_images(images, direction='horizontal', aligment='top',
                        bg_color=(220, 140, 60))
combo_3 = append_images([combo_1, combo_2], direction='vertical')
combo_3.save('combo_3.png')

Example concatenated image

teekarna
  • 1,004
  • 1
  • 10
  • 13
  • 1
    I'm not 100% on where the issue is, but this function does something weird with images, causing the objects i'm iterating over to go from total weight of 25mb to 2gb. so be careful using this method – FlyingZebra1 Dec 07 '20 at 12:55
6

If all image's heights are same,

from PIL import Image
import numpy as np

imgs = ['a.jpg', 'b.jp', 'c.jpg']
concatenated = Image.fromarray(
  np.concatenate(
    [np.array(Image.open(x)) for x in imgs],
    axis=1
  )
)

Maybe you can resize images before the concatenation like this,

import numpy as np

imgs = ['a.jpg', 'b.jpg', 'c.jpg']
concatenated = Image.fromarray(
  np.concatenate(
    [np.array(Image.open(x).resize((640,480)) for x in imgs],
    axis=1
  )
)
Björn Lindqvist
  • 19,221
  • 20
  • 87
  • 122
plhn
  • 5,017
  • 4
  • 47
  • 47
3

Here's my solution:

from PIL import Image


def join_images(*rows, bg_color=(0, 0, 0, 0), alignment=(0.5, 0.5)):
    rows = [
        [image.convert('RGBA') for image in row]
        for row
        in rows
    ]

    heights = [
        max(image.height for image in row)
        for row
        in rows
    ]

    widths = [
        max(image.width for image in column)
        for column
        in zip(*rows)
    ]

    tmp = Image.new(
        'RGBA',
        size=(sum(widths), sum(heights)),
        color=bg_color
    )

    for i, row in enumerate(rows):
        for j, image in enumerate(row):
            y = sum(heights[:i]) + int((heights[i] - image.height) * alignment[1])
            x = sum(widths[:j]) + int((widths[j] - image.width) * alignment[0])
            tmp.paste(image, (x, y))

    return tmp


def join_images_horizontally(*row, bg_color=(0, 0, 0), alignment=(0.5, 0.5)):
    return join_images(
        row,
        bg_color=bg_color,
        alignment=alignment
    )


def join_images_vertically(*column, bg_color=(0, 0, 0), alignment=(0.5, 0.5)):
    return join_images(
        *[[image] for image in column],
        bg_color=bg_color,
        alignment=alignment
    )

For these images:

images = [
    [Image.open('banana.png'), Image.open('apple.png')],
    [Image.open('lime.png'), Image.open('lemon.png')],
]

Results will look like:


join_images(
    *images,
    bg_color='green',
    alignment=(0.5, 0.5)
).show()

enter image description here


join_images(
    *images,
    bg_color='green',
    alignment=(0, 0)

).show()

enter image description here


join_images(
    *images,
    bg_color='green',
    alignment=(1, 1)
).show()

enter image description here

Mikhail Gerasimov
  • 36,989
  • 16
  • 116
  • 159
3

There is also skimage.util.montage to create a montage of images of the same shape:

import numpy as np
import PIL
from PIL import Image
from skimage.util import montage

list_im = ['Test1.jpg', 'Test2.jpg', 'Test3.jpg']
imgs    = [ np.array(Image.open(i)) for i in list_im ]

montage(imgs)
moi
  • 1,835
  • 2
  • 18
  • 25
1
""" 
merge_image takes three parameters first two parameters specify 
the two images to be merged and third parameter i.e. vertically
is a boolean type which if True merges images vertically
and finally saves and returns the file_name
"""
def merge_image(img1, img2, vertically):
    images = list(map(Image.open, [img1, img2]))
    widths, heights = zip(*(i.size for i in images))
    if vertically:
        max_width = max(widths)
        total_height = sum(heights)
        new_im = Image.new('RGB', (max_width, total_height))

        y_offset = 0
        for im in images:
            new_im.paste(im, (0, y_offset))
            y_offset += im.size[1]
    else:
        total_width = sum(widths)
        max_height = max(heights)
        new_im = Image.new('RGB', (total_width, max_height))

        x_offset = 0
        for im in images:
            new_im.paste(im, (x_offset, 0))
            x_offset += im.size[0]

    new_im.save('test.jpg')
    return 'test.jpg'
Raj Yadav
  • 9,677
  • 6
  • 35
  • 30
1
from __future__ import print_function
import os
from pil import Image

files = [
      '1.png',
      '2.png',
      '3.png',
      '4.png']

result = Image.new("RGB", (800, 800))

for index, file in enumerate(files):
path = os.path.expanduser(file)
img = Image.open(path)
img.thumbnail((400, 400), Image.ANTIALIAS)
x = index // 2 * 400
y = index % 2 * 400
w, h = img.size
result.paste(img, (x, y, x + w, y + h))

result.save(os.path.expanduser('output.jpg'))

Output

enter image description here

Jayesh Baviskar
  • 295
  • 4
  • 3
0

Just adding to the solutions already suggested. Assumes same height, no resizing.

import sys
import glob
from PIL import Image
Image.MAX_IMAGE_PIXELS = 100000000  # For PIL Image error when handling very large images

imgs    = [ Image.open(i) for i in list_im ]

widths, heights = zip(*(i.size for i in imgs))
total_width = sum(widths)
max_height = max(heights)

new_im = Image.new('RGB', (total_width, max_height))

# Place first image
new_im.paste(imgs[0],(0,0))

# Iteratively append images in list horizontally
hoffset=0
for i in range(1,len(imgs),1):
    **hoffset=imgs[i-1].size[0]+hoffset  # update offset**
    new_im.paste(imgs[i],**(hoffset,0)**)

new_im.save('output_horizontal_montage.jpg')
Kelmok
  • 11
  • 1
0

my solution would be :

import sys
import os
from PIL import Image, ImageFilter
from PIL import ImageFont
from PIL import ImageDraw 

os.chdir('C:/Users/Sidik/Desktop/setup')
print(os.getcwd())

image_list= ['IMG_7292.jpg','IMG_7293.jpg','IMG_7294.jpg', 'IMG_7295.jpg' ]

image = [Image.open(x) for x in image_list]  # list
im_1 = image[0].rotate(270)
im_2 = image[1].rotate(270)
im_3 = image[2].rotate(270)
#im_4 = image[3].rotate(270)

height = image[0].size[0]
width = image[0].size[1]
# Create an empty white image frame
new_im = Image.new('RGB',(height*2,width*2),(255,255,255))

new_im.paste(im_1,(0,0))
new_im.paste(im_2,(height,0))
new_im.paste(im_3,(0,width))
new_im.paste(im_4,(height,width))


draw = ImageDraw.Draw(new_im)
font = ImageFont.truetype('arial',200)

draw.text((0, 0), '(a)', fill='white', font=font)
draw.text((height, 0), '(b)', fill='white', font=font)
draw.text((0, width), '(c)', fill='white', font=font)
#draw.text((height, width), '(d)', fill='white', font=font)

new_im.show()
new_im.save('BS1319.pdf')   
[![Laser spots on the edge][1]][1]
aVral
  • 65
  • 1
  • 9
0
    #**How to merge cropped images back to original image**
    images = [Image.open(x) for x in images_list]
    print("Length:: ", len(images))
    widths, heights = zip(*(i.size for i in images))
    print(widths, heights)
    total_width = sum(widths)
    max_height = sum(heights)
    print(total_width,max_height)
    
    new_im = Image.new('RGB', (5*384, 5*216))
    
    x_offset = 0
    y_offset = 0
    
    img_size = [384,216]
    def grouped(iterable, n):
        return zip(*[iter(iterable)]*n)
    
    for x,y,a,b,c in grouped(images, 5):
        temp = []
        temp.append([x,y,a,b,c])
        print(temp[0])
        print(len(temp[0]))
        for lsingle_img in temp[0]:
            # print(lsingle_img)
            print("x_y_offset: ", (x_offset, y_offset))
            new_im.paste(lsingle_img, (x_offset, y_offset))
            x_offset += img_size[0]
        temp = []
        x_offset = 0
        y_offset += img_size[1]
    new_im.save('test.jpg')