Math for CV Beginners: Vectors, Matrices, Convolutions
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- Responsible CV: Privacy, Fairness, Security, Governance
You do not need a math degree to do computer vision. You need about five ideas, and you need them cold, because they show up in every single method after this. This post works through all five with numbers you can check by hand.
Prerequisites: What is computer vision. If you want a refresher on vectors and matrices in general, scalars and vectors and matrix operations cover the wider ground.
Idea 1: shapes
Everything starts with knowing what shape your data is. A grayscale image is a 2D array. A colour image is 3D. A batch of colour images is 4D.
| # | What it is | Shape | Read it as |
|---|---|---|---|
| 1 | One grayscale photo | (480, 640) | 480 rows, 640 columns |
| 2 | One colour photo (OpenCV) | (480, 640, 3) | 480 rows, 640 cols, 3 channels (B, G, R) |
| 3 | One colour photo (PyTorch) | (3, 480, 640) | 3 channels, 480 rows, 640 cols |
| 4 | A batch of 32 photos (PyTorch) | (32, 3, 224, 224) | 32 images, 3 channels, 224x224 each |
| 5 | A conv layer's output | (32, 64, 112, 112) | 32 images, 64 feature maps, 112x112 each |
The five shapes you will meet constantly
Two things trip everyone up.
Rows come before columns. img.shape gives (height, width), but almost every drawing function takes (x, y) which is (column, row). So img.shape[0] is height and cv2.circle wants the width coordinate first. Mixing these up produces images that look transposed or drawings that land in the wrong place.
OpenCV puts channels last, PyTorch puts them first. OpenCV uses HWC (height, width, channel). PyTorch uses CHW. Converting between them is a transpose:
import numpy as np
img_hwc = np.zeros((480, 640, 3), dtype=np.uint8) # OpenCV layout
img_chw = np.transpose(img_hwc, (2, 0, 1)) # PyTorch layout
print(img_hwc.shape, img_chw.shape) # (480, 640, 3) (3, 480, 640)
And one more: OpenCV reads colour as BGR, not RGB. If your image looks blue where it should be orange, this is why.
import cv2
bgr = cv2.imread("photo.jpg") # B, G, R order
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
Idea 2: convolution, by hand
convolution Slide a small grid of weights (a kernel) over an image. At each position, multiply overlapping values pairwise and add them up. The sum becomes one output pixel. is the single most important operation in computer vision. It powers blurring, sharpening, edge detection, and every convolutional neural network.
Let’s do one completely by hand.
The input
A 5×5 grayscale patch with a vertical edge: dark on the left, bright on the right.
Position by position
The kernel is 3×3, so it fits in the 5×5 input at 3 horizontal positions and 3 vertical positions. That is why the output is 3×3.
Output position (0, 0). The kernel covers input rows 0–2, columns 0–2:
| 20 | 20 | 20 |
| 20 | 20 | 20 |
| 20 | 20 | 20 |
Multiply each value by the matching kernel weight and add:
Total: . Flat region, no edge, zero response. Good.
Output position (0, 1). Shift one column right. The kernel now covers columns 1–3:
| 20 | 20 | 220 |
| 20 | 20 | 220 |
| 20 | 20 | 220 |
Total: . Strong positive response. The kernel found the edge.
Output position (0, 2). Columns 2–4:
| 20 | 220 | 220 |
| 20 | 220 | 220 |
| 20 | 220 | 220 |
Total: again.
Every row of the input is identical, so every row of the output is identical. Final output:
Reading the result
Three things worth noticing, because they explain behaviour you will see constantly.
The edge is one column wide, but the response is two columns wide. A 3×3 kernel “feels” the edge from one column before it and one column after. This blur is why raw Sobel output looks thick and why Canny edge detection adds a thinning step.
The response is 800, far outside the 0–255 range. Convolution output is not an image. It is a signed number that can be negative or huge. If you save it directly as uint8 it wraps around and you get garbage. Always keep filter output in a float or signed type until you deliberately rescale it.
Flip the input (bright on the left) and the response becomes −800. The sign tells you the direction of the edge, not just that one exists. Take the absolute value if you only care about edge strength.
The response profile across the row:
The blue line is the brightness. The red line is what the filter computed. Where brightness is flat, the response is zero. Where brightness jumps, the response spikes. That is all an edge detector does.
Slide the kernel yourself
Step through the positions and watch the window move. The count of stopping positions is exactly the output size, which is the next idea.
Try different kernels on the same input
Pick “Blur (Average)” and the output looks like a softened copy. Pick “Edge (Vertical)” and only vertical boundaries light up. Same input, same sliding operation, completely different result. The only thing that changed is nine numbers in the kernel.
That is the key insight for neural networks: the kernel numbers are the only thing that matters, so let the model learn them instead of you choosing them.
Idea 3: the output size formula
Before running anything, you can compute exactly how big the output will be:
where is input width, is kernel size, is padding added to each side, and is the stride (how far the kernel jumps each step).
Three worked cases
Case 1: our example above. , , , :
Matches the 3×3 output we computed by hand.
Case 2: “same” padding. , , , :
Input size equals output size. This is why nearly every modern CNN uses 3×3 kernels with padding 1: the spatial size only changes where you deliberately want it to.
Case 3: the ResNet stem. , , , :
A stride of 2 halves the resolution. That is the standard way networks shrink a feature map, and you will see it again in CNN architectures.
| # | target | Effect | ||||
|---|---|---|---|---|---|---|
| 1 | 5 | 3 | 0 | 1 | 3 | shrinks by F−1 |
| 2 | 224 | 3 | 1 | 1 | 224 | size preserved |
| 3 | 224 | 3 | 0 | 1 | 222 | shrinks by 2 |
| 4 | 224 | 7 | 3 | 2 | 112 | halved |
| 5 | 112 | 3 | 1 | 2 | 56 | halved |
| 6 | 224 | 5 | 2 | 4 | 56 | quartered |
Common convolution settings and what they do to spatial size
To keep the size unchanged with an odd kernel, set . So needs , needs , needs . That pattern covers almost every layer you will write.
Idea 4: normalization
Raw pixels run from 0 to 255. That range causes two concrete problems.
Problem 1: overflow. uint8 arithmetic wraps around.
import numpy as np
a = np.array([200], dtype=np.uint8)
print(a + 100) # [44] — 300 wrapped around to 44
print(a.astype(np.float32) + 100) # [300.] correct
Problem 2: scale. Gradient-based training works badly when inputs are large and all positive. Every weight gets pushed in the same direction on every step.
The fix is two steps: scale to 0–1, then standardize per channel.
Worked example
Take a pixel with value 200 in the red channel. Using the standard ImageNet statistics (, ):
The pixel is 1.31 standard deviations brighter than the average red value in ImageNet.
Now a dark pixel, value 30:
| # | Raw value | ÷255 | Normalized (R channel) target | Meaning |
|---|---|---|---|---|
| 1 | 0 | 0 | -2.118 | darkest possible |
| 2 | 30 | 0.118 | -1.603 | dark |
| 3 | 124 | 0.486 | 0.004 | about average |
| 4 | 200 | 0.784 | 1.306 | bright |
| 5 | 255 | 1 | 2.249 | brightest possible |
Red-channel pixel values before and after ImageNet normalization
After normalization the values are centred near zero and mostly land between −2 and +2, which is the range optimizers are happiest with.
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
def normalize(rgb_uint8):
x = rgb_uint8.astype(np.float32) / 255.0
return (x - mean) / std
Use the ImageNet numbers when you fine-tune a pretrained model, because that is what its weights expect. Compute your own dataset’s mean and standard deviation when you train from scratch, especially for medical or satellite images whose brightness looks nothing like ImageNet photos.
Idea 5: 2D transforms as matrices
Rotating, scaling, and moving an image are all matrix multiplications once you write points the right way.
A point becomes a 3-element homogeneous coordinate A 2D point written as (x, y, 1) so that translation, which is an addition, can be expressed as part of a matrix multiplication. vector . Then any affine transform is a single 3×3 matrix:
The top-left 2×2 block handles rotation, scaling, and shear. The right column handles the shift.
Worked example: rotate 90° then shift
Rotating counter-clockwise by angle uses:
At , and , so:
Apply it to the point :
The point moved to . It is off the image now, which is exactly what happens when you rotate about the origin instead of the image centre. Add a translation of to bring it back:
| # | Transform | Matrix top-left 2×2 | Right column | Effect on (100, 50) |
|---|---|---|---|---|
| 1 | Identity | 1, 0 / 0, 1 | 0, 0 | (100, 50) |
| 2 | Scale ×2 | 2, 0 / 0, 2 | 0, 0 | (200, 100) |
| 3 | Rotate 90° | 0, −1 / 1, 0 | 0, 0 | (−50, 100) |
| 4 | Translate +30, +40 | 1, 0 / 0, 1 | 30, 40 | (130, 90) |
| 5 | Flip horizontally (width 640) | −1, 0 / 0, 1 | 640, 0 | (540, 50) |
Five affine transforms and where they send the point (100, 50)
In OpenCV you rarely build these by hand:
import cv2
h, w = img.shape[:2]
centre = (w / 2, h / 2)
M = cv2.getRotationMatrix2D(centre, angle=30, scale=1.0) # returns 2x3
rotated = cv2.warpAffine(img, M, (w, h))
getRotationMatrix2D builds the rotation and the translation needed to keep the centre fixed, which is what you almost always want.
Why this matters beyond rotation: the same idea with a full 3×3 matrix (no fixed bottom row) is a homography A 3x3 matrix that maps one flat plane in an image to another. It handles perspective, not just rotation and scaling. , which is how you flatten a photographed document, stitch a panorama, or correct a camera looking at a plane at an angle. That is the whole subject of camera calibration and perspective correction.
Bonus: mean vs median, and why filters differ
One more piece of arithmetic that explains a lot of behaviour later. Take a 3×3 window with one bad pixel from sensor noise:
| 50 | 52 | 240 |
| 48 | 50 | 51 |
| 51 | 49 | 52 |
Mean filter. Sum is . Divide by 9:
Median filter. Sort the nine values: 48, 49, 50, 50, 51, 51, 52, 52, 240. The middle one is:
The true value in that region is around 50. The mean was dragged to 71 by a single outlier. The median ignored it completely.
| Mean / Gaussian blur | Median blur | |
|---|---|---|
| Operation | Weighted sum | Sort and pick middle |
| Is it a convolution? | Yes | No, it is non-linear |
| Effect of one outlier | Pulls the result | Ignored entirely |
| Effect on edges | Softens them | Preserves them |
| Best for | Gaussian sensor noise | Salt-and-pepper noise, dead pixels |
| Speed | Very fast (separable) | Slower (sorting per window) |
Median filtering is not a convolution, because sorting cannot be written as multiply-and-add. That distinction matters: convolutions can be stacked and optimized on a GPU, and non-linear filters cannot. You will use both in image fundamentals.
Where each idea shows up later
graph TD S["Shapes and channel order"] --> A["Every post. Bugs here waste days."] C["Convolution"] --> B["Edge detection, blurring, CNNs, feature maps"] O["Output size formula"] --> D["Designing and debugging CNN layers"] N["Normalization"] --> E["Training stability, transfer learning, deployment parity"] T["Matrix transforms"] --> F["Warping, calibration, stitching, augmentation, 3D vision"]
Practice task
Work these out on paper, then check them in code. The point is to trust the arithmetic before you trust a library.
- Apply the Sobel-Y kernel (the transpose of Sobel-X) to the same 5×5 input from earlier. Predict the output before computing it. Why is it all zeros?
- A layer takes a input, uses a 5×5 kernel with padding 2, stride 2, and 32 output channels. What is the output shape? Verify with the formula.
- Compute the normalized value of a blue-channel pixel with raw value 90, using and .
- Build the matrix that flips an image vertically when the image height is 480, then apply it to the point .
import torch, torch.nn as nn
x = torch.zeros(1, 3, 256, 256)
layer = nn.Conv2d(in_channels=3, out_channels=32, kernel_size=5, padding=2, stride=2)
print(layer(x).shape) # check your answer to question 2
Answers: (1) all zeros, because the input has no horizontal edge — every column is constant top to bottom. (2) . (3) . (4) .
Summary
Five ideas cover the math you need for the rest of this series.
Shapes tell you what your data looks like, and channel order (HWC vs CHW, BGR vs RGB) is the top source of silent bugs. Convolution is multiply-and-add over a sliding window, and you worked one out cell by cell. The output size formula lets you design layers on paper. Normalization moves pixels from 0–255 into a range that optimizers can work with, and the same numbers must be used at training and at inference. Affine matrices with homogeneous coordinates turn rotate, scale, and shift into a single multiplication, which generalizes to the homographies used in calibration and stitching.
- img.shape is (height, width). Drawing functions take (x, y). Keep them straight.
- OpenCV is HWC and BGR. PyTorch is CHW and RGB. Convert explicitly, never by accident.
- Convolution output can be negative or far above 255. Keep it in a float type until you rescale on purpose.
- Output size = floor((W − F + 2P) / S) + 1. For an odd kernel, P = (F−1)/2 preserves the size.
- Normalize with the same mean and std at training and inference, or the model quietly degrades.
- Homogeneous coordinates let translation live inside a matrix multiply, which is what makes warping and calibration tractable.
- Median filtering is not a convolution, which is exactly why it survives outliers that blur cannot.
What comes next
Next up is image fundamentals, which puts this arithmetic to work on real images: colour spaces, histograms that reveal exposure problems, and the filters that clean noise without destroying the edges you need. Then OpenCV setup gets you a working environment and ten hands-on tasks.