You won't be taught — you will discover. Every step builds on the last. You will build everything from scratch: pixel by pixel, loop by loop.
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'].Call show_images([color_img], titles=['Color Image']) for the color image. For grayscale, add cmap_list=['gray'] so matplotlib doesn't apply a default colormap.
The color image shows three channels (RGB) producing full color, while the grayscale image has only one channel representing brightness. They show the same scene, but the grayscale version loses all color information.
# Display the color image
show_images([color_img], titles=['Color Image'])
# Display the grayscale image
show_images([gray_img], titles=['Grayscale Image'], cmap_list=['gray'])
# Display both side by side
show_images([color_img, gray_img],
titles=['Color Image', 'Grayscale Image'],
cmap_list=[None, '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=___.The color image has 3 channels (Red, Green, Blue), so its shape is (height, width, 3). The grayscale image has only one brightness value per pixel, so its shape is (height, width).
Pixel values are uint8 — unsigned 8-bit integers from 0 to 255. 0 = black, 255 = white (for grayscale) or maximum intensity (for a color channel).
A color image has shape (H, W, 3). The third dimension is 3 because each pixel stores three values: Red, Green, and Blue. A grayscale image has shape (H, W) with no third dimension because each pixel is a single brightness value. Values range from 0 to 255 (uint8). color_arr[0, 0] gives the RGB triplet of the top-left pixel; color_arr[0, 0, 0] gives just its red intensity.
color_arr = np.array(color_img)
gray_arr = np.array(gray_img)
print("--- Color Array ---")
print("Shape:", color_arr.shape) # (H, W, 3)
print("Dtype:", color_arr.dtype) # uint8
print("Min:", color_arr.min(), " Max:", color_arr.max())
print("Pixel at [0,0]:", color_arr[0, 0]) # [R, G, B] of top-left pixel
print("Red value at [0,0]:", color_arr[0, 0, 0]) # Red channel only
print("\n--- Grayscale Array ---")
print("Shape:", gray_arr.shape) # (H, W)
print("Dtype:", gray_arr.dtype) # uint8
print("\nFirst 5x5 block of grayscale:")
print(gray_arr[:5, :5])
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].
The center row is height // 2 and center column is width // 2. The bottom-right pixel is at [height - 1, width - 1].
For average brightness, loop over all rows and columns, accumulate the sum and count, then divide: for r in range(height): for c in range(width): total += gray_arr[r, c]; count += 1.
height = color_arr.shape[0]
width = color_arr.shape[1]
print(f"Image size: {height} rows x {width} columns")
# Center pixel
center_row = height // 2
center_col = width // 2
print("Center pixel:", color_arr[center_row, center_col])
# Bottom-right corner pixel
print("Bottom-right pixel:", color_arr[height - 1, width - 1])
# First row of grayscale
print("First row of grayscale:", gray_arr[0, :])
print("Number of values:", len(gray_arr[0, :]))
# Average brightness using ONLY Python loops
total = 0
count = 0
h, w = gray_arr.shape
for r in range(h):
for c in range(w):
total += int(gray_arr[r, c])
count += 1
avg_brightness = total / count
print(f"Average brightness (manual loop): {avg_brightness:.2f}")
print(f"Verify with numpy: {gray_arr.mean():.2f}")
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'].Use slicing: gray_arr[:20, :20] for the top-left patch. For the middle, compute mid_r = height // 2 and mid_c = width // 2, then use gray_arr[mid_r-10:mid_r+10, mid_c-10:mid_c+10].
# Top-left 20x20 patch of grayscale
gray_patch = gray_arr[:20, :20]
show_images([gray_patch], titles=['Grayscale top-left 20x20'], cmap_list=['gray'])
# Top-left 20x20 patch of color
color_patch = color_arr[:20, :20]
show_images([color_patch], titles=['Color top-left 20x20'])
# Middle 20x20 patch
mid_r = gray_arr.shape[0] // 2
mid_c = gray_arr.shape[1] // 2
gray_mid = gray_arr[mid_r-10:mid_r+10, mid_c-10:mid_c+10]
color_mid = color_arr[mid_r-10:mid_r+10, mid_c-10:mid_c+10]
show_images([gray_mid, color_mid],
titles=['Gray middle 20x20', 'Color middle 20x20'],
cmap_list=['gray', None])
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?Start with test = np.zeros((4, 4, 3), dtype=np.uint8). Then set test[0, :] = [255, 0, 0] for the red row, etc.
test[1, 0, 1] indexes row 1, column 0, channel 1 — that is the Green channel of the first pixel in the green row. The value should be 255.
test[1, 0, 1] is the Green channel (index 1) of the pixel at row 1, column 0. Since row 1 is all green [0, 255, 0], the value is 255.
# 1. Top-left pixel
print("Top-left pixel R,G,B:", color_arr[0, 0])
# 2. Build the 4x4 test array
test = np.zeros((4, 4, 3), dtype=np.uint8)
test[0, :] = [255, 0, 0] # Row 0: red
test[1, :] = [0, 255, 0] # Row 1: green
test[2, :] = [0, 0, 255] # Row 2: blue
test[3, :] = [255, 255, 255] # Row 3: white
show_images([test], titles=['Test 4x4 color array'])
# 3. Channel access
print("test[0,0,0] (Red of first pixel): ", test[0, 0, 0]) # 255
print("test[1,0,1] (Green of second row): ", test[1, 0, 1]) # 255
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'].Inside the nested loop: red_channel[r, c] = color_arr[r, c, 0]. Channel 0 is Red, channel 1 is Green, channel 2 is Blue.
Bright areas in the red channel mean those pixels have a high red component. Dark areas have little red. This is independent of green and blue.
Bright areas in the red channel mean those pixels have a high red component. Dark areas have little red. Each channel is independent of the others.
height = color_arr.shape[0]
width = color_arr.shape[1]
# Extract Red channel
red_channel = np.zeros((height, width), dtype=np.uint8)
for r in range(height):
for c in range(width):
red_channel[r, c] = color_arr[r, c, 0]
show_images([red_channel], titles=['Red Channel (as grayscale)'], cmap_list=['gray'])
# Extract Green and Blue channels the same way
green_channel = np.zeros((height, width), dtype=np.uint8)
blue_channel = np.zeros((height, width), dtype=np.uint8)
for r in range(height):
for c in range(width):
green_channel[r, c] = color_arr[r, c, 1]
blue_channel[r, c] = color_arr[r, c, 2]
# Display all three channels side by side
show_images(
[red_channel, green_channel, blue_channel],
titles=['Red', 'Green', 'Blue'],
cmap_list=['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.
Build red_image = np.zeros((height, width, 3), dtype=np.uint8), then in the loop set only red_image[r, c, 0] = red_channel[r, c].
When adding the three images, use int() to avoid uint8 overflow and clamp with min(255, value). The recombined image should look identical to the original because R + G + B channels reconstruct the full color.
The recombined image looks identical to the original because each pixel's RGB values are reconstructed exactly (R + 0 + 0 = R, etc.). Using int() and min(255, ...) prevents uint8 overflow.
# Build red_image: (H, W, 3) with only red channel filled
red_image = np.zeros((height, width, 3), dtype=np.uint8)
for r in range(height):
for c in range(width):
red_image[r, c, 0] = red_channel[r, c]
show_images([red_image], titles=['Red channel as color'])
# Build green_image and blue_image similarly
green_image = np.zeros((height, width, 3), dtype=np.uint8)
blue_image = np.zeros((height, width, 3), dtype=np.uint8)
for r in range(height):
for c in range(width):
green_image[r, c, 1] = green_channel[r, c]
blue_image[r, c, 2] = blue_channel[r, c]
show_images([red_image, green_image, blue_image],
titles=['Red', 'Green', 'Blue'])
# Add them together using a loop (handle overflow)
combined = np.zeros((height, width, 3), dtype=np.uint8)
for r in range(height):
for c in range(width):
for ch in range(3):
val = int(red_image[r, c, ch]) + int(green_image[r, c, ch]) + int(blue_image[r, c, ch])
combined[r, c, ch] = min(255, val)
show_images([color_arr, combined],
titles=['Original', 'Recombined R+G+B'])
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):
The simplest approach is to average: $$\text{gray} = \frac{R + G + B}{3}$$ Did you come up with this one? Other common ideas: take the max, or take a weighted combination. Think about whether all three channels contribute equally to perceived brightness.
Three common ideas for combining R, G, B into a single brightness value:
The simple average assumes R, G, and B contribute equally to perceived brightness, which is not true for human vision. Our eyes are most sensitive to green and least to blue.
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)).
def rgb_to_gray_average(arr):
"""
Converts color image to grayscale using simple average: (R+G+B)/3.
arr: numpy array of shape (H, W, 3), dtype uint8
Returns: numpy array of shape (H, W), dtype uint8
"""
h, w = arr.shape[0], arr.shape[1]
result = np.zeros((h, w), dtype=np.uint8)
for r in range(h):
for c in range(w):
R, G, B = arr[r, c, 0], arr[r, c, 1], arr[r, c, 2]
gray = int((R + G + B) / 3)
result[r, c] = max(0, min(255, gray))
return result
For the Lightness method, use Python's built-in max() and min(): gray = int((max(R, G, B) + min(R, G, B)) / 2).
For the Luminosity method: gray = int(0.299 * R + 0.587 * G + 0.114 * B). Notice Green gets the highest weight because human eyes are most sensitive to green light.
def rgb_to_gray_average(arr):
h, w = arr.shape[0], arr.shape[1]
result = np.zeros((h, w), dtype=np.uint8)
for r in range(h):
for c in range(w):
R, G, B = int(arr[r, c, 0]), int(arr[r, c, 1]), int(arr[r, c, 2])
gray = int((R + G + B) / 3)
result[r, c] = max(0, min(255, gray))
return result
def rgb_to_gray_lightness(arr):
h, w = arr.shape[0], arr.shape[1]
result = np.zeros((h, w), dtype=np.uint8)
for r in range(h):
for c in range(w):
R, G, B = int(arr[r, c, 0]), int(arr[r, c, 1]), int(arr[r, c, 2])
gray = int((max(R, G, B) + min(R, G, B)) / 2)
result[r, c] = max(0, min(255, gray))
return result
def rgb_to_gray_luminosity(arr):
h, w = arr.shape[0], arr.shape[1]
result = np.zeros((h, w), dtype=np.uint8)
for r in range(h):
for c in range(w):
R, G, B = int(arr[r, c, 0]), int(arr[r, c, 1]), int(arr[r, c, 2])
gray = int(0.299 * R + 0.587 * G + 0.114 * B)
result[r, c] = max(0, min(255, gray))
return result
def rgb_to_gray_mymethod(arr):
"""Desaturation: use the median of R, G, B."""
h, w = arr.shape[0], arr.shape[1]
result = np.zeros((h, w), dtype=np.uint8)
for r in range(h):
for c in range(w):
vals = [int(arr[r, c, 0]), int(arr[r, c, 1]), int(arr[r, c, 2])]
vals.sort()
result[r, c] = vals[1] # median
return result
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):
The difference image highlights where Average and Luminosity disagree most. Expect the biggest differences in areas with strong color — e.g., saturated reds or blues — because the average treats all channels equally while luminosity weights green heavily.
Human eyes have more green-sensitive cone cells than red or blue. The luminosity formula mirrors human perception: green light appears brightest to us, blue appears dimmest, so a pure blue pixel should map to a darker gray than a pure green pixel at the same intensity.
The luminosity method produces the most perceptually accurate grayscale because it mirrors how human eyes perceive brightness. Green gets the most weight (0.587) because our eyes have more green-sensitive cone cells. Blue gets the least (0.114) because blue light appears dimmer to us. The biggest differences appear in areas with strong color saturation, especially reds and blues.
# Apply all methods
gray_average = rgb_to_gray_average(color_arr)
gray_lightness = rgb_to_gray_lightness(color_arr)
gray_luminosity = rgb_to_gray_luminosity(color_arr)
gray_mymethod = rgb_to_gray_mymethod(color_arr)
# Display all side by side
show_images(
[color_arr, gray_average, gray_lightness, gray_luminosity, gray_mymethod],
titles=['Original', 'Average', 'Lightness', 'Luminosity', 'Median'],
cmap_list=[None, 'gray', 'gray', 'gray', 'gray']
)
# Compute pixel-wise difference between average and luminosity
h, w = gray_average.shape
diff = np.zeros((h, w), dtype=np.uint8)
for r in range(h):
for c in range(w):
diff[r, c] = abs(int(gray_average[r, c]) - int(gray_luminosity[r, c]))
show_images([diff], titles=['Difference: Average vs Luminosity'], cmap_list=['hot'])
After converting a color image to grayscale, can you recover the original colors?
A pixel with (R=200, G=100, B=0) and a pixel with (R=100, G=100, B=100) could produce the same grayscale value. Can you tell them apart from just that one number?
Grayscale maps three numbers (R, G, B) to one number (brightness). Many different color combinations produce the same brightness — a pure red pixel and a pure green pixel could have identical grayscale values. Since multiple inputs map to the same output, you can't invert the process. This is a many-to-one function, and information is permanently lost. This matters in practice: algorithms working on grayscale can detect shapes and edges but can't distinguish objects by color.
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): ...Output pixel (r, c) maps to a 2x2 block starting at input (2*r, 2*c). You could take any one of the four values, their average, their max, etc. Information is always lost because four values are reduced to one.
1. Output pixel (r, c) corresponds to the 2x2 block of input pixels starting at (2*r, 2*c): namely (2r, 2c), (2r, 2c+1), (2r+1, 2c), (2r+1, 2c+1).
2. Three possible values to assign: (a) just take one of them, e.g. the top-left value (nearest neighbor / subsampling); (b) average all four values (average pooling); (c) take the maximum of the four (max pooling).
3. Yes, there is information loss. Four pixel values are reduced to one, so you cannot perfectly reconstruct the original. Different 2x2 blocks can produce the same output value.
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).
For average pooling: result[r, c] = int((img[2*r, 2*c] + img[2*r, 2*c+1] + img[2*r+1, 2*c] + img[2*r+1, 2*c+1]) / 4).
For max pooling, use Python's max() on the four values in the 2x2 block. The output has shape (height // 2, width // 2).
def downsample_nearest(img):
"""Downsamples (H, W) to (H//2, W//2) by taking every other pixel."""
h, w = img.shape
out_h, out_w = h // 2, w // 2
result = np.zeros((out_h, out_w), dtype=np.uint8)
for r in range(out_h):
for c in range(out_w):
result[r, c] = img[2 * r, 2 * c]
return result
def downsample_average(img):
"""Downsamples by averaging each 2x2 block."""
h, w = img.shape
out_h, out_w = h // 2, w // 2
result = np.zeros((out_h, out_w), dtype=np.uint8)
for r in range(out_h):
for c in range(out_w):
val = (int(img[2*r, 2*c]) + int(img[2*r, 2*c+1])
+ int(img[2*r+1, 2*c]) + int(img[2*r+1, 2*c+1])) / 4
result[r, c] = int(val)
return result
def downsample_max(img):
"""Downsamples by taking the maximum of each 2x2 block."""
h, w = img.shape
out_h, out_w = h // 2, w // 2
result = np.zeros((out_h, out_w), dtype=np.uint8)
for r in range(out_h):
for c in range(out_w):
result[r, c] = max(int(img[2*r, 2*c]), int(img[2*r, 2*c+1]),
int(img[2*r+1, 2*c]), int(img[2*r+1, 2*c+1]))
return result
def downsample_min(img):
"""Downsamples by taking the minimum of each 2x2 block."""
h, w = img.shape
out_h, out_w = h // 2, w // 2
result = np.zeros((out_h, out_w), dtype=np.uint8)
for r in range(out_h):
for c in range(out_w):
result[r, c] = min(int(img[2*r, 2*c]), int(img[2*r, 2*c+1]),
int(img[2*r+1, 2*c]), int(img[2*r+1, 2*c+1]))
return result
# Test
small = downsample_nearest(img_100)
print("Output shape:", small.shape) # (50, 50)
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:
Max pooling preserves the strongest activations (brightest values). In a feature map, the strongest activation often represents a detected feature. Average pooling dilutes strong activations by mixing them with weaker neighbors.
Nearest neighbor is the sharpest but can miss detail from skipped pixels. Average pooling is smoothest because it blends all four values. Max pooling preserves the brightest activations in each block, making it preferred in neural networks because strong feature responses (e.g., an edge) are kept even if neighboring pixels are weaker.
small_nearest = downsample_nearest(img_100)
small_average = downsample_average(img_100)
small_max = downsample_max(img_100)
small_min = downsample_min(img_100)
show_images(
[img_100, small_nearest, small_average, small_max, small_min],
titles=['Original 100x100', 'Nearest', 'Average', 'Max', 'Min'],
cmap_list=['gray'] * 5
)
# Difference map: nearest vs average
h, w = small_nearest.shape
diff_down = np.zeros((h, w), dtype=np.uint8)
for r in range(h):
for c in range(w):
diff_down[r, c] = abs(int(small_nearest[r, c]) - int(small_average[r, c]))
show_images([diff_down], titles=['Difference: Nearest vs Average'], cmap_list=['hot'])
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:
Strategy A: Nearest neighbor — just copy the closest input pixel. Strategy B: Interpolation — blend between neighbors using a weighted average based on distance.
Strategy A — Nearest neighbor replication: Each output pixel (r, c) copies from the closest input pixel (r//2, c//2). Every input pixel is "stretched" into a 2x2 block. Simple but blocky.
Strategy B — Interpolation: Output pixel (r, c) maps to input position (r/2, c/2), which may be fractional. Blend between the surrounding input pixels using their distances as weights. Smoother but slightly blurry.
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.
For bilinear interpolation at output pixel (r, c): compute in_r = r / scale, in_c = c / scale. Find the four surrounding input pixels and use lerp twice — first horizontally across the top and bottom rows, then vertically between the two interpolated values.
For lerp: return a + t * (b - a). For the bilinear formula: top = lerp(img[r0, c0], img[r0, c1], dc), bottom = lerp(img[r1, c0], img[r1, c1], dc), value = lerp(top, bottom, dr). Clamp r1 and c1 to the image boundary.
def upsample_nearest(img, scale=2):
"""Upsamples (H, W) to (H*scale, W*scale) by pixel replication."""
h, w = img.shape
out_h, out_w = h * scale, w * scale
result = np.zeros((out_h, out_w), dtype=np.uint8)
for r in range(out_h):
for c in range(out_w):
result[r, c] = img[r // scale, c // scale]
return result
def lerp(a, b, t):
"""Linear interpolation: t=0 gives a, t=1 gives b."""
return a + t * (b - a)
def upsample_bilinear(img, scale=2):
"""Upsamples using bilinear interpolation."""
h, w = img.shape
out_h, out_w = h * scale, w * scale
result = np.zeros((out_h, out_w), dtype=np.uint8)
for r in range(out_h):
for c in range(out_w):
in_r = r / scale
in_c = c / scale
r0 = int(in_r)
c0 = int(in_c)
r1 = min(r0 + 1, h - 1)
c1 = min(c0 + 1, w - 1)
dr = in_r - r0
dc = in_c - c0
top = lerp(float(img[r0, c0]), float(img[r0, c1]), dc)
bottom = lerp(float(img[r1, c0]), float(img[r1, c1]), dc)
value = lerp(top, bottom, dr)
result[r, c] = max(0, min(255, int(value)))
return result
def upsample_repeat_rows_cols(img, scale=2):
"""Upsamples by repeating each row and column."""
h, w = img.shape
out_h, out_w = h * scale, w * scale
result = np.zeros((out_h, out_w), dtype=np.uint8)
for r in range(out_h):
for c in range(out_w):
result[r, c] = img[r // scale, c // scale]
return result
big_nearest = upsample_nearest(img_50)
print("Output shape:", big_nearest.shape) # (100, 100)
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.def mean_absolute_error(img_a, img_b):
"""
Computes pixel-wise mean absolute error between two same-size images.
Use only Python loops — no np.mean, no array subtraction.
"""
h, w = img_a.shape
total = 0
for r in range(h):
for c in range(w):
total += abs(int(img_a[r, c]) - int(img_b[r, c]))
return total / (h * w)
Nearest neighbor is blocky because each input pixel occupies a 2x2 block with no transition. Bilinear is smoother because it blends between neighbors, but the trade-off is that it can appear blurry.
Nearest neighbor is blocky because each input pixel occupies a 2x2 block with no transition. Bilinear is smoother because it blends between neighbors, but the trade-off is slight blurriness. Bilinear typically achieves a lower MAE because averaging reduces extreme errors.
big_nearest = upsample_nearest(img_50)
big_bilinear = upsample_bilinear(img_50)
show_images(
[img_100, big_nearest, big_bilinear],
titles=['Original 100x100', 'Nearest (100x100)', 'Bilinear (100x100)'],
cmap_list=['gray'] * 3
)
# Mean absolute error (using loops)
def mean_absolute_error(img_a, img_b):
h, w = img_a.shape
total = 0
for r in range(h):
for c in range(w):
total += abs(int(img_a[r, c]) - int(img_b[r, c]))
return total / (h * w)
mae_nearest = mean_absolute_error(big_nearest, img_100)
mae_bilinear = mean_absolute_error(big_bilinear, img_100)
print(f"MAE (Nearest): {mae_nearest:.4f}")
print(f"MAE (Bilinear): {mae_bilinear:.4f}")
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?
Imagine two adjacent pixels with values 100 and 200. Nearest-neighbor gives you 100, 100, 200, 200. What would bilinear give for the middle pixels?
Nearest-neighbor duplicates each pixel, creating abrupt jumps between pixel blocks — the result looks "blocky" or pixelated. Bilinear interpolation computes weighted averages of neighboring pixels, creating smooth gradients. Between pixel values 100 and 200, bilinear might produce 100, 133, 167, 200 — a gradual ramp instead of a sudden step. The tradeoff: bilinear is smoother but slightly blurrier, while nearest-neighbor is sharper but blocky.
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.For resize_nearest: in_r = int(r * h / target_h), clamped to [0, h-1]. For resize_bilinear: in_r = r * (h - 1) / max(1, target_h - 1) to map edges correctly.
The bilinear body is the same as Exercise 5.2 Method B, but with in_r = r * (h - 1) / (target_h - 1) instead of r / scale. This ensures that the first and last output pixels map exactly to the first and last input pixels.
def resize_nearest(img, target_h, target_w):
"""Resizes img to (target_h, target_w) using nearest neighbor."""
h, w = img.shape
result = np.zeros((target_h, target_w), dtype=np.uint8)
for r in range(target_h):
for c in range(target_w):
in_r = int(r * h / target_h)
in_c = int(c * w / target_w)
in_r = min(in_r, h - 1)
in_c = min(in_c, w - 1)
result[r, c] = img[in_r, in_c]
return result
def resize_bilinear(img, target_h, target_w):
"""Resizes img to (target_h, target_w) using bilinear interpolation."""
h, w = img.shape
result = np.zeros((target_h, target_w), dtype=np.uint8)
for r in range(target_h):
for c in range(target_w):
in_r = r * (h - 1) / max(1, target_h - 1)
in_c = c * (w - 1) / max(1, target_w - 1)
r0 = int(in_r)
c0 = int(in_c)
r1 = min(r0 + 1, h - 1)
c1 = min(c0 + 1, w - 1)
dr = in_r - r0
dc = in_c - c0
top = lerp(float(img[r0, c0]), float(img[r0, c1]), dc)
bottom = lerp(float(img[r1, c0]), float(img[r1, c1]), dc)
value = lerp(top, bottom, dr)
result[r, c] = max(0, min(255, int(value)))
return result
# Resize to 150x150
img_150_nearest = resize_nearest(img_50, 150, 150)
img_150_bilinear = resize_bilinear(img_50, 150, 150)
show_images([img_50, img_150_nearest, img_150_bilinear],
titles=['Original 50x50', 'Nearest 150x150', 'Bilinear 150x150'],
cmap_list=['gray'] * 3)
# Resize to 75x75
img_75_nearest = resize_nearest(img_50, 75, 75)
img_75_bilinear = resize_bilinear(img_50, 75, 75)
show_images([img_50, img_75_nearest, img_75_bilinear],
titles=['Original 50x50', 'Nearest 75x75', 'Bilinear 75x75'],
cmap_list=['gray'] * 3)
Now let's push your implementation further.
Tasks:
img_50 (top-left corner): patch = img_50[:10, :10].Bilinear is blurry because it linearly interpolates between pixels, which smooths out transitions. Nearest neighbor is sharp but blocky because it uses the same value for an entire block of output pixels.
For preserving sharp edges, you might consider edge-aware interpolation: detect where edges are and avoid interpolating across them. Higher-order interpolation (bicubic) can also help preserve some sharpness while reducing blockiness.
Nearest neighbor is very blocky at 20x magnification because each original pixel becomes a 20x20 block of identical values. Bilinear is blurry because it linearly interpolates, creating smooth gradients that wash out details. To preserve sharp edges while upsampling, you could use edge-aware interpolation: detect edges first, then only interpolate along the edge (not across it). Bicubic interpolation also helps by using a wider neighborhood for smoother curves.
patch = img_50[:10, :10]
print("Patch shape:", patch.shape)
patch_200_nearest = resize_nearest(patch, 200, 200)
patch_200_bilinear = resize_bilinear(patch, 200, 200)
show_images([patch, patch_200_nearest, patch_200_bilinear],
titles=['10x10 Patch', 'Nearest (200x200)', 'Bilinear (200x200)'],
cmap_list=['gray', 'gray', 'gray'],
figsize=(15, 5))
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:
The formula is: $$\text{out}[r, c] = \sum_{i=0}^{2} \sum_{j=0}^{2} \text{img}[r+i-1, c+j-1] \times w[i, j]$$
At the edges, the 3x3 neighborhood extends outside the image. One strategy: only compute the output where the kernel fits entirely — the output is 2 pixels smaller in each dimension.
All weights = 1/9 computes the average of the 3x3 neighborhood — a blur. Center = 1, rest = 0 copies the center pixel unchanged — the identity operation.
1. Edge problem: At the edges, the 3x3 neighborhood extends outside the image boundaries. One solution is to only compute output where the kernel fits entirely ("valid" mode), making the output smaller. Another is to pad the image with zeros.
2. Output: The output is still a 2D grid of numbers (an image). For a 3x3 kernel on an HxW image in "valid" mode, the output is (H-2) x (W-2).
3. All weights = 1/9: This computes the average of the 3x3 neighborhood at each pixel — a box blur that smooths the image.
4. Center weight = 1, rest = 0: This copies the center pixel unchanged — the identity operation. The output is the same image (just slightly cropped in valid mode).
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 shape is (H - K + 1, W - K + 1). Inside the four nested loops: total += image[r + i, c + j] * weights[i, j].
Use np.zeros((out_H, out_W), dtype=np.float64) for the output. Don't forget to convert the input image to float first with img.astype(np.float64) to avoid integer overflow.
def conv(image, weights):
"""
Applies a convolution filter to a grayscale image.
image : 2D numpy array of shape (H, W)
weights: 2D numpy array of shape (K, K)
Returns: 2D numpy array of shape (H-K+1, W-K+1)
"""
H, W = image.shape
K = weights.shape[0]
out_H = H - K + 1
out_W = W - K + 1
output = np.zeros((out_H, out_W), dtype=np.float64)
for r in range(out_H):
for c in range(out_W):
total = 0.0
for i in range(K):
for j in range(K):
total += image[r + i, c + j] * weights[i, j]
output[r, c] = total
return output
# Sanity check: identity filter
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)
print("Output shape:", result.shape) # (98, 98)
print(f"result[0,0] = {result[0,0]:.1f}")
print(f"img_100[1,1] = {img_100[1,1]}")
print("Match:", abs(result[0,0] - float(img_100[1,1])) < 1e-9)
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.
def show_conv_result(original, output, title='Convolution Output', clip=False):
"""
Displays the original image and convolution output side by side.
Normalizes the output to [0, 255] for display.
"""
if clip:
display_out = np.clip(output, 0, 255).astype(np.uint8)
else:
lo, hi = output.min(), output.max()
if hi > lo:
display_out = ((output - lo) / (hi - lo) * 255).astype(np.uint8)
else:
display_out = np.zeros_like(output, dtype=np.uint8)
pad = (original.shape[0] - display_out.shape[0]) // 2
orig_trimmed = original[pad:pad+display_out.shape[0],
pad:pad+display_out.shape[1]]
show_images(
[orig_trimmed, display_out],
titles=['Original (trimmed)', title],
cmap_list=['gray', 'gray']
)
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):
Look at each kernel's weights carefully. Think about what happens when you multiply and sum: does the kernel compute an average? Does it amplify the center relative to neighbors? Does it subtract one side from another?
For combining Kernels C and D, use import math and math.sqrt(result_C[r,c]**2 + result_D[r,c]**2) in a loop. Both outputs have the same shape, so this works element-wise.
Now that you've seen what each kernel does:
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.A 5x5 blur is blurrier because it averages over a larger area (25 pixels instead of 9). The Gaussian-like kernel gives more weight to nearby pixels and less to corners, producing a smoother, more natural-looking blur.
Applying blur multiple times is equivalent to applying a larger blur kernel. Each application smooths further. After 5 iterations, fine details will be almost entirely erased. Note: the image shrinks slightly with each "valid" convolution — use conv_same (Exercise 7.6) to avoid this.
The 5x5 blur is blurrier than the 3x3 blur because it averages over 25 pixels instead of 9 — a larger neighborhood smooths more detail. The Gaussian-like kernel produces a more natural-looking blur because it gives higher weight to nearby pixels and less to corners. Repeated blurring progressively erases fine details until the image becomes uniformly smooth.
# Task 1: Custom emboss filter
my_kernel = np.array([
[-2, -1, 0],
[-1, 1, 1],
[ 0, 1, 2]
], dtype=np.float64)
result_mine = conv(img_100.astype(np.float64), my_kernel)
show_conv_result(img_100, result_mine, title='Emboss Filter')
# Task 2: 5x5 blur
kernel_blur_5x5 = np.full((5, 5), 1.0 / 25.0)
result_blur_5 = conv(img_100.astype(np.float64), kernel_blur_5x5)
show_conv_result(img_100, result_blur_5, title='5x5 Blur')
# Task 3: Gaussian-like blur
gaussian_approx = np.array([
[1, 2, 1],
[2, 4, 2],
[1, 2, 1]
], dtype=np.float64)
gaussian_approx = gaussian_approx / gaussian_approx.sum() # normalize to sum=1
result_gaussian = conv(img_100.astype(np.float64), gaussian_approx)
show_conv_result(img_100, result_gaussian, title='Gaussian-like Blur')
# Task 4: Apply blur multiple times
img_float = img_100.astype(np.float64)
kernel_A = np.full((3, 3), 1.0 / 9.0)
current = img_float
for _ in range(5):
current = conv(current, kernel_A)
show_conv_result(img_100, current, title='5x Blur')
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.For pad_image: create a new array of size (H + 2*pad_size, W + 2*pad_size) filled with zeros. Then copy the original image into the center: result[r + pad_size, c + pad_size] = img[r, c].
For conv_same: compute pad_size = (K - 1) // 2, pad the image, then call your conv function on the padded image. The output will have the same size as the original.
def pad_image(img, pad_size):
"""Pads a (H, W) image with pad_size zeros on all sides."""
H, W = img.shape
new_H = H + 2 * pad_size
new_W = W + 2 * pad_size
result = np.zeros((new_H, new_W), dtype=img.dtype)
for r in range(H):
for c in range(W):
result[r + pad_size, c + pad_size] = img[r, c]
return result
def conv_same(image, weights):
"""Convolution with 'same' padding: output is the same size as input."""
K = weights.shape[0]
pad_size = (K - 1) // 2
padded = pad_image(image, pad_size)
return conv(padded, weights)
# Test
padded = pad_image(img_100, pad_size=1)
print("Padded shape:", padded.shape) # (102, 102)
kernel_A = np.full((3, 3), 1.0 / 9.0)
result_same = conv_same(img_100.astype(np.float64), kernel_A)
print("conv_same output shape:", result_same.shape) # (100, 100)
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.Loop over ch in range(3). Extract each channel as image_rgb[:, :, ch].astype(np.float64), apply conv_same, store the result in the corresponding channel of the output array.
To display the color result, normalize to [0, 255]: lo = output.min(); hi = output.max(); display = ((output - lo) / (hi - lo) * 255).astype(np.uint8).
You might want different kernels per channel if, for example, you want to blur the blue channel more to reduce noise (blue sensors are noisier in cameras) while keeping the green channel sharp.
def conv_color(image_rgb, weights):
"""Applies conv_same independently to each color channel."""
H, W = image_rgb.shape[0], image_rgb.shape[1]
result = np.zeros((H, W, 3), dtype=np.float64)
for ch in range(3):
channel = image_rgb[:, :, ch].astype(np.float64)
result[:, :, ch] = conv_same(channel, weights)
return result
# Apply blur to color image
kernel_A = np.full((3, 3), 1.0 / 9.0)
color_blurred = conv_color(color_arr, kernel_A)
# Display helper for color convolution output
def show_color_conv(original, output, title='Color Convolution'):
lo = output.min()
hi = output.max()
if hi > lo:
display = ((output - lo) / (hi - lo) * 255).astype(np.uint8)
else:
display = np.zeros_like(output, dtype=np.uint8)
show_images([original, display], titles=['Original', title])
show_color_conv(color_arr, color_blurred, title='Color Blur (Kernel A)')
# Apply Sobel horizontal to color image
kernel_C = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float64)
color_edges = conv_color(color_arr, kernel_C)
show_color_conv(color_arr, color_edges, title='Color Sobel Horizontal')
A 3x3 convolution filter has the values [[-1, -1, -1], [0, 0, 0], [1, 1, 1]]. What does this filter detect?
The filter subtracts the top row of pixels from the bottom row. Where would this difference be large?
This filter computes (bottom row) minus (top row). In a smooth area, the top and bottom pixels are similar — the result is near zero. But at a horizontal edge (where brightness suddenly changes from top to bottom), the difference is large, producing a strong response. The negative top row and positive bottom row form a "derivative in the vertical direction", which responds to horizontal boundaries. A filter like [[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]] would detect vertical edges instead (differences left-to-right).
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.
Chain the functions: step1 = rgb_to_gray_luminosity(color_arr), step2 = resize_bilinear(step1, 50, 50), step3 = conv_same(step2.astype(np.float64), kernel_A), then apply Kernels C and D to step3 and combine with edge magnitude.
For display, you may need to convert intermediate float arrays to uint8 via np.clip(arr, 0, 255).astype(np.uint8). Use show_images with all five steps to see the full pipeline at a glance.
import math
# Step 1: Convert to grayscale (luminosity)
step1_gray = rgb_to_gray_luminosity(color_arr)
# Step 2: Resize to 50x50
step2_small = resize_bilinear(step1_gray, 50, 50)
# Step 3: Blur (Kernel A)
kernel_A = np.full((3, 3), 1.0 / 9.0)
step3_blur = conv_same(step2_small.astype(np.float64), kernel_A)
# Step 4: Edge detection (Sobel H and V, then combine)
kernel_C = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float64)
kernel_D = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float64)
step4_edges_h = conv_same(step3_blur, kernel_C)
step4_edges_v = conv_same(step3_blur, kernel_D)
h, w = step4_edges_h.shape
step4_edges = np.zeros((h, w), dtype=np.float64)
for r in range(h):
for c in range(w):
step4_edges[r, c] = math.sqrt(
step4_edges_h[r, c] ** 2 + step4_edges_v[r, c] ** 2
)
# Normalize edge magnitude to [0, 255]
lo, hi = step4_edges.min(), step4_edges.max()
if hi > lo:
edges_display = ((step4_edges - lo) / (hi - lo) * 255).astype(np.uint8)
else:
edges_display = np.zeros_like(step4_edges, dtype=np.uint8)
# Display all steps
show_images(
[color_arr, step1_gray, step2_small,
np.clip(step3_blur, 0, 255).astype(np.uint8), edges_display],
titles=['1. Color', '2. Grayscale', '3. 50x50', '4. Blurred', '5. Edges'],
cmap_list=[None, 'gray', 'gray', 'gray', 'gray']
)
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.Think about what each kernel you've seen so far has in common: the weights determine which pixels contribute positively and which negatively. Placing opposite signs on different parts of the 3x3 grid highlights differences in that direction.
Try placing positive and negative weights in various arrangements: along diagonals, in L-shapes, or as rings. Make sure the weights sum to zero if you want to detect changes rather than compute averages. Compare each result to what you predicted.
# Kernel 1: Diagonal edge detector
# Positive on one diagonal, negative on the other
my_kernel_1 = np.array([
[-1, 0, 1],
[ 0, 0, 0],
[ 1, 0, -1]
], dtype=np.float64)
result_1 = conv_same(img_100.astype(np.float64), my_kernel_1)
show_conv_result(img_100, result_1, title='Diagonal Edge Detector')
# Observation: highlights diagonal edges (top-left to bottom-right vs
# top-right to bottom-left)
# Kernel 2: Laplacian (all-direction edge detector)
# Negative ring around a large positive center; sums to zero
my_kernel_2 = np.array([
[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1]
], dtype=np.float64)
result_2 = conv_same(img_100.astype(np.float64), my_kernel_2)
show_conv_result(img_100, result_2, title='Laplacian (All Edges)')
# Observation: detects edges in ALL directions, not just horizontal or vertical
# Kernel 3: Corner-only detector
# Only the corners have non-zero weights
my_kernel_3 = np.array([
[ 1, 0, -1],
[ 0, 0, 0],
[-1, 0, 1]
], dtype=np.float64)
result_3 = conv_same(img_100.astype(np.float64), my_kernel_3)
show_conv_result(img_100, result_3, title='Corner Detector')
# Observation: responds to diagonal texture changes; highlights corners
# and diagonal patterns
Tasks:
Your thoughts on stacked layers:
Repeated sharpening amplifies noise and can cause extreme values. Repeated blurring progressively smooths the image until all detail is lost. In CNNs, early layers tend to detect simple features (edges, textures), while deeper layers combine these into complex features (shapes, object parts).
Use conv_same to avoid the image shrinking with each application. Store snapshots at iteration counts [1, 3, 5, 10] and display them side by side.
Repeated sharpening amplifies noise and creates extreme values. Repeated blurring progressively erases detail until the image becomes nearly uniform. Sobel followed by blur produces smooth, thick edge lines. In a CNN: Layer 1 detects simple features (edges, gradients). Layer 2 combines edges into textures and corners. Deeper layers detect complex structures like shapes, object parts, and eventually entire objects.
# Define kernels
kernel_A = np.full((3, 3), 1.0 / 9.0)
kernel_B = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float64)
kernel_C = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float64)
# Task 1: Apply Kernel B (sharpen) 10 times
current = img_100.astype(np.float64)
snapshots_b = []
for i in range(10):
current = conv_same(current, kernel_B)
if i + 1 in [1, 3, 5, 10]:
snap = current.copy()
lo, hi = snap.min(), snap.max()
if hi > lo:
snap = ((snap - lo) / (hi - lo) * 255).astype(np.uint8)
else:
snap = np.zeros_like(snap, dtype=np.uint8)
snapshots_b.append((i + 1, snap))
show_images([s for _, s in snapshots_b],
titles=[f'{n}x Kernel B' for n, _ in snapshots_b],
cmap_list=['gray'] * len(snapshots_b))
# Task 2: Apply Kernel A (blur) 20 times
current = img_100.astype(np.float64)
for _ in range(20):
current = conv_same(current, kernel_A)
blur_20 = np.clip(current, 0, 255).astype(np.uint8)
show_images([img_100, blur_20],
titles=['Original', '20x Blur'],
cmap_list=['gray', 'gray'])
# Task 3: Kernel C then Kernel A (edge detect then blur)
edges = conv_same(img_100.astype(np.float64), kernel_C)
smoothed_edges = conv_same(edges, kernel_A)
show_conv_result(img_100, smoothed_edges, title='Sobel then Blur')
Answer these questions in your own words: