Objective: Build a small bank of hand-designed convolutional filters, run them over a synthetic test image, pool the results, and visualize every stage of the pipeline. All in runnable, in-browser Python.
Task 1: Build a More Interesting Test Image
A single square doesn't exercise many filter types. This image has a square, a diagonal line, and a checkerboard patch, giving different filters something distinct to respond to.
import numpy as np
import matplotlib.pyplot as plt
image = np.zeros((24, 24))
image[3:9, 3:9] = 1.0 # a square
for i in range(24):
j = 23 - i
if 0 <= j < 24:
image[i, max(0, j-1):j+1] = 1.0 # a faint diagonal line
image[15:21, 15:21] = (np.indices((6, 6)).sum(axis=0) % 2) # a checkerboard patch
plt.imshow(image, cmap="gray")
plt.title("Test image: square + diagonal + checkerboard")
show_plot()
Task 2: Run a Filter Bank and Collect Feature Maps
import numpy as np
import matplotlib.pyplot as plt
def convolve2d(img, kernel, padding=1):
if padding > 0:
img = np.pad(img, pad_width=padding, mode="constant", constant_values=0)
kh, kw = kernel.shape
out_h, out_w = img.shape[0] - kh + 1, img.shape[1] - kw + 1
out = np.zeros((out_h, out_w))
for i in range(out_h):
for j in range(out_w):
out[i, j] = np.sum(img[i:i+kh, j:j+kw] * kernel)
return out
image = np.zeros((24, 24))
image[3:9, 3:9] = 1.0
for i in range(24):
j = 23 - i
if 0 <= j < 24:
image[i, max(0, j-1):j+1] = 1.0
image[15:21, 15:21] = (np.indices((6, 6)).sum(axis=0) % 2)
filters = {
"Vertical edges": np.array([[1, 0, -1], [1, 0, -1], [1, 0, -1]], dtype=float),
"Horizontal edges": np.array([[1, 1, 1], [0, 0, 0], [-1, -1, -1]], dtype=float),
"Diagonal (/)": np.array([[0, 1, 1], [-1, 0, 1], [-1, -1, 0]], dtype=float),
"Blur": np.ones((3, 3)) / 9,
}
feature_maps = {name: convolve2d(image, k) for name, k in filters.items()}
fig, axes = plt.subplots(1, 5, figsize=(15, 3.5))
axes[0].imshow(image, cmap="gray"); axes[0].set_title("Original")
for ax, (name, fmap) in zip(axes[1:], feature_maps.items()):
ax.imshow(fmap, cmap="gray"); ax.set_title(name)
for ax in axes:
ax.axis("off")
plt.tight_layout()
show_plot()
Look closely at which region of the image lights up most strongly for each filter: the vertical/horizontal edge filters should respond most to the square's straight sides, the diagonal filter should respond most along the diagonal line, and all the edge filters should produce a busy, high-contrast response across the checkerboard patch, since every pixel there is an edge relative to its neighbor.
Task 3: Pool the Feature Maps and Compare
import numpy as np
import matplotlib.pyplot as plt
def convolve2d(img, kernel, padding=1):
if padding > 0:
img = np.pad(img, pad_width=padding, mode="constant", constant_values=0)
kh, kw = kernel.shape
out_h, out_w = img.shape[0] - kh + 1, img.shape[1] - kw + 1
out = np.zeros((out_h, out_w))
for i in range(out_h):
for j in range(out_w):
out[i, j] = np.sum(img[i:i+kh, j:j+kw] * kernel)
return out
def max_pool2d(fmap, size=2, stride=2):
h, w = fmap.shape
out_h, out_w = (h - size) // stride + 1, (w - size) // 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.max(fmap[r:r+size, c:c+size])
return out
image = np.zeros((24, 24))
image[3:9, 3:9] = 1.0
vertical_edge = np.array([[1, 0, -1], [1, 0, -1], [1, 0, -1]], dtype=float)
fmap = convolve2d(image, vertical_edge)
pooled = max_pool2d(fmap)
print("Feature map shape:", fmap.shape)
print("Pooled shape: ", pooled.shape)
fig, axes = plt.subplots(1, 3, figsize=(10, 3.5))
axes[0].imshow(image, cmap="gray"); axes[0].set_title("Original")
axes[1].imshow(fmap, cmap="gray"); axes[1].set_title(f"Feature map {fmap.shape}")
axes[2].imshow(pooled, cmap="gray"); axes[2].set_title(f"Max pooled {pooled.shape}")
for ax in axes:
ax.axis("off")
plt.tight_layout()
show_plot()
The pooled feature map is half the height and width of the original, but the strongest edge responses are still clearly visible, the spatial information that mattered most survived the downsampling, while the total amount of data the next layer has to process was cut to a quarter.
Task 4: Stack a Second Convolution on Top of Pooled Output
A second convolutional layer doesn't see the raw image at all, it sees the first layer's pooled feature maps. This is the mechanism behind "deeper layers see more abstract combinations": the second filter below responds to patterns in where edges were detected, not to raw pixel brightness.
import numpy as np
import matplotlib.pyplot as plt
def convolve2d(img, kernel, padding=1):
if padding > 0:
img = np.pad(img, pad_width=padding, mode="constant", constant_values=0)
kh, kw = kernel.shape
out_h, out_w = img.shape[0] - kh + 1, img.shape[1] - kw + 1
out = np.zeros((out_h, out_w))
for i in range(out_h):
for j in range(out_w):
out[i, j] = np.sum(img[i:i+kh, j:j+kw] * kernel)
return out
def max_pool2d(fmap, size=2, stride=2):
h, w = fmap.shape
out_h, out_w = (h - size) // stride + 1, (w - size) // 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.max(fmap[r:r+size, c:c+size])
return out
def relu(z): return np.maximum(0, z)
image = np.zeros((24, 24))
image[3:9, 3:9] = 1.0
vertical_edge = np.array([[1, 0, -1], [1, 0, -1], [1, 0, -1]], dtype=float)
layer1_out = max_pool2d(relu(convolve2d(image, vertical_edge)))
# A second-layer filter looking for a small blob of activity in the pooled edge map
blob_filter = np.ones((3, 3))
layer2_out = relu(convolve2d(layer1_out, blob_filter))
fig, axes = plt.subplots(1, 3, figsize=(10, 3.5))
axes[0].imshow(image, cmap="gray"); axes[0].set_title("Original")
axes[1].imshow(layer1_out, cmap="gray"); axes[1].set_title(f"Layer 1 output {layer1_out.shape}")
axes[2].imshow(layer2_out, cmap="gray"); axes[2].set_title(f"Layer 2 output {layer2_out.shape}")
for ax in axes:
ax.axis("off")
plt.tight_layout()
show_plot()
Layer 2's filter never touches a raw pixel value; its entire input is layer 1's pooled, ReLU'd feature map. This is exactly the compositional depth discussed in the CNN architectures lesson: each successive layer builds on increasingly processed, increasingly abstract representations of the original image.