Login to unlock
Deep Learning · Phase 5: Convolutional Neural Networks · The Convolution Operation

Build a Convolution From Scratch

Lab 2 of 3 3 min read
Back to course

Objective: Implement 2D convolution with configurable padding and stride from raw NumPy, then apply a small bank of hand-designed filters to a synthetic image and inspect what each one detects. All in runnable, in-browser Python.


Task 1: Build a Synthetic Test Image

A simple synthetic shape (a square on a flat background) makes it easy to predict, and then verify, what an edge detector should produce.

import numpy as np
import matplotlib.pyplot as plt

image = np.zeros((20, 20))
image[6:14, 6:14] = 1.0

plt.imshow(image, cmap="gray")
plt.title("Synthetic test image: a bright square")
show_plot()

Task 2: Implement Convolution With Padding and Stride

import numpy as np

def convolve2d(img, kernel, stride=1, padding=0):
    if padding > 0:
        img = np.pad(img, pad_width=padding, mode="constant", constant_values=0)
    kh, kw = kernel.shape
    out_h = (img.shape[0] - kh) // stride + 1
    out_w = (img.shape[1] - kw) // stride + 1
    out = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            r, c = i * stride, j * stride
            out[i, j] = np.sum(img[r:r+kh, c:c+kw] * kernel)
    return out

image = np.zeros((20, 20))
image[6:14, 6:14] = 1.0
kernel = np.ones((3, 3)) / 9

no_pad = convolve2d(image, kernel, stride=1, padding=0)
same_pad = convolve2d(image, kernel, stride=1, padding=1)
strided = convolve2d(image, kernel, stride=2, padding=0)

print("No padding:   ", image.shape, "->", no_pad.shape)
print("Same padding: ", image.shape, "->", same_pad.shape)
print("Stride 2:     ", image.shape, "->", strided.shape)

Task 3: A Bank of Hand-Designed Filters

Apply a vertical-edge filter, a horizontal-edge filter, and a blur filter to the same image, and compare the results.

import numpy as np
import matplotlib.pyplot as plt

def convolve2d(img, kernel, stride=1, padding=0):
    if padding > 0:
        img = np.pad(img, pad_width=padding, mode="constant", constant_values=0)
    kh, kw = kernel.shape
    out_h = (img.shape[0] - kh) // stride + 1
    out_w = (img.shape[1] - kw) // stride + 1
    out = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            r, c = i * stride, j * stride
            out[i, j] = np.sum(img[r:r+kh, c:c+kw] * kernel)
    return out

image = np.zeros((20, 20))
image[6:14, 6:14] = 1.0

vertical_edge = np.array([[1, 0, -1], [1, 0, -1], [1, 0, -1]], dtype=float)
horizontal_edge = vertical_edge.T
blur = np.ones((3, 3)) / 9

fig, axes = plt.subplots(1, 4, figsize=(13, 3.5))
axes[0].imshow(image, cmap="gray"); axes[0].set_title("Original")
for ax, kernel, title in zip(
    axes[1:], [vertical_edge, horizontal_edge, blur],
    ["Vertical edges", "Horizontal edges", "Blur"],
):
    out = convolve2d(image, kernel, padding=1)
    ax.imshow(out, cmap="gray"); ax.set_title(title)
for ax in axes:
    ax.axis("off")
plt.tight_layout()
show_plot()

The vertical-edge filter should light up strongly along the square's left and right borders (where brightness changes left-to-right), the horizontal-edge filter along the top and bottom borders, and the blur filter should produce a softened, smoothed-out version of the square with no sharp edges at all.


Task 4: Verify Against a Known Case by Hand

Pick a single output position and confirm the convolution arithmetic by computing it manually, a useful habit whenever you're not fully sure your implementation is correct.

import numpy as np

def convolve2d(img, kernel, stride=1, padding=0):
    if padding > 0:
        img = np.pad(img, pad_width=padding, mode="constant", constant_values=0)
    kh, kw = kernel.shape
    out_h = (img.shape[0] - kh) // stride + 1
    out_w = (img.shape[1] - kw) // stride + 1
    out = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            r, c = i * stride, j * stride
            out[i, j] = np.sum(img[r:r+kh, c:c+kw] * kernel)
    return out

small_image = np.array([
    [0, 0, 1, 1],
    [0, 0, 1, 1],
    [0, 0, 1, 1],
    [0, 0, 1, 1],
], dtype=float)
vertical_edge = np.array([[1, 0, -1], [1, 0, -1], [1, 0, -1]], dtype=float)

result = convolve2d(small_image, vertical_edge)

# Manual check at position (0, 0): patch is the top-left 3x3 block
patch = small_image[0:3, 0:3]
manual_value = np.sum(patch * vertical_edge)

print("Full result:\n", result)
print("\nPatch at (0,0):\n", patch)
print("Manual computation at (0,0):", manual_value)
print("Function's result at (0,0): ", result[0, 0])
print("Match:", np.isclose(manual_value, result[0, 0]))
Found this useful?

Finished this lab?

Mark it complete to update your Phase 5: Convolutional Neural Networks progress.