import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
import requests
from io import BytesIO
def load_color_image():
"""Downloads a small color image.
Falls back to a synthetic image if download fails."""
try:
url = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Bikesgray.jpg/320px-Bikesgray.jpg"
response = requests.get(url, timeout=5)
img = Image.open(BytesIO(response.content)).convert('RGB')
return img
except Exception:
arr = np.zeros((100, 100, 3), dtype=np.uint8)
for r in range(100):
for c in range(100):
arr[r, c] = [r * 2, c * 2, 128]
return Image.fromarray(arr)
def load_gray_image():
"""Downloads a small grayscale image.
Falls back to a synthetic image if download fails."""
try:
url = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Bikesgray.jpg/320px-Bikesgray.jpg"
response = requests.get(url, timeout=5)
img = Image.open(BytesIO(response.content)).convert('L')
return img
except Exception:
arr = np.zeros((100, 100), dtype=np.uint8)
for r in range(100):
for c in range(100):
arr[r, c] = (r + c) % 256
return Image.fromarray(arr)
color_img = load_color_image()
gray_img = load_gray_image()
def show_images(images, titles=None, cmap_list=None, figsize=None):
"""
Displays a list of images side by side.
Parameters:
images : list of numpy arrays or PIL Images
titles : list of title strings
cmap_list : list of colormaps (e.g. ['gray', None, 'hot'])
use None for color images, 'gray' for grayscale
figsize : optional (width, height) tuple
"""
n = len(images)
if figsize is None:
figsize = (5 * n, 4)
if titles is None:
titles = [f'Image {i+1}' for i in range(n)]
if cmap_list is None:
cmap_list = [None] * n
fig, axes = plt.subplots(1, n, figsize=figsize)
if n == 1:
axes = [axes]
for ax, img, title, cmap in zip(axes, images, titles, cmap_list):
if isinstance(img, Image.Image):
img = np.array(img)
ax.imshow(img, cmap=cmap)
ax.set_title(title)
ax.axis('off')
plt.tight_layout()
plt.show()
Before we can process images, we need to understand what they are to a computer.
Run the helper above to load two test images — one colored, one grayscale.
Tasks:
show_images to display both the color and grayscale images.
cmap_list=['gray'].An image is just a grid of numbers. Let's prove it.
A PIL Image can be converted to a NumPy array using np.array(img).
Tasks:
color_arr and gray_arr.color_arr.shape and gray_arr.shape. What are the dimensions?color_arr.dtype and gray_arr.dtype. What is the data type of each pixel?color_arr.min() and color_arr.max().color_arr[0, 0]. What does this value represent? What about color_arr[0, 0, 0]?Write down your answers:
(H, W, ?). The third dimension is... because...(H, W). There is no third dimension because...color_arr[0, 0] gives [R, G, B] — this means the pixel at row 0, column 0 has Red=___, Green=___, Blue=___.Now that you know the structure, explore the image as a grid.
Tasks:
Important rule for this notebook: When asked to avoid NumPy, that means: no
np.mean(), nonp.sum(), no array slicing magic. Use Pythonforloops,range(), and plain arithmetic. You can still access array elements by index likearr[r, c].
Let's look at a small patch of the image zoomed in, so we can literally see the pixels.
Tasks:
show_images with cmap_list=['gray'].A color pixel is stored as three numbers: (R, G, B) — Red, Green, Blue. Each value is between 0 and 255.
(255, 0, 0) → pure red(0, 255, 0) → pure green(0, 0, 255) → pure blue(0, 0, 0) → black(255, 255, 255) → white(128, 128, 128) → grayTasks:
color_arr[0, 0]. What are the R, G, B values of the top-left pixel?Create a tiny 4x4 test array manually (use np.array) where each pixel is a known color:
[255, 0, 0][0, 255, 0][0, 0, 255][255, 255, 255]Display it. Does it look right?
test[0, 0, 0] — this is the Red channel of the first pixel. Access test[1, 0, 1] — what channel is this? What value do you expect?Your job is to extract only the Red channel from the color image. The result should be a 2D array (H x W) of numbers — just the red values.
Rules for this exercise:
color_arr[:, :, 0]np.split, no np.take, no fancy indexingfor loops and index access arr[r, c, 0]Tasks:
np.zeros((height, width), dtype=np.uint8).show_images with cmap_list=['gray'].['Reds', 'Greens', 'Blues'].When we display the Red channel as grayscale, bright = high red value, dark = low red value. But what if we want to display it as an actual red image?
Think: to show the red channel in red, you need a color image where:
red_channelTasks:
red_image of shape (H, W, 3) where only the red component is non-zero.Hint for step 4: Be careful about overflow! Adding uint8 values can exceed 255. Think about how to handle this.
A grayscale image has only one number per pixel — its brightness. A color image has three (R, G, B).
The question is: given R, G, B — how do you compute a single brightness value?
There is no single "right" answer. But there are good ones and bad ones. Your job is to invent several formulas and see what happens.
Tasks:
Your 3 ideas (before looking at any answers):
For each formula you invented (and the ones below), write a function rgb_to_gray_METHOD(color_array) that:
(H, W, 3) numpy array(H, W) numpy array of type uint8np.mean(), no slicing across channelsFormulas to implement:
| Method | Formula |
|---|---|
| Average | $\frac{R + G + B}{3}$ |
| Lightness | $\frac{\max(R,G,B) + \min(R,G,B)}{2}$ |
| Luminosity (ITU-R BT.601) | $0.299 R + 0.587 G + 0.114 B$ |
| Your own idea | (whatever you came up with above) |
Tip on
uint8: When you compute a float result, convert it back touint8by wrapping inint(...)and clamping:max(0, min(255, value)).
Tasks:
color_arr.show_images with cmap_list=['gray', 'gray', 'gray', 'gray'].gray_average and gray_luminosity: For each pixel, compute abs(average[r,c] - luminosity[r,c]). Display this difference image. Where are the biggest differences?Hint for question 6: Think about how human eyes perceive brightness in different colors.
Your reflection (fill in):
After converting a color image to grayscale, can you recover the original colors?
A. Yes — the grayscale values contain enough information to reconstruct RGB
B. No — grayscale combines three channels (R, G, B) into one number, permanently losing which colors contributed to that brightness
C. Yes, but only if you used the averaging method
D. No, but only because Python deletes the original data
img_100 = np.array(gray_img.resize((100, 100), Image.LANCZOS))
print("Shape of 100x100 image:", img_100.shape)
show_images([img_100], titles=['100x100 Grayscale'], cmap_list=['gray'])
You have a 100x100 image. You want to produce a 50x50 image.
The output has 4x fewer pixels than the input. Each output pixel must somehow be derived from the input pixels.
Before writing any code, answer these questions:
(r, c) in the 50x50 image corresponds to which input pixel(s) in the 100x100 image?(0, 0) covers input pixels (0,0), (0,1), (1,0), (1,1) — what single value should you assign to it? Think of at least 3 different choices.Your answers before coding:
(r, c) corresponds to input pixel(s): ...Rules: Use only Python loops. No cv2.resize, no PIL .resize, no np.mean over blocks.
Method A — Nearest Neighbor (Subsampling)
For each output pixel (r, c), simply copy the value from input pixel (2*r, 2*c).
Method B — Average Pooling
For each output pixel (r, c), take the average of the 2x2 block:
input[2r, 2c], input[2r, 2c+1], input[2r+1, 2c], input[2r+1, 2c+1]
Method C — Max Pooling
For each output pixel (r, c), take the maximum of the same 2x2 block.
Method D — Your own! Invent a fourth method. Some ideas: min pooling, median of the 4 values, or weighted average (give corners less weight).
Tasks:
img_100. Display the results side by side (along with the original).abs(method_A[r,c] - method_B[r,c]) for every pixel. Where do methods disagree most?Your observations:
Now you have a 50x50 image (use small_nearest from Part 4). You want to produce a 100x100 image.
This is the reverse of downsampling, but it's harder: you need to invent information that wasn't there.
Before coding, think:
(r, c) in the 100x100 image comes from which input pixel(s) in the 50x50 image?(r, c) falls exactly between two input pixels — e.g., output pixel (1, 0) lands between input (0,0) and (1,0)?Your strategies before coding:
Rules: Use only Python loops. No cv2.resize, no PIL .resize.
Method A — Nearest Neighbor Replication
Each output pixel (r, c) copies from input pixel (r//2, c//2). Every input pixel gets "stretched" into a 2x2 block.
Method B — Bilinear Interpolation (1D first, then 2D) This one is harder. Let's build it up:
lerp(a, b, t) = a + t * (b - a) where t is between 0 and 1.(r, c) maps to input position (r/2, c/2).r/2 = 1.5, you interpolate between row 1 and row 2 with weight 0.5.Method C — Your own! Ideas: replicate rows/columns, use the average of neighbors, or anything you can think of.
Tasks:
img_100:
error[r, c] = abs(int(upsampled[r,c]) - int(img_100[r,c]))
Which method is closest to the original? Does this surprise you?img_100.When upsampling a 50x50 image to 100x100, nearest-neighbor repeats each pixel while bilinear interpolation blends neighboring pixels. Which produces a smoother result, and why?
A. Nearest-neighbor is smoother because it preserves the original pixel values exactly
B. Bilinear interpolation is smoother — it creates gradual transitions between pixels by computing weighted averages, while nearest-neighbor creates visible blocky squares
C. Both produce identical results at 2x scaling
D. Neither is smooth — you need at least 4x scaling for smooth results
So far you've doubled the image (50→100). Now go from 50x50 to 150x150 — a scale factor of 3.
Nearest neighbor is easy: output pixel (r, c) maps to input (r//3, c//3).
Bilinear is similar: output (r, c) maps to input coordinates (r/3, c/3).
But here's a deeper challenge: what about going from 50x50 to 75x75? The scale factor is 1.5 — not an integer.
Output pixel (r, c) maps to input position (r / 1.5, c / 1.5) = (r * 2/3, c * 2/3). This is a fractional coordinate — you must interpolate.
The key insight: Your bilinear implementation already handles this! The formula in_r = r / scale works for any scale — integer or fractional.
Tasks:
upsample_nearest and upsample_bilinear to accept an arbitrary target_h, target_w instead of a fixed scale.resize_nearest(img, target_h, target_w) and resize_bilinear(img, target_h, target_w).img_50 to 150x150 using both methods.img_50 to 75x75 using both methods.Now let's push your implementation further.
Tasks:
img_50 (top-left corner): patch = img_50[:10, :10].You've learned that a pixel is a single number. Now let's think differently: a pixel together with its neighbors tells you something about local structure.
Consider a grayscale image. Look at a 3x3 patch around any pixel (r, c):
img[r-1, c-1] img[r-1, c] img[r-1, c+1]
img[r, c-1] img[r, c] img[r, c+1]
img[r+1, c-1] img[r+1, c] img[r+1, c+1]
A filter (also called a kernel or weight matrix) is a small grid of numbers, also 3x3:
w[0,0] w[0,1] w[0,2]
w[1,0] w[1,1] w[1,2]
w[2,0] w[2,1] w[2,2]
The convolution output at pixel (r, c) is the sum of element-wise products. Write a mathematical expression for out[r, c] in terms of the image pixels and filter weights. Use summation notation if you can.
Before coding, think:
1/9? What computation would that be?1 and all others are 0?Your answers before coding:
Implement conv(image, weights) where:
image is a 2D numpy array of shape (H, W)weights is a 2D numpy array of shape (K, K) where K is odd (3, 5, 7, ...)Boundary strategy — "valid" mode: Only compute output where the kernel fits fully inside the image. For a 3x3 kernel on an HxW image, the output will be (H-2) x (W-2). In general, for a KxK kernel the output is (H-K+1) x (W-K+1).
For each output position (r, c), you need to combine the KxK neighborhood of the image starting at (r, c) with the weight grid. Figure out how to compute the output value using what you learned in Exercise 7.1.
Note: The output can have values outside
[0, 255]— don't clamp yet. Usefloatarrays for the output and we'll handle display separately.
Sanity check: Use the identity filter to verify your implementation:
identity_kernel = np.array([
[0, 0, 0],
[0, 1, 0],
[0, 0, 0]
], dtype=np.float64)
result = conv(img_100.astype(np.float64), identity_kernel)
# result[0,0] should equal img_100[1,1]
The output of convolution may have negative values or values > 255. We need a helper to normalize and display it.
Run this cell to make the helper available.
Now the fun part. Apply your conv function with different kernels and discover what each one does to the image.
Rules: You must apply each kernel, display the result, and describe what you see before reading the reveal below.
Kernel A
1/9 1/9 1/9
1/9 1/9 1/9
1/9 1/9 1/9
Kernel B
0 -1 0
-1 5 -1
0 -1 0
Kernel C
-1 -2 -1
0 0 0
1 2 1
Kernel D
-1 0 1
-2 0 2
-1 0 1
Tasks:
img_100.For Kernels C and D: can you combine the two output maps? Try:
combined[r,c] = sqrt(result_C[r,c]**2 + result_D[r,c]**2)
(Implement with a loop.)
Your observations (fill in before reading the reveal):
Now you know what a convolution filter does. Design your own.
Tasks:
1/25. Apply it. Compare to the 3x3 blur. Which is blurrier? Why?Gaussian blur idea: Instead of equal weights, what if the center gets more weight and edges get less? Design a 3x3 kernel where the center = 4, direct neighbors = 2, corners = 1 (then normalize so they sum to 1).
gaussian_approx = np.array([
[1, 2, 1],
[2, 4, 2],
[1, 2, 1]
], dtype=np.float64)
gaussian_approx = gaussian_approx / gaussian_approx.sum()
Apply it. How does it compare to the flat blur?
conv(conv(img, blur), blur)). What happens? Apply it 3 times, 5 times.You may have noticed that each convolution slightly shrinks the image (98x98 from a 100x100 image with a 3x3 kernel).
A common fix is zero-padding: surround the image with a border of zeros before convolving, so the output stays the same size as the input.
Formula: pad_size = (K - 1) // 2
Tasks:
pad_image(img, pad_size) that returns a new array with pad_size rows/columns of zeros added on all sides. Use loops — no np.pad!conv_same(image, weights) that uses pad_image internally, so the output is the same size as the input.conv_same with Kernel A to img_100. Verify the output shape is (100, 100).conv_same with Kernels C and D. Notice that now edges near the image boundary are included in the output.So far, conv works on grayscale (2D). But real images are color (3D: H x W x 3).
How should convolution work on a color image?
Approach 1 — Per-channel: Apply the same kernel independently to each of R, G, B. Stack the three results back into a color image.
Tasks:
conv_color(image_rgb, weights) that:
(H, W, 3) array and a kernelconv_same to each channel separately(H, W, 3) float arraycolor_arr with Kernel A. Display the result.A 3x3 convolution filter has the values [[-1, -1, -1], [0, 0, 0], [1, 1, 1]]. What does this filter detect?
A. It blurs the image by averaging neighbors
B. It detects horizontal edges — it responds strongly where pixel values change rapidly from top to bottom
C. It detects vertical edges
D. It inverts the colors of the image
You now have all the building blocks. Let's build a real pipeline.
Goal: Given the original color image:
Use your own implementations of each step — no library functions for the image processing.
Here is a challenge with no given formula — you must invent the filter.
Goal: Design a 3x3 kernel — experiment with different weight patterns and observe what each one does to the image.
Think:
Tasks:
img_100 and check.Tasks:
Your thoughts on stacked layers:
Answer these questions in your own words:
You just built a computer vision system from scratch — pixel by pixel, loop by loop. Everything in a CNN's early layers is a version of what you built here.
Source on GitHub · Back to all chapters
© 2026 CloudxLab. All rights reserved.