Image processing with pillow and NumPy

To be frank, I had heard from various sources that “images are just arrays,” and I used to nod along until I actually got into working with NumPy. And guess what? I had no clue whatsoever.When I looked at an image, I saw a picture. My brain processed it as a whole thing a cat, a face, a sunset. But NumPy doesn't see any of that. NumPy sees a table of numbers. And the moment I truly accepted that, everything clicked.

Wanna learn the basics?

Check out my repository here: https://github.com/Mominulcse11/Numpy

Images Are Matrices

In computer science, an image is just a grid of numbers representing pixels.

Grayscale (2D Array): Shape: (Height, Width)Data:Represented by a single 2D grid. Each number represents the light intensity (or brightness) of that pixel, typically ranging from [0(pure black) , 255 (pure white)]

RGB (3D Array): Shape: (Height, Width, 3) Data:Represented by three overlapping 2D grids (channels) stacked on top of each other, usually for Red, Green, and Blue (RGB). Each pixel is a combination of these three values, allowing for millions of possible colors

RGBA (3D Array): Shape: (Height, Width, 4) Data: Includes the three RGB channels plus a 4th Alpha channel for transparency (0 is fully transparent, 255 is fully opaque).

RGB Structure

Every pixel in a color image is a triplet — [R, G, B]. Three numbers, each between 0 and 255.

So img_array[0, 0] gives you the top-left pixel. Something like [255, 230, 180]. That's almost white with a warm tint.

The axes mean:

PIL (Pillow) library and NumPy to load and inspect an image:

import numpy as np
import matplotlib.pyplot as plt
from PIL import Image

img = Image.open("cat.jpg")
print(type(img))
img_array = np.array(img)

print(img_array.shape)

Output: (148, 269, 3)

148 rows. 269 columns. 3 color channels. That's your Meow.

A quicck check whats going on to the display:

plt.imshow(img_array)
plt.show()

Output:

The function plt.imshow() converts the matrix into a color map.It prepares the image
plt.show() displays the image on the display.

crop

Cropping an image is just array slicing:

crop = img_array[100:140, 50:250]

plt.imshow(crop)
plt.show()
print(img_array.shape)

Output:


like you are saying give me rows 100 to 140, columns 50 to 250.General pattern is img_array[row_start:row_end, col_start:col_end]

Flipping

Horizontal flip — reverse the columns:

horflipped = img_array[:, ::-1]

plt.imshow(horflipped)
plt.show()

Output:

: (First dimension): Selects all rows.

::-1: Selects all columns but steps backward by 1. This reverses the horizontal order of pixels.Slice notation generally follows the format [start:stop:step]

Vertical flip — reverse the rows:

verflipped = img_array[::-1, :]

plt.imshow(verflipped)
plt.show()

Output:

Same idea.But on axis 0.The image flips Upside DOwn like Hawkins.

Rotation

Rotation is bit more interesting .Cuz there is no single step slice for this

you need to use transpose first .Then flipping columns give rotation.

transpose = img_array.transpose(1, 0, 2)

rotate = transpose[:, ::-1]
plt.imshow(rotate)
plt.show()

Output:

Why (1, 0, 2)?

So (H, W, 3) becomes (W, H, 3) .Then nedd flip the columns.

Tiny Experiments

The real learning happens when you break things on purpose.

Brighten the image:

bright = img_array + 50
bright = np.clip(bright, 0, 255)

plt.imshow(bright)
plt.show()

Output:

Use clip cuz pixel range [0,250]

Isolate the red channel:

redd = img_array.copy()
redd[:, :, 0] = 0  # zero out red
redd[:, :, 1] = 0  # zero out green

plt.imshow(redd)
plt.show()

Output:

Using masking here — selectively zeroing out channels. Only red survives.

Convert to grayscale:

RGB image:3 channels

Grayscale:1 intensity value

Luminosity (Weighted) Method: This is the most accurate method because it accounts for human visual perception—our eyes are most sensitive to green, then red, and least to blue. Formula: 0.299R+0.587G+0.114B

Average Method: Simply averages the three channels. It is easier to calculate but may result in less visual depth.Formula: (Gray = R + G + B\3)

Data Compression: Grayscale images use only 8 bits per pixel (one channel) compared to 24 bits for RGB (three channels), significantly reducing file size. Image Processing: Many complex algorithms (like edge detection or face recognition) work faster and more reliably on single-channel grayscale data.

gray = np.dot(
    img_array[..., :3],
    [0.299, 0.587, 0.114]
)

plt.imshow(gray, cmap='gray')
plt.show()

output:

blur

Its the most interesting experiment.

Blur is conceptually simple also not simple: replace each pixel with the average of its nearby pixels.

he more neighbors you include, the more blurred it gets. That neighborhood is called a kernel. A 3×3 kernel looks at 9 pixels around each point. A 5×5 looks at 25.

blurred = img_array.copy()

for i in range(1, img_array.shape[0]-1):
    for j in range(1, img_array.shape[1]-1):

        blurred[i, j] = np.mean(
            img_array[i-1:i+2, j-1:j+2],
            axis=(0,1)
        )

plt.imshow(blurred.astype(np.uint8))
plt.show()

output:

Blur is conceptually simple: replace each pixel with the average of its nearby pixels.

he more neighbors you include, the more blurred it gets. That neighborhood is called a kernel. A 3×3 kernel looks at 9 pixels around each point. A 5×5 looks at 25.

Try these yourself:

- Flip the image vertically
- Isolate only the green channel
- Rotate the image counterclockwise
- Brighten only the top half of the image
- Make a black and white mask where red > 150