Search code examples
pythonnumpyimage-processingpython-imaging-libraryreshape

Replacing for loops with numpy functions to reduce image dimensions and number of colors


First of all, I'm relatively new to Python and its libraries. I'm trying to loop through all the pixels of an image using Pillow to divide an image into a fraction of the original dimensions, by take the closest RGB color for the division matrix from all the colors of the original image. With the following function, this takes more than three hours for an average image with a large number of colors:


def divide_image_by_rgb(image_file, division=4):
    input_img = Image.open(image_file)
    width, height = input_img.size
    pixels_count = width * height

    if division < 2:
        raise Exception("Invalid division!")

    white = (255, 255, 255)
    width_out = width // division
    height_out = height // division
    output_img = Image.new('RGB', (width_out, height_out), white)
    all_colors = list(get_colors_count(input_img).keys())

    for x in range(width_out):
        for y in range(height_out):
            pixels = []
            [r, g, b] = [0, 0, 0]
            for d1 in range(division):
                for d2 in range(division):
                    pos_x = x * division + d1
                    pos_y = y * division + d2
                    if pos_x > width or pos_y > height:
                        continue

                    pixel = input_img.getpixel((pos_x, pos_y))
                    pixels.append(pixel)
                    r += pixel[0] ** 2
                    g += pixel[1] ** 2
                    b += pixel[2] ** 2

            div = division ** 2
            mean_color = (
                round(sqrt(r // div)),
                round(sqrt(g // div)),
                round(sqrt(b // div))
            )
            mean_color = closest(all_colors, mean_color)
            output_img.putpixel((x, y), mean_color)
            # log_reduce_progress(changed, pixels_count, height, width, idx, x, y)

    output_file = f"resized.[{division}].{os.path.basename(image_file)}"

    output_img.save(output_file)
    return output_file



def closest(colors, color):
    colors = np.array(colors)
    color = np.array(color)
    distances = np.sqrt(np.sum((colors - color) ** 2, axis=1))
    index_of_smallest = np.where(distances == np.amin(distances))
    smallest_distance = tuple(colors[index_of_smallest][0])
    return smallest_distance

Goal: I want to replace explicit loops using numpy

Input image: The input image is something like this:

PNG, Size: (6.32MP) Dimensions: 2228x2836, Depth: 24bit, Colors count: 632576

Result:

PNG, Size: (0.39MP) Dimensions: 557x709, Depth: 24bit, Colors count: 110896

Why: I want reduce image dimensions and colors to be smoothed with the least number of colors, the hue, uniformity and composition of the image should not be lost. I use libimagequant, quantize ADAPTIVE, reduce colors from given RGB palette csv, etc. before, but this method gives me the closest color combination to the desired result, I use result image as RGB palette to reduce original Image colors with respect to main colors.

Try: I tried to do this with numpy functions which failed and with every change, my errors and confusion increase:


def resize_image_manual_ex(image_file, division=4):

    input_img = Image.open(image_file)
    width, height = input_img.size
    pixels_count = width * height

    white = (255, 255, 255)
    width_out = width // division
    height_out = height // division
    output_img = Image.new('RGB', (width_out, height_out), white)

    # Convert the input image to a NumPy array
    input_array = np.array(input_img)

    # Use NumPy's reshaping to create sub-images
    sub_images = input_array.reshape(height_out, division, width_out, division, 3)

    # Calculate the mean color of each sub-image
    mean_colors = np.mean(sub_images, axis=(1, 3))

    # Find the closest color for each mean color
    all_colors = list(get_colors_count(input_img).keys())
    closest_colors = [closest(all_colors, color) for color in mean_colors]

    for x in range(width_out):
        for y in range(height_out):
            output_img.putpixel((x, y), closest_colors[y, x])

    output_file = f"resized.[{division}].{os.path.basename(image_file)}"

    output_img.save(output_file)
    return output_file

Last error:

sub_images = input_array.reshape(height_out, division, width_out, division, 3)

ValueError: cannot reshape array of size 56925 into shape (57,2,82,2,3)

Is there any way to replace for loops with numpy (or any else) to reduce the running time? Thank you very much.


Solution

  • This should do what you want, I think. I'm not to clear on PIL commands, so check those.

    import numpy as np
    from PIL import Image
    from skimage.util import view_as_blocks
    from scipy.spatial import KDTree
    import os
    
    def divide_image_by_rgb(image_file, division=4):
        """this is all PIL stuff, ask another question if this doesn't work"""
        input_img = Image.open(image_file)
    
        if division < 2:
            raise Exception("Invalid division!")
    
        np_image = np.array(input_img)
        mean_img = mean_divide_np_image(np_image, division)
    
        output_img = Image.fromarray(mean_img)
        output_file = f"resized.[{division}].{os.path.basename(image_file)}"
    
        output_img.save(output_file)
        
        return output_file
    
    def mean_divide_np_image(input_img, division):
        width, height, _ = input_img.shape
        width_out = width // division
        height_out = height // division
        np_image = np.array(input_img)[:width_out * division, :height_out * division]
        all_colors = np.unique(np_image.reshape(-1, 3), axis = 0)
        img_blocks = view_as_blocks(np_image, (width_out, height_out, 3))
        mean_img = np.mean(img_blocks**2, axis = (0, 1, 2))
        mean_img = np.sqrt(closest(all_colors**2, mean_img)).astype(input_img.dtype)
        
        return mean_img
    
    def closest(colors, img):
        color_tree = KDTree(colors)
        dist, i = color_tree.query(img)
        closest_color = colors[i]
        return closest_color
    

    Big things I did:

    • vectorized the mean function, so it's not going pixel by pixel. Also used a view from view_as_blocks to save memory. Maintained the root mean squared functionality needed to average RGB values, also carrying the squares through the closest search.
    • Changed closest to a KDTree functionality. This is much faster for large numbers of colors