Photo Editing

Brighten dark photos in your browser with a 590 KB neural network

A brightness slider adds the same amount everywhere, which is why a dark photo goes pale instead of bright. A small trained network predicts a different correction for every pixel, plus one gamma and one colour matrix for the whole frame. It runs in your browser tab and your picture never leaves it.

Brighten dark photos in your browser with a 590 KB neural network

The photo was fine at the time. The room was warm, the light was low, and the phone did what phones do: it opened the shutter as long as it dared and handed you something dark, flat and grainy. On the screen it looked passable. On a page, next to a photo taken in daylight, it looks like a mistake.

So you drag the brightness slider. Everything gets lighter, including the parts that were already light, and the picture goes pale rather than bright. Then you try contrast, which pushes the shadows back down. Then you give up and use the picture anyway, or you do not use it at all.

The reason that loop never converges is that a brightness slider does not know anything about the picture. It adds the same amount to every pixel, so it cannot lift a face out of shadow without also washing out the window behind it. What it lacks is not power. It lacks an opinion about which parts of the frame were underexposed and which were not.

A model has that opinion

Networks trained for this problem are shown pairs: the same scene photographed badly and photographed well. What they learn is not a filter. They learn to predict a correction that varies across the frame, and the tool below runs one of them. It weighs about 590 KB, which is smaller than the photograph you are about to feed it.

Drop a dark picture in, press the button once to fetch the model, and look at what comes back. The model runs in this browser tab. Your picture is not uploaded, and there is nothing to upload it to: the only thing that travels is the model itself, coming down from this site the first time you ask for it, after which your browser keeps it.

Low light photo lifter

A photo taken indoors or after sunset comes back dark, flat and full of noise, and pulling the brightness slider up only makes a pale version of the same picture. This runs a small neural network over it instead, one that was trained on pairs of badly exposed and well exposed photographs. The whole network weighs about 590 KB, both sets of weights together. Your picture is read in this browser tab, never uploaded, and there is nowhere for it to be uploaded to.

Photo
Drop a dark photo here
or press Enter to pick one. JPEG, PNG or WebP.

Sample photo, drawn in this browser.

How the correction is made

Two sets of weights for the same network. The first was trained on ordinary badly exposed photographs and keeps the colours. The second was trained on near darkness and lifts much harder, at the cost of washing colour out. Try the first one first.

The model has one opinion about your photo. This dial mixes that opinion back with the original, so you can take half of it.

Off, the result comes back at most 2048 pixels on the long edge, which is more than any web page uses and takes a fraction of the time.

Load the model to begin.
As it came in
After the model

What this deliberately cannot do. It cannot invent detail that the sensor never recorded. A photo whose shadows are pure black stays black there, and lifting the rest only makes that obvious. It does not remove noise: brightening a dark photo brightens its grain too, and a denoiser is a different model that is not loaded here. It has no opinion about your taste, so a photo that was meant to be dark comes back looking wrong. And it is not a raw converter: it sees the eight bits per channel that the JPEG kept, not the twelve the camera had.

Why it works on the whole picture at once. The network has two halves. One is a stack of convolutions that only ever looks at a pixel's neighbourhood, so it can be run on tiles without a seam appearing. The other estimates a single gamma and a single colour matrix for the entire photograph, and that half is not indifferent to size: fed a small preview it reads the scene as less dark than it is and corrects too gently. So the global half sees the picture at up to 2048 pixels, the local half runs at full resolution in tiles, and the two are put back together here in the browser. Comparable tools exist, several of them good. What is unusual here is that nothing is sent anywhere.

WunderPaint
The Dynamic Design and Automation Studio
WunderPaint is a layered image editor for your WordPress media library. WunderPaint Studio is the same thing in any browser, free and without an account.

What the model actually predicts

This one is called an Illumination Adaptive Transformer, and it does not output a picture. It outputs four things, and the picture is assembled from them afterwards. Two of the four have one value per pixel, and two of them have one value for the whole photograph.

The per pixel pair is a multiplier and an offset. For each pixel and each colour channel the model says: take what you have, scale it by this much, then add this much. That is why it can lift a shadowed face without touching a bright window. The multiplier in the shadows is large and in the highlights it is close to one.

The whole image pair is a gamma and a three by three colour matrix. Gamma is the curve that decides how the midtones sit relative to the ends, the same curve you would reach for by hand in a curves panel. The colour matrix is a small correction for the cast that low light leaves behind, close to the identity matrix but not quite. The final picture is the multiplied and offset pixels, pushed through the matrix, then raised to the power of gamma.

Table of the four outputs of the network: mul and add carry one value per pixel and can be computed on tiles, while gamma is a single number and the colour matrix is three by three, neither of which can be tiled.

Written out, the whole reassembly is four lines. Everything difficult happened before this point.

high   = low * mul + add
coloured = colour_matrix x high
clamped  = clamp(coloured, 1e-8, 1)
result   = clamped ^ gamma

The half that does not scale

Here is where a naive implementation goes wrong, and it is worth spelling out because the failure is silent. The obvious way to make this fast on a large photograph is to run the model on a small copy, work out the correction, and stretch that correction back over the full sized picture. Corrections are smooth, the reasoning goes, so stretching one costs nothing.

It costs a great deal. Measured against running the model at full resolution, the stretched version was off by an average of 21 grey levels out of 255, with peaks past 120. The multiplier and offset maps are not smooth at all: they follow the texture of the picture, because that is the point of them being per pixel.

The second attempt was better and revealed the real problem. The network has two halves, and only one of them cares about size. The half that produces the multiplier and offset is a stack of convolutions, which means each output pixel depends only on a small neighbourhood of input pixels. Feed it a tile and it gives exactly the same answer for the middle of that tile as it would have given for the middle of the whole picture. That half is safe to cut up.

The half that produces gamma and the colour matrix is not. It pools the picture down to a handful of tokens and reasons about all of it at once, and it turns out to read a shrunken copy as a less desperate situation than the original. The numbers are unambiguous.

Table of the gamma the model requests when shown the same photograph at different sizes: 0.557 at 384 pixels, 0.490 at 512, 0.423 at 768, 0.388 at 1024, 0.351 at 1536, 0.331 at 2048 and 0.322 at full size, with bars showing how far each sits from the full size answer.

Gamma below one brightens, and the further below one, the harder. Shown a 384 pixel preview the model asked for 0.56. Shown the same photograph in full, it asked for 0.32. A tool built on the preview would correct roughly half as hard as the model intended and there would be no error message, no warning, nothing to notice: just a result that is quietly too timid. By 2048 pixels the difference is down to 0.009, which is where the tool draws its line.

Two sets of weights, and when to switch

The tool offers the same network with two different sets of weights, and the difference between them is the training data rather than the architecture. One set was trained on ordinary badly exposed photographs, the kind everyone has: a room that was dimmer than it looked, a subject against a window, a restaurant at the wrong hour. The other was trained on near darkness, on scenes that are almost black.

They behave very differently on the same input. The everyday set is conservative and keeps the colours close to what was recorded. The near darkness set lifts far harder and pays for it by washing colour out, which is the right trade when there is barely any colour left to preserve and the wrong one when there is. Run the everyday set first. If the result is still too dark, and only then, switch.

Both together are the 590 KB, so switching between them costs nothing after the first load. That is also why the tool marks a result as stale rather than leaving it on screen when you change the setting: the picture you are looking at was made by the other set of weights, and quietly leaving it there would invite you to download the wrong file.

Tiles that do not show

So the work is split. The global half sees the picture at up to 2048 pixels on its long edge, which costs a fraction of a second. The local half runs at full resolution in tiles of 384 pixels, and the two results are combined in JavaScript using the four lines above.

Tiling has one trap of its own. A convolutional network gives the right answer for a pixel only when that pixel has its real neighbourhood around it, and a pixel on the edge of a tile does not. Feed the tiles as they are and you get a faint grid across the picture, visible on smooth gradients like a sky. The fix is a margin: each tile is fed with sixteen extra pixels on every side that exist only to give the border pixels their context, and those sixteen pixels are thrown away before the tile is written into the result. Every output pixel comes from exactly one tile, and every one of them had its neighbourhood.

Reading the numbers it shows you

After each run the tool prints what it did, and two of those rows are worth reading rather than skipping. The first is the gamma the model chose. Below one means it decided to brighten, and how far below tells you how badly it thought the photograph was exposed. A gamma of 0.9 on a picture you thought was very dark means the model disagrees with you, and the result will be underwhelming for a reason that has nothing to do with the tool.

The second is whether it ran on the graphics card or the processor. On the processor a full sized phone photograph takes real time, on the order of a minute for twelve megapixels, because a single WebAssembly thread is doing every convolution. That is why the default caps the result at 2048 pixels on the long edge, which is already more than any web page serves, and why the option to keep the full resolution says what it costs before you tick it. If you are only ever going to publish the picture, the cap is not a compromise, it is the size you were going to resize to anyway.

What it deliberately cannot do

It cannot invent what the sensor never recorded. If your shadows are clipped to pure black, they contain no information, and lifting everything around them only makes that obvious. The tool will happily brighten such a photograph and the black patches will stay black.

It does not remove noise. Grain is signal too, as far as the multiplier is concerned, so brightening a dark photo brightens its grain. Denoising is a separate model, a much larger one, and it is not loaded here. If your result looks correctly exposed and speckled, that is the honest outcome and the next step is a different tool.

It has no taste. A photograph that was meant to be dark, a candlelit table, a silhouette against a window, comes back looking wrong, because the model was trained to produce well exposed pictures and it does not know that you wanted this one gloomy. That is what the strength dial is for: it mixes the model’s opinion back towards the original so you can take half of it.

And it is not a raw converter. It sees the eight bits per channel that survived into the JPEG, not the twelve the camera actually captured. If you still have the raw file, a proper converter has more to work with than this ever will.

Where this sits

Comparable tools exist, several of them good, and most of them work by sending your photograph to a server. The difference here is mechanical rather than qualitative: the weights come down to your browser once and your picture never leaves it. That matters more for some pictures than others, and if you have ever looked at what a photo carries in its metadata you already know which of yours you would rather not upload.

If the exposure is fine and the colours are the problem, that is a different job with a different answer, either a deliberate pass of colour grading or, if your images look flat everywhere on the site rather than just in one photo, the far more likely culprit of a colour profile going missing on upload.

The useful thing to take from the model, beyond the pictures it fixes, is the shape of the answer. Two per pixel maps and two global numbers is a small vocabulary, and almost everything a person does by hand to rescue an underexposed photograph can be written in it. The model is not doing something a person could not describe. It is doing something a person could describe and would not have the patience to specify for every pixel.

Brighten dark photos in your browser with a 590 KB neural network

Table of Contents

Learn it by building something

Every week one thing you can make the same afternoon, from dynamic templates to 3D type. Written down step by step.

One mail a week, and then it ends.
Unsubscribe in one click.

WordPress Images

WordPress srcset: How Core Builds It, and Where It Goes Wrong

WordPress writes the srcset attribute for you and gets it right. Then it writes the sizes attribute, which is a guess about a layout core has never seen, and that is where the bandwidth goes.

WordPress Development

wp-config.php Explained: The Constants That Earn Their Place

wp-config.php is the first file WordPress reads that is specific to your install, and almost every fix you will ever be handed ends with "add this line to wp-config.php". Here is what the file actually does, which constants earn their place, and why the placement rule matters.

WordPress Images

Upload Simulator: What WordPress Actually Writes to Disk

One 3000 by 2000 photo becomes eight files on disk. A 1200 by 800 photo becomes five. Drop your own file in and see exactly which registered sizes fire, which ones core refuses, what dimensions come out and what the whole set weighs.

SEO & Structured Data

WordPress OG Image: The Shop Window You Never See

WordPress core never writes an og:image tag, so a wrong or missing link preview is always somebody else's output. Here is the fallback chain a plugin walks, the registered size trap that makes a valid tag point at a missing file, and two curl commands that settle it.

WordPress Images

How to Convert Images to WebP in WordPress

Core converts on upload through a single filter and ignores everything already in the library. Here is what that filter really covers, what regeneration adds, and why the uploads folder grows before it shrinks.

CSS & Front-End

Font Face Generator: The Licence and the Layout Shift

Two things go wrong with web fonts: the licence nobody opened, and the layout that jumps when the font finally arrives. Both answers are already inside the file, in tables you can read in your own browser before you serve a single byte.

Download the free WunderPaint Plugin for WordPress

The WunderPaint workspace with the layers panel, adjustment sliders, text style presets and the asset library along the bottom

The Image Editor & Design Studio

Everything described here can be done in the browser, on your own site. The live demo runs the full editor with nothing to install.

Free

Chaos Art

Autonomous painters make one-of-a-kind abstract art in 3D space - gestures, art movements, painterly media, and embeds that paint a new original for every visitor.

Pro

Particle Strokes

Paint with swarms of light: twenty-two movements, a stamp you draw yourself, and curves that give a stroke a shape - the swarm keeps painting for a few seconds after you let go.

Pro

City Diorama

Any place on earth as a miniature you could hold: real streets, water and building footprints raised into a 3D diorama - or wrapped around a sphere as your own tiny planet.

Free

Papercut Art

Layered paper pictures with real depth - a photo sliced along its actual depth into up to twenty layers, parametric landscapes, animals and clouds you can shape, letters with real counters, and a look that runs on three dials.

Pro

3D Earth Studio

A hyperrealistic globe - day and night with city lights, live clouds, atmosphere halo, country borders and highlights, click-to-place markers with flight-route arcs, satellite orbits, seamless rotation video and a live website embed.