Why plain neural networks fail on images, how convolution fixes it, the classic architectures from AlexNet to ResNet, and how YOLO finds objects in real time.
Author
MLStation
Published
October 1, 2026
A factory camera photographs every metal part on a conveyor belt and has to sort it into one of four bins: washer, hex nut, plate or bracket. Parts arrive at random angles, in slightly different places, under changing light. A person does this without thinking. In this article we teach a computer to do it, first the naive way, then the way modern vision systems really work.
TipWhat you will learn
Why a plain neural network (ANN) on raw pixels breaks as soon as the object moves
What a convolution, a filter, ReLU and pooling do, with live demos
How AlexNet, VGG16, VGG19, GoogLeNet and ResNet changed computer vision
How transfer learning lets you reuse those models on your own images
How YOLO goes from “what is this?” to “what is where?” in one pass
Step 1: what the computer sees
To a computer, an image is just a grid of numbers. Our camera produces 32 × 32 grayscale images, so each picture is 1,024 numbers between 0 (black) and 1 (white). We simulate 3,200 of them.
Simulate the factory camera (click to see how)
import numpy as np, pandas as pd, warningswarnings.filterwarnings("ignore")from scipy.ndimage import convolvefrom sklearn.neural_network import MLPClassifierfrom sklearn.linear_model import LogisticRegressionfrom sklearn.preprocessing import StandardScalerimport plotly.graph_objects as gofrom plotly.subplots import make_subplotsfrom ms_charts import COLORS, PLOTLY_CONFIGrng = np.random.default_rng(1)S =32yy, xx = np.mgrid[0:S, 0:S]CLASSES = ["Washer", "Hex nut", "Plate", "Bracket"]def draw_part(c, cx, cy, r, angle):"""Return a 32x32 mask of one part centred at (cx, cy), radius r, rotated by angle.""" dx, dy = xx - cx, yy - cy a = np.deg2rad(angle) u, v = dx * np.cos(a) + dy * np.sin(a), -dx * np.sin(a) + dy * np.cos(a) d = np.hypot(dx, dy)if c ==0: # washer: a ring m = (d < r) & (d > r *0.55)elif c ==1: # hex nut: hexagon with a hole th = np.arctan2(v, u) edge = r * np.cos(np.pi /6) / np.cos((th % (np.pi /3)) - np.pi /6) m = (d < edge) & (d > r *0.35)elif c ==2: # plate: a square m = (np.abs(u) < r *0.85) & (np.abs(v) < r *0.85)else: # bracket: an L shape m = ((np.abs(u) < r *.85) & (v > r *.35) & (v < r *.85)) | ((u >-r *.85) & (u <-r *.35) & (np.abs(v) < r *.85))return m.astype(float)def camera(n, where="centre"): X, y = [], []for _ inrange(n): c, r = rng.integers(4), rng.uniform(6, 9)if where =="centre": cx, cy = rng.uniform(13, 19, 2) # belt guide keeps parts near the middleelse: cx, cy = [rng.choice([rng.uniform(9, 11), rng.uniform(21, 23)]) for _ inrange(2)] # guide broke img = draw_part(c, cx, cy, r, rng.uniform(0, 360)) * rng.uniform(.6, 1) + rng.uniform(0, .3) X.append(np.clip(img + rng.normal(0, .08, (S, S)), 0, 1)); y.append(c)return np.array(X), np.array(y)X_train, y_train = camera(2000) # training images, parts near the centreX_test, y_test = camera(600) # new images, same conditionsX_shift, y_shift = camera(600, where="corner") # new images, parts pushed towards the corners
Show the code
fig = make_subplots(rows=2, cols=4, subplot_titles=[CLASSES[c] for c in y_train[:8]], horizontal_spacing=0.02, vertical_spacing=0.1)for i inrange(8): fig.add_trace(go.Heatmap(z=X_train[i], colorscale="gray", showscale=False, hoverinfo="skip"), row=i //4+1, col=i %4+1)fig.update_xaxes(visible=False); fig.update_yaxes(visible=False, autorange="reversed", scaleanchor="x")fig.update_layout(height=380, margin=dict(t=30, b=0, l=0, r=0))fig.show(config=PLOTLY_CONFIG)
Figure 1: Eight simulated camera images. Same four parts, different angles, sizes and lighting.
Step 2: the naive way, an ANN on raw pixels
The obvious idea: flatten the 32 × 32 grid into a list of 1,024 numbers and feed it to a normal neural network. We train one with two hidden layers.
ann = MLPClassifier(hidden_layer_sizes=(128, 64), max_iter=300, early_stopping=True, random_state=0)# flatten each 32x32 image into 1,024 numbersann.fit(X_train.reshape(len(X_train), -1), y_train)acc_ann_same = ann.score(X_test.reshape(len(X_test), -1), y_test)acc_ann_shift = ann.score(X_shift.reshape(len(X_shift), -1), y_shift)
On new images taken under the same conditions it scores 97%. Impressive. Then the belt guide breaks and parts start arriving near the corners. Accuracy drops to 22%, which is close to random guessing among four classes (25%).
WarningWhy the ANN breaks
When you flatten an image, pixel 300 is just “input number 300”. The network learns that a washer means these particular inputs are bright. Move the washer a few pixels and completely different inputs light up, so the network sees something it has never met. It also has no idea that pixel 300 and pixel 301 are neighbours. An ANN is blind to space.
It is also wasteful. A colour photo of 224 × 224 pixels has 150,528 inputs. Connecting those to just 256 hidden neurons already needs 38,535,168 weights.
Step 3: convolution, a small window that slides
A convolutional neural network (CNN) looks at the image through a small window, typically 3 × 3 pixels, called a filter or kernel. The filter holds 9 weights. It slides across the whole image and, at every position, multiplies the 9 pixels under it by its 9 weights and adds them up:
\text{out}(i,j) = \sum_{m=-1}^{1}\sum_{n=-1}^{1} w_{m,n}\; x_{i+m,\,j+n} + b
\tag{1}
The result is a new image called a feature map, which is bright wherever the pattern the filter is looking for appears. The key ideas:
The same 9 weights are used everywhere (weight sharing). A filter that detects a vertical edge in the top-left corner detects it in the bottom-right too.
Neighbours stay neighbours. The filter always looks at pixels that touch each other.
In a CNN the filter weights are learned, exactly like the weights in our earlier neural network article. Below we use classic hand-made filters so you can see what they do.
Pick a filter and move the window with the sliders. The left panel is the camera image, the right panel is the feature map.
Notice how the vertical-edge filter lights up the left and right sides of the bracket in opposite colours, and stays near zero on flat areas. Turn on ReLU (\max(0, z)) and only the positive responses survive, exactly like in a normal neural network.
Step 4: pooling, and why it gives tolerance to movement
After convolution and ReLU, a CNN usually pools: it divides the feature map into small blocks (often 2 × 2) and keeps only the largest value in each. This:
shrinks the map, so later layers have less to process,
keeps the strongest evidence (“there is an edge somewhere here”),
makes the network care less about the exact position of a pattern.
Repeat convolution → ReLU → pooling a few times, and early layers find edges, middle layers combine edges into corners and curves, and deep layers respond to whole parts like “ring” or “L shape”.
Real CNNs learn their filters with frameworks like Keras or PyTorch. To keep this page light, we build a mini-CNN with eleven fixed filters (the edge, outline and blur filters above, in both polarities), apply ReLU and max pooling, summarise each feature map, and train a simple classifier on top. Then we run the broken-belt test again.
BASE = [np.array(k, float) for k in ( [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], # vertical edge [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], # horizontal edge [[0, 1, 2], [-1, 0, 1], [-2, -1, 0]], # diagonal edge [[2, 1, 0], [1, 0, -1], [0, -1, -2]], # other diagonal [[0, 1, 0], [1, -4, 1], [0, 1, 0]])] # outline (Laplacian)FILTERS = BASE + [-k for k in BASE] + [np.ones((3, 3)) /9] # both polarities + blur = 11 filtersdef mini_cnn_features(X): out = []for img in X: f = []for k in FILTERS: fmap = np.maximum(convolve(img, k, mode="nearest"), 0) # convolution + ReLU pooled = fmap.reshape(8, 4, 8, 4).max(axis=(1, 3)) # 4x4 max pooling -> 8x8 hist = np.histogram(fmap, bins=6, range=(0, fmap.max() +1e-6))[0] / fmap.size f += [pooled.mean(), pooled.max(), (pooled > pooled.mean()).mean(), pooled.std(), *hist] out.append(f)return np.array(out)F_train, F_test, F_shift =map(mini_cnn_features, (X_train, X_test, X_shift))scaler = StandardScaler().fit(F_train)head = LogisticRegression(max_iter=3000).fit(scaler.transform(F_train), y_train)acc_cnn_same = head.score(scaler.transform(F_test), y_test)acc_cnn_shift = head.score(scaler.transform(F_shift), y_shift)
Show the code
fig = go.Figure()for name, vals, c in [("ANN on raw pixels", [acc_ann_same, acc_ann_shift], COLORS["slate"]), ("Mini-CNN", [acc_cnn_same, acc_cnn_shift], COLORS["indigo"])]: fig.add_bar(x=["Same conditions", "Parts moved to corners"], y=vals, name=name, marker_color=c, text=[f"{v:.0%}"for v in vals], textposition="outside")fig.add_hline(y=0.25, line_dash="dot", line_color="#8A93A3")fig.update_layout(barmode="group", height=360, yaxis=dict(tickformat=".0%", range=[0, 1.12], title="Accuracy"))fig.show(config=PLOTLY_CONFIG)
Figure 2: The ANN memorises pixel positions and collapses when parts move. Convolution and pooling look for patterns wherever they are. Dotted line: random guessing (25%).
The mini-CNN scores 95% in normal conditions and 97% after the parts move. The ANN fell to 22%. That robustness to position, with far fewer weights, is the whole reason CNNs took over computer vision.
A real, trainable CNN in Keras
This is how you would build a CNN that learns its own filters. The code is shown, not run, so the page builds without TensorFlow installed.
A convolution layer has (k \times k \times C_{in} + 1) \times C_{out} parameters, where k is the filter size and C the number of channels:
Model
Parameters
ANN on flattened pixels (1024 → 128 → 64 → 4)
139,716
Small CNN above (Conv 16, Conv 32, Dense 4)
4,932
About 28× fewer weights, because each filter’s few weights are reused at every position.
Step 6: the famous architectures
Between 2012 and 2015, the yearly ImageNet challenge (1.2 million photos, 1,000 classes) turned CNNs from a research idea into the standard. The headline metric is top-5 error: how often the right answer is not among the model’s five best guesses.
Table 1: Milestones in image classification
Year
Model
Big idea
ImageNet top-5 error
2012
AlexNet
First deep CNN trained on GPUs; ReLU and dropout at scale
VGG’s recipe is easy to remember: blocks of 3 × 3 convolutions, then a 2 × 2 max pool, with the number of filters doubling each block (64, 128, 256, 512, 512), and three large dense layers at the end. VGG16 has 13 convolution layers + 3 dense; VGG19 has 16 + 3. Let us count the parameters from the recipe:
Figure 3: Where VGG16’s parameters live. The convolution blocks do the seeing; the dense layers at the end hold most of the weights.
Total: 138,357,544 parameters for VGG16 and 143,667,240 for VGG19, matching the published 138.4M and 143.7M (Keras Applications). The first dense layer alone holds 74% of VGG16. Later designs replaced those giant dense layers with global pooling.
ResNet: the shortcut that made depth work
Stacking more layers should help, but before 2015 very deep plain networks trained worse. The learning signal (the gradient) is multiplied layer after layer on its way back to the start, and it fades away. ResNet adds a skip connection: each block learns only a correction \mathcal{F}(x) and adds the input back (He et al.):
y = \mathcal{F}(x) + x
\tag{2}
Because of the “+ x”, the signal always has a direct path backwards. This toy model shows the effect. Each plain layer passes back a fraction of the signal; each residual block passes back the same fraction plus the shortcut.
viewof depth = Inputs.range([5,150], {value:50,step:1,label:"Number of layers"})viewof gain = Inputs.range([0.5,0.99], {value:0.85,step:0.01,label:"Signal kept per plain layer"})
sig = d3.range(depth +1).flatMap(l => [ {layer: l,value:Math.pow(gain, l),net:"Plain network"}, {layer: l,value:Math.pow(1+ (gain -1) *0.02, l),net:"ResNet (with shortcuts)"}])Plot.plot({...msStyle,height:320,marginLeft:60,x: {label:"Layers travelled back from the output →"},y: {type:"log",label:"↑ Learning signal remaining",domain: [1e-12,1.5],tickFormat:"~e"},color: {domain: ["Plain network","ResNet (with shortcuts)"],range: [ms.rose, ms.teal],legend:true},marks: [Plot.line(sig, {x:"layer",y: d =>Math.max(d.value,1e-12),stroke:"net",strokeWidth:3})]})
With 50 layers at 85%, the plain network keeps about of its learning signal; the early layers barely learn. The residual network keeps most of it. This is why ResNet-50 and ResNet-152 are still standard backbones a decade later.
Accuracy vs size of ready-made models
All of these come pre-trained on ImageNet in Keras. Hover a bubble for details.
Figure 4: Top-1 ImageNet accuracy against size for pre-trained Keras models. Bubble size shows parameter count. Data from Keras Applications (keras.io/api/applications).
Bigger is not better: VGG19 has 40 times the parameters of MobileNetV2 for the same accuracy, and newer designs like EfficientNet and ConvNeXt beat both.
Step 7: transfer learning, the practical shortcut
You rarely train a large CNN from scratch. Instead you take a network pre-trained on ImageNet, keep its convolution layers (which already know edges, textures and shapes), and train only a small new head for your classes. A few hundred images per class are often enough.
import torch, torchvisionmodel = torchvision.models.resnet50(weights="IMAGENET1K_V2")for p in model.parameters(): p.requires_grad =False# freeze the backbonemodel.fc = torch.nn.Linear(model.fc.in_features, 4) # new 4-class head
Step 8: from “what” to “what and where”: YOLO
Classification answers what is in the picture. A real factory belt carries several parts at once, so we need object detection: a box around every part, with a label and a confidence score.
Older detectors worked in two stages: propose thousands of candidate regions, then classify each one. YOLO (You Only Look Once) does it in a single pass of one CNN, which made it the first truly real-time detector at 45 frames per second (Redmon et al., 2016). The idea:
Divide the image into a grid.
Each grid cell predicts a few boxes (centre, width, height), a confidence and a class.
Many boxes overlap the same object, so keep the most confident one and delete boxes that overlap it too much. This clean-up is non-maximum suppression (NMS).
Overlap is measured with Intersection over Union:
\text{IoU} = \frac{\text{area of overlap}}{\text{area of union}}
\tag{3}
Try it. The detector has proposed many boxes on a belt with three parts. Raise the confidence threshold to drop weak guesses, and lower the NMS IoU threshold to remove duplicates more aggressively.
html`<div class="ms-readout"><span>Proposed boxes</span><b>${cands.length}</b><span>above confidence</span><b>${kept.pool.length}</b><span>kept after NMS</span><b>${kept.keep.length}</b></div>`
With sensible settings you end with exactly three boxes, one per part. Set the confidence threshold too low and false alarms appear; set the NMS threshold near 1 and duplicates survive.
Where detection is today
YOLO has been refined many times. The current Ultralytics release, YOLO26 (January 2026), is designed for edge devices, offers NMS-free end-to-end prediction by default, and covers detection, segmentation, classification, pose, depth and oriented boxes in one family (Ultralytics docs). Other notable approaches include two-stage detectors such as Faster R-CNN (accurate, slower) and transformer-based detectors such as DETR and RT-DETR, which treat detection as a set-prediction problem without hand-made anchors.
detect_parts.py
from ultralytics import YOLOmodel = YOLO("yolo26n.pt") # small pre-trained modelresults = model("conveyor_belt.jpg") # boxes, classes, confidences# fine-tune on your own labelled imagesmodel.train(data="factory_parts.yaml", epochs=50, imgsz=640)
The big picture
flowchart LR
A["ANN on pixels<br/>blind to space"] --> B["CNN<br/>filters + pooling"] --> C["Deep CNNs<br/>AlexNet, VGG, ResNet"] --> D["Transfer learning<br/>reuse backbones"] --> E["Detection<br/>YOLO, DETR"]
Common pitfalls
Pitfall
Fix
Flattening images into an ANN
Use convolution layers, or a pre-trained CNN
Training only on tidy, centred images
Augment: random shifts, rotations, flips, brightness
Training a huge model from scratch on little data
Transfer learning from an ImageNet backbone
Forgetting to scale pixels
Divide by 255, or use the backbone’s own preprocessing
Judging a detector by accuracy alone
Use mean average precision (mAP) and check IoU thresholds
Summary
An image is a grid of numbers. An ANN on flattened pixels memorises positions and breaks when objects move.
A CNN slides small learned filters over the image (Equation 1), reuses the same weights everywhere, and pools to tolerate movement.
AlexNet started the deep learning era, VGG16/19 showed that stacked 3 × 3 filters work, and ResNet’s skip connections (Equation 2) made very deep networks trainable.
Transfer learning lets you reuse these networks on your own images with little data.
YOLO detects many objects in one pass using a grid, confidence scores and IoU (Equation 3); modern versions such as YOLO26 drop the NMS step entirely.
For the theory behind convolutional networks, see Goodfellow et al. (2016).