Image Processing & Computational Photography
Image Enhancement
Making an image easier to see or more pleasing without a model of how it was degraded, by point, histogram, spatial, frequency, colour-balance and local-contrast operators, and how such results are judged when there is no ground truth.
beginner
Image enhancement changes an image so that it is easier to see, or better suited to some use, without trying to undo a modelled degradation: lifting a dark photograph, spreading a washed-out histogram, removing a colour cast, sharpening edges, bringing out detail in both shadows and highlights. Image processing frames it against restoration. In the distinction Gonzalez and Woods (2018, §1.4) draw, as that article summarizes it, enhancement makes an image more suitable for a particular application and is judged subjectively, while restoration rests on a model of how the image was degraded and is objective. The consequence for practice is that an enhanced image has no single correct answer to be measured against.
The operators themselves are mostly old and have their own articles. This article maps them as a family, covers the parts no other article owns (automatic colour balance as Simplest Color Balance and Automatic Color Enhancement define it, and two recent local-contrast methods, Simulated Exposure Fusion and adaptive logarithmic mappings), and asks how enhancement is judged when there is no reference. The worked example scores eight operators on a degradation we built, with both full-reference measures (which need the clean image) and no-reference scores (which do not), and shows how differently the scores rank them.
Problem Definition
Given an image , produce that a viewer, or a later algorithm, finds better for a purpose. Nothing in the problem says what “should” have been. Simplest Color Balance’s authors put this plainly for their own method: it does not necessarily improve the image, but it always increases its readability, and it is not a physical white balance, since it cannot restore the colours of the scene without a captured sample of known colours or a model of the lighting (Limare et al., 2011, §1).
That separates enhancement from its neighbours:
- Restoration assumes a degradation model, in the form image processing gives, and estimates . Image denoising, image deblurring and super-resolution are restoration tasks.
- Illuminant estimation recovers the colour of the light so that it can be divided out; that is color constancy, which has a ground truth (the measured light) and a metric (the angular error). Colour balance in the sense below is enhancement: it needs no light estimate and has no ground truth.
- Model-based enhancement sits between them. Low-light image enhancement and image dehazing start from a physical model (Retinex illumination, Koschmieder’s haze model) but still produce an image that is judged by eye or against a reference someone chose.
Input and Output
The input is one image, greyscale or colour, for example an 8-bit photograph whose values are gamma-encoded for display. The output is an image of the same size. Two input conventions matter for every operator below:
- Where colour is processed. An operator can act on each RGB channel separately, which changes the ratios between channels and so the colours, or on an intensity such as , after which each channel is multiplied by to keep the ratios (Limare et al., 2011, §4; Lisani, 2020, eq. 7). Simulated Exposure Fusion works on the V channel of HSV instead (Hessel, 2019, §2.2.1), and scikit-image 0.25.2’s CLAHE does the same, through a decorator that converts RGB inputs to HSV and processes the value channel.
- Encoding. Gains that model light act on linear values; the same gains applied to sRGB codes are a different operation. Gamma correction explains the encodings.
Variants
Enhancement operators can be sorted along three axes, each of which has articles that own its members.
- By how much of the image an output pixel depends on.
- Point operators map each value through one curve: negatives, logarithms, power laws, stretches and look-up tables (point operations, gamma correction).
- Histogram operators derive that curve from the image’s own histogram, as histogram equalization does.
- Spatial operators use a neighbourhood: unsharp masking and Laplacian sharpening, edge-preserving smoothing with the bilateral filter.
- Frequency-domain operators weight spatial frequencies: homomorphic filtering, and retinex treated as a filter (frequency domain filtering).
- Global or local. A global operator applies one curve everywhere; a local one lets the curve depend on the neighbourhood. Lisani (2020, §1) gives the shortcoming of global methods: an increase in contrast in the dark regions leads to a decrease in the bright regions and vice versa. Local operators avoid that trade but can produce halos along strong edges, the failure tone mapping explains for its local operators. CLAHE, ACE, retinex, Simulated Exposure Fusion and adaptive log mappings are local.
- Intensity or colour. Contrast operators change the distribution of intensities; colour operators change the balance between channels. Colour balance, the second kind, is below.
Challenges
- No target. Without a reference, “better” depends on the viewer and the use. Simplest Color Balance’s examples show the same method helping one image and making another’s colours “quickly unearthly” (Limare et al., 2011, Fig. 12); the authors find that the right saturation percentage varies from image to image (§7).
- Noise amplification. Any operator that raises contrast raises the contrast of noise. Image Processing’s example shows a stretch multiplying signal and noise by the same factor, and Image Sharpening derives the noise gain of each sharpening kernel.
- Halos and over-enhancement. Local methods trade global consistency for local contrast. Hessel (2019, §3) reports halos for CLAHE and the backlit-image method of Buades et al. in his comparison, and over-enhancement of already well-exposed regions by local Laplacian filtering; Lisani (2020, §3) reports that halos from his method grow with the scale of the neighbourhood.
- Clipping and saturation. Stretches saturate a share of pixels to black or white; and an operator that keeps the R/G/B ratios by multiplying each channel by can push colours outside the RGB cube, so it needs a projection back into the cube (Limare et al., 2011, §4.2).
Approaches
Point and histogram operators
The global curve is the simplest enhancement. A linear stretch maps a chosen input range onto the output range; a power law brightens or darkens mid-tones; histogram equalization maps each level through the image’s cumulative histogram, so that the levels are used about equally often. Point operations lists the curves and what each one loses through rounding, gamma correction the power laws used for encoding, and histogram equalization the equalizing curve, adaptive equalization and CLAHE. One property matters below: a point operation on 8-bit codes can merge levels but cannot split them, so it never adds information to the image.
Spatial and frequency operators
Sharpening adds a multiple of a high-pass image to the original; image sharpening gives the unsharp mask, its overshoot and its noise gain. Homomorphic filtering and retinex compress slow illumination variation while keeping local detail; frequency domain filtering treats both as filters, and low-light image enhancement treats the Retinex decomposition as a model of the scene. Edge-preserving smoothing separates a base layer from a detail layer, which can then be boosted, as bilateral filter and tone mapping describe.
Automatic colour balance
Colour balance in enhancement means stretching or equalizing the channels so that the image looks neutral and fills the available range, without estimating the light. Two methods published with analysed implementations in Image Processing On Line (IPOL) show the range.
Simplest Color Balance. Limare et al. (2011) state its assumption: the highest values of R, G and B in the image must correspond to white, and the lowest to darkness. Each channel is mapped by an affine function onto the full range. Because a few aberrant pixels at 0 or 255 would block that stretch, a share percent of the darkest and percent of the brightest values are first saturated. With the channel’s sorted values, the bounds are
the values at those positions in the sorted array of pixels, counted from 0 (§3.1 gives the positions as and ; we round them down, as the example’s code does), and each value is clipped to and mapped by
Applied to each channel, it is “RGB color balance”: the channels get different affine maps, so a coloured cast is reduced, and a yellowish ambient light, for example, loses its hue because the blue channel is stretched more (§1, §4.1). Applied to the intensity with each channel multiplied by , it is “IRGB intensity balance”, which keeps the R/G/B ratios but needs a projection back into the RGB cube, and that projection makes the map lose commutativity and monotonicity (§4.2). The authors’ examples use to 3% split equally between the two ends.
The paper also gives a histogram method (§3.2) and pseudo-code (Algorithm 1), and the two do not agree. As §3.2 words it, is the lowest label whose cumulative count exceeds , and is the label after the highest label whose cumulative count is at most ; these match the sorting method, except that, by our derivation, the two differ when some cumulative count equals exactly. Algorithm 1, read from the rendered page, stops its first loop one label before that , and adds one to its after the loop has already reached the worded , so its bounds are one level wider at each end. The example’s check C1 confirms this on 12 channels. The example uses the sorting method of §3.1.
Automatic Color Enhancement (ACE). ACE was introduced by Gatta, Rizzi and Marini (2002) and developed by Rizzi, Gatta and Marini (2003) and by Bertalmío, Caselles, Provenzi and Rizzi (2007), as Getreuer (2012) reports. Getreuer’s IPOL article describes it as modelling gray world, white patch, lateral inhibition and local–global adaptation, and gives the method on each channel scaled to in two stages (eqs. 3–4):
with the slope function , . The first stage compares each pixel with every other, weighting by inverse distance; amplifies small differences and saturates large ones, which expands or compresses the dynamic range according to local content. The second stage stretches the result to , which Getreuer describes as a global white balance. Bertalmío et al. showed, as Getreuer reports, that ACE minimizes a variational energy, and that uniform histogram equalization is a minimizer when and the weight is constant; Getreuer’s summary is that ACE can be seen as a smoothed and localized histogram equalization. Direct computation costs for an image; Getreuer’s article implements two approximations, one, due to Bertalmío et al., replacing by an odd polynomial so that becomes a sum of convolutions, the other interpolating between a few intensity levels, and his examples use , weights and levels unless stated otherwise (§6). In his examples, histogram equalization is described as effective but sometimes too harsh, producing artificial colours in dark foregrounds, and ACE as more moderate (§6.4); he also applies ACE to faded film scans (§6.5).
ACE models the two assumptions behind the estimators that color constancy calls gray world and white patch, and Simplest Color Balance’s assumption that the highest values are white is, in our reading, the white-patch one; but neither method estimates an illuminant or is scored against one. Their results are judged by eye.
Local contrast from simulated exposures and adaptive curves
Two later IPOL methods target the global-curve trade-off from different directions.
Simulated Exposure Fusion (SEF). Hessel and Morel (2020), as Hessel (2019) reports, adapted exposure fusion, which blends a bracketed exposure sequence in a Laplacian pyramid (HDR imaging and image blending describe it), to a single image; Hessel (2019) gives the analysed implementation. From the luminance (the V channel), SEF generates a sequence of images with remapping functions (eq. 3)
so that positive simulates longer exposures, which stretch the dark range, and negative stretches the bright range. Because every factor is at least 1, Hessel (2019, §2.1.1) notes, the fused image is guaranteed not to lose contrast. Instead of clipping, each image’s range is “restrained” to a window of width centred at (eq. 4) by a smooth clipping function (eq. 5), which fixes the out-of-range artefact of the original fusion. The numbers and follow from the image’s median, so a dark image gets more over-exposed simulations. The images are fused with weights that multiply a well-exposedness term, a Gaussian of with (eq. 8), and a contrast term taken from the derivative of the remapping function (eq. 9), followed by a robust normalization that clips 1% at each end. Hessel compares SEF visually with ACE, multiscale retinex, histogram equalization, CLAHE and other methods on five images and finds it free of halos and as good as the Retinex-type methods, with more natural-looking results (§3). He recommends between 0.4 and 0.6 and avoiding small when is small (§4).
Adaptive logarithmic mappings (LLCC). Lisani’s method (2018; IPOL implementation 2020) follows, as Lisani (2020, §2) describes it, Moroney’s (2000) local colour correction, which applies a concave power law in dark neighbourhoods and a convex one in bright neighbourhoods. It replaces the power laws by a family of logarithmic curves (eq. 2),
and chooses per pixel from a weight map of local brightness normalized to , through for and above, with (eq. 6). Dark neighbourhoods get logarithmic curves and bright ones inverted logarithms, so contrast rises in both. The weight map can be a Gaussian blur, a mean-curvature-motion filter (the 2018 paper’s choice) or a bilateral filter; Lisani concludes that the bilateral version is the better choice for computation time and for the quality of the result, and the best option against halos, and suggests and as a safe default (§2.1, §3). In his comparison with LCC, SEF and the backlit-image method, all improve visibility in dark and bright areas, and LLCC improves bright areas most, at the cost of a halo in one test image (§4).
Model-based and learned enhancement
When the cause of poor visibility is known, enhancement borrows a model. Low-light image enhancement estimates an illumination map and divides it out, or learns curves without references; image dehazing inverts the scattering model; tone mapping compresses a radiance map whose values are proportional to scene radiance. Learned enhancement networks, trained on paired references or with no-reference losses, are covered in those articles, which own the learned families and their benchmarks.
Evaluation Metrics
Enhancement is judged in four ways, and the first three need no reference.
- Visual comparison. Every IPOL method above is evaluated by showing results side by side with competitors and naming visible faults: halos, lost contrast, over-enhanced regions, unnatural colours (Hessel, 2019, §3; Lisani, 2020, §4). Such comparisons depend on settings. Lisani turned off SEF’s 1% clipping in his comparison, for a fairer comparison with the other methods (§4), and Getreuer reports the RMSE of his fast ACE approximations against an exact computation, which measures the implementation, not the enhancement (§6.3).
- No-reference scores. Scalar functions of the output alone: entropy, contrast measures, sharpness or blur measures, and learned or statistical quality models such as NIQE. Image quality assessment surveys them and how they are validated against human ratings, and reports that blind models such as NIMA were proposed to guide photo enhancement. Each score measures one property of the image, and an operator can raise it without making the image better. The example shows three cases.
- User studies. Ratings or pairwise preferences from viewers; image quality assessment covers their protocols, and low-light image enhancement gives examples from that field.
- Full-reference measures on synthetic degradations. If the degraded image was made from a clean one, PSNR and SSIM against the clean image are available. This turns enhancement into restoration for the purposes of evaluation, and rewards undoing the particular degradation that was simulated.
Downstream evaluation, which feeds enhanced images to a fixed detector and measures its accuracy, is a fifth option where the purpose is machine vision; low-light enhancement papers use it, as that article describes.
Example: Judging Enhancement Without Ground Truth
The script degrades two photographs by a known contrast loss and noise, applies eight operators, and scores each result in two ways: by full-reference measures against the clean photograph, which a real enhancement problem would not have, and by three no-reference scores computed from the output alone. It then asks how each score ranks the operators. It is an illustration: the operators, their parameters, the degradation and the scores were fixed before the pilot and the first run, and the expected outcomes listed below were written, except where stated, with the output of a planning script on this same set-up in view, so they record what that script showed rather than predictions tested by the run.
Data. scikit-image’s bundled astronaut (512 × 512) and coffee (400 × 600) photographs are the clean references , scaled to . Each channel is degraded as with Gaussian noise of standard deviation 0.02 (seeds 0, 1 and 2), clipped to and stored as 8 bits. There are two losses:
- grey: and in every channel, a contrast loss to 40% around a lifted black;
- cast: and , a contrast loss that differs by channel and adds a warm cast.
The script prints the fraction of values clipped before storage; the noise-free values lie at least 0.20, ten noise standard deviations, from either bound.
Operators. Each output is clipped to and stored as 8 bits before it is scored.
- input: the degraded image unchanged.
- oracle: , the exact inverse of the loss. It is a restoration, possible only because we know the model, and stands for the best the full-reference measures can expect.
- stretch: one affine map for all channels, sending the 1st and 99th percentiles of all values to 0 and 1.
- scb: Simplest Color Balance per channel, , bounds by the sorting method.
- he: scikit-image’s
equalize_histon the whole 8-bit array, one cumulative histogram over all three channels (scikit-image warns on stderr that it is flattening a colour image, which is the intended behaviour here). - clahe: scikit-image’s
equalize_adapthistwith its defaults (clip limit 0.01, tiles of 1/8 of each dimension), applied to HSV value. - gamma:
adjust_gammawith , which darkens a washed-out image. - unsharp:
unsharp_maskwith radius 2 and amount 1, called on each 2-D channel separately.
Full-reference measures. PSNR in dB over all channels with peak value 1, and scikit-image’s SSIM with its defaults (7 × 7 uniform window, , ) over the three channels.
No-reference scores. Each is defined from its source or labelled as ours.
- H, entropy. scikit-image’s
shannon_entropy, which its source defines as over the frequencies of the distinct values in the array. On an 8-bit RGB array this pools the codes of all three channels. Higher is read as more information. - C, RMS contrast (our definition). The standard deviation of the intensity in , the intensity Limare et al. (2011, §4.2) and Lisani (2020, eq. 7) use. Higher is read as more contrast.
- B, blur effect. scikit-image’s
blur_effecton , with its default re-blurring size of 11. Its implementation, which cites Crete et al. (2007, not read), blurs the image along each axis with an 11-sample box filter, compares the absolute Sobel responses of the image and its re-blurred copy, and returns, for the worst axis, the share of the image’s gradient that re-blurring does not remove: near 0 for a sharp image, which loses much of its gradient when blurred, and up to 1 for a blurred one. Lower is read as sharper.
Agreement. For each image, loss and seed, Kendall’s τ (SciPy’s kendalltau, the τ-b variant) between each no-reference score and PSNR, and between each and SSIM, over the eight operators, with B negated so that higher is better for every score.
Checks.
- C1: Simplest Color Balance’s bounds by the sorting method (§3.1), by the histogram rule as §3.2 words it, and by Algorithm 1 as printed, on the 12 seed-0 channels: the first two should be equal, and Algorithm 1 one level wider at each end.
- C2: the blur effect is unchanged by an affine change with of unquantized, unclipped intensities, since the Sobel responses of the image and of its re-blurred copy both scale by and the ratio cancels it.
- C3: the oracle recovers the noise-free image exactly.
- C4: each channel’s
unsharp_maskequals , clipped, with SciPy’s Gaussian. - C5: our PSNR equals scikit-image’s.
Expected outcomes. Only H3 is derived. Let be the pooled 8-bit codes of the input and any function of a code. The distribution of is obtained by adding together the probabilities of codes that sends to the same output, and merging probabilities never increases entropy, so , with equality when is one-to-one on the codes present. The stretch, histogram equalization and gamma apply one table to every channel code, and so does the oracle under the grey loss; Simplest Color Balance, the oracle under the cast loss, CLAHE and unsharp masking do not. The other outcomes were written from the planning script’s output.
- H1: PSNR ranks the oracle first in every run, at least 1.0 dB ahead of the second.
- H2: SSIM ranks the oracle first on astronaut and gamma first on coffee, under both losses.
- H3 (derived): every single-table operator has .
- H4: unsharp masking and CLAHE raise H above the input’s, and CLAHE has the highest H in every run.
- H5: τ between C and PSNR is at least 0.5, and the highest C belongs to the oracle or to Simplest Color Balance.
- H6: unsharp masking has the lowest B, at least 0.03 below the next; the stretch changes B by less than 0.002; τ between −B and PSNR is at most 0.1.
- H7: between H and PSNR, and between −B and PSNR, is at most 0.5.
- H8: under the cast loss Simplest Color Balance beats the stretch in PSNR by at least 3 dB; under the grey loss the stretch is at least as good.
The script uses NumPy 2.3.5, SciPy 1.16.3 and scikit-image 0.25.2 and runs in about 6 seconds. Two runs printed identical output. It is the script as first run; nothing was changed after the first run.
import os
os.environ["OMP_NUM_THREADS"] = "1" # one BLAS thread, set before NumPy loads
os.environ["OPENBLAS_NUM_THREADS"] = "1"
import numpy as np
from scipy import ndimage
from scipy.stats import kendalltau
from skimage import data, exposure, filters, measure, metrics
def q8(img): # store as 8 bits
return np.rint(np.clip(img, 0, 1) * 255).astype(np.uint8)
# Known contrast loss y = o + b * x per channel, plus Gaussian noise (sigma 0.02).
LOSS = {"grey": (np.array([0.30, 0.30, 0.30]), np.array([0.40, 0.40, 0.40])),
"cast": (np.array([0.35, 0.30, 0.20]), np.array([0.45, 0.40, 0.30]))}
SIGMA, SEEDS = 0.02, (0, 1, 2)
# Simplest Color Balance (Limare et al., IPOL 2011), s1 = s2 = 1 percent.
def scb_sort(ch8, s1=1.0, s2=1.0): # Sec. 3.1: quantiles of the sorted values
v = np.sort(ch8.ravel()); N = v.size
return int(v[int(N * s1 / 100)]), int(v[int(N * (1 - s2 / 100)) - 1])
def scb_text(ch8, s1=1.0, s2=1.0): # Sec. 3.2 as worded, cumulative histogram
h = np.cumsum(np.bincount(ch8.ravel(), minlength=256)); N = ch8.size
L = np.nonzero(h <= N * (1 - s2 / 100))[0].max()
return int(np.argmax(h > N * s1 / 100)), int(L + 1)
def scb_alg1(ch8, s1=1.0, s2=1.0): # Algorithm 1 as printed
h = np.cumsum(np.bincount(ch8.ravel(), minlength=256)); N = ch8.size
vmin = 0
while h[vmin + 1] <= N * s1 / 100:
vmin += 1
vmax = 255 - 1
while h[vmax - 1] > N * (1 - s2 / 100):
vmax -= 1
if vmax < 255 - 1:
vmax += 1
return vmin, vmax
def scb(y8): # RGB colour balance, channel by channel
out = np.empty(y8.shape)
for c in range(3):
lo, hi = scb_sort(y8[..., c])
out[..., c] = (np.clip(y8[..., c], lo, hi) - lo) / (hi - lo)
return out
def unsharp(y): # radius 2, amount 1, one 2-D channel at a time
return np.stack([filters.unsharp_mask(y[..., c], radius=2, amount=1)
for c in range(3)], axis=-1)
def psnr(a8, b8):
return 10 * np.log10(1.0 / np.mean((a8 / 255.0 - b8 / 255.0) ** 2))
def scores(o8, x8):
lum = o8.mean(axis=-1) / 255.0 # I = (R + G + B) / 3
return np.array([psnr(o8, x8),
metrics.structural_similarity(o8, x8, channel_axis=-1, data_range=255),
measure.shannon_entropy(o8), # H: bits, pooled 8-bit codes
lum.std(), # C: RMS contrast (our definition)
measure.blur_effect(lum)]) # B: 0 sharp ... 1 blurred
OPS = ["input", "oracle", "stretch", "scb", "he", "clahe", "gamma", "unsharp"]
PSNR, SSIM, H, C, B = range(5)
def enhance(y8, o, b):
y = y8 / 255.0
lo, hi = np.percentile(y, [1, 99])
return {"input": y,
"oracle": (y - o) / b, # needs the loss model
"stretch": (np.clip(y, lo, hi) - lo) / (hi - lo),
"scb": scb(y8),
"he": exposure.equalize_hist(y8),
"clahe": exposure.equalize_adapthist(y8),
"gamma": exposure.adjust_gamma(y, 1.5),
"unsharp": unsharp(y)}
# ---- checks on exact data ----------------------------------------------------
x = data.astronaut() / 255.0
o, b = LOSS["cast"]
lum = x.mean(axis=-1)
ref = np.clip(x[..., 0] + (x[..., 0] - ndimage.gaussian_filter(
x[..., 0], 2, mode="reflect", truncate=4.0)), 0, 1)
print(f"C2 blur effect, affine change of I: {abs(measure.blur_effect(0.3 + 0.4 * lum) - measure.blur_effect(lum)):.1e}")
print(f"C3 oracle on noise-free loss, max err: {np.abs((o + b * x - o) / b - x).max():.1e}")
print(f"C4 unsharp_mask against formula: {np.abs(filters.unsharp_mask(x[..., 0], radius=2, amount=1) - ref).max():.1e}")
x8 = data.astronaut(); z8 = q8(0.9 * x)
print(f"C5 PSNR against scikit-image: {abs(psnr(z8, x8) - metrics.peak_signal_noise_ratio(x8, z8)):.1e}")
# ---- the experiment ------------------------------------------------------------
for name, x8 in [("astronaut", data.astronaut()), ("coffee", data.coffee())]:
for loss, (o, b) in LOSS.items():
runs, taus, clipped = [], [], []
for seed in SEEDS:
rng = np.random.default_rng(seed)
yc = o + b * (x8 / 255.0) + rng.normal(0, SIGMA, x8.shape)
clipped.append(np.mean((yc < 0) | (yc > 1)))
y8 = q8(yc)
if seed == 0:
c1 = all(scb_sort(y8[..., c]) == scb_text(y8[..., c]) and
scb_alg1(y8[..., c]) == (scb_text(y8[..., c])[0] - 1,
scb_text(y8[..., c])[1] + 1)
for c in range(3))
bounds = [scb_sort(y8[..., c]) for c in range(3)]
S = np.array([scores(q8(v), x8) for v in enhance(y8, o, b).values()])
runs.append(S)
sgn = np.array([1, 1, 1, 1, -1]) # blur effect: lower is better
taus.append([[kendalltau(S[:, ref_], sgn[m] * S[:, m])[0] for m in (H, C, B)]
for ref_ in (PSNR, SSIM)])
R, T = np.array(runs), np.array(taus)
M = R.mean(axis=0)
print(f"\n{name}, {loss} loss; clipped before storage {max(clipped):.6f}; "
f"SCB (Vmin, Vmax) per channel {bounds}; C1 sort = text, alg1 = text -1/+1: {c1}")
print(f"{'operator':9s} {'PSNR dB':>8s} {'SSIM':>7s} {'H bits':>7s} {'C':>7s} {'B':>7s}"
" (means over seeds 0-2)")
for i, k in enumerate(OPS):
print(f"{k:9s} {M[i, PSNR]:8.2f} {M[i, SSIM]:7.4f} {M[i, H]:7.4f} {M[i, C]:7.4f} {M[i, B]:7.4f}")
top = lambda m, s=1: [OPS[i] for i in np.argmax(s * R[:, :, m], axis=1)]
srt = np.sort(R[:, :, PSNR], axis=1)
print(f"top by run: PSNR {top(PSNR)}, SSIM {top(SSIM)}, H {top(H)}, C {top(C)}, B {top(B, -1)}")
print(f"PSNR gap, best minus second: {(srt[:, -1] - srt[:, -2]).min():.2f}"
f" to {(srt[:, -1] - srt[:, -2]).max():.2f} dB")
tbl = [OPS.index(k) for k in ("stretch", "he", "gamma")] + ([1] if loss == "grey" else [])
dH = R[:, 0, H][:, None] - R[:, tbl, H]
print(f"H(input) - H(single-table op) >= 0 in {int((dH >= 0).sum())}/{dH.size} cases,"
f" smallest {dH.min():.4f}")
print(f"H(unsharp) - H(input) {(R[:, 7, H] - R[:, 0, H]).min():.4f} to {(R[:, 7, H] - R[:, 0, H]).max():.4f};"
f" H(clahe) - H(input) {(R[:, 5, H] - R[:, 0, H]).min():.4f} to {(R[:, 5, H] - R[:, 0, H]).max():.4f}")
sb = np.sort(R[:, :, B], axis=1)
print(f"B: second lowest minus lowest {(sb[:, 1] - sb[:, 0]).min():.4f};"
f" |B(stretch) - B(input)| max {np.abs(R[:, 2, B] - R[:, 0, B]).max():.4f}")
print(f"PSNR(scb) - PSNR(stretch) {(R[:, 3, PSNR] - R[:, 2, PSNR]).min():.2f}"
f" to {(R[:, 3, PSNR] - R[:, 2, PSNR]).max():.2f} dB")
for j, ref_ in enumerate(("PSNR", "SSIM")):
print(f"Kendall tau with {ref_}: " + ", ".join(
f"{m} {T[:, j, i].min():+.3f} to {T[:, j, i].max():+.3f}"
for i, m in enumerate(("H", "C", "-B"))))
Output:
C2 blur effect, affine change of I: 8.3e-16
C3 oracle on noise-free loss, max err: 2.2e-16
C4 unsharp_mask against formula: 0.0e+00
C5 PSNR against scikit-image: 0.0e+00
astronaut, grey loss; clipped before storage 0.000000; SCB (Vmin, Vmax) per channel [(69, 179), (69, 176), (68, 176)]; C1 sort = text, alg1 = text -1/+1: True
operator PSNR dB SSIM H bits C B (means over seeds 0-2)
input 14.22 0.5370 6.7550 0.1175 0.3508
oracle 26.50 0.5695 6.4373 0.2883 0.3540
stretch 23.48 0.5409 6.6798 0.2764 0.3510
scb 23.23 0.5396 7.5016 0.2757 0.3509
he 21.06 0.5036 6.7229 0.2612 0.3423
clahe 18.46 0.5307 7.7635 0.2356 0.3339
gamma 13.25 0.5313 6.6510 0.1203 0.3519
unsharp 14.35 0.4636 7.0572 0.1281 0.2693
top by run: PSNR ['oracle', 'oracle', 'oracle'], SSIM ['oracle', 'oracle', 'oracle'], H ['clahe', 'clahe', 'clahe'], C ['oracle', 'oracle', 'oracle'], B ['unsharp', 'unsharp', 'unsharp']
PSNR gap, best minus second: 3.02 to 3.02 dB
H(input) - H(single-table op) >= 0 in 12/12 cases, smallest 0.0313
H(unsharp) - H(input) 0.3020 to 0.3024; H(clahe) - H(input) 0.9956 to 1.0160
B: second lowest minus lowest 0.0642; |B(stretch) - B(input)| max 0.0002
PSNR(scb) - PSNR(stretch) -0.25 to -0.25 dB
Kendall tau with PSNR: H -0.143 to -0.143, C +0.929 to +0.929, -B -0.357 to -0.357
Kendall tau with SSIM: H -0.357 to -0.286, C +0.429 to +0.500, -B -0.714 to -0.643
astronaut, cast loss; clipped before storage 0.000000; SCB (Vmin, Vmax) per channel [(82, 204), (69, 176), (43, 127)]; C1 sort = text, alg1 = text -1/+1: True
operator PSNR dB SSIM H bits C B (means over seeds 0-2)
input 14.00 0.5277 7.2852 0.1129 0.3486
oracle 25.79 0.5514 7.2892 0.2880 0.3493
stretch 15.13 0.5405 7.2118 0.1875 0.3490
scb 22.75 0.5246 7.4001 0.2740 0.3463
he 14.92 0.5399 7.2467 0.1960 0.3458
clahe 16.51 0.5339 7.7745 0.1985 0.3287
gamma 12.63 0.5040 7.1190 0.1141 0.3503
unsharp 14.11 0.4553 7.4438 0.1231 0.2671
top by run: PSNR ['oracle', 'oracle', 'oracle'], SSIM ['oracle', 'oracle', 'oracle'], H ['clahe', 'clahe', 'clahe'], C ['oracle', 'oracle', 'oracle'], B ['unsharp', 'unsharp', 'unsharp']
PSNR gap, best minus second: 3.04 to 3.04 dB
H(input) - H(single-table op) >= 0 in 9/9 cases, smallest 0.0379
H(unsharp) - H(input) 0.1584 to 0.1588; H(clahe) - H(input) 0.4880 to 0.4908
B: second lowest minus lowest 0.0615; |B(stretch) - B(input)| max 0.0004
PSNR(scb) - PSNR(stretch) 7.61 to 7.62 dB
Kendall tau with PSNR: H +0.286 to +0.286, C +0.857 to +0.857, -B +0.000 to +0.000
Kendall tau with SSIM: H -0.143 to -0.143, C +0.286 to +0.286, -B -0.286 to -0.286
coffee, grey loss; clipped before storage 0.000000; SCB (Vmin, Vmax) per channel [(80, 180), (71, 175), (69, 171)]; C1 sort = text, alg1 = text -1/+1: True
operator PSNR dB SSIM H bits C B (means over seeds 0-2)
input 14.51 0.5430 6.7150 0.0878 0.2666
oracle 26.35 0.5676 6.5336 0.2167 0.2680
stretch 24.23 0.5427 6.6443 0.2102 0.2668
scb 22.89 0.5242 7.3122 0.2182 0.2665
he 17.31 0.4881 6.6823 0.2169 0.2605
clahe 16.16 0.4945 7.6264 0.1761 0.2529
gamma 14.57 0.5816 6.6027 0.0903 0.2675
unsharp 14.55 0.4734 6.9634 0.0982 0.2018
top by run: PSNR ['oracle', 'oracle', 'oracle'], SSIM ['gamma', 'gamma', 'gamma'], H ['clahe', 'clahe', 'clahe'], C ['scb', 'scb', 'scb'], B ['unsharp', 'unsharp', 'unsharp']
PSNR gap, best minus second: 2.12 to 2.12 dB
H(input) - H(single-table op) >= 0 in 12/12 cases, smallest 0.0303
H(unsharp) - H(input) 0.2479 to 0.2486; H(clahe) - H(input) 0.9057 to 0.9208
B: second lowest minus lowest 0.0501; |B(stretch) - B(input)| max 0.0003
PSNR(scb) - PSNR(stretch) -1.34 to -1.33 dB
Kendall tau with PSNR: H -0.286 to -0.286, C +0.643 to +0.643, -B -0.429 to -0.429
Kendall tau with SSIM: H -0.500 to -0.500, C -0.143 to -0.143, -B -0.786 to -0.786
coffee, cast loss; clipped before storage 0.000000; SCB (Vmin, Vmax) per channel [(94, 205), (71, 175), (43, 122)]; C1 sort = text, alg1 = text -1/+1: True
operator PSNR dB SSIM H bits C B (means over seeds 0-2)
input 16.05 0.5564 7.2553 0.0853 0.2664
oracle 25.73 0.5465 7.2827 0.2163 0.2644
stretch 17.69 0.5764 7.1849 0.1401 0.2667
scb 21.71 0.5058 7.2760 0.2174 0.2624
he 15.10 0.5343 7.1932 0.1381 0.2569
clahe 18.11 0.5471 7.6350 0.1502 0.2549
gamma 15.74 0.5877 7.0529 0.0875 0.2686
unsharp 16.10 0.4827 7.4296 0.0951 0.2014
top by run: PSNR ['oracle', 'oracle', 'oracle'], SSIM ['gamma', 'gamma', 'gamma'], H ['clahe', 'clahe', 'clahe'], C ['scb', 'scb', 'scb'], B ['unsharp', 'unsharp', 'unsharp']
PSNR gap, best minus second: 4.01 to 4.02 dB
H(input) - H(single-table op) >= 0 in 9/9 cases, smallest 0.0619
H(unsharp) - H(input) 0.1741 to 0.1743; H(clahe) - H(input) 0.3772 to 0.3815
B: second lowest minus lowest 0.0526; |B(stretch) - B(input)| max 0.0004
PSNR(scb) - PSNR(stretch) 3.97 to 4.07 dB
Kendall tau with PSNR: H +0.429 to +0.429, C +0.643 to +0.643, -B +0.071 to +0.071
Kendall tau with SSIM: H -0.571 to -0.500, C -0.286 to -0.214, -B -0.786 to -0.714
Checks. C1 held: on all 12 channels the sorting method and the worded histogram rule gave the same bounds, and Algorithm 1 gave bounds one level wider at each end. C2 held (a difference of ), and so did C3, C4 and C5. Nothing was clipped before storage in any run.
What the full-reference measures say. PSNR ranks the oracle first in every run, 2.12 to 4.02 dB ahead of the second, so H1 held in all four cases. That is the expected reward for inverting the exact degradation. The two global stretches come next under the grey loss, and under the cast loss Simplest Color Balance pulls far ahead of the single stretch, in our reading because its per-channel bounds undo most of the per-channel loss: H8 held in all four cases (+7.61 to +7.62 and +3.97 to +4.07 dB with the cast; −0.25 and −1.34 to −1.33 dB without it). SSIM disagrees with PSNR on coffee, where gamma has the highest SSIM under both losses and the oracle is behind the stretch under the cast loss; on astronaut SSIM ranks the oracle first. H2 held in all four cases, but it describes these two images, not SSIM in general. In our reading, the cause is noise: the oracle multiplies it by , between 2.2 and 3.3, along with the signal, and SSIM, computed in 7 × 7 windows, weighs that noise against local structure differently from PSNR’s single average. Two reference measures, then, already disagree about which enhancement is best.
Entropy. Every single-table operator lowered the pooled entropy or left it unchanged, in all 42 cases the script counts; H3 held in all four cases, as the derivation requires. The oracle, which restores the image exactly in the noise-free case, scores lower entropy than the degraded input under the grey loss (6.44 against 6.76 bits on astronaut). Entropy cannot reward a global contrast correction, however good: a table only merges codes. In our reading, what raises it is anything that does not act as one table on the codes: CLAHE’s per-tile curves, unsharp masking’s added high-frequency variation, which includes amplified noise, and per-channel tables. CLAHE had the highest H in every run, and unsharp masking raised H by 0.16 to 0.30 bits, so H4 held in all four cases. Entropy’s rank agreement with PSNR ran from −0.29 to +0.43 (H7 held for H in all four cases).
RMS contrast. Here the no-reference score agrees with the reference fairly well: τ with PSNR from +0.64 to +0.93, and the highest C belonged to the oracle on astronaut and to Simplest Color Balance on coffee, so H5 held in all four cases. In our reading, this agreement comes from the degradation we chose: a contrast loss lowers the standard deviation of intensity, which is what RMS contrast measures, and the operators that undo it raise that standard deviation again. The score does not know whether the spread is right; histogram equalization also raises C well above the input’s in every case while scoring 5.4 to 10.9 dB below the oracle in PSNR.
Blur effect. Unsharp masking had the lowest B in every run, at least 0.050 below the next operator (the smallest margins in the four cases run from 0.050 to 0.064, and CLAHE has the next lowest mean B in each), although its PSNR is almost that of the untouched input; the stretch moved B by at most 0.0004; and τ between −B and PSNR ran from −0.43 to +0.07. H6 held in all four cases, and H7 held for B in all four cases. The construction explains part of it: the blur effect is a ratio of gradient sums and is unchanged by an affine change of intensity (C2), so it is blind to the contrast loss the degradation applied; in our reading, what moves it is the high-frequency content that sharpening adds.
Taken together. Each no-reference score measures one property: entropy the number and spread of distinct codes, RMS contrast the spread of intensities, blur effect the high-frequency share of the gradient. Each can be raised by an operator that does not improve the image for this degradation: entropy and blur effect both ranked unsharp masking, whose PSNR is within 0.13 dB of the untouched input, above the oracle in every case. In our reading, a score that agrees with the reference does so here because the degradation and the score measure the same thing. This is why, in our reading, enhancement methods are compared with visual tests and user studies, and why a no-reference score used for tuning should be one validated against human judgements for the kind of image and degradation at hand, as image quality assessment describes.
Datasets and Benchmarks
The IPOL articles above share no benchmark: they compare methods on small sets of test photographs chosen to show their strengths and failures, five images in Hessel (2019, Fig. 4) and Lisani (2020, Fig. 14), and Getreuer (2012, §6.3) times and checks his ACE approximations on the 24 Kodak images. Benchmarks with references or ratings exist for specific enhancement problems: low-light datasets with long-exposure references and no-reference test sets (low-light image enhancement), haze synthesized from clear images with depth (image dehazing), and the rated image databases of image quality assessment.
Applications
- Photography and display. Contrast and colour adjustment of photographs; local operators such as SEF and LLCC aim to improve dark and bright regions of the same image at once (Lisani, 2020, abstract, §4).
- Restoration of faded material. Getreuer (2012, §6.5) applies ACE to film that had discoloured and faded with age.
- Medical and scientific images. AHE and CLAHE were developed and evaluated largely for medical image display, as histogram equalization records; Gonzalez and Woods, as image processing summarizes, warn that a method suited to X-ray images may not suit infrared satellite images.
- Preprocessing for vision systems. Enhancement before detection or recognition in poor light or weather, evaluated by the downstream task, as in low-light image enhancement.
Open Problems
- Evaluation without a reference. No-reference scores measure single properties and can be raised without improving the image, as the example shows; validated quality models generalize imperfectly across distortions and databases (image quality assessment).
- Halos against local contrast. Local methods trade halos for contrast; Lisani (2020, §3) analyses how the weight map and its scale move LLCC along that trade, while Hessel (2019, §3.1) reports SEF free of artefacts across the parameter ranges he tested.
- Parameters per image. Simplest Color Balance’s authors find that the needed saturation share varies with the image (§7), and Lisani (2020, §3) advises a larger scale for images without large uniform areas; in our reading, choosing such parameters automatically remains open.
- Noise. Enhancement and denoising interact: raising contrast raises noise, and, in our reading, denoising first removes detail that enhancement would have shown.
Related
- Histogram Equalization
A contrast enhancement method that maps each intensity level through the image's cumulative histogram so that output levels are used approximately uniformly, with adaptive variants such as CLAHE that equalize local regions.
- Color Constancy
Estimating the colour of the light that illuminates a scene from a single image so that it can be divided out, from gray world, white patch, shades of gray and gray-edge to gamut mapping, corrected moments and learned estimators such as CCC, FFCC and FC4, measured by the angular error.
- Low-Light Image Enhancement
Recovering a well-exposed, natural-looking image from one taken in poor light; global curves, Retinex illumination estimation with max-RGB and edge-aware refinement, Zero-DCE's light-enhancement curves and non-reference losses, Retinexformer, and raw-to-RGB learning in the dark, with the benchmarks and metrics used to compare them.
- Tone Mapping
Compressing a high-dynamic-range radiance map into the range a display or print can show, with global curves such as Reinhard's photographic operator, local operators such as automatic dodging-and-burning, and base–detail decompositions such as Durand and Dorsey's bilateral-filter method, and why local operators produce halos.
- Image Quality Assessment
Predicting how good an image looks to people, with or without a pristine reference; how human quality scores are collected (ITU-R BT.500 methods, MOS and DMOS, paired comparisons), the benchmark databases, how a metric is validated by rank and linear correlation after a logistic fit, the families of metrics from PSNR to deep no-reference models, and why metrics trained on one kind of distortion fail on another.
- 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.
References
- Limare, N., Lisani, J.-L., Morel, J.-M., Petro, A. B. & Sbert, C. (2011). Simplest Color Balance. Image Processing On Line, 1, 297–315.
- Getreuer, P. (2012). Automatic Color Enhancement (ACE) and its Fast Implementation. Image Processing On Line, 2, 266–277.
- Hessel, C. (2019). Simulated Exposure Fusion. Image Processing On Line, 9, 469–482.
- Lisani, J.-L. (2020). Local Contrast Enhancement based on Adaptive Logarithmic Mappings. Image Processing On Line, 10, 43–61.
- Gonzalez, R. C. & Woods, R. E. (2018). Digital Image Processing (4th ed.). Pearson.
- Gatta, C., Rizzi, A. & Marini, D. (2002). ACE: An Automatic Color Equalization Algorithm. Conference on Colour in Graphics, Imaging, and Vision (CGIV) (not read).
- Rizzi, A., Gatta, C. & Marini, D. (2003). A New Algorithm for Unsupervised Global and Local Color Correction. Pattern Recognition Letters, 24, 1663–1677 (not read).
- Bertalmío, M., Caselles, V., Provenzi, E. & Rizzi, A. (2007). Perceptual Color Correction Through Variational Techniques. IEEE Transactions on Image Processing, 16(4), 1058–1072 (not read).
- Hessel, C. & Morel, J.-M. (2020). An Extended Exposure Fusion and its Application to Single Image Contrast Enhancement. IEEE Winter Conference on Applications of Computer Vision (WACV) (not read).
- Lisani, J.-L. (2018). Adaptive Local Image Enhancement Based on Logarithmic Mappings. IEEE International Conference on Image Processing (ICIP) (not read).
- Moroney, N. (2000). Local Color Correction Using Non-Linear Masking. IS&T/SID Eighth Color Imaging Conference, 108–111 (not read).
- Crete, F., Dolmiere, T., Ladret, P. & Nicolas, M. (2007). The Blur Effect: Perception and Estimation with a New No-Reference Perceptual Blur Metric. Proc. SPIE 6492, Human Vision and Electronic Imaging XII, 64920I (not read).
- scikit-image developers (2025). skimage.measure.blur_effect, skimage/measure/_blur_effect.py, scikit-image v0.25.2 source code.
- scikit-image developers (2025). skimage.exposure.equalize_adapthist and skimage.color.adapt_rgb.hsv_value, skimage/exposure/_adapthist.py and skimage/color/adapt_rgb.py, scikit-image v0.25.2 source code.