Devblog · 2026-06-23

Better Light for the Bandit

A stretch goal: can a handful of ImageMagick scripts fix the raccoon photo’s bad lighting? Mostly — and the one textbook fix that should have nailed the rest is the one that failed.
Date 2026-06-23 Status Shipped Tools ImageMagick 6.9

A stretch goal, the morning after branding the NuGet server with a watercolour raccoon: the source photo was shot on cold-press paper under whatever light was handy, and it shows. Can a handful of ImageMagick scripts fix the bad lighting? Mostly yes — and the one textbook technique that should have fixed the rest is the one that failed.

What We Worked On

The raccoon was photographed, not scanned: a warm cast, a paper that photographs grey instead of white, and an uneven wash of light across the sheet. The goal was a small kit of ImageMagick correction scripts, tried against the measured problems rather than guessed, ending in one recipe worth keeping.

Diagnosing the Light First

Before touching a pixel, measure. Two cheap signals tell you almost everything: the overall channel means (a colour cast pulls R/G/B apart) and the paper colour in each corner (uneven light makes the corners disagree; bad exposure makes “white” paper land far from 250).

#!/usr/bin/env bash # Diagnose a photo's lighting BEFORE correcting it. Prints the overall channel # means (a warm/cool cast shows up as R/G/B drifting apart) and the paper colour # in each corner (uneven illumination shows up as the corners disagreeing, and # under/over-exposure as paper that is far from ~250 white). # # Usage: ./raccoon-diagnose-lighting.sh <source.png> set -euo pipefail SRC="${1:?source image}" convert "$SRC" -colorspace sRGB -format \ "overall mean R/G/B: %[fx:int(mean.r*255)] / %[fx:int(mean.g*255)] / %[fx:int(mean.b*255)]\n" info: W=$(identify -format %w "$SRC"); H=$(identify -format %h "$SRC"); P=120 for spec in "TL:0:0" "TR:$((W - P)):0" "BL:0:$((H - P))" "BR:$((W - P)):$((H - P))"; do lbl=${spec%%:*}; rest=${spec#*:}; x=${rest%%:*}; y=${rest#*:} rgb=$(convert "$SRC" -crop ${P}x${P}+${x}+${y} +repage -colorspace sRGB \ -format "%[fx:int(mean.r*255)],%[fx:int(mean.g*255)],%[fx:int(mean.b*255)]" info:) echo "corner $lbl paper: $rgb" done

On the raccoon that prints:

overall mean R/G/B: 188 / 168 / 149 corner TL paper: 227,227,224 corner TR paper: 190,188,184 corner BL paper: 245,247,246 corner BR paper: 198,188,184
Three diagnoses, for free

Warm cast: the means lean warm (R well above B). Under-exposed / dull: “white” paper sits at 188–245, not ~250. Uneven gradient: the corners disagree by ~55 levels — bright at bottom-left, dim at top-right. Now we know what to aim each tool at.

The Corrections, One Knob at a Time

The cheap global fixes do most of the work. -auto-level stretches each channel to full range while preserving colour; -contrast-stretch does the same but lets you clip a tiny percentage to really snap the paper white:

convert source.png -auto-level autolevel.png convert source.png -contrast-stretch 0.4%x0.1% contraststretch.png convert source.png -modulate 100,118,100 vibrance.png # +18% saturation

For the warm cast specifically, a paper-referenced white balance: sample a strip that’s mostly paper, then scale each channel so that strip averages back to grey.

#!/usr/bin/env bash # Paper-referenced white balance. The paper is *supposed* to be neutral, so we # sample a strip of it, then scale each channel by (grey-average / channel) so # the paper averages out to grey again. Kills the warm cast; leaves the # brightness gradient alone (that is the flat-field's job). # # Usage: ./raccoon-white-balance.sh <source.png> <out.png> set -euo pipefail SRC="${1:?source}"; OUT="${2:?output}" # A 900x90 strip along the top edge is almost all paper for this scan. read R G B < <(convert "$SRC" -gravity North -crop 900x90+0+10 +repage \ -format "%[fx:mean.r] %[fx:mean.g] %[fx:mean.b]\n" info:) avg=$(awk -v r="$R" -v g="$G" -v b="$B" 'BEGIN{printf "%.5f", (r + g + b) / 3}') gR=$(awk -v p="$R" -v a="$avg" 'BEGIN{printf "%.4f", a / p}') gG=$(awk -v p="$G" -v a="$avg" 'BEGIN{printf "%.4f", a / p}') gB=$(awk -v p="$B" -v a="$avg" 'BEGIN{printf "%.4f", a / p}') convert "$SRC" \ -channel R -evaluate multiply "$gR" \ -channel G -evaluate multiply "$gG" \ -channel B -evaluate multiply "$gB" +channel \ "$OUT" echo "white-balanced (gains R=$gR G=$gG B=$gB) -> $OUT"

Here are the contenders side by side — source, white balance, auto-level, contrast-stretch, the flat-field (next section), and the final polish:

Six lighting-correction approaches compared in a 3x2 grid
Top: source · white balance · auto-level. Bottom: contrast-stretch · flat-field (note the pale-green blow-out) · the final polish.

The Flat-Field That Wasn’t

The textbook fix for an uneven illumination gradient is flat-fielding: divide the image by a heavily blurred copy of itself — your estimate of the light — and restore the tone. My first attempt blurred in colour and the divide dragged the midtones green; blurring in grayscale fixes the hue shift, so the brightness is corrected without tinting:

#!/usr/bin/env bash # Flat-field illumination correction — the textbook fix for an uneven lighting # gradient. Divide the image by a heavily blurred copy of itself (the estimated # illumination), then restore tone by the illumination's mean. The blur is taken # in GRAYSCALE so we correct brightness without dragging the colour balance # around (a colour blur tints the midtones — that was the first, failed attempt). # # Honest caveat: this is built for flat scanned documents. On a watercolour with # a bright paper background it pushes the paper past white and blows the # highlights — see the devblog. Kept because it is the *right* tool for the # wrong picture, and the comparison is the lesson. # # Usage: ./raccoon-flatfield.sh <source.png> <out.png> [sigma=90] set -euo pipefail SRC="${1:?source}"; OUT="${2:?output}"; SIGMA="${3:-90}" mean=$(convert "$SRC" -colorspace Gray -blur 0x${SIGMA} -format "%[fx:mean]" info:) convert "$SRC" \( +clone -colorspace Gray -blur 0x${SIGMA} \) +swap \ -compose divide -composite -evaluate multiply "$mean" \ "$OUT" echo "flat-fielded (sigma=$SIGMA, illumination mean=$mean) -> $OUT"
Right tool, wrong picture

Flat-fielding assumes the subject sits on a roughly mid-grey field; our subject sits on a bright paper, so dividing by the illumination shoves the paper past white and blows the highlights — the whole frame goes pale and faintly green. It is the right tool for a scanned document and the wrong tool for this picture. Kept in the kit because the comparison is the lesson: measure, try, and look, because the canonical answer is sometimes wrong for your image.

The Recipe That Worked

No flat-field. Just: white balance, a white-point stretch to brighten the dull paper, a vibrance bump so the watercolour reads, and a gentle S-curve for depth.

#!/usr/bin/env bash # The recipe that actually worked on the raccoon, in order: # 1. paper white balance (kills the warm cast) # 2. white-point contrast stretch (brightens the dull paper, fixes exposure) # 3. vibrance +18% (the watercolour pops; reds and golds deepen) # 4. gentle sigmoidal S-curve (a little contrast/depth without crushing) # No flat-field: on this bright-paper image, global tone beat the gradient fix. # # Measured effect on the source: paper lum 245 -> 250, mean saturation 25% -> 37%. # # Usage: ./raccoon-polish.sh <source.png> <out.png> set -euo pipefail SRC="${1:?source}"; OUT="${2:?output}" read R G B < <(convert "$SRC" -gravity North -crop 900x90+0+10 +repage \ -format "%[fx:mean.r] %[fx:mean.g] %[fx:mean.b]\n" info:) avg=$(awk -v r="$R" -v g="$G" -v b="$B" 'BEGIN{printf "%.5f", (r + g + b) / 3}') gR=$(awk -v p="$R" -v a="$avg" 'BEGIN{printf "%.4f", a / p}') gG=$(awk -v p="$G" -v a="$avg" 'BEGIN{printf "%.4f", a / p}') gB=$(awk -v p="$B" -v a="$avg" 'BEGIN{printf "%.4f", a / p}') convert "$SRC" \ -channel R -evaluate multiply "$gR" \ -channel G -evaluate multiply "$gG" \ -channel B -evaluate multiply "$gB" +channel \ -contrast-stretch 0.3%x0.05% \ -modulate 100,118,100 \ -sigmoidal-contrast 2x50% \ "$OUT" echo "polished -> $OUT"
Before and after: the dull warm source on the left, the polished version on the right
Source (left) → polished (right): white paper, deeper apple-red, richer fur, blacker mask.

Measured, the paper went from luminance 245 to 250 (clean white at last) and mean saturation from 25% to 37% — the apple deepens to a true cadmium red, the fur to saturated sienna, the mask to a proper black. Crucially this also feeds a better cutout: an evenly-lit, well-exposed raccoon segments more cleanly than one with a grey wash creeping up one side.

What Went Well

Measure-first paid off. The diagnostic numbers named the cast, the exposure, and the gradient before any guessing, so each tool had a target.

The boring tools won. -auto-level, -contrast-stretch, -modulate and a paper white balance did 90% of the work in four short lines.

Every script is runnable and embedded verbatim by the same deterministic builder the first raccoon post used.

What Didn’t Go Well

The bug that hardening introduced

read R G B < <(convert …) returns non-zero when the convert output has no trailing newline, and under set -e that silently killed the script after assigning the variables. Adding \n to the -format string fixed it. The interactive prototype had no set -e, so the bug only appeared once the code became a real script — hardening a script can introduce the bug.

The colour-blur flat-field tinted the midtones green until the blur was taken in grayscale — and even corrected, flat-fielding was simply the wrong tool for a bright-paper subject.

Takeaways

  1. 1
    Diagnose before you correct.

    Corner samples + channel means turn “it looks off” into “warm cast, +55 gradient, paper at 190.”

  2. 2
    Global tone beats clever local tricks on a bright background.

    Reach for -contrast-stretch and white balance before flat-fielding.

  3. 3
    A grayscale illumination map corrects brightness without shifting hue.

    If you must flat-field, never divide by a colour blur.

  4. 4
    Newline-terminate -format output you feed to read under set -e.

    Otherwise the missing newline makes read return non-zero and the script dies mid-stride.

The Watercolor

If the first raccoon piece was painted on the paper, this one is painted about the light falling on the paper. I’d lay the same bandit down twice on one sheet and let the difference be the whole composition.

The left figure I’d paint under a dirty north window: ochre and raw umber muddied with a grey wash that pools heavier toward the top-right corner, the way the photograph’s light died over there. Everything a half-stop too dim, the apple a brick rather than a jewel, the white of the page really a tired oatmeal. Honest, and a little sad.

Then I’d reach for the same three pigments at full strength and paint the right figure as if someone finally opened the curtains: the paper snapping to true white, the fur deepening to confident sienna, the apple flaring cadmium, the mask going from charcoal smudge to clean black velvet. Between them, faint, I’d sketch the one experiment that didn’t make it — a third raccoon ghosted in pale green, bleached where the flat-field divide pushed him past the paper. He’s the cautionary underpainting: the move the textbook promised would work, left visible under the varnish so the next person remembers to look and not just trust the recipe.