Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Thresholding from the Histogram

DRAFT, not finalized. This chapter is new and still under review.

stough 202-

Preview: Otsu's Method
  1. Thresholding

  2. Choosing a threshold: Otsu’s method

  3. Implementing it

  4. Interactive thresholding

  5. Where one threshold fails

  6. Local thresholds

  7. 🔨 Your Turn: Count the Coins

  8. 🧠 Further Efforts

Segmentation is the task of dividing an image into meaningful regions: the object and the background, the cells in a micrograph, the text on a page. It’s the step between processing an image and understanding it, since we can only measure, count, or recognize the things we’ve separated from their surroundings.

The simplest segmentation is thresholding: every pixel brighter than some value tt is foreground, and everything else is background. The whole difficulty is in choosing tt. In this notebook we’ll derive and implement the classic automatic choice, Otsu’s method (1979), which picks tt from nothing but the image’s histogram. Then we’ll see where any single threshold breaks down, and what to do about it.

 

Thresholding

Our test image is scikit-image’s coins, a photo of 24 old coins on a darker background, as 8-bit grayscale.

<Figure size 900x300 with 2 Axes>

The histogram has a tall peak around 40 for the dark background, and a long, lumpy shoulder from about 80 to 200 for the coins, which are brighter and much more varied. It isn’t a textbook bimodal histogram, with two clean humps and an empty valley between them, but there’s clearly a dark population and a bright one, and a threshold somewhere between them should separate coins from background reasonably well. But where, exactly? We’d like a rule that picks the threshold automatically and reproducibly, without our eyeballing every image.

 

Choosing a Threshold: Otsu’s Method

Otsu’s idea: a good threshold splits the pixels into two classes that are each as uniform as possible. Think of it as the threshold that makes the two classes’ histograms as narrow as possible.

Let pkp_k be the fraction of pixels with intensity kk (the histogram, normalized to sum to 1). A threshold tt splits the intensities into class 0, k≤tk \le t, and class 1, k>tk > t. For each class we need its share of the pixels and its mean intensity:

ω0(t)=∑k=0tpk,ω1(t)=1−ω0(t),μ0(t)=1ω0∑k=0tk pk,μ1(t)=1ω1∑k=t+1255k pk.\omega_0(t) = \sum_{k=0}^{t} p_k, \quad \omega_1(t) = 1 - \omega_0(t), \qquad \mu_0(t) = \frac{1}{\omega_0}\sum_{k=0}^{t} k\,p_k, \quad \mu_1(t) = \frac{1}{\omega_1}\sum_{k=t+1}^{255} k\,p_k.

“As uniform as possible” means a small within-class variance, σW2=ω0σ02+ω1σ12\sigma_W^2 = \omega_0\sigma_0^2 + \omega_1\sigma_1^2, the weighted average of the two classes’ variances. Here’s the key fact. The variance of all the pixels together, σT2\sigma_T^2, doesn’t depend on tt, and it splits into two parts:

σT2=σW2(t)+σB2(t),whereσB2(t)=ω0 ω1 (μ0−μ1)2\sigma_T^2 = \sigma_W^2(t) + \sigma_B^2(t), \qquad \text{where} \qquad \sigma_B^2(t) = \omega_0\,\omega_1\,(\mu_0 - \mu_1)^2

is the between-class variance: how far apart the two class means are, weighted by the class sizes. Since the total is fixed, minimizing the within-class variance is the same as maximizing the between-class variance. And σB2\sigma_B^2 is much easier to compute, because it needs only the class sizes and means, not their variances.

So Otsu’s method is: compute σB2(t)\sigma_B^2(t) for every possible threshold tt, and pick the tt where it’s largest. It rewards a split that is both balanced (the ω0ω1\omega_0\omega_1 factor is largest when the classes are of similar size) and well separated (the class means are far apart).

 

Implementing It

We could loop over the 256 thresholds, recomputing the sums each time. But every quantity above is a running sum over the histogram, which np.cumsum computes for every tt at once. With m(t)=∑k≤tk pkm(t) = \sum_{k \le t} k\,p_k and the overall mean μT=m(255)\mu_T = m(255), a little algebra (substitute μ0=m/ω0\mu_0 = m/\omega_0 and μ1=(μT−m)/ω1\mu_1 = (\mu_T - m)/\omega_1) gives

σB2(t)=(μT ω0(t)−m(t))2ω0(t) (1−ω0(t)).\sigma_B^2(t) = \frac{\left(\mu_T\,\omega_0(t) - m(t)\right)^2}{\omega_0(t)\,\left(1 - \omega_0(t)\right)}.
our threshold: 107
skimage threshold_otsu: 107

The otsu function is a direct translation of the formula. np.bincount gives the histogram counts for each intensity 0 to 255, and dividing by the number of pixels makes it pkp_k. The two cumsums compute ω0(t)\omega_0(t) and m(t)m(t) for all thresholds at once. At the extremes, one class is empty, the formula divides 0 by 0, and nan_to_num sets those to 0 so they can never win. np.argmax picks the best tt.

Our threshold agrees with scikit-image’s threshold_otsu, which uses the same convention: foreground is I > t. Let’s look at σB2(t)\sigma_B^2(t) over the histogram, and at the segmentation.

separability: sigma_B^2 / sigma_T^2 = 0.76
<Figure size 1000x320 with 3 Axes>

The orange curve, σB2(t)\sigma_B^2(t), rises smoothly to a single broad peak, between the background peak and the bulk of the coin pixels, and falls off toward the ends, where one class gets too small to matter. Its peak, the red dashed line, is Otsu’s threshold.

The printed separability, σB2/σT2\sigma_B^2 / \sigma_T^2, is the fraction of the image’s total variance that the two-class split explains. It’s between 0 and 1, and the closer to 1, the more cleanly the histogram splits into two. It’s a useful check on whether a two-class threshold even makes sense for an image.

The segmentation is good but not perfect. Most coins come out as white disks, but several, especially in the bottom row, have dark speckles and cracks inside them where their own darker details fell below tt. And the background isn’t evenly lit: along the top edge, and especially at the top left, it’s brighter than elsewhere, and it comes out white, merging with the first coins. One global tt has to compromise between all parts of the image.

 

Interactive Thresholding

Drag the threshold to see how sensitive the result is. Start at Otsu’s tt and move a few steps either way.

<Figure size 900x320 with 2 Axes>

 

Where One Threshold Fails

Otsu’s method assumes that one threshold can separate foreground from background everywhere in the image. That assumption breaks when the lighting is uneven. Here’s scikit-image’s page sample, a photo of a printed page whose illumination falls off toward the left.

<Figure size 1000x280 with 2 Axes>

The global threshold does well where the paper is brightly lit, but toward the darker left side the paper itself falls below tt and turns black, swallowing the text. No single threshold can fix this: the paper at the far left (a median intensity of about 98) is darker than much of the ink on the right (whose darkest 5% are below about 105). You can check that with the interactive figure above, by setting I = P and re-running it.

 

Local Thresholds

The fix is to let the threshold vary across the image. A local (or adaptive) threshold computes a separate threshold for every pixel from its own neighborhood. Since what matters is whether a pixel is darker than its surroundings, a natural choice is the local mean, slightly offset:

t(i,j)=IˉN(i,j)−ct(i, j) = \bar{I}_{N(i,j)} - c

where IˉN(i,j)\bar{I}_{N(i,j)} is the mean over a window around pixel (i,j)(i, j), which is exactly the low-pass filter we already know. threshold_local computes it (with a Gaussian-weighted mean by default), given an odd block_size for the window width and an offset cc.

<Figure size 1000x280 with 3 Axes>

The middle panel is the threshold itself, an image: a smooth version of the page that is darker where the page is darker. Comparing each pixel against it (right) removes the effect of the lighting, and the text comes through cleanly across the whole page.

The window size matters. It must be larger than the features we’re segmenting (a few letters across), so that the local mean reflects the paper and not just the ink, but small enough that the lighting is roughly constant within it. The offset keeps flat paper, where the local mean is just the paper itself, from flickering between classes due to noise. Local thresholding is the standard approach for documents, and the idea of comparing a pixel to its own neighborhood will come back again and again.

 

🔨 Your Turn: Count the Coins

A segmentation is only useful if we can do something with it. Let’s count.

Task: Starting from a thresholded coins image, count the coins automatically, and show an image in which each coin you found has its own color. Your count should be 24. Report how close you get, and what went wrong where it isn’t right.

Guidance:

  • scipy.ndimage.label finds the connected groups of True pixels in a binary image and gives each group a number, as we used for hysteresis in edge detection. The number of groups is a first count. plt.imshow(labels, cmap='nipy_spectral') shows each group in its own color.

  • Expect your first count to be far too high. Look at the labeled image to see why: what are all those extra groups?

  • np.bincount(labels.ravel()) gives the size, in pixels, of every group. What size is a coin? How can you use that to ignore the groups that aren’t coins?

  • Coins with gaps in them, or coins touching each other or the edge of the image, can also throw off your count. Try a local threshold as well as Otsu’s, and try smoothing the image before thresholding. Which helps?

 

🧠 Further Efforts

  1. Three classes. Some images have three kinds of region, and a trimodal histogram. Extend Otsu’s method to two thresholds t1<t2t_1 < t_2, by maximizing the between-class variance over all pairs. Test it on an image with a dark, a medium, and a bright region, and compare with scikit-image’s threshold_multiotsu.
    Guidance: For KK classes, the between-class variance is ∑kωk(μk−μT)2\sum_k \omega_k(\mu_k - \mu_T)^2. There are only about 2562/2256^2/2 pairs of thresholds, so trying them all is feasible, especially using the cumulative sums.

  2. Noise and separability. Add increasing amounts of Gaussian noise to coins and plot Otsu’s threshold and the separability σB2/σT2\sigma_B^2/\sigma_T^2 against the noise level. Then smooth each noisy image with a Gaussian before thresholding. What does smoothing do to the histogram, and why does it help?
    Guidance: Plot the histograms of the noisy and smoothed images together. The probability notebook is a good refresher on what adding noise does to a distribution.

  3. Thresholding color. Otsu’s method needs one number per pixel. For a color image, which single channel would you threshold to separate an object from its background: R, G, B, a gray conversion, or a channel of HSV or Lab? Try several on a photo of a colorful object on a plain background, and use the separability to compare them.
    Guidance: Rescale each channel to integers in [0,255][0, 255] first, so that our otsu function applies.