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.
or press Enter to pick one. JPEG, PNG or WebP.
Sample photo, drawn in this browser.
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.
| What happened | Value |
|---|
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.
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.
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.
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.