Part 1: Computer Vision and You

What interests me about computer vision is honestly how much of it comes down to arithmetic on a grid of numbers, but the results feel much more “intelligent” than the math suggests. Coming from clinical data science, I keep thinking about medical imaging: chest x-rays, MRI slices, retinal scans, pathology slides. A model that can flag a suspicious region on a scan before a radiologist even opens the file could shorten time to diagnosis, especially in under-resourced clinics that don’t have a radiologist on site around the clock. That’s the kind of value I care about, not “the model is 99% accurate” as an abstract number, but the actual process it changes.

An organization example: a hospital system could use computer vision to triage incoming radiology studies, flagging likely-urgent cases (like a possible stroke on a CT scan) to move to the front of the queue instead of sitting in a first-in-first-out list. That’s not replacing the radiologist’s judgment, it’s reducing the risk that something urgent waits too long. I’m still a little skeptical of how well this generalizes across different scanners, patient populations, and hospitals, since I know from the ML/AI course discussions that dataset shift is a real problem in clinical models. So I’d want to see a lot of external validation before trusting something like that in a real workflow.

Part 2: Images Are Numbers

Step 1: Creating the image as a NumPy array

import numpy as np

image = np.array([
    [0,   0,   0,   0,   0],
    [0, 255, 255, 255,   0],
    [0, 255, 255, 255,   0],
    [0, 255, 255, 255,   0],
    [0,   0,   0,   0,   0]
], dtype=np.uint8)

print(image.shape)  # (5, 5)
print(image.dtype)  # uint8
print(image.min())  # 0
print(image.max())  # 255

Output: Shape: (5, 5), Dtype: uint8, Min: 0, Max: 255

Step 2: Normalizing to 0-1

image_norm = image.astype(np.float32) / 255.0
print(image_norm)

Output:

[[0. 0. 0. 0. 0.]
 [0. 1. 1. 1. 0.]
 [0. 1. 1. 1. 0.]
 [0. 1. 1. 1. 0.]
 [0. 0. 0. 0. 0.]]

I normalize because raw pixel values in the 0-255 range are on a much bigger scale than the small weights a network initializes with. If you feed a network unscaled 0-255 values, the early gradients and activations get pushed to extremes, which slows down or destabilizes training. Keeping everything in a small consistent range (0-1) makes optimization better behaved.

Step 3: Reshaping to a PyTorch tensor

import torch

img_tensor = torch.tensor(image, dtype=torch.float32).reshape(1, 1, 5, 5)
print(img_tensor.shape)  # torch.Size([1, 1, 5, 5])

In [N, C, H, W]:

  • N = 1, the batch size (one image in this batch)
  • C = 1, the number of channels (grayscale has just one, RGB would be 3)
  • H = 5, image height in pixels
  • W = 5, image width in pixels

Step 4: Manual calculation of the upper-left output value

The first 3x3 patch (rows 0-2, columns 0-2) of the image is:

0    0    0
0  255  255
0  255  255

Multiplying element-by-element with the kernel:

1   0  -1        0     0     0
1   0  -1   ->   0     0  -255
1   0  -1        0     0  -255

Row by row:

  • Row 0: 0x1 + 0x0 + 0x(-1) = 0
  • Row 1: 0x1 + 255x0 + 255x(-1) = -255
  • Row 2: 0x1 + 255x0 + 255x(-1) = -255

Sum: 0 + (-255) + (-255) = -510

Step 5: PyTorch conv2d

import torch.nn.functional as F

kernel = np.array([
    [1, 0, -1],
    [1, 0, -1],
    [1, 0, -1]
])
kernel_tensor = torch.tensor(kernel, dtype=torch.float32).reshape(1, 1, 3, 3)

output = F.conv2d(img_tensor, kernel_tensor, stride=1, padding=0)
print(output.shape)  # torch.Size([1, 1, 3, 3])
print(output)

Output shape: [1, 1, 3, 3]

Feature map:

-510     0    510
-765     0    765
-510     0    510

Step 6: Comparing to the manual calculation

The PyTorch output at position [0,0] is -510, which matches my manual calculation exactly.

Step 7: Applying ReLU

relu_output = F.relu(output)
print(relu_output)

After ReLU:

0     0    510
0     0    765
0     0    510

Step 8: Interpretation

ReLU just zeroes out anything negative and leaves the positive values untouched. What that means here is that the left column of the feature map (which was strongly negative, -510 and -765) got wiped out, while the right column (which was strongly positive, 510 and 765) survived.

Looking at how the kernel is built, [1, 0, -1] in each row, the output is really “brightness on the left of the patch minus brightness on the right of the patch.” At the left edge of the white square, the left side of the patch is dark and the right side is bright, so that comes out negative. At the right edge of the square, it’s the opposite, bright on the left, dark on the right, so that comes out positive. The middle column is 0 because there’s no left-right change within that patch, it’s uniformly bright.

So the raw feature map picks up both vertical edges of the square (one negative, one positive), but ReLU only keeps one of them, the “bright to dark” transition. That’s a good thing for me to notice going in: ReLU isn’t just “clean up the noise,” it actually discards real edge information depending on which direction the kernel happened to detect it in. In a real network with many filters, you’d expect a mirrored filter somewhere else in the layer to catch the edge type this one throws away.

Visualization

Original image and feature maps before/after ReLU
Original image and feature maps before/after ReLU

GenAI disclosure: I used generative AI to help verify my manual arithmetic against the PyTorch output and to help draft the explanation of the tensor dimensions and the ReLU interpretation. I worked through the manual calculation myself first and confirmed the code output matched it.