26  PyPhotoshop: Image Manipulation App

Have you ever applied an Instagram filter to your photos to instantly change their look and feel? Images are just arrays of numbers, and we can manipulate these numbers to create various effects.

One popular effect is the “negative” filter, which inverts the colors of an image, turning light areas dark and dark areas light (see example in Figure 26.1). This concept can be directly applied to grayscale images using a simple mathematical transformation: 255 - pixel_value. The formula inverts the grayscale value of a pixel, where pixel_value is the original grayscale value ranging from 0 (black) to 255 (white).

(a) Original
(b) Inverted
Figure 26.1: Original and Inverted Grayscale Images of Lena (a standard test image in digital image processing).

To implement this filter effect in Python, we can read an image into a matrix, iterate over each pixel value, apply the inversion formula, and update the pixel value in the matrix. The function negative_filter should receive an image stored in a matrix (list of lists) and then process it to invert the grayscale values. For example, for an image with width 3 and height 3, the algorithm would turn the 3x3 matrix of pixel values shown in Table 26.1 (a) to the inverted matrix displayed in Table 26.1 (b). It would iterate over each cell in the matrix, apply the inversion formula 255 - pixel_value, and update the cell with the new value.

Table 26.1: Original and Inverted Grayscale Matrices for a 3x3 image. The original matrix contains the pixel values before inversion, and the inverted matrix shows the pixel values after applying the inversion algorithm.
(a) Original Grayscale Matrix.
A B C
1 155 105 55
2 135 75 35
3 115 95 15
(b) Inverted Grayscale Matrix
A B C
1 100 150 200
2 120 180 220
3 140 160 240

In Listing 26.1, we provide utility functions to read an image into a matrix (list of lists) and save a matrix as an image.

Listing 26.1
# Library to read and manipulate images
from PIL import Image
import numpy as np

def get_image_as_matrix(image_path: str) -> list[list[int]]:
    """
    Read an image file and convert it to a grayscale matrix.

    Parameters
    ----------
    image_path : str
        The path to the image file.

    Returns
    -------
    list[list[int]]
        A list of lists representing the grayscale image matrix.

    
    Examples
    --------
    >>> get_image_as_matrix('img/lena-gray.bmp')
    [[ 0 240 240 ... 240 240 240]
     [ 0 240 240 ... 240 240 240]
     [ 0 240 240 ... 240 240 240]
     ...
     [ 0 240 240 ... 240 240 240]
     [ 0 240 240 ... 240 240 240]
     [ 0 240 240 ... 240 240 240]]
    """
    
    # Open the image and convert it to grayscale
    img = Image.open(input_image_path).convert('L')

    # Convert the image to a numpy array for easier manipulation
    img_array = np.array(img)

    # Convert the numpy array to a list of lists
    img_matrix = img_array.tolist()
    
    return img_matrix

    return df_img


def save_image_from_matrix(
        img_matrix: list[list[int]],
        output_image_path: str):
    """
    Convert a grayscale image matrix to an image and save it to a file.

    Parameters
    ----------
    img_matrix : list[list[int]]
        A list of lists representing the grayscale image matrix.
    output_image_path : str
        The path to save the output image file.

    Examples
    --------
    >>> save_image_from_matrix([[0, 240, 240, 240, 240, 240],
                                [0, 240, 240, 240, 240, 240],
                                [0, 240, 240, 240, 240, 240],
                                [0, 240, 240, 240, 240, 240],
                                [0, 240, 240, 240, 240, 240],
                                [0, 240, 240, 240, 240, 240]],
                                'img/lena-gray.bmp')
    """
    # Convert the list of lists to a numpy array
    img_array = np.array(img_matrix)

    # Convert the numpy array to an image
    img = Image.fromarray(img_array)

    # Save the image to the output path
    img.save(output_image_path)


def get_image_from_matrix(img_matrix: list[list[int]]) -> Image:
    """
    Convert an array of grayscale pixel values to an image.

    Parameters
    ----------
    img_matrix : list[list[int]]
        A list of lists representing the grayscale image matrix.

    Returns
    -------
    Image
        The image object created from the input matrix.

    Examples
    --------
    >>> get_image_from_matrix([[0, 240, 240, 240, 240, 240],
                               [0, 240, 240, 240, 240, 240],
                               [0, 240, 240, 240, 240, 240],
                               [0, 240, 240, 240, 240, 240],
                               [0, 240, 240, 240, 240, 240],
                               [0, 240, 240, 240, 240, 240]])
    <PIL.Image.Image image mode=L size=6x6 at 0x7F8D3D3D3D30>
    """

    # Convert the list of lists to a numpy array
    arr = np.array(img_matrix, dtype=np.uint8)

    # Convert the numpy array to an image
    img = Image.fromarray(arr)

    return img

In Listing 26.2, we provide the implementation of the negative_filter function that reads an image file, converts it to a grayscale matrix, inverts the grayscale values, and returns the inverted grayscale image.

Listing 26.2
def negative_filter(image_path: str)-> Image:
    """
    Read an image file, convert it to grayscale, and invert the colors.

    Parameters
    ----------
    image_path : str
        The path to the image file.

    Returns
    -------
    Image
        The inverted grayscale image.

    Examples
    --------
    >>> negative_filter('img/lena-gray.bmp')
    <PIL.Image.Image image mode=L size=512x512 at 0x7F8D3D3D3D30>
    """
    # Read the image as a grayscale matrix
    img_matrix = get_image_as_matrix(image_path)

    # Invert the grayscale values in the matrix
    img_matrix_inverted = []
    for row in img_matrix:
        inverted_row = []
        for pixel in row:
            inverted_pixel = 255 - pixel
            inverted_row.append(inverted_pixel)
        img_matrix_inverted.append(inverted_row)

    img = get_image_from_matrix(img_matrix_inverted)

    return img

Then, to get the negative of an image, you can use the negative_filter function as shown below:

# Read the image as a grayscale matrix
input_image_path = 'img/lena_grayscale.bmp'

# Get the negative of the image
img_inverted = negative_filter(input_image_path)

# Display the inverted image
img_inverted

26.1 Implementation

To test the snippets in Listing 26.1 and Listing 26.2, you need to have the image file lena_grayscale.bmp in the img directory. Click on Figure 26.1 (a) to download the original grayscale image of Lena.

Then, you can test the functions in the following ways:

  • Copy the code snippets to a Jupyter notebook cell and run them. Notice that the negative_filter function is at the end of the code snippet, and it reads the image file lena_grayscale.bmp to invert the grayscale values.
  • Copy the snippets to a Python script and run it using a Python interpreter. Use the show() method to display the image, for example, img_inverted.show().
  • Save the image to a file using the save_image_from_matrix function, for example, save_image_from_matrix(img_matrix_inverted, 'img/lena_grayscale_negative.bmp').

26.2 Exercise 1: Image Manipulation Functions

Using the the functions to read grayscale images as matrices, implement the following image manipulation functions:

  1. rotate_image(image_path: str, direction: str) -> Image: This function should read an image file, and rotate the image 90 degrees in the specified direction (either ‘clockwise’ or ‘counterclockwise’). The function should return the rotated image.
  2. flip_image(image_path: str, direction: str) -> Image: This function should read an image file, and flip the image in the specified direction (either ‘horizontal’ or ‘vertical’). The function should return the flipped image.
  3. resize_image(image_path: str, scale: float) -> Image: This function should read an image file, and resize the image by the specified scale factor. The function should return the resized image.
  4. crop_image(image_path: str, x: int, y: int, width: int, height: int) -> Image: This function should read an image file, and crop the image starting from the specified x and y coordinates, with the specified width and height. The function should return the cropped image.
  5. zoom_image(image_path: str, factor: float) -> Image: This function should read an image file, and zoom into the image by the specified factor. The function should return the zoomed image.

26.3 Exercise 2: Filters

In image processing, filters are used to enhance or modify the appearance of an image. Filters are applied by convolving the image with a kernel matrix. For example, consider the following matrix represents a 5x5 image:

\[ \begin{bmatrix} 0 & 255 & 255 & 255 & 0 \\ 255 & 0 & 255 & 0 & 255 \\ 255 & 255 & 0 & 255 & 255 \\ 255 & 0 & 255 & 0 & 255 \\ 0 & 255 & 255 & 255 & 0 \\ \end{bmatrix} \]

Figure 26.2: A 5x5 grayscale image matrix. The pixel values range from 0 (black) to 255 (white). The matrix represents a simple pattern (an ‘X’ shape) in the image.

Now, consider the following 3x3 kernel matrix:

\[ \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \\ \end{bmatrix} \]

Figure 26.3: A 3x3 Sobel kernel matrix used for edge detection. The kernel is applied to the image matrix to detect edges in the image.

To apply the kernel to the image matrix, we slide the kernel over the image matrix, multiply the kernel values by the corresponding pixel values in the image matrix, sum the products, and update the pixel value in the image matrix with the result. For example, consider the pixel at position \(p_{x,y} = p_{1,2} = 255\) in the image matrix Figure 26.2 (consider \(x\) and \(y\) starting from 0). The 3x3 neighborhood around this pixel is shown in Figure 26.4 (a), and the pixel values are shown in Figure 26.4 (b).

\[ \begin{bmatrix} p_{x-1,y-1} & p_{x,y-1} & p_{x+1,y-1} \\ p_{x-1,y} & p_{x,y} & p_{x+1,y} \\ p_{x-1,y+1} & p_{x,y+1} & p_{x+1,y+1} \\ \end{bmatrix} \]

(a) The 3x3 neighborhood around the pixel at position (1, 2) in the image matrix.

\[ \begin{bmatrix} 255 & 255 & 255 \\ 0 & \color{red}{255} & 0 \\ 255 & 0 & 255 \\ \end{bmatrix} \]

(b) The pixel values in the 3x3 neighborhood around the pixel at position (1, 2) in the image matrix. The pixel value at position (1, 2) is at the center of the neighborhood (highlighted in red).
Figure 26.4: The pixel value after applying the Sobel kernel is calculated as the sum of the products of the pixel values and the kernel values in the neighborhood:

\[ \begin{align*} p_{1,2} = & (-1 \times 255) + (0 \times 255) + (1 \times 255) \\ & (-2 \times 0) + (0 \times 255) + (2 \times 0) \\ & (-1 \times 255) + (0 \times 0) + (1 \times 255) \\ p_{1,2} = & -255 + 0 + 255 \\ & +0 + 0 + 0 \\ & -255 + 0 + 255 \\ = & 0 \end{align*} \]

Figure 26.5: The pixel value at position (1, 2) after applying the Sobel kernel.

In this exercise, we will implement three filters: the Box Blur Filter, the Edge Detection Filter, and the Sharpen Filter.

26.3.1 Box Blur Filter

The box blur filter is used to reduce noise and detail in an image. It works by averaging the pixel values in the neighborhood of each pixel. The formula for the blurred value of a pixel is the average of the pixel values in a 3x3 neighborhood centered around the pixel. For example, consider the pixel at position (x, y) in the image matrix img_matrix. The blurred value of this pixel is calculated as follows:

blurred_value = 1/9 * (img_matrix[x-1][y-1] + img_matrix[x][y-1] + img_matrix[x+1][y-1] +
                       img_matrix[x-1][y]   + img_matrix[x][y]   + img_matrix[x+1][y]   +
                       img_matrix[x-1][y+1] + img_matrix[x][y+1] + img_matrix[x+1][y+1])

Implement the function apply_blur_filter(image_path: str) -> Image that reads an image file, applies the blur filter to the image, and returns the blurred image.

26.3.2 Edge Detection Filter

The edge detection filter is used to highlight the edges in an image. It works by detecting sharp changes in intensity between neighboring pixels. One common edge detection filter is the Sobel filter, which calculates the gradient of the image intensity at each pixel. The formula for the edge detection value of a pixel is the sum of the products of the pixel values and the Sobel kernel values in a 3x3 neighborhood centered around the pixel. For example, consider the pixel at position (x, y) in the image matrix img_matrix. The edge detection value of this pixel is calculated as follows:

edge_value = (1 * img_matrix[x-1][y-1] + 0 * img_matrix[x][y-1] + (-1) * img_matrix[x+1][y-1] +
              2 * img_matrix[x-1][y]   + 0 * img_matrix[x][y]   + (-2) * img_matrix[x+1][y]   +
              1 * img_matrix[x-1][y+1] + 0 * img_matrix[x][y+1] + (-1) * img_matrix[x+1][y+1])

Implement the function apply_edge_detection_filter(image_path: str) -> Image that reads an image file, applies the edge detection filter to the image, and returns the edge-detected image.

26.3.3 Sharpen Filter

The sharpen filter is used to enhance the details in an image. It works by increasing the contrast between neighboring pixels. The sharpen filter is implemented by convolving the image with a sharpening kernel. The sharpening kernel is a 3x3 matrix with the following values: [[0, -1, 0], [-1, 5, -1], [0, -1, 0]]. The sharpened value of a pixel is the sum of the products of the kernel values and the neighboring pixel values. For example, consider the pixel at position (x, y) in the image matrix img_matrix. The sharpened value of this pixel is calculated as follows:

sharpened_value = (  0  * img_matrix[x-1][y-1] + (-1) * img_matrix[x][y-1] +   0  * img_matrix[x+1][y-1] +
                   (-1) * img_matrix[x-1][y]   +   5  * img_matrix[x][y]   + (-1) * img_matrix[x+1][y]   +
                     0  * img_matrix[x-1][y+1] + (-1) * img_matrix[x][y+1] +   0  * img_matrix[x+1][y+1])

Implement the function apply_sharpen_filter(image_path: str) -> Image that reads an image file, applies the sharpen filter to the image, and returns the sharpened image.