Low-Light Enhancement
Low-light enhancement rescues near-black photos by separating illumination from true colour, brightening the light without bleaching the scene.
Why Does This Exist?
Night photos bury scenes in darkness with noise riding along. Global brightening lifts both: grey wash, amplified grain, clipped highlights around every lamp. The damage mixes illumination failure with sensor noise, and one slider cannot split them.
Retinex methods decompose each pixel into illumination and reflectance : enhance , preserve , denoise jointly. Zero-reference variants learn the decomposition without paired dark-bright photos, which barely exist. The task map is image-to-image tasks, and the grain side is image denoising.
Think of It Like This
Restoring a charcoal sketch under dim bulbs
A gallery shows charcoal sketches under dying bulbs. Visitors see grey murk, but the sketch holds full contrast: dark lines, white paper. Replacing bulbs reveals the art; scrubbing the sketch itself destroys it.
Illumination is the bulbs and reflectance is the sketch. Brighten bulbs, preserve lines. The work fails when murk ate the lines entirely: no bulb reveals strokes never drawn, which is the information limit below.
How It Actually Works
Retinex writes observed , element-wise reflectance times illumination. Networks estimate as smooth (large blur-like structures) and as detail, then remap upward with gamma or learned curves while stays fixed:
Zero-reference losses penalise spatial inconsistency, overexposure, and colour drift without any bright ground truth. Denoising runs jointly because brightening multiplies hidden noise visibly.
Worked example
Pixel value . Gamma lifts it to , back to : nearly four times brighter. Reflectance preserved means a red wall stays red-shifted rather than washing grey. Noise at levels becomes after lifting, which is why the pipeline denoises after, never before, the brightening step.
Code
Gamma lifting with the brightness math exposed.
def gamma_lift(v: int, gamma: float = 0.5) -> int: """Brighten one 8-bit pixel through a gamma curve.""" return round(255 * (v / 255) ** gamma)
print(gamma_lift(20)) # -> 71 nearly four times brighterprint(gamma_lift(200)) # -> 226 highlights compress, not clipWatch Out For
Amplifying noise into confetti
Dark regions hide heavy noise that brightening multiplies into coloured speckle. Pipelines that enhance before denoising print confetti across shadows.
Order the pipeline enhance-then-denoise or joint. Validate shadow crops at full zoom; global metrics forgive speckle averages hide.
Colour casts from channel-independent curves
Independent per-channel brightening shifts white balance: sodium lamps turn everything orange-amplified, moonlight goes blue. Reflectance preservation must span channels jointly.
Constrain colour ratios during enhancement or correct white balance after. Check neutrals: grey walls should stay grey.
The Quick Version
- Low-light photos mix dim illumination with noise; one slider cannot split them.
- Retinex decomposes and brightens while fixing .
- Our pixel lifts under gamma while highlights compress safely.
- Zero-reference losses train without paired dark-bright photos.
- Denoise after brightening, and keep colour ratios joint across channels.