Image Processing & Computational Photography

Image Sharpening

Raising the contrast of fine detail by adding back a multiple of what a blur removes; unsharp masking, Laplacian and fixed-kernel sharpening analysed exactly for their overshoot, halo width and noise gain, with the clipping and threshold rules of scikit-image and Pillow.

beginner

Image sharpening makes an image look crisper by raising the contrast of its fine detail: edges get steeper, texture stronger and small features more distinct. Torralba, Isola and Freeman (2024, §18.11) describe the goal as amplifying the high spatial frequencies of the image, and add that a sharpening filter does not create new fine detail; it only enhances detail that is already there. The sharpeners in this article are all variants of one idea, unsharp masking: blur the image, subtract the blur to isolate the detail, and add a multiple of that detail back. The convolution, image filtering, Gaussian filter and Laplacian operator articles each state the formula in a sentence. This article works out what it does: how far an edge overshoots, how wide the halo beside it is, how much it amplifies noise, and how the scikit-image and Pillow implementations differ from the formula.

Purpose

An image can look soft for many reasons: an out-of-focus lens, motion during the exposure, interpolation when it was resized, or processing that smooths fine detail. Sharpening does not undo any of these exactly. It applies a fixed boost to the high frequencies, with no model of the blur that caused the softness and no knowledge of the noise. That makes it cheap, stable and easy to control, and it is why sharpening is an enhancement step rather than a restoration: it changes how the image looks, not how close it is to the scene.

Image deblurring is the restoration counterpart. It starts from a model of the blur and inverts it, which is ill-posed: frequencies the blur nearly removed must be amplified enormously, and the noise with them. scikit-image’s documentation for its unsharp masking function makes the contrast explicit: unsharp masking is a linear operation and numerically stable, unlike deconvolution, which is ill-posed, and for this stability it is often preferred (scikit-image 0.25.2, _unsharp_mask.py). The Wiener filter sits between the two: it inverts a known blur, with a gain at each frequency that depends on the signal-to-noise ratio there.

When to Use It

Sharpening helps when the detail is present but weak:

  • Display and output. In our reading, an image that will be shown smaller, printed or compressed can gain from a mild boost of the frequencies that survive.
  • After resizing. Interpolation attenuates detail below the Nyquist frequency (image interpolation measures by how much for each kernel), and a mild sharpen restores some of the contrast.
  • In a camera pipeline. Image signal processors include a sharpening stage, often combined with demosaicing (digital camera).
  • Uneven illumination. Subtracting a low-pass version of the image also removes slowly varying shading. For intensity inhomogeneity in MRI, Morel, Petro and Sbert (2014, §4.1), describing the classification of Vovk et al., list unsharp masking and homomorphic filtering among the filtering methods, which treat the inhomogeneity as a low-frequency artefact.

It does not help, or does harm, in these cases:

  • Noisy images. Every sharpener boosts the frequencies where white noise has as much power as anywhere else; the example below measures by how much.
  • Measurement. Sharpening changes intensities near edges, so sizes, profiles and photometry measured afterwards are biased. A detector or a learned model that was trained on unsharpened images sees a different input distribution.
  • Severe blur. Detail that the blur removed entirely cannot be brought back by boosting; recovering it needs deconvolution with a model of the blur, and even then only where the noise allows.

How It Works

Unsharp masking

Let ff be the image, GσG_\sigma a Gaussian of standard deviation σ\sigma and a≥0a \ge 0 the amount. Unsharp masking computes

s=f+a (f−Gσ∗f).s = f + a\,(f - G_\sigma * f).

The difference f−Gσ∗ff - G_\sigma * f is the detail that the blur removes, the “unsharp mask”. By linearity the whole operation is one convolution with the kernel

h=(1+a) δ−a Gσ,h = (1 + a)\,\delta - a\,G_\sigma ,

a large positive centre and a negative surround, as convolution shows. Its frequency response is

H(u)=1+a (1−G^σ(u)),G^σ(u)=e−2π2σ2∣u∣2H(\mathbf{u}) = 1 + a\,\bigl(1 - \hat G_\sigma(\mathbf{u})\bigr), \qquad \hat G_\sigma(\mathbf{u}) = e^{-2\pi^2 \sigma^2 |\mathbf{u}|^2}

for the continuous Gaussian, with u\mathbf{u} in cycles per pixel. The response is exactly 1 at zero frequency, so flat regions and the mean brightness are unchanged; it rises smoothly with frequency and approaches 1+a1 + a once G^σ\hat G_\sigma has decayed. The two parameters divide the work: aa sets how strong the boost is, and σ\sigma sets where it starts. A small σ\sigma boosts only the finest detail, near the Nyquist frequency; a large σ\sigma boosts everything above a low frequency, which increases local contrast more than it sharpens edges.

Libraries name the parameters differently. scikit-image’s unsharp_mask(image, radius, amount) uses radius for the Gaussian’s standard deviation and amount for aa, and blurs with SciPy’s gaussian_filter in reflect mode with the default truncation at 4 standard deviations (scikit-image 0.25.2, _unsharp_mask.py and _shared/filters.py; SciPy 1.16.3, _filters.py). Pillow’s ImageFilter.UnsharpMask(radius, percent, threshold) uses radius for the standard deviation too, percent for 100a100a, and adds a threshold described below (Pillow 12.0.0, ImageFilter.py).

The step response: overshoot and halo

The cost of sharpening is easiest to see at a straight edge. Take a step of contrast cc, from lo\text{lo} to hi\text{hi}, between two pixel columns, and let gkg_k be the sampled, normalized Gaussian taps that SciPy uses, g0g_0 the centre tap and T(n)=∑k>ngkT(n) = \sum_{k > n} g_k the tail beyond nn. At the nn-th pixel on the bright side (n=0,1,…n = 0, 1, \dots) the blurred step has risen to lo+c (1−T(n))\text{lo} + c\,(1 - T(n)), so the sharpened value is

s−hi=a c T(n).s - \text{hi} = a\,c\,T(n).

The result above the bright level is a halo, a band of exaggerated brightness that decays away from the edge with the Gaussian’s tail; by symmetry a dark band of the same shape forms on the dark side (our derivation, checked by the example). Three numbers describe it:

  • Peak overshoot. At the first bright pixel, a c T(0)=a c (1−g0)/2a\,c\,T(0) = a\,c\,(1 - g_0)/2. It is proportional to the amount and grows with σ\sigma towards a c/2a\,c/2, since g0g_0 falls roughly as 1/(σ2π)1/(\sigma\sqrt{2\pi}). In the continuous limit a unit step overshoots by exactly a/2a/2.
  • Width. The halo decays like the Gaussian’s tail, so its width in pixels is proportional to σ\sigma and does not depend on aa.
  • Area. Summing the halo over the bright side gives a c∑k≥1k gka\,c\sum_{k \ge 1} k\,g_k, which approaches a c σ/2πa\,c\,\sigma/\sqrt{2\pi} for the continuous Gaussian.

In our reading, a halo is what makes an edge look sharper: the eye reads the exaggerated contrast across the edge as a steeper transition. Too large an amount makes it visible as a light or dark outline, and too large a σ\sigma makes the outline wide; the image filtering article notes that strong gains produce halos at edges.

Noise gain

For white noise of standard deviation σn\sigma_n, a linear filter hh returns noise of standard deviation ∥h∥2 σn\|h\|_2\,\sigma_n, where ∥h∥2=(∑hk2)1/2\|h\|_2 = (\sum h_k^2)^{1/2}. By Parseval’s theorem ∥h∥2\|h\|_2 is also the root-mean-square of ∣H∣|H| over all frequencies, so the noise gain is the filter’s average boost, weighted equally over the whole band, because white noise has equal power at every frequency. The noise is also no longer white: its spectrum takes the shape of ∣H∣2|H|^2, as image noise notes for sharpened images.

For unsharp masking, with the 2D Gaussian G=g g⊤G = g\,g^\top and its centre G0=g02G_0 = g_0^2,

∥h∥22=(1+a)2−2a(1+a) G0+a2∥G∥22.\|h\|_2^2 = (1 + a)^2 - 2a(1 + a)\,G_0 + a^2 \|G\|_2^2 .

Every entry of GG is at most G0G_0 and the entries sum to 1, so ∥G∥22≤G0\|G\|_2^2 \le G_0, and

1+a−a G0  ≤  ∥h∥2  <  1+a1 + a - a\,G_0 \;\le\; \|h\|_2 \;<\; 1 + a

(our derivation; the lower bound is the centre tap alone). The noise gain is close to 1+a1 + a for any σ\sigma beyond a pixel or so: the amount, not the radius, decides how much the noise grows, while the radius decides how much of the image’s own detail is boosted along with it.

Laplacian sharpening

Subtracting a multiple of the Laplacian, f−c ∇2ff - c\,\nabla^2 f, boosts whatever differs from its surroundings (Laplacian operator). With the four-neighbour Laplacian and c=1c = 1 the kernel has 5 at the centre and −1-1 at the four neighbours. Its response,

H(u,v)=5−2cos⁡2πu−2cos⁡2πv,H(u, v) = 5 - 2\cos 2\pi u - 2\cos 2\pi v,

is 1 at zero frequency, 5 at the horizontal Nyquist frequency and 9 at the diagonal corner (12,12)(\tfrac12, \tfrac12); its noise gain is 25+4=29\sqrt{25 + 4} = \sqrt{29}. Since Gσ∗f≈f+σ22∇2fG_\sigma * f \approx f + \tfrac{\sigma^2}{2}\nabla^2 f for small σ\sigma, unsharp masking with a small radius is approximately Laplacian sharpening with c=aσ2/2c = a\sigma^2/2, as the Laplacian Operator article derives. The difference is in what the two do at high frequencies: the Laplacian’s boost keeps growing up to the Nyquist frequency and is largest along the diagonals, while unsharp masking’s levels off at 1+a1 + a.

Fixed 3 × 3 kernels and the “twice minus blur” form

Small fixed kernels do the same job without a radius parameter. Torralba, Isola and Freeman (2024, eq. 18.7) build theirs by starting from twice the image and subtracting the blurred components:

h=[000020000]−116[121242121],h = \begin{bmatrix} 0 & 0 & 0 \\ 0 & 2 & 0 \\ 0 & 0 & 0 \end{bmatrix} - \frac{1}{16}\begin{bmatrix} 1 & 2 & 1 \\ 2 & 4 & 2 \\ 1 & 2 & 1 \end{bmatrix},

whose DC gain is 1, so it leaves one copy of the image plus the detail. This is unsharp masking with a=1a = 1 and the 3×33 \times 3 binomial filter as the blur. The general form Af−B∗fA f - B * f, with a blur BB and a constant A>1A > 1, is sometimes called high-boost filtering; no source read for this article defines the name, so it is used here only as a label. Its DC gain is A−1A - 1, and dividing by A−1A - 1 gives unsharp masking with amount a=1/(A−1)a = 1/(A - 1) (our derivation). Torralba et al. apply their filter up to five times in succession and note that the result looks substantially sharper, but that too much sharpening enhances noise and introduces artefacts.

Pillow’s ImageFilter.SHARPEN is the kernel with 32 at the centre and −2-2 at the eight neighbours, divided by 16 (Pillow 12.0.0, ImageFilter.py): centre 2, neighbours −18-\tfrac18, DC gain 1. It is unsharp masking with the 3×33 \times 3 box (all nine weights 19\tfrac19) as the blur and a=98a = \tfrac98: the neighbours get −98⋅19=−18-\tfrac98 \cdot \tfrac19 = -\tfrac18 and the centre 1+98−18=21 + \tfrac98 - \tfrac18 = 2 (our derivation). Its noise gain is 4+8/64=4.125\sqrt{4 + 8/64} = \sqrt{4.125}. Its EDGE_ENHANCE kernel, 10 at the centre and −1-1 at the eight neighbours divided by 2, is the same construction with a=4.5a = 4.5 (our derivation).

The threshold

A plain unsharp mask boosts small differences, such as noise in a flat sky, as much as large ones. Pillow’s implementation adds a threshold: it computes the integer difference d=f−Gσ∗fd = f - G_\sigma * f between each pixel and the 8-bit blurred image and changes the pixel only where ∣d∣|d| exceeds the threshold, setting it to f+d⋅percent/100f + d \cdot \text{percent}/100 in integer arithmetic and clipping to 0–255; elsewhere it leaves the pixel as it was (Pillow 12.0.0, UnsharpMask.c). The default is UnsharpMask(radius=2, percent=150, threshold=3). The blur is not an exact Gaussian but three passes of an extended box filter whose length is chosen to match the requested standard deviation (BoxBlur.c). The threshold makes the filter nonlinear: edges, whose differences are large, are sharpened in full, while differences of a few grey levels, mostly noise, are left alone.

Clipping

Overshoot can leave the valid range. Pillow clips each result to 0–255. scikit-image works in floating point. Unless preserve_range=True, it first converts the image as img_as_float does (an 8-bit image to [0,1][0, 1]) and clips the result to [0,1][0, 1], or to [−1,1][-1, 1] if the converted image has negative values; with preserve_range=True it neither rescales nor clips (scikit-image 0.25.2, _unsharp_mask.py). Clipping removes the part of a halo that does not fit, so a bright edge near white loses its overshoot while the same edge in a mid-grey region keeps it.

How to Apply It

An exact analysis on a step and on white noise

The script below measures the three costs of sharpening on inputs whose answers are known in closed form. It uses NumPy 2.3.5, SciPy 1.16.3, scikit-image 0.25.2 and Pillow 12.0.0, and runs in about 1.5 seconds.

  • Part A sharpens a vertical step from 0.2 to 0.8 (contrast 0.6) on a 32×25632 \times 256 image with scikit-image’s unsharp_mask, at σ=0.5\sigma = 0.5 to 8 with a=1a = 1 and at a=0.25a = 0.25 to 4 with σ=2\sigma = 2. It prints, as fractions of the contrast, the peak overshoot and undershoot, the prediction a(1−g0)/2a(1 - g_0)/2 from SciPy’s taps, the halo width (bright-side pixels above 10% of the peak overshoot), the halo area divided by aσa\sigma, and the overshoot left by scikit-image’s default clipping. We chose the step’s levels at planning so that the clipping would engage from a=1a = 1 at σ=2\sigma = 2 and not at the two smaller amounts: the headroom above 0.8 is a third of the contrast. The example therefore illustrates the derivation above rather than testing it.
  • Part B filters 512×512512 \times 512 white Gaussian noise of unit variance with three seeds, through five unsharp masks, the Laplacian kernel, Pillow’s SHARPEN kernel and Torralba et al.’s kernel. For each it prints the response HH at zero frequency, at 18\tfrac18 cycle per pixel horizontally, at the horizontal Nyquist frequency and at the diagonal corner, the norm ∥h∥2\|h\|_2, the measured noise gain (output over input standard deviation, away from the borders) for the three seeds, the noise added per unit of boost at 18\tfrac18 cycle per pixel, (∥h∥2−1)/(H(18,0)−1)(\|h\|_2 - 1)/(H(\tfrac18, 0) - 1), and the lower bound 1+a−aG01 + a - aG_0.
  • Part C runs Pillow’s UnsharpMask(radius=2, percent=150) with thresholds 0, 3 and 6 on an 8-bit step from 78 to 178 and on flat 8-bit noise (grey 128 plus noise of standard deviation 2, rounded), and checks the output against the rule in UnsharpMask.c applied to Pillow’s own blur.

Before the script was first run we wrote down the checks and predictions in a plan and computed the values a correct implementation reaches with independent scripts. Pilot output and first-run output differ in one line: the pilot printed the step’s values as NumPy scalars, and that line was changed to print plain numbers before the first run, a change the editor approved; nothing else changed.

import os
os.environ["OMP_NUM_THREADS"] = "1"           # one BLAS thread, set before NumPy is imported
os.environ["OPENBLAS_NUM_THREADS"] = "1"
import numpy as np
from scipy import ndimage
from skimage.filters import unsharp_mask
from PIL import Image, ImageFilter


def gaussian_taps(sigma, truncate=4.0):
    """The normalized 1-D taps SciPy's gaussian_filter uses (radius int(4 sigma + 0.5))."""
    r = int(truncate * sigma + 0.5)
    x = np.arange(-r, r + 1)
    g = np.exp(-0.5 * (x / sigma) ** 2)
    return x, g / g.sum()


# Part A: a vertical step from 0.2 to 0.8, sharpened with f + amount * (f - G_sigma * f).
lo, hi = 0.2, 0.8
c = hi - lo
step = np.full((32, 256), lo)
step[:, 128:] = hi
print(f"step image: values {sorted(set(step.ravel().tolist()))}, bright columns {int((step[0] == hi).sum())}")
print(f"{'sigma':>5s} {'amount':>6s} {'overshoot':>9s} {'undershoot':>10s} {'a(1-g0)/2':>9s} "
      f"{'halo px':>7s} {'area/(a*sigma)':>14s} {'clipped':>8s}")
conds = [(s, 1.0) for s in (0.5, 1, 2, 4, 8)] + [(2.0, a) for a in (0.25, 0.5, 2, 4)]
err_hand = err_formula = 0.0
for sigma, a in conds:
    out = unsharp_mask(step, radius=sigma, amount=a, preserve_range=True)
    hand = step + a * (step - ndimage.gaussian_filter(step, sigma, mode="reflect"))
    err_hand = max(err_hand, np.abs(out - hand).max())
    row = out[16] - hi                                   # bright side: columns 128..255
    over = row.max() / c
    under = (lo - out[16].min()) / c
    halo = int((row[128:] / c > 0.1 * over).sum())       # pixels above 10% of the peak overshoot
    area = row[128:].sum() / c
    x, g = gaussian_taps(sigma)
    g0 = g[x == 0][0]
    pred_over = a * (1 - g0) / 2
    pred_area = a * (x[x >= 1] * g[x >= 1]).sum()
    err_formula = max(err_formula, abs(over - pred_over), abs(under - pred_over), abs(area - pred_area))
    clipped = (unsharp_mask(step, radius=sigma, amount=a)[16].max() - hi) / c   # default: clip to [0, 1]
    print(f"{sigma:5.2f} {a:6.2f} {over:9.6f} {under:10.6f} {pred_over:9.6f} {halo:7d} "
          f"{area / (a * sigma):14.5f} {clipped:8.6f}")
print(f"check: unsharp_mask vs f + a(f - G*f), max abs diff {err_hand:.1e}")
print(f"check: measured overshoot, undershoot, area vs taps formula, max abs diff {err_formula:.1e}")
print(f"1/sqrt(2 pi) = {1 / np.sqrt(2 * np.pi):.5f}; headroom/contrast = {(1 - hi) / c:.6f}")

# Part B: noise gain on white noise, against the kernel's L2 norm (and its frequency response).
def usm_kernel(sigma, a):
    x, g = gaussian_taps(sigma)
    h = -a * np.outer(g, g)
    h[len(x) // 2, len(x) // 2] += 1 + a
    return h

kernels = {
    "USM s=1 a=1": (1, 1), "USM s=2 a=1": (2, 1), "USM s=4 a=1": (4, 1),
    "USM s=1 a=2": (1, 2), "USM s=2 a=4": (2, 4),
    "Laplacian 5/-1": np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], float),
    "Pillow SHARPEN": np.array([[-2, -2, -2], [-2, 32, -2], [-2, -2, -2]], float) / 16,
    "Torralba (18.7)": 2 * np.pad([[1.0]], 1) - np.array([[1, 2, 1], [2, 4, 2], [1, 2, 1]]) / 16,
}
noise = {sd: np.random.default_rng(sd).standard_normal((512, 512)) for sd in (0, 1, 2)}
print("\nnoise: " + ", ".join(f"seed {sd} mean {z.mean():+.4f} std {z.std():.4f}" for sd, z in noise.items()))
print(f"{'kernel':16s} {'DC':>6s} {'H(1/8,0)':>8s} {'H(1/2,0)':>8s} {'H(.5,.5)':>8s} {'||h||':>7s} "
      f"{'measured (3 seeds)':>17s} {'(gain-1)/(H(1/8)-1)':>19s} {'1+a-aG0':>8s}")
dev = parseval = err_kernel = 0.0
for name, spec in kernels.items():
    h = usm_kernel(*spec) if isinstance(spec, tuple) else spec
    m = h.shape[0] // 2
    yy, xx = np.mgrid[-m:m + 1, -m:m + 1]
    H = lambda fx, fy: np.real((h * np.exp(-2j * np.pi * (fx * xx + fy * yy))).sum())
    norm = np.sqrt((h ** 2).sum())
    parseval = max(parseval, abs(np.sqrt(np.mean(np.abs(np.fft.fft2(h, (64, 64))) ** 2)) - norm))
    gains = []
    for z in noise.values():
        if isinstance(spec, tuple):
            out = unsharp_mask(z, radius=spec[0], amount=spec[1], preserve_range=True)
            err_kernel = max(err_kernel, np.abs(out - ndimage.correlate(z, h, mode="reflect")).max())
        else:
            out = ndimage.correlate(z, h, mode="reflect")
        gains.append(out[16:-16, 16:-16].std() / z[16:-16, 16:-16].std())
    dev = max(dev, max(abs(gn / norm - 1) for gn in gains))
    lower = ""
    if isinstance(spec, tuple):
        x, g = gaussian_taps(spec[0])
        lower = f"{1 + spec[1] - spec[1] * g[x == 0][0] ** 2:8.4f}"
    print(f"{name:16s} {H(0, 0):6.3f} {H(0.125, 0):8.4f} {H(0.5, 0):8.4f} {H(0.5, 0.5):8.4f} {norm:7.4f} "
          f"{min(gains):8.4f}..{max(gains):.4f} {(norm - 1) / (H(0.125, 0) - 1):19.3f} {lower}")
print(f"check: unsharp_mask vs explicit 2-D kernel, max abs diff {err_kernel:.1e}")
print(f"check: Parseval, RMS |H| vs ||h||, max abs diff {parseval:.1e}")
print(f"check: measured gain / ||h|| - 1, max abs {dev:.4f}")

# Part C: Pillow's 8-bit UnsharpMask(radius=2, percent=150, threshold) and its threshold.
def pillow_rule(u8, radius, percent, threshold):
    """UnsharpMask.c: diff = in - blur; if |diff| > threshold: clip(in + diff*percent/100) (C int)."""
    blur = np.asarray(Image.fromarray(u8).filter(ImageFilter.GaussianBlur(radius)), dtype=np.int64)
    i = u8.astype(np.int64)
    d = i - blur
    q = np.sign(d) * (np.abs(d) * percent // 100)       # C integer division truncates toward zero
    return np.where(np.abs(d) > threshold, np.clip(i + q, 0, 255), i).astype(np.uint8)

step8 = np.full((64, 256), 78, np.uint8)
step8[:, 128:] = 178
flat = {sd: np.clip(np.rint(128 + 2.0 * np.random.default_rng(sd).standard_normal((512, 512))), 0, 255)
        .astype(np.uint8) for sd in (0, 1, 2)}
print("\n8-bit step: values " + str(sorted(set(step8.ravel().tolist())))
      + "; flat noise std " + ", ".join(f"{f.astype(float).std():.4f}" for f in flat.values()))
x, g = gaussian_taps(2.0)
print(f"Gaussian prediction of the peak overshoot: 1.5 * (1 - g0)/2 * 100 = {150 * (1 - g[x == 0][0]) / 2:.2f} levels")
print(f"{'threshold':>9s} {'overshoot':>9s} {'undershoot':>10s} {'halo px':>7s} {'flat-noise gain (3 seeds)':>25s}")
mismatch = 0
for thr in (0, 3, 6):
    usm = ImageFilter.UnsharpMask(radius=2, percent=150, threshold=thr)
    s8 = np.asarray(Image.fromarray(step8).filter(usm))
    mismatch += int((s8 != pillow_rule(step8, 2, 150, thr)).sum())
    row = s8[32].astype(int)
    gains = []
    for f in flat.values():
        o = np.asarray(Image.fromarray(f).filter(usm))
        mismatch += int((o != pillow_rule(f, 2, 150, thr)).sum())
        gains.append(o[16:-16, 16:-16].astype(float).std() / f[16:-16, 16:-16].astype(float).std())
    print(f"{thr:9d} {row.max() - 178:9d} {78 - row.min():10d} {int((row[128:] > 178).sum()):7d} "
          f"{min(gains):16.4f}..{max(gains):.4f}")
print(f"check: Pillow UnsharpMask vs the rule on Pillow's GaussianBlur, differing pixels {mismatch}")

Output:

step image: values [0.2, 0.8], bright columns 128
sigma amount overshoot undershoot a(1-g0)/2 halo px area/(a*sigma)  clipped
 0.50   1.00  0.106715   0.106715  0.106715       1        0.21396 0.106715
 1.00   1.00  0.300528   0.300528  0.300528       2        0.36378 0.300528
 2.00   1.00  0.400263   0.400263  0.400263       3        0.39049 0.333333
 4.00   1.00  0.450130   0.450130  0.450130       7        0.39679 0.333333
 8.00   1.00  0.475065   0.475065  0.475065      13        0.39834 0.333333
 2.00   0.25  0.100066   0.100066  0.100066       3        0.39049 0.100066
 2.00   0.50  0.200131   0.200131  0.200131       3        0.39049 0.200131
 2.00   2.00  0.800525   0.800525  0.800525       3        0.39049 0.333333
 2.00   4.00  1.601051   1.601051  1.601051       3        0.39049 0.333333
check: unsharp_mask vs f + a(f - G*f), max abs diff 0.0e+00
check: measured overshoot, undershoot, area vs taps formula, max abs diff 9.3e-14
1/sqrt(2 pi) = 0.39894; headroom/contrast = 0.333333

noise: seed 0 mean +0.0005 std 1.0011, seed 1 mean -0.0030 std 0.9986, seed 2 mean -0.0009 std 0.9998
kernel               DC H(1/8,0) H(1/2,0) H(.5,.5)   ||h|| measured (3 seeds) (gain-1)/(H(1/8)-1)  1+a-aG0
USM s=1 a=1       1.000   1.2654   1.9856   1.9998  1.8555   1.8558..1.8563               3.224   1.8408
USM s=2 a=1       1.000   1.7088   2.0000   2.0000  1.9649   1.9649..1.9658               1.361   1.9602
USM s=4 a=1       1.000   1.9928   2.0000   2.0000  1.9913   1.9912..1.9918               0.998   1.9901
USM s=1 a=2       1.000   1.5308   2.9712   2.9996  2.7219   2.7224..2.7234               3.244   2.6817
USM s=2 a=4       1.000   3.8352   4.9999   5.0000  4.8710   4.8711..4.8742               1.365   4.8408
Laplacian 5/-1    1.000   1.5858   5.0000   9.0000  5.3852   5.3901..5.3932               7.486 
Pillow SHARPEN    1.000   1.2197   2.5000   2.0000  2.0310   2.0313..2.0319               4.693 
Torralba (18.7)   1.000   1.1464   2.0000   2.0000  1.7722   1.7726..1.7730               5.273 
check: unsharp_mask vs explicit 2-D kernel, max abs diff 4.3e-14
check: Parseval, RMS |H| vs ||h||, max abs diff 8.9e-16
check: measured gain / ||h|| - 1, max abs 0.0015

8-bit step: values [78, 178]; flat noise std 2.0235, 2.0172, 2.0211
Gaussian prediction of the peak overshoot: 1.5 * (1 - g0)/2 * 100 = 60.04 levels
threshold overshoot undershoot halo px flat-noise gain (3 seeds)
        0        60         60       5           2.3400..2.3408
        3        60         60       4           1.6530..1.6555
        6        60         60       3           1.0255..1.0293
check: Pillow UnsharpMask vs the rule on Pillow's GaussianBlur, differing pixels 0

What the step shows

The overshoot and undershoot columns are equal in every row and agree with the prediction a(1−g0)/2a(1 - g_0)/2; the second check line puts the largest difference between measurement and taps formula, over overshoot, undershoot and area, at about 10−1310^{-13}, and the first shows that unsharp_mask computes exactly f+a(f−Gσ∗f)f + a(f - G_\sigma * f). At σ=2\sigma = 2 the overshoot is proportional to the amount: 0.100066 at a=0.25a = 0.25 is a quarter of 0.400263 at a=1a = 1, and 1.601051 at a=4a = 4 is four times it. At a=4a = 4 the bright side of the edge would reach 0.8+1.6×0.6=1.760.8 + 1.6 \times 0.6 = 1.76, far outside the image’s range.

The radius changes the overshoot much less than the amount does. From σ=0.5\sigma = 0.5 to σ=8\sigma = 8 the peak rises from 0.107 to 0.475 of the contrast and approaches the continuous limit of one half from below; 1−2×0.475065=0.049871 - 2 \times 0.475065 = 0.04987 is the centre tap at σ=8\sigma = 8, which matches 1/(82π)≈0.04991/(8\sqrt{2\pi}) \approx 0.0499. What the radius controls is the halo’s extent: 1, 2, 3, 7 and 13 pixels for σ=0.5,1,2,4,8\sigma = 0.5, 1, 2, 4, 8, between 1.5 and 2 times σ\sigma from σ=1\sigma = 1 on, and 3 pixels at every amount at σ=2\sigma = 2. The halo’s area, divided by aσa\sigma, rises towards 1/2π=0.398941/\sqrt{2\pi} = 0.39894 and is within 0.2% of it at σ=8\sigma = 8 (0.39834). A wide halo with a modest peak reads as a change of local contrast; a narrow one with a high peak reads as an outline.

The last column shows scikit-image’s default call on the same image. Wherever the unclipped overshoot exceeds the headroom of 0.333333 of the contrast, the default result is cut to exactly that value: at a=1a = 1 from σ=2\sigma = 2 on, and at a=2a = 2 and 4. Below the headroom the two calls agree. The default therefore makes the overshoot depend on where the edge sits in the intensity range, which the formula alone does not show.

What the noise shows

The measured noise gains agree with ∥h∥2\|h\|_2: the largest relative deviation over all kernels and seeds is 0.0015, and the root-mean-square response over a 64×6464 \times 64 frequency grid equals the norm to rounding error, as Parseval’s theorem requires. For the unsharp masks the gain lies between the lower bound 1+a−aG01 + a - aG_0 and 1+a1 + a, and at a=1a = 1 it rises with the radius only slightly, from 1.8555 at σ=1\sigma = 1 to 1.9913 at σ=4\sigma = 4. Doubling the amount to 2 at σ=1\sigma = 1 raises it to 2.72, and a=4a = 4 at σ=2\sigma = 2 to 4.87: the amount sets the noise.

The radius sets what the noise buys. At σ=1\sigma = 1 the response at 18\tfrac18 cycle per pixel, a detail with an 8-pixel period, is only 1.27, so each unit of added noise gain buys little boost there (ratio 3.22); at σ=2\sigma = 2 the response is 1.71 (ratio 1.36), and at σ=4\sigma = 4 nearly the full 2 (ratio 1.00). The three fixed 3×33 \times 3 kernels are at the other extreme. The Laplacian kernel multiplies white noise by 5.39 while boosting the 8-pixel detail only to 1.59, a ratio of 7.49, because its boost grows all the way to the Nyquist frequency, where natural images have little detail and white noise as much power as anywhere. Pillow’s SHARPEN and Torralba et al.’s kernel have noise gains near 2, close to an unsharp mask with a=1a = 1, but give much less boost at 18\tfrac18 cycle per pixel. Both small kernels are also anisotropic: the Laplacian kernel’s response is 5 at the horizontal Nyquist frequency and 9 on the diagonal, and Pillow’s SHARPEN gives 2.5 and 2.

What the threshold shows

Pillow’s output matches the integer rule of UnsharpMask.c at every pixel of all twelve images (four per threshold), once the rule is applied to Pillow’s own box-filter blur and its division truncates towards zero, as C integer division does. At the 100-level step the overshoot and undershoot are 60 levels at every threshold, matching the Gaussian prediction of 60.04 levels despite the box approximation; the threshold removes only the halo’s faint outer tail, which narrows from 5 to 3 pixels. On flat noise of about two grey levels the effect is large: the noise gain is 2.34 without a threshold, 1.65 with the default threshold of 3 and 1.03 with a threshold of 6, where almost every noisy pixel is left untouched. A threshold of about three times the noise’s standard deviation (6 levels here) keeps the edge’s full overshoot and nearly removes the noise amplification, at the price of not sharpening any texture whose contrast is below it.

Outcomes against the plan

The plan fixed seven checks and twelve hypotheses before the first run, with each criterion set from the value the independent scripts reached on the same set-up, so a “held” confirms that the libraries behave as derived rather than a prediction made without those values. All held:

  • Checks. The data check held (step values, 128 bright columns, noise means within 0.0030 of 0 and standard deviations within 0.0014 of 1, 8-bit noise standard deviations 2.017 to 2.024). The comparison of unsharp_mask with the hand formula held (0.0); the comparison of measured overshoot, undershoot and area with the taps formula held (about 10−1310^{-13}); the comparison of unsharp_mask with the explicit 2D kernel on noise held (about 10−1410^{-14}); the Parseval check held (about 10−1510^{-15}, against a tolerance of 10−1210^{-12} for these four); the measured noise gains held within the tolerance of 0.5% (largest deviation 0.15%); and Pillow’s agreement with its rule held (0 differing pixels).
  • Step. The overshoot equals a(1−g0)/2a(1 - g_0)/2 and is linear in the amount: held. At a=1a = 1 it rises with σ\sigma and stays below one half: held. The halo width is the same at every amount, does not decrease with σ\sigma and lies between 1.5 and 2 times σ\sigma for σ≥1\sigma \ge 1: held. The halo area over aσa\sigma rises with σ\sigma and is within 1% of 1/2π1/\sqrt{2\pi} for σ=4\sigma = 4 and 8: held (0.54% and 0.15% below). The default call’s overshoot equals the smaller of the unclipped overshoot and the headroom in every row: held.
  • Noise. The measured gains match ∥h∥2\|h\|_2 for every kernel and seed: held. Each unsharp mask’s gain lies in [1+a−aG0,1+a)[1 + a - aG_0, 1 + a) and rises with σ\sigma at a=1a = 1: held. The Laplacian kernel adds more noise per unit of boost at 18\tfrac18 cycle per pixel than the unsharp mask with σ=2\sigma = 2, a=1a = 1: held (7.486 against 1.361).
  • Pillow. The output equals the rule: held. The noise gain falls as the threshold rises, with the three seeds’ ranges separate, and stays at least 1: held. The step overshoot is the same at every threshold and the halo does not widen: held. The overshoot is within 2 levels of the Gaussian prediction: held (60 against 60.04).

Practical settings

The example suggests an order for choosing the parameters, in our reading:

  1. Choose the radius from the scale of the detail. A radius of about one pixel sharpens fine edges; several pixels raise local contrast instead. The halo, counted to 10% of its peak, is about 1.5 to 2 radii wide on each side of an edge.
  2. Choose the amount from the noise. The noise grows by nearly 1+a1 + a for any radius of a pixel or more, so a noisy image tolerates only a small amount, or a threshold.
  3. Set a threshold of a few times the noise’s standard deviation when sharpening a noisy photograph, and check that the texture you want to keep is above it.
  4. Sharpen the luminance only. scikit-image’s documentation warns that sharpening colour channels independently can make colours bleed and suggests processing only the brightness channel of a colour space such as HSV, YUV or YCbCr (scikit-image 0.25.2, _unsharp_mask.py). Its unsharp_mask sharpens each channel separately only when channel_axis is given; without it, the Gaussian also blurs across the channel axis of a colour array. In 0.25.2 the per-channel slice is built as (slice(None),) * channel_axis + (c, ...), which for a negative channel_axis such as −1-1 indexes the first axis instead of the channels, so pass the non-negative index, 2 for an M×N×3M \times N \times 3 array (our reading of _unsharp_mask.py and _shared/utils.py).
  5. Sharpen last, at the output size. Resizing after sharpening rescales the halos and can alias the boosted detail.

Variants

  • Unsharp masking with a Gaussian blur, the form analysed above, in scikit-image as filters.unsharp_mask and in Pillow as ImageFilter.UnsharpMask.
  • “Twice minus blur” and high-boost forms, Af−B∗fA f - B * f, of which Torralba et al.’s eq. 18.7 is the A=2A = 2 case; dividing by A−1A - 1 turns any of them into an unsharp mask.
  • Laplacian sharpening, f−c∇2ff - c\nabla^2 f, and its 3×33 \times 3 kernels; see the Laplacian operator.
  • Fixed small kernels, such as Pillow’s SHARPEN and EDGE_ENHANCE, which are unsharp masks with a 3×33 \times 3 box blur.
  • Thresholded unsharp masking, which sharpens only where the detail exceeds a threshold, as in Pillow.
  • Frequency-domain sharpening. Any high-frequency emphasis can be applied by multiplying the Fourier transform; adding a high-pass result back to the image sharpens it (Fourier transform). Frequency domain filtering writes this as high-frequency emphasis, of which the response 1+a(1−G^σ)1 + a(1 - \hat G_\sigma) above is a case with a Gaussian low-pass filter.
  • Edge-preserving detail enhancement. Replacing the Gaussian by an edge-preserving smoother, such as the bilateral filter, splits the image into a base layer and a detail layer that can be boosted; the bilateral filter paper article notes that amplifying the residual detail layer can produce gradient-reversal artefacts next to edges.
  • Deconvolution, when the blur is known: image deblurring and the Wiener filter adapt the boost at each frequency to the blur and the noise.

Best Practices

These follow from the analysis above and the sources cited, in our reading.

  • Inspect at 100% magnification. Halos one or two pixels wide disappear when an image is scaled down for viewing.
  • Work in floating point and clip once at the end. Intermediate clipping or 8-bit rounding changes the overshoot, as the scikit-image default does in Part A.
  • State the parameters and their conventions. “Radius” means the Gaussian’s standard deviation in both scikit-image and Pillow, but Pillow’s percent is 100a100a, applies a threshold by default and uses a box approximation of the blur.
  • Denoise before sharpening, not after: sharpening first amplifies the noise that the denoiser then has to remove, and correlates it, so that it is no longer white (image noise), which a denoiser built for white noise does not expect.
  • Do not repeat sharpening steps. Applying a filter NN times multiplies its response by itself NN times; Torralba et al. show the artefacts this builds up, and image interpolation reads its repeated-shift errors the same way for a resampling kernel whose response slightly exceeds 1.

Pitfalls

  • Halos. Too large an amount gives visible light and dark outlines along strong edges; too large a radius makes them wide.
  • Noise amplification. The amount multiplies the noise by nearly 1+a1 + a, and the 3×33 \times 3 Laplacian kernel by 29≈5.39\sqrt{29} \approx 5.39.
  • Silent clipping. Library defaults clip the result to the valid range, which cuts halos differently depending on the brightness of the edge.
  • Coloured fringes from sharpening the colour channels independently.
  • Biased measurements. The profile, contrast and apparent width of every structure change, so measurements made after sharpening differ from those made before.
  • Sharpening as a substitute for focus. It cannot recover detail that was not recorded; Torralba et al. state that the sharpening filter enhances existing details without introducing new ones.

Evidence and Impact

Unsharp masking is the form in which scikit-image and Pillow expose sharpening, and camera pipelines include a sharpening stage (digital camera). Its behaviour follows from linear filter theory: the step response, halo and noise gain derived above are exact for the sampled Gaussian, and the example confirms them to rounding error. Sharpening is also related to models of vision. Torralba, Isola and Freeman (2024, §18.10–18.11) model the early visual system with a kernel that combines a negative Laplacian of a Gaussian with a small multiple of the Gaussian, whose spectrum resembles the human contrast sensitivity function, and remark that their sharpening filter is very similar to that model.

What sharpening does to perceived quality is not measured here; Torralba et al. report that their repeatedly sharpened image looks substantially sharper but shows artefacts on close inspection. For the signal the trade-off can be stated in numbers, as the example does: every unit of boost of detail comes with an increase of the noise, smallest when the boost is placed at the scale of the detail and largest when it extends to the Nyquist frequency.

Related

  • Convolution

    The operation that replaces every pixel by a weighted sum of its neighbors, with the weights given by a kernel; the basis of linear filtering and of convolutional networks.

  • Gaussian Filter

    A linear smoothing filter that replaces each pixel by an average of its neighbors weighted by a Gaussian, whose standard deviation sets the scale of the detail removed.

  • Image Noise

    The random variation in recorded pixel values that does not come from the scene, its physical sources in the camera, and how it is modeled and measured.

  • Image Deblurring

    Recovering a sharp image from a blurred one, covering the convolution model and point-spread function, inverse, Wiener and Tikhonov filtering, Landweber and Richardson–Lucy iterations, regularization and stopping, boundary handling and ringing, blind deconvolution, motion and defocus blur, and learned deblurring in outline.

  • Fourier Transform

    The decomposition of an image into two-dimensional sinusoidal gratings of different frequencies, orientations, amplitudes, and phases, and the frequency-domain view of filtering, sampling, and registration that it provides.

  • Digital Camera

    The camera as an imaging system, from lens, shutter and sensor to the image signal processor that demosaics, white-balances, colour-corrects, tone-maps and compresses its output; RAW against processed images, metadata, machine-vision interfaces, camera classes, and where processing breaks the assumptions of vision methods.

References

  1. Torralba, A., Isola, P. & Freeman, W. T. (2024). Foundations of Computer Vision. MIT Press. Chapter 18, Image Derivatives, §18.10–18.11 (read in the online edition).
  2. Morel, J.-M., Petro, A. B. & Sbert, C. (2014). Screened Poisson Equation for Image Contrast Enhancement. Image Processing On Line, 4, 16–29.
  3. scikit-image developers (2025). skimage.filters.unsharp_mask, skimage/filters/_unsharp_mask.py, skimage/_shared/filters.py and skimage/_shared/utils.py, scikit-image v0.25.2 source code.
  4. SciPy developers (2025). scipy.ndimage.gaussian_filter, scipy/ndimage/_filters.py, SciPy v1.16.3 source code.
  5. Pillow developers (2025). ImageFilter.UnsharpMask and ImageFilter.SHARPEN, src/PIL/ImageFilter.py, src/libImaging/UnsharpMask.c and src/libImaging/BoxBlur.c, Pillow 12.0.0 source code.