A small toolkit that tells you if the light you scan film with is any good, using nothing but photos of it taken with your own camera.
A good scan and a weird one from the same roll. lightdrift found the light got dimmer and more orange in the weird one.
When you scan film with a camera, you put the negative on a light (a light pad, a lightbox, an LED panel) and take a photo of it. The light is the secret ingredient. If it gets dimmer, changes color, or is brighter in the middle than at the edges, your scans won't match each other, and you'll spend hours fixing colors that were never the film's fault.
The problem: your eyes are bad at spotting this. A light that gets 5% dimmer or a tiny bit bluer looks exactly the same to you. Your camera's sensor notices, though. lightdrift reads the raw numbers straight from the sensor and tells you what changed.
Think of it like a thermometer. You don't guess if you have a fever, you measure it.
It has two modes.
Light check (the default). You take photos of the bare light, with no film, over an hour or so. It answers:
- Does it get brighter or dimmer over time? Like a car engine, a lot of lights need a few minutes to warm up before they settle down.
- Does its color change? A light that slowly turns bluer will tint your later scans.
- Is it even? Is the middle brighter than the corners?
- Is anything blown out? Too bright for the sensor to measure.
Film check (--film). You give it photos of real negatives, like a scan that came out great and one that came out weird. Every negative has an orange border around the picture that's identical in every frame. lightdrift compares that border between scans. If it changed, your light did too.
It measures whether your light is steady and even. It can't tell you whether the light has a "good" spectrum (things like CRI). That needs a spectrometer, not a camera.
- A camera that shoots raw files (ARW, CR2, CR3, NEF, DNG, RW2, ORF, PEF and friends)
- Python 3 (tested on 3.11)
- The light you want to test
git clone https://github.com/davidesqalv/lightdrift.git
cd lightdrift
pip install -r requirements.txt- Set up your camera on your scanning rig like you normally would, but with no film in the holder.
- Put the camera in manual (M): fixed shutter speed, aperture and ISO, manual focus. If the settings change between shots, the brightness can't be compared.
- Expose so the light looks bright but not blown out. On the histogram, the hump should sit right of center, not crushed against the right edge.
- Turn the light on and right away take two shots back to back. Those two tell you how much the numbers wiggle on their own, so you know what counts as noise.
- Then take one shot every 5 to 10 minutes for an hour or so. Don't touch anything in between.
- Copy the raw files into a folder inside
captures/, likecaptures/my-light/.
python light_drift.py # pick a folder from captures/ from a list
python light_drift.py captures/my-light # or point straight at oneEvery run gets its own folder in light_drift_results/ named after the date and time, so old results never get overwritten.
A report.txt with the short answer at the top. Here's a real one: three shots of a light, right after switching it on, then 30 minutes later, then an hour later.
THE SHORT ANSWER
Brightness CHANGED Got 5% dimmer by minute 31, then held steady (ended at -6.2%).
Color CHANGED At most 2.7% bluer than the first shot.
Evenness OK The middle and the corners are within 1% of each other, and that
didn't change.
Same settings OK Same shutter speed, aperture and ISO in every shot.
Blown out OK Nothing blown out.
Bottom line: Let the light warm up before scanning. It had settled by minute 31, the first shot
after the start, so it may need less. Shoot every minute or two at the start to find out. Its
color shifts too, enough to tint scans slightly.
This light is perfectly even, but it's a bit like a car on a cold morning. For the first half hour it gets about 5% dimmer and a little bluer, then it settles down. So: switch it on, wait, then scan.
Here's the chart from that same session:
You also get charts:
| File | What it shows |
|---|---|
over_time.png |
Brightness, color and evenness, shot by shot |
uniformity.png |
A map of the frame: which parts are brighter or dimmer than average |
level_change.png |
First shot vs. last shot: where it got brighter or dimmer |
color_shift.png |
First shot vs. last shot: where the color changed |
previews.png |
Every shot, with the differences cranked up so you can see them |
results.csv |
All the numbers, for your own spreadsheets |
| Check | Good light |
|---|---|
| Brightness | Moves less than 2% once it's warmed up |
| Color | Moves less than 1% (under 2% is still small) |
| Evenness | Middle and corners within 5% of each other, and that doesn't change |
| Word | Meaning |
|---|---|
OK |
Nothing to worry about |
SMALL |
It moved, but only a little |
CHANGED |
It moved enough to show up in your scans |
CHECK |
Worth a look |
FIX |
Something in how you shot it needs changing (the report says what) |
? |
Not enough information to tell |
Shoot your negatives like you normally would. Just make sure a bit of the orange border and the little holes along the edge are in the photo.
python light_drift.py --film good.ARW bad.ARW --ref good.ARW # compare against the one you likeStick to one roll at a time, since every film is a slightly different orange.
Here's a real test: a good scan and a weird one from the same roll, taken 86 minutes apart. lightdrift found the light was dimmer and more orange in the weird one. So the light was the problem, not the editing.
That's the picture at the top of this page. Green is the orange border it measured, red is light coming through the holes, and blue is the dark holder around the film.
--film film check mode (scans of color negatives)
--ref FILE film mode: the scan to compare everything against (default: the first negative)
--base L,T,R,B film mode: where the clear film is, as fractions of the frame, if auto-detect misses it
--order time|given sort by capture time from the file (default) or keep the order you gave
--grid N columns in the evenness grid (default 9)
--inset F margin trimmed off each side of the lit area (default 0.05)
--full-frame measure the whole frame instead of finding the lit area
--gain N how much previews.png exaggerates differences (default 5)
--out DIR where results go (default light_drift_results)
- Files are opened with rawpy (LibRaw). The per-channel black level is subtracted and each value is divided by
white - black, so1.0means the sensor clipped. - No demosaicing, no white balance, no color matrix, no gamma. Each 2x2 Bayer quad is split into four half-resolution planes and the two greens are averaged. The numbers are the linear sensor response to the light, seen through the camera's own color filters. Nothing about a converter or a camera profile is in them.
- A pixel counts as clipped at 98% of the white level.
- Capture order and the minutes axis come from EXIF
DateTimeOriginal+SubSecTimeOriginal(via exifread). Shutter, aperture, ISO and exposure program come from EXIF too. - Files are read one at a time and only the measurements plus a downsampled preview are kept, so memory stays low even with dozens of 30 MB raws.
- Finding the lit area. Luminance is
Y = (R + 2G + B) / 4. Pixels above half the 99th percentile count as lit. Rows and columns where the lit fraction is over half its maximum give a bounding box, which is then inset by 5% per side (--inset). The box comes from the first capture and is reused for every capture, so the exact same pixels are compared every time. - Brightness. The median of each channel over the box, plus
(R + 2G + B) / 4overall, as % change vs. the earliest capture. - Color.
R/GandB/Gof those medians. Because the data is linear, changing exposure scales every channel by the same factor and the ratios don't move. If they change, the light's spectrum (or something else in the light path) changed. - Evenness. The box is split into a grid (
--gridcolumns, rows to match the aspect ratio) and the median of each cell is taken. Reported as center cell(s) vs. the mean of the four corner cells, plus the coefficient of variation across cells. This includes the lens's own vignetting, so the change over time is more telling than the absolute number. The "what changed" heatmap normalizes each frame by its own mean first, so overall dimming doesn't hide a change in shape. - Reading the brightness series. A total span of 2% or less is
OK. Three or more steps over 3% in both directions is "jumpy". Otherwise it looks for the first shot after which every remaining shot stays within 1.5% of their median, and reports that as the warm-up time.
Each scan is cut into 16x16 blocks and labeled:
- Film base: orange blocks (
R >= 1.2 * B) just inside the film's edge, with matching brightness and color.--baselets you point at it by hand. Scans without an orange base are skipped. - Bare light: more than 0.4 stops brighter than the film base (sprocket holes).
- Holder: dark area touching the frame edge.
Then every scan is compared with the reference (--ref):
- Brightness: how much the film base brightness changed,
log2(base / base_ref), minus what the settings predict,log2(t * ISO / N^2). What's left over is the light. - Color:
R/GandB/Gof the film base, plusR/Bof the light in the sprocket holes (green clips there). Flagged past 2%. - Exposure: stops between the film base and clipping, and whether the densest film sinks into sensor noise.
- Evenness and stray light: film base brightness at mid-edge vs. corners, and how much light lands on the black holder.
Thresholds are named constants in light_drift.py (COLOR_TOL, EXPOSURE_TOL, EVEN_TOL and so on).
- Bayer sensors only. Fujifilm X-Trans, Foveon and monochrome sensors won't give correct numbers.
- It measures stability, not spectrum. It can tell you the color changed. It can't tell you CRI, TLCI or whether the spectrum has gaps.
- Evenness includes your lens. Shoot every test at the same aperture if you want to compare lights with each other.
- Film mode is for color negatives with an orange mask, one roll per run.
- Everything is relative to a reference capture. Absolute numbers depend on your camera.
light_drift.py the whole toolkit, one file
requirements.txt numpy, rawpy, matplotlib, exifread
captures/ put your raw files here, one subfolder per session (ignored by git)
docs/ the example charts in this README
MIT

