Why it works: Unit 1.2: Image sensing, Unit 1.3: The image as data

Fix a Badly Exposed Photo with OpenCV

The first OpenCV in Practice project: open a photo taken two stops too dark, read its histogram, try five ways to fix it, and measure each against a longer, better-exposed shot of the same scene (the target). Multiplying lands closest; the automatic fixes land furthest.

Luis Condados · · 6 min read Beginner
The photo shot two stops dark (left half) and the same photo multiplied by 2.4 (right half).
The photo shot two stops dark (left half) and the same photo multiplied by 2.4 (right half).

A photo taken with too little light can often be brightened to look like a longer exposure of the same scene, and this project measures which way of brightening gets closest.

The animation turns a single setting up, the multiplier applied to every pixel, and shows the fixed photo next to a longer, better-exposed shot of the same scene (the target), with the gap between them counting down.

A dark photo of a desk lamp and colour chart brightening step by step as a multiplier rises from 1.0 to 2.4, beside the target exposure, with the distance to it falling from 47.0 to 9.1.
The multiplier climbs from 1.0 to 2.4 and the distance to the target exposure (right) falls from 47.0 to 9.1. Frames from the HDR Photographic Survey [1].
1 · open the photocv2.imread2 · read its histogramcv2.calcHist3 · fix the exposuregain · gamma · equalize4 · measure the gapagainst the target shot5 · crop, resize, savecv2.resize · cv2.imwritetry another setting
  1. Open the photo. Load the file into a grid of numbers, one per colour per point (lesson 1).
  2. Read its histogram. Count how many pixels sit at each brightness, which shows how dark the photo is and what is already burnt out (lesson 2).
  3. Fix the exposure. Change every value by one rule: add a constant, multiply by a gain, bend with a gamma curve, or let an automatic equalizer decide (lessons 3, 4 and 5).
  4. Measure the gap. Compare the fixed photo with a longer, better-exposed shot of the same scene (the target), pixel by pixel, and average the difference. Lower is closer; 0 is identical.
  5. Crop, resize, save. Keep the part you want, shrink it without aliasing, and write it to a file (lesson 6).

Where you have met it: the Exposure and Brightness sliders in phone photo editors and in Lightroom; the Auto button next to them; the histogram on a camera’s playback screen.

TL;DR. Brightening a dark photo by scaling its values gets far closer to the target exposure than adding to them or letting an automatic tool decide, and nothing brings back what the camera burnt out. Two stops dark (almost a quarter of the light the target got), a gain of 2.4 leaves an average gap of 9.1 (out of 255) to the target, adding brightness 18.0, and histogram equalization 30.6. Three stops dark, gamma wins, 13.9 against 15.1.

The scene and the target

Every lesson works on one scene: a desk lamp, a card and a colour chart, photographed eighteen times at shutter times from 1/800 s to 30 s for Mark Fairchild’s HDR Photographic Survey [1]. Unit 1.2 of the Fundamentals track measures the same eighteen raw files. This module uses four, developed the same way:

FileShutterMean greyGrey ≥ 250Role
reference.webp5 s104.711.7%the target every fix is scored against
dark.webp1.3 s56.67.8%about two stops under
darker.webp1/2 s40.86.3%more than three stops under
bright.webp20 s173.827.2%two stops over

The distance used throughout is the mean absolute difference between the fixed photo and the target, over every pixel and colour channel, on the 0–255 scale.

The whole fix, in one cell

Python · OpenCV
import cv2
import numpy as np

img = cv2.imread("dark.webp")
ref = cv2.imread("reference.webp")

fixed = cv2.convertScaleAbs(img, alpha=2.4)
gap = np.abs(fixed.astype(int) - ref.astype(int)).mean()
print("distance to the reference:", round(gap, 1))

cv2.imwrite("fixed.jpg", fixed, [cv2.IMWRITE_JPEG_QUALITY, 90])
side = [cv2.resize(x, None, fx=0.5, fy=0.5) for x in (img, fixed, ref)]
cv2.imshow("dark | fixed | reference", np.hstack(side))
Output
distance to the reference: 9.1
dark | fixed | reference
dark | fixed | reference

What each fix gets

Each fix was scored at its best setting, found by trying its parameter in small steps (values in brackets).

FixDark, 1.3 sDarker, 1/2 sBright, 20 s
nothing47.062.368.4
add a constant (value)18.0 (50)25.7 (65)68.4 (0)
multiply (gain)9.1 (2.4)15.1 (4.2)22.8 (0.5)
gamma (γ)10.7 (0.55)13.9 (0.4)33.4 (2.35)
equalizeHist on brightness30.631.930.3
CLAHE on brightness35.551.358.8

Four things come out of the table:

  • A gain beats an offset. A longer exposure multiplies the light, so a multiplier is the right shape of fix, and it halves the gap that adding leaves (lesson 3). OpenCV’s own tutorial warns that the offset leaves a grey veil [3].
  • Gamma overtakes the gain as the photo gets darker. A gain large enough to lift three stops of shadow pushes everything above 60 to white; gamma maps 0 to 0 and 255 to 255 at every setting, so it never clips [2] (lesson 4).
  • The automatic fixes land furthest. Equalization spreads brightness by how common each level is [2], and this photo is mostly dark wall and desk (lesson 5).
  • Overexposure is the hard direction. Nothing gets the bright photo closer than 22.8, because 15.6% of its pixels are at 250 or above where the target still has detail. Those values are gone.

What to take from it

  • A photo a stop or two dark: multiply by one gain and check it against something you trust (a grey card, a second shot). Here that closed 47.0 to 9.1.
  • Much darker than that: use gamma instead, or a gain and gamma together, and accept flatter shadows. Here 62.3 went to 13.9.
  • No reference and you only need to see detail: equalization or CLAHE, knowing the result will not look like the scene.
  • Overexposed: re-shoot or bracket. Software got the bright photo only to 22.8, and 15.6% of it could not be recovered by any setting. High dynamic range shows how several exposures of this scene are merged to keep everything.
  • When a model is worth it: this module did not test a learned enhancer. The number to beat is 9.1 two stops down and 13.9 three stops down.

Limitations

  • The target is a choice, not the truth. The 5-second shot already has 11.7% of its pixels at 250 or above. The dark photo kept detail in the lamp that the target lost, so a fix that preserved it is scored as worse.
  • The best settings were picked by looking at the answer. Every gain and gamma above was chosen to minimise the distance to the target. Without a target you would have to choose by eye or by a rule, and do worse.
  • One scene, one camera. A lamp in a dark room is an extreme range of light. A flatter scene would favour equalization more, and the order of the fixes could change.
  • The distance weighs every pixel equally. Most of this frame is dark wall and desk, so the score mostly reflects the shadows. A metric that weighted the lamp and chart more would rank the fixes differently.
  • The inputs are WebP at quality 90. Every number was computed from the published files, so a reader’s Run reproduces it, but they are not the raw sensor values.

The lessons

  1. Open an Image and Read Its Pixels in OpenCV
  2. Image Histograms in OpenCV
  3. Brightness and Contrast in OpenCV
  4. Gamma Correction in OpenCV with a Lookup Table
  5. Histogram Equalization and CLAHE in OpenCV
  6. Crop, Resize and Save an Image in OpenCV

Every cell of the module is also in one notebook, to run on your own machine with the OpenCV pip installs: open it in Colab. Its outputs match the page’s, cell for cell.

How these numbers were made
DataHDR Photographic Survey, “Luxo Double Checker” [1]: files _MDF0010.NEF (1/2 s), _MDF0011.NEF (1.3 s), _MDF0013.NEF (5 s), _MDF0015.NEF (20 s). Research and non-commercial terms; downloaded, not redistributed as raw
Developmentrawpy 0.27.0 (LibRaw 0.22.1): camera white balance, no auto-brightening, half size, 8-bit; resized to 960 px wide with INTER_AREA; written as WebP quality 90
ScoresMean absolute difference to reference.webp over all pixels and channels. Grids: add 0–150 in steps of 5; gain 0.2–6.0 in steps of 0.1; gamma 0.20–3.00 in steps of 0.05; CLAHE clipLimit=2.0, tileGridSize=(8, 8)
In the pagePyodide 314.0.7, OpenCV 4.11.0, NumPy 2.4.6 (every cell’s shown output is rendered by npm run pycells with this build)
LocallyOpenCV 5.0.0, NumPy 2.5.2, Python 3.12; every number in the table above matches the page’s to one decimal
MachineIntel Core i7-12700H, Ubuntu 22.04.5 LTS. Nothing is timed; the numbers are deterministic
Commandsuv run --extra sensing sensing-download, then uv run --extra sensing practice-m1 --assets --media in fundamentals-lab; npm run pycells in the site repo
Artifactoutput/practice_m1_numbers.json

Further reading

  • Gonzalez & Woods, Digital Image Processing, chapter 3 [2], for intensity transformations and histogram processing with the theory behind each.
  • OpenCV’s tutorial Changing the contrast and brightness of an image! [3], which corrects a different underexposed photo with α, β and γ.
  • Image sensing, the Fundamentals unit on this same scene, for why the camera clipped where it did and how the eighteen exposures merge into one.

References

[1] Fairchild, M. D. (2007). The HDR Photographic Survey. IS&T 15th Color Imaging Conference, pp. 233–238. doi:10.2352/CIC.2007.15.1.art00044

[2] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.), chapter 3 “Intensity Transformations and Spatial Filtering”: section 3.2 “Some Basic Intensity Transformation Functions”, p. 136, and section 3.3 “Histogram Processing”, p. 147. Pearson.

[3] OpenCV. Changing the contrast and brightness of an image! (tutorial, doc/tutorials/core/basic_linear_transform/basic_linear_transform.markdown). OpenCV 4.11.0, commit 31b0eee. github.com/opencv/opencv