Question
Segmenting by intensity starts with one decision: choose a gray level, and call everything brighter object and everything darker background. Looking at a histogram, a person cuts at the valley between two humps. Otsu’s paper begins by noting that valleys are often flat, noisy, or missing altogether when one hump is much taller than the other, so “cut at the valley” is not a rule a machine can follow.Keep the unequal humps in mind. They are the problem the paper set out to solve, and, in a different form, the place where its method fails.
What the paper claims
- A threshold can be chosen from the gray-level histogram alone, with no other prior knowledge.
- The choice needs only cumulative sums of the histogram and its first moment, so the search is simple.
- The best score, η*, measures how separable the image is, and does not change if the gray levels are shifted or scaled.
- The method extends directly to two or more thresholds.
What it rests on
- A conjecture, named as one. Well-thresholded classes should be separated in gray level, and conversely, “a threshold giving the best separation of classes in gray levels would be the best threshold.” The converse is the claim that can fail.
- Two classes. The paper itself warns that thresholds become less credible as the number of classes grows.
- Thresholds where both classes exist. The search is limited to an effective range. Most implementations drop this line, and Build shows what that costs.
- Evidence by eye. The examples are a few 64 × 64 pictures, with no ground truth to measure against.
Intuition
Otsu is handed a histogram, not an image. Everything else on this page follows from that, so start there: shuffle the pixels into random positions. The picture is destroyed. The histogram, and the threshold Otsu returns, do not change at all.
Drag the line across the histogram to watch the score. It rises when the two sides have well-separated means and both sides are substantial: a cut at the far left isolates a few dark pixels and scores almost nothing, however consistent that tiny class is.
Math
Setup. Gray levels are
Cumulative moments. Everything is built from two running sums and one constant:
The class probabilities are
One total, two parts. With class variances
They add up to the total variance,
Why the between-class form. Substituting
This needs only probabilities and means, where
The condition under the arg max is the paper’s effective range. Outside it one class is empty and
Two consequences, for later. The factor
Build
The implementation follows the math line by line. Levels run
import numpy as np
def otsu_threshold(image, L=256):
"""Otsu (1979). Returns k* with C0 = {i <= k*}, C1 = {i > k*}.
`image` holds integer gray levels 0, ..., L-1."""
n = np.bincount(image.ravel(), minlength=L)[:L] # histogram n_i
N = n.sum()
p = n / N # p_i
i = np.arange(L)
omega = np.cumsum(p) # omega(k)
mu = np.cumsum(i * p) # mu(k)
mu_T = mu[-1] # total mean
with np.errstate(divide="ignore", invalid="ignore"):
sigma_B2 = (mu_T * omega - mu) ** 2 / (omega * (1 - omega))
N0 = np.cumsum(n)
sigma_B2[(N0 == 0) | (N0 == N)] = np.nan # outside Otsu's effective range
return int(np.nanargmax(sigma_B2))
def to_8bit(raw, bits=12):
"""Rescale a `bits`-bit image to 0..255."""
return np.round(raw.astype(float) * 255 / (2 ** bits - 1)).astype(np.uint8)
def segment(raw, bits=12):
"""The whole pipeline: scanner values in, object mask out."""
img = to_8bit(raw, bits)
return img > otsu_threshold(img)
Writing this takes minutes. So does writing each of the three versions below, and all three run without complaint and return a plausible number.
- Plain argmax. Drop the effective-range line and call
np.argmax. Empty classes produce= NaN, and np.argmaxtreats NaN as the largest value, so it returns the position of the first one. . Score the split below and report it as . Every answer is one gray level too high. - 12-bit cast. Replace the rescaling with
raw.astype(np.uint8). Values are kept modulo 256, so a 12-bit image arrives as noise. Otsu itself is untouched.
Verify
A test here checks a property the method must have, on an input where the right answer is known without trusting the code under test. Four of them:
import numpy as np
from otsu import otsu_threshold, segment
def phantom(mu0=80, mu1=170, s0=15, s1=15, frac=0.3, size=128, seed=0):
"""A disk on a background, with Gaussian noise. Returns 8-bit image and truth."""
r = np.random.default_rng(seed)
yy, xx = np.mgrid[:size, :size] / size
obj = (xx - .5) ** 2 + (yy - .5) ** 2 < frac / np.pi
img = np.where(obj, r.normal(mu1, s1, obj.shape), r.normal(mu0, s0, obj.shape))
return np.clip(np.round(img), 0, 255).astype(np.uint8), obj
def test_definition():
# argmax of sigma_B^2 must equal argmin of sigma_W^2, computed slowly from class variances
img, _ = phantom()
x = img.ravel().astype(float)
sw = [np.average([c0.var(), c1.var()], weights=[c0.size, c1.size])
if c0.size and c1.size else np.inf
for c0, c1 in ((x[x <= k], x[x > k]) for k in range(256))]
assert otsu_threshold(img) == int(np.argmin(sw))
def test_shift():
img, _ = phantom()
assert img.max() + 20 <= 255 # stay inside uint8
assert otsu_threshold(img + 20) == otsu_threshold(img) + 20
def test_two_spikes():
img = np.array([50] * 100 + [200] * 300, dtype=np.uint8)
assert 50 <= otsu_threshold(img) < 200 # any k in the gap is optimal
def test_end_to_end():
# the clean case, through the whole pipeline, from 12-bit scanner values
img, truth = phantom()
raw = np.round(img.astype(float) * 4095 / 255).astype(np.uint16) # 12-bit scanner values
assert np.mean(segment(raw) != truth) < 0.02
All four pass on the faithful version. Running them against each wrong version gives this catch matrix:
| Definition | Shift | Two spikes | End to end | |
|---|---|---|---|---|
| Plain argmax | fails | fails | fails | fails |
| C₀ = {i < k} | fails | passes | passes | passes |
| 12-bit cast | passes | passes | passes | fails |
Read it by column as well as by row. No single test catches every bug. Only the slow brute-force comparison sees the off-by-one, because it alone knows the right answer to the gray level. Only the end-to-end test sees the cast, because that bug is not in Otsu at all. And none of the four ever asks whether Otsu was the right method for the image: they must pass on any input, so they say nothing about the world. That is the next section’s job.
Break
The bench below works on a world where the truth is known, so the error of any answer can be split into four parts:
- Code: what the code returned against what the math gives;
- Search: what the math gives against the true optimum of the criterion;
- Criterion: that optimum against the best threshold there is;
- Reach: what even the best threshold gets wrong.
The first two are bugs; the last two are boundaries. For Otsu, search is always zero: it tries every
Each symptom below comes from a world like these. Decide where the error lives before opening it.
The object covers 2% of the image. Otsu labels 44% of the image as object.Where does it live?
Criterion a boundary.
The tests pass, and
A tight background and a widely spread object. Otsu’s threshold sits deep inside the object’s range.Where does it live?
Criterion a boundary.
The optimum sits midway between the class means, whatever their spreads; the best boundary between a narrow and a wide distribution lies close to the narrow one. With spreads 6 and 40 on the test phantom,
Brightness drifts from left to right. The errors pile up on one side of the image.Where does it live?
Reach a boundary.
Partly criterion, mostly reach. The object on the bright side is brighter than the object on the dark side, and so is the background, so the classes overlap in the histogram although they are clearly apart in space. On the test phantom the best single threshold still gets 5.3% wrong, and Otsu adds 2.8 points more. No criterion over the histogram can recover space. Crossed by bias field correction, which sees it.
Two tissues share most of their gray levels. Even the best possible threshold misclassifies more than a fifth of the image.Where does it live?
Reach a boundary.
Nothing on this page can fix it, because the information is not in the histogram. This boundary is crossed by a different measurement, such as a second contrast, a longer acquisition or a better scanner, more than by a different algorithm.
Three tissues, three humps in the histogram. Otsu merges two of them.Where does it live?
Reach a boundary.
One threshold can only ever say two things. The method needs to say more: crossed by multilevel thresholding, which keeps the same criterion and adds thresholds.
On the small-object world, a classmate’s version gets only 2% wrong, far better than yours. Their code must be better.Where does it live?
Code a bug.
Run their code against the tests. If it is the plain-argmax version, all four fail: the first NaN sits at the top of this histogram, so it returns the brightest gray level and calls everything background. On an image that is 98% background, that is nearly right. A good answer is not evidence of correct code.
Two spikes, at 50 and 200. Your code returns 50; a classmate’s returns 199.Where does it live?
Nowhere a tie, not an error.
Every
Learn
Carry forward.
- Otsu sees values, never positions. Anything spatial, such as shape, neighbors or a smooth drift, is outside its reach.
- Minimizing within-class variance and maximizing between-class variance are the same problem. The second needs only cumulative first-order sums, so one pass suffices.
- The criterion favors balanced classes and places the cut midway between the means. Class size and spread are ignored by construction, and both reappear as failures.
- Tests decide whether the code computes the math. A world with known truth decides whether the math answers your question. Neither can do the other’s job.
Work on.
- Show that
is unchanged by a shift of all intensities, and find what a scaling by does to and to . - Prove that
for a single Gaussian. Then use the bench to find a two-class world with a lower , and explain why cannot tell you whether a second class exists. - Write a test that catches the off-by-one version without the brute-force comparison.
- Add a fourth wrong version that the catch matrix above misses entirely, and the test that would catch it.
Crossings
Every boundary in Break is a place where another method starts. These are the ones the atlas already knows about.
- Criterion When one class is much smaller or more spread out than the other Mixtures and EM plannedsame information, better criterion
- Reach When there are more than two classes Multilevel thresholds plannedsays more: several thresholds
- Reach When brightness drifts across the image Bias field correction plannedsees more: where each pixel is
Record
Bugs, tests, failure cases and crossings added to this node, semester by semester. How to add one.
- Fall 2026Silent bugPlain
np.argmaxlets an empty class winInstructors - Fall 2026Silent bug
astype(np.uint8)wraps 12-bit values instead of rescaling themInstructors - Fall 2026Silent bugScoring the split at k − 1 and reporting it as kInstructors
- Fall 2026TestThe whole pipeline on a clean phantom, from 12-bit valuesInstructors
- Fall 2026Failure caseA small object pulls the threshold into the backgroundInstructors