This site has a light theme and a dark theme, and when you flip between them the post's cover image cross-fades too — the daytime painting dissolves into a night version of the same scene. It's a small touch. It's also a trap.

For the fade to work, a cover needs two variants that are the same picture under different light. If the night version drifts even a few pixels, or an object gets redrawn, the crossfade doesn't dissolve — it visibly slides and morphs, and the effect goes from "nice" to "broken". And the laziest way to make a "dark" variant — take the day image and turn the brightness down — produces something that is pixel-perfectly aligned, unmistakably darker, and still wrong, because a dimmed noon is not night.

So every illustrated cover on this site is really a pair, and before a pair ships I want to prove three things: the two images line up, they're the same scene, and the dark one is a genuine night — not the day image with the lights turned down. Two of those three checks run on a GPU. This is how that works.


## Two images, one scene

The rule that makes everything else fall into place: the dark cover is produced as an edit of the light one, never as a second independent generation.

The light (daytime) image comes first, generated in a Ghibli hand-painted style at 1280×512. Then, instead of asking for "the same thing but at night" from scratch, I hand the model the finished light image and ask it to relight only — keep every object exactly where it is, change nothing but the light.

Why so strict? Because two independent text-to-image runs of "a cozy studio" will place the desk lamp thirty pixels to the left the second time, give the window four panes instead of three, and quietly swap the mug. On a static page you'd never notice. Under a crossfade, every one of those differences becomes a little animation of things sliding around. Editing from the light image is the only way to keep the two renders registered.

Swap the light *source*, don't dim the scene

The single instruction that separates a real night cover from a fake one: the emitter has to be visible in frame and change identity. An exterior-lit scene swaps the sun for the moon, same spot in the sky. An interior scene lets the daylight through the window fall away and turns the lamps, screens and candles into the dominant light. "Make it dark" is not that. "Replace the sun with the moon and let the desk lamp take over" is.


## The fake that beats pixels

Here's the uncomfortable part, and the reason this whole pipeline exists.

The most obvious way to cheat — grade the light image darker in place, maybe drop the saturation — produces the image that is, pixel-for-pixel, the closest possible match to itself. It has zero drift. It's darker by construction. It is the same scene, because it's literally the same pixels. Any check that compares the two images structurally gives a colour-graded fake a perfect score. It outranks a genuine, honestly re-rendered night cover, which has real brushwork differences.

It gives itself away in exactly one place: physics. A colour grade rotates the hue of saturated colours while leaving near-white pixels roughly untouched. Real relighting does the opposite — a coloured object under a new light keeps its own hue, while a white object takes on the colour of the light. A graded "night" has a suspiciously white moon and daylight-blue sky just... dimmed. So catching the fake can't be a pixel comparison at all. It has to be a judgement about what the picture depicts.

That splits the validation into two kinds of check: structure (do the pixels line up, is it the same scene, is it darker) and meaning (is it actually night). Three gates in total.


## Gate 1 — the fast structural check

The first gate is a zero-dependency Node script that runs on every merge request. It enforces four things about each pair:

//   1. both URLs resolve                — catches typos and deleted/renamed assets
//   2. identical pixel dimensions       — the page emits ONE width/height for both variants, so a
//                                         mismatched pair gets stretched after a theme toggle
//   3. the dark variant is darker       — catches swapped URLs, or the same image in both fields
//   4. the two renders are REGISTERED   — same framing.

The interesting one is registration. It fetches a small copy of each image, blurs slightly, takes the gradient, then slides one gradient map over the other looking for the offset with the highest normalised cross-correlation. A correctly-relit pair peaks at exactly (0,0). A drifted one peaks somewhere else and fails.

Why NCC and not a plain difference

Using RMS pixel difference here would be backwards: a dark variant has weaker edges in the shadows, and parking those weak edges over flat regions of the other image minimises the difference. So the "best" alignment by RMS is often a wrong one. Cross-correlation rewards edges landing on edges, which is what "same framing" actually means.

And a genuinely nasty bug that lived in an early version of this script — a decoding trap worth writing down:

The grayscale BMP trap

To read raw pixels I ask Cloudinary for a BMP. If you request f_bmp with e_grayscale, Cloudinary hands back an 8-bit palettised, RLE8-compressed BMP — and a naive row reader decodes that into garbage while looking like it worked. The brightness and correlation numbers come out plausible and completely meaningless. The fix is to ask for f_bmp without e_grayscale (plain 24-bit uncompressed rows) and compute luma yourself. The script now hard-fails on any compressed BMP rather than risk reading one wrong.

This gate is fast and free, but it is fundamentally a global translation check. It can tell you the whole image shifted by two pixels. It cannot tell you that the phone on the desk got redrawn as a different phone while everything else stayed put. For that you need to look everywhere at once — which is gate two, and where the GPU comes in.


## Gate 2 — deep matching on the GPU

The second gate runs LoFTR — a deep feature matcher — over every pair. LoFTR is trained for wide appearance change (day-to-night matching is one of its standard benchmarks), which is exactly the regime here: same geometry, very different lighting. It produces per-location correspondences between the two images, and three numbers fall out:

Term What it measures
inlier % share of matches that agree on one global translation — high means the two renders are the same scene, consistently placed
shift that translation, in original pixels, sub-pixel accurate
coverage share of an 8×8 grid over the light image that has a consistent match on the dark side

Coverage is the number the fast checker can't see. A relit-but-identical scene matches nearly everywhere, so coverage is near-total. A redrawn object leaves a hole that no global shift can fill — the grid cells over that object simply have no agreeing match. Measured across the real corpus, a correct pair sits near 99% inliers, 97% coverage, ~1px shift; a known-bad one landed at 18% / 27% / 32px. The thresholds sit in the wide gap between those.

MIN_INLIER_PCT = 70.0
MIN_COVERAGE   = 0.70
MAX_SHIFT_PX   = 6.0     # in ORIGINAL pixels

This needs torch + kornia + CUDA, so it runs on the GPU box. It's also written to be lazy: torch and the LoFTR weights are only imported after the pair list is resolved, so a merge request that touches no covers imports nothing and exits in about a second.


## Gate 3 — is it actually night?

Gates one and two both verify pixels. Neither can catch the colour-graded fake from earlier — same pixels, zero drift, darker by construction is a perfect score on both. Telling a real re-light from a graded fake means understanding what the image shows, so this gate is a vision-model judgement, not pixel math.

It asks qwen3-vl:32b, running on the GPU box's local Ollama, exactly one question about the dark variant — and the question is deliberately about the exterior, not the brightness:

Judge the EXTERIOR — the sky and anything visible through a window or doorway.
  PASS if it is night, dusk, twilight, sunset, sunrise, golden or blue hour, OR
       there is no bright exterior and the room is lit by lamp / screen / candle /
       moonlight. A lit lamp or a low sun is allowed to be bright.
  FAIL if REAL DAYLIGHT is still visible — a bright blue midday sky, direct sun,
       or sunlit greenery through a window — EVEN IF the room has been darkened
       and has lamps or glowing screens.

The key move is that brightness is not the test. A lit lamp is bright; a sunset is bright. What fails is daylight that never got swapped — a bright blue sky behind a window that someone just dimmed. That's the exact signature of a graded fake, and it's invisible to any pixel comparison but obvious to a model that can see.

What each gate is actually for

  • Fast check — did the pair line up and is it darker? (every MR, no GPU)
  • LoFTR — is it the same scene, or did objects get redrawn? (GPU)
  • Night vision — is it genuinely night, or a graded day? (GPU)

Structure, structure, meaning. The third one is the only one that can't be fooled by feeding the day image back to itself.


## Why there's a GPU in a blog pipeline

Both heavy gates — LoFTR and the vision model — run on Andromeda, the DGX Spark I bought to fine-tune models. Its GB10 has ~120 GB of unified memory; checking blog covers is not what a Grace-Blackwell box is for. But the weights load, the runner has a GPU, and a job that already exists costs nothing to reuse — so the training rig moonlights as an art-director between fine-tunes.

The two GPU jobs share the one physical card through a resource_group, so they never run at once:

check:covers:ml:
  tags: [gpu]
  resource_group: gpu   # shares the one physical GPU with check:covers:night
check:covers:night:
  tags: [gpu]
  resource_group: gpu   # — never run both at once

They run on merge requests, scoped to the covers the branch actually changed — but they also run unscoped every night. That nightly pass matters more than it sounds: the MR gates only see files a branch edits, and a cover image can change without any file changing. If I overwrite a Cloudinary asset in place, the URL in the frontmatter is byte-identical, no .md file is touched, and every MR gate is structurally blind to it. The nightly job re-checks the whole corpus against the live images, so an in-place swap that slips past review gets caught within a day.


## Where it stands

It earns its keep. The gates have already caught the exact failure they were built for: a batch of dark covers that had been dimmed rather than re-lit — bright daylight still sitting in the windows behind darkened rooms. The structural gates waved them through, perfectly aligned and dutifully darker. The night-vision gate failed all of them, named the sky state, and pointed at the rule: swap the source, don't dim the scene. They got re-rendered as genuine night, and this cover — the one at the top of this post — went through the same three gates before it shipped.

The whole thing is two small scripts, a vision model, and a GPU that was idle anyway. For a hand-rolled static site, that's a slightly absurd amount of machinery to make sure a painting looks right when you flip the lights off. But the crossfade is the kind of detail you only notice when it's broken — so it seemed worth making sure it never is.

support

Found this useful? You can buy me a coffee.

☕ buy me a coffee