JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

From Pixels to Tone: Histograms, Brightness, and Thresholds

Start where every image edit begins — reshape brightness, contrast, and tone one pixel at a time, and read an image's whole story in its histogram.

What a pixel value really means

In the first track you met the central fact of imaging: a grayscale image is nothing more mysterious than a grid of numbers. Each cell in that grid is one pixel, and its number is an intensity — for an 8-bit image, a whole number from 0 (pure black) up to 255 (pure white), with every shade of gray in between. Picture a giant spreadsheet where each cell holds a light-meter reading taken at that exact spot on the scene: bright spots get big numbers, shadows get small ones. The whole photo is just that spreadsheet of readings.

Zoom in far enough and the picture dissolves into a grid of numbered cells — each pixel is one intensity reading.

A photo on the left zoomed into a grid of cells on the right, each cell labelled with an intensity number between 0 and 255.

Once you accept that an image is just numbers, the phrase 'image processing' loses its mystery: it literally means doing arithmetic on those numbers. Want a brighter photo? Add to every number. Want black-and-white? Replace each number by either 0 or 255. Everything in this whole track is, underneath, a recipe for turning the input numbers into output numbers.

The simplest and most important kind of recipe is a point operation: a rule applied to each pixel completely on its own, ignoring its neighbours. The new value of a pixel depends only on its old value — never on the pixel to its left or above. That independence is what makes point operations easy to reason about and lightning-fast to compute. Here is a tiny 3×3 patch and a point operation you can do in your head — 'add 40 to every pixel':

# A tiny 3x3 grayscale patch (each number is one pixel's brightness, 0..255)
img = [[ 10,  50,  90],
       [100, 150, 200],
       [120, 130, 255]]

# A POINT OPERATION: brighten by adding 40 to EVERY pixel, independently.
b = 40
for y in range(3):
    for x in range(3):
        out[y][x] = min(255, img[y][x] + b)  # clamp at 255 so we never overflow

# img[0][0]  10 -> 50,   img[1][1] 150 -> 190,   img[2][2] 255 -> 255 (already maxed)
Each pixel is updated using only itself — that is what makes this a point operation.

The image histogram: an image's fingerprint

Before you change an image, you want to read it. The single most useful summary is the image histogram: a bar chart that counts how many pixels carry each intensity. The horizontal axis runs over the tones 0…255; the height of each bar is the number of pixels at that tone. It is the image's fingerprint — like an EKG trace, its overall shape tells you the story at a glance: a dark night scene bunches its bars on the left, an over-exposed snow photo piles them on the right, and a flat, hazy image squeezes everything into a narrow lump in the middle.

h(i) = \#\{\,(x,y) : f(x,y) = i\,\}

Read this as 'the histogram value at level i is the count of all pixel positions whose intensity equals i'. Symbol by symbol: i is one intensity level, any whole number from 0 to 255; f(x,y) is the intensity stored at the pixel in column x, row y; the curly braces with the # in front mean 'how many things are in this set', i.e. how many positions (x,y) satisfy the condition; and h(i) is that count. So h(0) is how many perfectly black pixels there are, h(255) how many perfectly white ones. A tall bar means that tone is everywhere; a zero-height bar means that exact shade never appears.

# A 4x4 toy image, 2-bit so intensities are only 0,1,2,3 (easy to count by hand)
img = [[0, 0, 1, 3],
       [0, 1, 1, 2],
       [2, 2, 1, 0],
       [3, 3, 2, 1]]

h = [0, 0, 0, 0]          # one bin per intensity level: h[0], h[1], h[2], h[3]
for row in img:
    for v in row:
        h[v] += 1         # tally this pixel into its bin

# h == [4, 5, 4, 3]  ->  four 0s, five 1s, four 2s, three 3s   (sums to N = 16)
Build it by hand: sweep the grid once and drop each pixel into its bin. Four 0s, five 1s, four 2s, three 3s.
p(i) = \frac{h(i)}{N}

This turns raw counts into fractions: p(i) is the normalized histogram, where N is the total number of pixels (here N = 16). For our toy image p(0) = 4/16 = 0.25, p(1) = 5/16 ≈ 0.3125, p(2) = 0.25, p(3) ≈ 0.1875, and they sum to 1. The payoff: p(i) behaves exactly like the probability that, if you closed your eyes and poked one random pixel, it would have tone i. That is why p(i) is the right object for the equalization math later — it is a genuine probability distribution over tones. One more crucial idea: a histogram throws away WHERE the pixels are and keeps only HOW MANY of each tone exist. Two completely different pictures can share the very same histogram, which is exactly why histograms are cheap to compute yet so revealing.

Brightness and contrast as a single line

Now let us actually change tone. The workhorse point operation is the linear intensity map, and it bundles the two adjustments you reach for most: brightness and contrast. Brightness is adding or subtracting a constant — like a volume knob that turns the whole image louder (lighter) or quieter (darker). Contrast is multiplying by a factor — like grabbing a rubber band and stretching it, so dark and light tones spread further apart (more contrast) or get pinched together (less). Both happen at once with one formula:

g(x,y) = a\cdot f(x,y) + b

This is just the equation of a straight line, 'output = slope × input + intercept'. Symbol by symbol: f(x,y) is the input intensity at pixel (x,y); g(x,y) is the output intensity that replaces it; a is the contrast gain — the slope of the line; and b is the brightness offset — the intercept. Intuitively, a tilts and steepens the tone curve (a > 1 fans tones apart, a < 1 squashes them together), while b slides the whole curve straight up or down. With a = 1, b = 0 you get back the original image unchanged. One non-negotiable rule: the result must be clamped (clipped) to the valid range [0, 255], because a pixel cannot be darker than black or brighter than white.

Watch what each knob does to the image histogram. Adding b shifts every bar bodily to the right (brighter) or left (darker) — the shape is unchanged, the whole fingerprint just slides. Multiplying by a stretches the bars apart around 0 (if a > 1) so the histogram fans out to fill more of the range, or pulls them together (if a < 1). Reading the histogram before and after is the surest way to see whether your edit did what you intended.

A worked example. Take a mid-gray pixel f = 100 and apply a = 1.5 (boost contrast 50%) with b = 10 (a small brightness lift). Then g = 1.5 × 100 + 10 = 160 — comfortably inside [0, 255], so it survives untouched. Now take a bright pixel f = 200 with the same a and b: g = 1.5 × 200 + 10 = 310, which exceeds 255, so it clamps down to 255. That second case is the trap to respect.

Gamma correction: matching the eye and the screen

A straight line treats every tone the same, but neither your eye nor your screen does. Human vision is wildly nonlinear: we are far more sensitive to small changes in dark tones than in bright ones — the step from near-black to dim gray is dramatic to us, while the step from bright gray to white is barely noticeable. Display hardware is nonlinear too. To handle this gracefully we use gamma correction, a power-law curve instead of a line.

Think of gamma as a smarter dimmer switch — one that is extra gentle in the shadows, where your eye is fussiest, and coarser up in the highlights, where you can't tell the difference anyway. By bending the tone curve instead of tilting it, gamma can brighten or darken the midtones while leaving pure black at 0 and pure white at 255 fixed, so it never clips.

s = c \cdot r^{\gamma}

Symbol by symbol: r is the input intensity, but first normalized to the range [0, 1] (you divide the 0…255 value by 255); s is the output intensity, also in [0, 1]; γ (gamma) is the exponent that bends the curve; and c is a scaling constant, almost always just 1. The intuition lives in the exponent. When γ = 1 the curve is a straight 45° line — no change at all. When γ < 1 (say 0.5) the curve bows upward, lifting shadows and brightening midtones. When γ > 1 (say 2.2) it bows downward, deepening shadows and darkening midtones. The endpoints stay pinned: 0^γ = 0 and 1^γ = 1 for any γ, which is precisely why pure black and pure white never move.

Two real numbers make it concrete. Take a dark pixel value 50. Normalize: r = 50/255 ≈ 0.196. Apply γ = 0.5 (which is a square root): s = 0.196^0.5 ≈ 0.443. Scale back: 0.443 × 255 ≈ 113. The shadow pixel jumped from 50 to 113 — a huge visible lift exactly where the eye cares. Now take a midtone 128: r ≈ 0.502, with γ = 2.2 we get s = 0.502^2.2 ≈ 0.220, back to ≈ 56 — markedly darker. That γ ≈ 2.2 is no accident: typical displays apply a gamma of about 2.2 to the signal they receive, so cameras and image files pre-encode with roughly the inverse, 1/2.2 ≈ 0.45, so that the picture looks correct after the screen darkens it back.

# Gamma correction: the correct 3-step workflow
def gamma_correct(pixel, gamma, c=1.0):
    r = pixel / 255.0         # 1) normalize 0..255  ->  0..1
    s = c * (r ** gamma)      # 2) apply the power law
    return round(s * 255.0)   # 3) scale back to 0..255

gamma_correct(50,  0.5)   # -> 113   gamma < 1 lifts shadows (50 brightens)
gamma_correct(128, 2.2)   # -> 56    gamma > 1 deepens midtones (128 darkens)
Always normalize to [0,1] BEFORE exponentiating, then scale back — exponentiating raw 0..255 values is meaningless.

Histogram equalization: spreading the tones

Linear and gamma maps need you to pick the knobs. Histogram equalization is the automatic version: it looks at the image histogram and, with no parameters to tune, spreads a cramped set of tones across the full 0…255 range to maximize contrast. The goal is a flatter histogram, one where every tone is used roughly equally. Picture a concert hall where the whole crowd has bunched into a few rows; equalization is the usher who re-seats everyone evenly across all the rows, so no section is jammed and none sits empty.

  1. Compute the histogram h(i): count how many pixels sit at each level.
  2. Normalize it to p(i) = h(i)/N to get the probability of each tone.
  3. Form the cumulative distribution CDF(r) by adding up p(i) from 0 to r.
  4. Use the CDF itself as the remapping curve: new level = (L−1) × CDF(old level).
s = T(r) = (L-1)\cdot \mathrm{CDF}(r), \qquad \mathrm{CDF}(r) = \sum_{i=0}^{r} p(i)

Symbol by symbol: r is an input level and s its new output level; T is the transformation (the remapping curve) we build; L is the number of levels, 256 for an 8-bit image, so L−1 = 255 is the brightest output; p(i) is the normalized histogram from before; and CDF(r) is the cumulative sum of p from level 0 up to level r, running smoothly from 0 up to 1. Here is the magic: the CDF rises steeply wherever pixels pile up and stays flat across empty tone ranges. Because we use that steepness as the new tone curve, crowded tones get pulled far apart (steep slope ⇒ big output gaps) while empty ranges get squeezed shut. The crowd spreads out exactly in proportion to how crowded it was.

# Histogram equalization on a 3-bit image (levels 0..7, so L = 8)
img = [[3,3,4,5],
       [3,4,4,5],
       [4,4,5,5],
       [3,4,5,4]]
L, N = 8, 16

h = [0]*L
for row in img:
    for v in row: h[v] += 1        # h   = [0,0,0,4,7,5,0,0]

p = [c / N for c in h]             # p   = [0,0,0,.25,.4375,.3125,0,0]
cdf, run = [], 0.0
for pi in p:
    run += pi
    cdf.append(run)                # cdf = [0,0,0,.25,.6875,1,1,1]

T = [round((L-1) * c) for c in cdf]   # T  = [0,0,0,2,5,7,7,7]
# old tones {3,4,5} (span 2)  ->  new tones {2,5,7} (span 5): contrast stretched!
The three used tones 3,4,5 were cramped together; equalization maps them to 2,5,7, filling the range.

Thresholding: from gray to black-and-white

The final point operation answers a yes/no question instead of adjusting tone. Thresholding is the simplest form of segmentation — splitting an image into meaningful regions. You pick a single cutoff value T; every pixel brighter than T becomes white (the foreground), and everything darker becomes black (the background). A grayscale image collapses into a clean two-colour mask: ink versus paper, object versus backdrop, cell versus slide.

g(x,y) = \begin{cases} 255 & \text{if } f(x,y) > T \\ 0 & \text{otherwise} \end{cases}

Symbol by symbol: f(x,y) is the input intensity at the pixel; T is the threshold cutoff you choose; and g(x,y) is the binary output — exactly 255 or exactly 0, nothing in between. This is a step function applied to tone: as you sweep the input from dark to bright, the output stays glued at 0 until the input crosses T, then jumps instantly to 255 and stays there. There are no intermediate grays in the result, which is the whole point — you have turned a question of 'how bright?' into a verdict of 'foreground or background?'.

Where should T sit? Read the histogram. A scanned document has two natural humps — a tall one near white (the paper) and another near black (the ink) — with a valley between them. The best global threshold sits right in that valley, cleanly separating the two populations. But a single global T fails when lighting is uneven across the image (a shadow on one corner of the page darkens the paper below T and turns it black). The cure is adaptive thresholding: compute a different local T for each small region, so each part of the page is judged against its own background. And when you don't want to eyeball the valley at all, Otsu's method picks T automatically.

# Global thresholding of a scanned document (T chosen at the histogram valley)
T = 128
for y in range(H):
    for x in range(W):
        out[y][x] = 255 if img[y][x] > T else 0   # paper -> white, ink -> black

# Otsu: instead of guessing T = 128, try every candidate T in 0..255 and keep
# the one that splits the histogram into the two TIGHTEST humps
# (minimum within-class variance). No manual tuning, no eyeballing the valley.
Otsu turns 'pick the valley by eye' into an objective search over all possible cutoffs.