Gopi Trinadh Maddikunta

Gopi Trinadh Maddikunta

Copyright @ 2026 GT Groups.
All rights are reserved.

How a Machine Learns to See a Face and Read a Feeling | gopitrinadh.site

A Builder's Guide · From Pixels to Feelings

How a Machine Learns to See a Face — and Read a Feeling

I am writing down how I finally understood Convolutional Neural Networks: starting from raw pixels, building up to the moment a machine says “that is a face,” and then training one to tell which emotion the face is showing. It ends with the real model I trained, running live in your browser. Simple enough for a high-school student, still useful for an engineer, with a few activities you can try as you read.

01 · Where I started

Where I got stuck, and how I got unstuck

When I first saw a neural-network formula full of Greek letters, I could read it out loud but I did not really understand it. I knew the words — weights, activation, convolution — without having the picture in my head. So I sat down and rebuilt the idea from the bottom, one small step at a time. This post is that rebuild, written the way I would explain it to a friend.

I keep one simple goal the whole way: get a machine to look at an image and answer one question, "is there a face here?" That single question covers almost every core idea in a CNN. In Part 2, I take the same idea and turn it into a live emotion detector.

The machine starts with numbers, so that is where I start too.

02 · Foundations

An image is just a grid of numbers

A computer never sees a "face" or a "3." It sees a grid of dots, and each dot — a pixel — is a single number describing how bright that dot is.

The scale runs from 0 (black) to 1 (white), with greys in between. So a 28×28 image is just 784 brightness numbers stacked in a square.

A "5" at low resolution. Hit the button to see that every single square is just a number from 0.0 to 1.0.
Common mistake — fix it early A pixel value is not a probability. 0.9 does not mean "90% chance of something." It means "this dot is quite bright." The word probability only shows up at the very end, when the network finally makes a decision. Keep these two ideas apart and everything downstream gets easier.

One more detail: the images here are grayscale — one number per pixel. A color photo uses three numbers per pixel (red, green, blue). Same idea, just stacked three deep.

# An image really is just an array of brightness values import numpy as np from PIL import Image img = Image.open("face.png").convert("L") # "L" = grayscale grid = np.asarray(img) / 255.0 # scale 0..255 down to 0..1 print(grid.shape) # e.g. (28, 28) print(grid[0][:5]) # first 5 brightness numbers

That array — nothing more — is everything the network ever receives.

03 · The building block

A neuron is just a weighted vote

Before CNNs, meet the smallest unit: the neuron. Here is the single-layer network equation that used to scare me:

f(X) = β₀ + Σ βₖ · g( wₖ₀ + Σ wₖⱼ Xⱼ )

Read it inside-out. There are only two steps.

Step 1 — each hidden neuron votes. Take every input Xⱼ (a pixel), multiply it by a weight wₖⱼ, add them all up, then add a baseline number called the bias wₖ₀. Squash the result with an activation g (it bends the math so the network can learn curves, not just straight lines). That gives one neuron's output.

Step 2 — the output neuron votes on the votes. Take all the hidden-neuron outputs, weight each by βₖ, add them up, add a bias β₀. That final number is the answer.

  • w = weights from input → hidden
  • β = weights from hidden → output
  • the …₀ terms = biases (a default nudge)
  • g = the activation (the squashing function)

The 11th-grade version

Should you go outside? Inputs: temperature, rain chance, how tired you are. You weigh each one (temperature matters a lot, tiredness a little), add them up, and if the total clears a bar, you go. One neuron is exactly that: a weighted vote with a bar. A network is just many of these votes feeding into more votes.

# one neuron in plain Python def neuron(inputs, weights, bias): total = sum(x*w for x,w in zip(inputs, weights)) + bias return max(0, total) # ReLU activation (more on this soon)
A single-layer (feed-forward) network. Every input connects to every hidden neuron, and every hidden neuron feeds the one output. Each line is a weight — that is what the network learns.
04 · Why CNN

Why not just feed every pixel to a plain network?

You could — but it falls apart fast. Here is what a CNN fixes.

1. It uses far fewer weights

In a plain network, every pixel connects to every hidden neuron. 784 pixels × 100 neurons = 78,400 weights — for a tiny image. A real photo (1000×1000 = a million pixels) explodes. A CNN instead uses one small filter (say 3×3 = nine numbers) and slides those same nine numbers across the whole image. Reuse beats uniqueness. This is called parameter sharing.

2. It tolerates small shifts

Because the same filter slides over every position, an eye in the top-left or nudged a few pixels right triggers the same detector wherever it lands. The network isn't glued to one spot.

3. It uses local correlations

Nearby pixels belong together — an edge, an eye, a curve is a local cluster. A 3×3 filter looks at a neighborhood all at once, so it captures these local patterns. Stack filters and you climb a ladder: edges → parts (an eye, a nose) → whole faces.

05 · The core idea

Filters and convolution

A filter is a tiny grid of numbers that acts as a pattern detector. The one we'll use looks for "a dark center surrounded by bright pixels" — exactly like an eye on skin:

1
1
1
1
-8
1
1
1
1

Three questions everyone has, answered:

  • Why do the numbers look random? At the start of training they are random. Training slowly tunes them until they become useful detectors. This filter is hand-picked so you can see the result — real CNNs learn their filters.
  • Why many filters, not one? One filter spots one pattern. A face needs many — vertical edges, curves, eye-blobs — so you run a whole bank of filters, each producing its own map.
  • Why slide it around? Because the feature could be anywhere. Sliding checks every location with the same detector.

Convolution is that sliding: lay the filter on a 3×3 patch, multiply matching cells, add the nine results into one number. A high number means "the pattern is here." Do it everywhere and you get a new grid called the feature map.

The same 3×3 filter sliding across the image — one number computed at every stop.

Let's do one stop by hand

Take the top-left patch (it sits over the left eye) and line up the filter:

0.9
0.9
0.9
0.9
0.1
0.9
0.9
0.9
0.5
×
1
1
1
1
-8
1
1
1
1

Multiply each pair, then add the nine results. The eight outer cells use the +1 weight; the dark center (the eye) uses −8:

(0.9 + 0.9 + 0.9) top row + (0.9 + (0.1 × −8) + 0.9) middle row + (0.9 + 0.9 + 0.5) bottom row = 2.7 + 1.0 + 2.3 = 6.0

A big positive number means the filter found its "dark center, bright surround" pattern. That single 6.0 becomes one cell of the feature map.

Exercise — your turn

Now you compute one

Same filter. A new patch, this time with two dark cells in the middle row:

0.9
0.9
0.9
0.1
0.9
0.1
0.9
0.5
0.9
×
1
1
1
1
-8
1
1
1
1

Work out the multiply-and-sum on paper, then check yourself.

Reveal the answer
(0.9 + 0.9 + 0.9) top row + (0.1 + (0.9 × −8) + 0.1) middle row + (0.9 + 0.5 + 0.9) bottom row = 2.7 + (−7.0) + 2.3 = −2.0

A negative number means "the pattern is not here." In the next section, ReLU turns this into 0.

Activity 1 — Try it yourself

Convolution playground

Click any highlighted cell in the toy face to place the 3×3 filter there. Watch the multiply-and-sum, then build the whole feature map.

Toy face (click a center)
Result at this position
Click a highlighted cell to begin.
Feature map
06 · ReLU

Throw away the weak signals

After convolution, some numbers are negative — meaning "this patch does not match." We only care where the pattern is present, so we zero out everything negative. That's ReLU:

ReLU(x) = max(0, x) # keep positives, turn negatives into 0

In the playground above, hit Apply ReLU and watch the negative cells snap to 0. The strong eye-signals stay; the noise disappears. This one trick is what lets the next step focus only on real evidence.

07 · The decision

From features to a "yes, that's a face"

Now we turn the feature map into a decision. A simple classifier takes a few key values, weights them, and adds a bias:

score = 0.4·(left-eye) + 0.4·(right-eye) + 0.2·(mouth) − 2.5

The −2.5 bias keeps the model cautious so it won't shout "face!" at everything. Then the sigmoid squashes that score into a probability between 0 and 1:

probability = 1 / (1 + e−score)

Rule of thumb: score above 0 → probability above 0.5 → "face." Below 0 → "not a face." Play with it:

Activity 2 — Make the call

Face decision calculator

Drag the feature strengths. Watch the score, the probability, and the verdict update live.

Sigmoid — your score plotted
Sanity check Point the model at empty background and every feature is 0, so score = −2.5 and probability ≈ 0.08. It correctly says "not a face." A good detector should be quiet where there's nothing to see.

That completes the core CNN: image → filter (convolution) → ReLU → classifier → sigmoid → decision. If you can narrate that one line and explain each arrow, you understand the engine.

08 · The whole flow

End to end, from pixels to a yes or no

Here is the complete path one image takes through the network. Every step is something we built above — now stacked in order.

Step through it, click the stages, or hit Play. Our toy version is the bare-minimum chain — real networks repeat the convolution-and-pooling block many times before the final decision.

Part 2 extends step 5 from one sigmoid to softmax (seven emotions at once) and wraps the whole flow in a sliding-box search so it works on a full photo, not just a single patch.

09 · Going deeper

The rest of the CNN toolkit

What we built is the skeleton. Real CNNs add a few more parts on top. Here are the big ones, kept plain — this is roughly the level the Stanford CS-230 cheatsheet covers, just unpacked.

Stride and padding

Stride (S) is how far the filter jumps each step. Stride 1 moves one pixel at a time (lots of overlap); stride 2 skips every other spot, giving a smaller output.

Padding (P) adds a border of zeros around the image so the filter can reach the edges. Three common modes: valid (no padding — the output shrinks, which is why our 5×5 became 3×3), same (just enough padding so the output stays the same size as the input), and full (the most padding, so the filter sees every edge end to end).

The one formula that ties it together

Given input size I, filter size F, padding P, and stride S, the output size is:

O = (I − F + 2P) / S + 1

Check it on our toy face: I = 5, F = 3, P = 0, S = 1 → (5 − 3 + 0) / 1 + 1 = 3. That is exactly why our feature map came out 3×3.

Pooling — shrink and toughen

After convolution, a pooling layer downsamples the feature map: slide a small window and keep one number per window. Max pooling (keep the largest value) is the most common and keeps the strongest signal; average pooling keeps the mean. Pooling shrinks the data and adds a bit more tolerance to small shifts. It has no weights to learn.

Many filters = depth

One filter finds one pattern and makes one feature map. Use K filters and you get K stacked feature maps — that stack is the layer's depth. On a color image with C channels, each filter is actually F × F × C, and K filters produce an output of size O × O × K.

The real architecture

Production CNNs repeat the convolution-and-pooling block several times — each round shrinks the spatial size and grows the depth — then flatten into fully-connected layers and finish with softmax. The interactive pipeline above walks the simplest version of that chain end to end.

Why CNNs stay cheap A convolution layer has only (F×F×C + 1)·K weights (one bias per filter), and pooling has zero. The heavy, parameter-hungry part is the fully-connected layers at the end. That weight-sharing is the whole reason a CNN scales to real images where a plain network would not.

Softmax, the sliding-box search, IoU, and non-maximum suppression are the next layer up — and they're exactly what Part 2 uses to go from one face to seven live emotions. Full reference: the Stanford CS-230 CNN cheatsheet by Afshine and Shervine Amidi.

10 · See it work

Follow one image through all six levels

Reading about layers is one thing; watching the numbers move is another. Pick one of the tiny 8×8 images in the panel below and step it through every level — pixels, convolution, ReLU, pooling, flatten, and the final decision. It is a toy with one hand-picked filter, built so you can see exactly what happens at each stage.

11 · From one answer to eight

One face, eight possible feelings

We ended the first half on a single sigmoid: one number that answered one yes-or-no question, "is this a face?" Emotions need a different ending. Now there are eight possible answers — neutral, happiness, surprise, sadness, anger, disgust, fear, contempt — and exactly one of them should win.

The tool for that is softmax. It takes the eight raw scores the network produces and turns them into eight probabilities that always add up to 100%. A sigmoid squashes one number on its own; softmax makes the options compete. Raise the score for one emotion and the others have to give way, because the total is fixed. The biggest score becomes the prediction, but the runners-up still get a share — which matters, because real faces are rarely one clean feeling.

Interactive · softmax

Make the scores compete

Four raw scores go in; softmax turns them into probabilities that always sum to 100%. Drag one up and watch the others shrink.

12 · The data

About 35,000 faces — wildly uneven ones

I trained on FER+, a cleaned-up version of the well-known FER-2013 set: roughly 35,000 small grayscale faces, each 48×48 pixels. What makes FER+ good is how it was labeled — ten people voted on each face across the eight emotions, instead of one person assigning one tag.

The catch is the balance. The classes are lopsided in the extreme: about 12,900 neutral faces, but only 216 labeled contempt. Trained naively, a model just learns to shout "neutral" and "happy" and ignore the rare ones, because that alone scores well.

I used two fixes. The first is obvious: weight the rare emotions more heavily in the loss so the model can't afford to ignore them. The second is the one that actually mattered, and it comes from how FER+ was built.

The trick that helped most Don't train on the winning label — train on the whole vote. If seven annotators said "sad" and three said "neutral," the truth isn't a flat "sad," it's "70% sad, 30% neutral." Feeding the model that full distribution teaches it the genuine ambiguity in a face, instead of pretending every face has one crisp answer.
13 · The model

A plain, classical CNN

Nothing exotic here — it's the same family of network from Part 1. Three convolution blocks (each one: convolution, then a normalization step, then ReLU, then pooling) shrink the 48×48 image down to 6×6 while the depth grows from 1 channel to 64 to 128 to 256. Then two fully-connected layers turn all of that into the eight emotion scores, and softmax finishes the job.

That's about 2.7 million parameters — small by modern standards — trained from scratch on a single GPU (an NVIDIA DGX Spark). No pretrained weights, no transformer, no tricks beyond the two above. I wanted to see how far an honest, textbook CNN gets on its own.

8emotions
2.7Mparameters
35ktraining faces
48×48input pixels
14 · Training it twice

Hard labels, then the vote distribution

I trained the exact same network twice, changing only the targets.

Round one — hard labels. Each face got its single winning emotion. This reached 78.1% on the held-out test set. Respectable, but it overfit: by around epoch 60 it had memorized the training set (95% accuracy there) while the test number had stopped moving. It was also overconfident, often very sure even when wrong.

Round two — soft labels. Same architecture, same optimizer, same number of epochs. The only change was feeding it the vote distribution from Section 02 instead of the single winner. Test accuracy rose to 80.1% — a clean two points — and, just as useful, the overconfidence went away. Now when the model is right it's sure, and when it's wrong it hesitates around 50%. That honesty is what makes the live demo below feel sensible rather than erratic.

Why two points is the right expectation Soft labels don't work magic; they typically buy a point or three. The bigger win is calibration — a model that knows when it's unsure. On a dataset this ambiguous, that's worth more than a slightly higher headline number.
15 · What it's good and bad at

The honest scorecard

Overall test accuracy is 80.1%. But the average hides the real story, which is that the model is excellent at the common emotions and shaky at the rare ones. Here is the per-class breakdown on faces it never saw during training.

EmotionPrecisionRecallF1Examples
happiness0.900.890.90928
neutral0.790.870.821262
surprise0.790.840.82444
anger0.810.710.75325
sadness0.640.620.63444
disgust0.780.300.4423
fear0.740.280.4193
contempt1.000.150.2627
overall0.793546

Per-class results on the held-out test set (v2, soft labels).

The pattern is clear. Happiness, neutral, and surprise are distinct and well-represented, so the model nails them. Disgust, fear, and contempt are rare — contempt has only 216 examples in the entire dataset — so the model barely learns them. Look at contempt's row: precision 1.00 but recall 0.15. It only says "contempt" when it's very sure, so it's right when it guesses, but it misses 85% of them. That's the rare-class problem made concrete, not a bug.

The mistakes it does make are human ones: sad confused with neutral, fear with surprise. And there's a ceiling here worth naming. Around 80% is close to the best a small model gets on FER+; even large, ensembled networks top out near 84–85%. The reason is the data itself — the ten annotators often disagreed, so past a point there is no single "right" answer to learn. Emotion from one grainy face is genuinely hard, for machines and for people.

16 · See it live

Try the model on your own face

This is the exact model from above, running entirely in your browser through ONNX Runtime Web. Nothing is uploaded — the video never leaves your machine. Allow the camera, keep your face in the box, and the bars update in real time.

Live emotion recognition — your trained CNN, in the browser.

What to expect It center-crops the webcam rather than running a face detector, so it's rougher than the 80% test number — keep your face centered in the box. Happiness, surprise, and neutral read clearly; the rare emotions are shaky, exactly as the table predicts. A production version would add a face detector for a tight crop, which is the next thing I'd build.
17 · The code

Everything, end to end

The whole project is on GitHub — the notebook that goes from raw data to a trained model, the saved weights, and this browser demo. It runs top to bottom, so you can reproduce every number above.

emotion-recognition-cnn

notebooks/  — emotion_cnn.ipynb (data → CNN → train → soft labels → evaluate → export)
models/  — emotion_cnn_v2.pt (PyTorch) · emotion_cnn_v2.onnx (browser)
web/  — index.html (the live demo above)
data/  — how to fetch FER+ · README.md

View the repository →
The end

Built and written by Gopi Trinadh. The CNN face-detection material this guide is based on comes from a lecture by Dr. Peizhu Qian. The emotion model is trained on the FER+ dataset (Barsoum et al., Microsoft), which relabels the FER-2013 set from the Kaggle facial-expression challenge. The live demo runs on ONNX Runtime Web.