Restoring HDR to AI-Edited iPhone Photos: The Long Version
I sometimes use the latest AI image-editing tools to touch up photos and remove objects. The problem is that the current batch I have tried, including OpenAI and Gemini, strip the HDR layer. That is the pop you see in the brightness of the sky in an iPhone photo. Specifically, it is a layer called the gain map.
Picture formats have changed a lot over the years. While we just see a box of pixels on the screen, more stuff is getting stuffed into the data itself. One of the modern ways to keep HDR photos compatible across devices is to include a standard SDR image and an additional grayscale gain map. Its job is to tell an HDR-capable display how much brighter to render each of those base pixels. Google and Apple ended up with different ways to structure this metadata, although the formats have started to converge around ISO 21496-1.
Now if we return to the image-editing tools of the modern AI stack, they operate on the simpler box of pixel values I brought up earlier. A matrix of pixels. Neural networks love matrices. The current crop just tosses out the HDR data, so if you want to edit a street sign out of a nature photo, the result looks flat next to the unedited ones on your MacBook or iPhone.
Apple says iPhones have been capturing gain-map HDR photos since 2020, and Google introduced Ultra HDR with Android 14. It is two parts, remember? The first is an 8-bit SDR JPEG, the kind of file any software written in the last thirty years can decode. The second is a grayscale image called the gain map, stored as another JPEG, which says how many stops brighter each part of the image should be rendered if the display can go brighter than SDR white.
This is not all HDR. Other formats store HDR pixels directly, including 10-bit HEIF and AVIF. The gain-map design is more pragmatic for a JPEG because it does not require a new codec or file extension. Old software can ignore the extra data and show the SDR image.
How the AI editors work
Each AI image editor I have looked at has roughly the same shape. The incoming file gets decoded into an RGB tensor. That tensor runs through some model, maybe diffusion or a segmentation-plus-inpainting pipeline. The output tensor gets encoded back to a JPEG or PNG and sent to the user. The piece that reads metadata, applies orientation, and writes the output is outside the model itself.
If the editor preserved the gain map byte for byte, the map would no longer be correct because the SDR pixels it was calibrated against are no longer the same pixels. The gain map describes the luminance difference between the SDR image and its brighter rendition. If you change the SDR image, the stencil-like overlay of HDR gain may no longer line up. Google's own editing guide makes the same distinction: crops and rotations can transform the gain map along with the base image, but edits to the actual contents may require changing or removing it.
Two kinds of gain-map metadata
One is the Google-Adobe format. Google defined it in the Ultra HDR spec that shipped with Android 14. The gain-map parameters live in an XMP packet in the primary JPEG, in an hdrgm namespace. There is a Container directory with a Primary item and a GainMap item. The GainMap item has a Length pointing at the size of the appended gain-map JPEG, and a Multi-Picture Format (MPF) marker points to where that second JPEG starts. Chrome on a capable display can parse this and render HDR.
The second is ISO 21496-1. The current iPhone files I inspected have an APP2 segment whose payload starts with urn:iso:std:iso:ts:21496:-1, and it appears in both the primary image and the gain-map image. In my tests, Preview.app on macOS and Photos on iOS would not show HDR from the Google XMP alone. With the ISO markers, they did. Current Android guidance now recommends encoding both kinds of metadata for maximum compatibility, so this is not just an Apple workaround anymore.
For my files to render as HDR in both Chrome and Apple's photo apps, they need all of this:
- the Google Container and
hdrgmXMP; - the ISO 21496-1 markers and parameters;
- the MPF offset table that links the primary image to the gain map; and
- an ICC profile for color accuracy.
All of this goes at the front of the file in a specific order, before the actual image data starts. It took a bit of trial and error to figure out.
The file structure, for one of my actual outputs, looks like this:
PRIMARY IMAGE (~223 KB):
SOI (start of image)
APP0 JFIF 16 bytes
APP1 XMP + Container + hdrgm 1,486 bytes <- Chrome needs this
APP2 MPF (offset table) 88 bytes <- links primary to gain map
APP2 ISO 21496-1 marker 34 bytes <- Preview.app needs this
APP2 ICC_PROFILE 604 bytes
DQT, SOF0, DHT, SOS, image data...
GAIN MAP (~10 KB):
SOI
APP0 JFIF 16 bytes
APP2 ICC_PROFILE 604 bytes
APP2 ISO 21496-1 marker + params 91 bytes <- Preview.app needs this
APP1 XMP + hdrgm params 616 bytes <- redundant, also helpful
DQT, SOF0, DHT, SOS, gain-map pixels...
Two JPEG files concatenated, with two kinds of HDR metadata woven through the
APP segments at the front of each one.
Building Ultra HDR JPEGs without libultrahdr
Google publishes a reference C++ library called libultrahdr that knows how to produce these files. It works, and for a general-purpose encoder it is the obvious choice. I only needed one narrow operation: combine an already-encoded SDR JPEG and gain-map JPEG using the metadata layout I had tested in Chrome and Apple's apps. I also wanted to understand the bytes. For that job, a few hundred lines of Python were easier for me to inspect and change than another compiled dependency. Code is cheap now, so I wrote it myself.
The module is called direct_ultrahdr.py. It takes the primary SDR JPEG, the gain-map JPEG, and a metadata object, then emits the final Ultra HDR JPEG bytes in about six hundred lines of Python.
The pieces are:
- Walk the primary JPEG and the gain-map JPEG to find the sequence of APP markers each one starts with.
- Build the XMP packets as plain strings. The Container XMP for the primary image goes into an APP1 segment with the Adobe XMP identifier
http://ns.adobe.com/xap/1.0/. ThehdrgmXMP for the gain map goes into the same kind of APP1 segment in the gain-map JPEG. - Build the MPF APP2 segment. MPF is a CIPA standard (DC-X007) with a TIFF-style header: big-endian, IFD at offset 8, three tags.
0xB000is the MPF version ("0100"),0xB001is the number of images (2), and0xB002is an MPEntry table with one entry per image. Each entry is 16 bytes: attributes, image size, offset relative to the start of the primary image, and two dependency fields. The primary image lives at offset 0 with sizesdr_size, and the gain map lives at offsetsdr_sizewith sizegainmap_size. - Build the ISO 21496-1 APP2 segments. This is the one piece I did not build from the spec. I pulled an HDR JPEG off a current iPhone, opened it in a hex editor, found the two APP2 segments whose payload started with
urn:iso:std:iso:ts:21496:-1, and copied them verbatim into my code as byte constants. The primary one is 36 bytes. The gain-map one is 93 bytes and includes gain-map parameters as packed integers. I know roughly what the fields are, but the spec is behind ISO's paywall and the easier path was to observe what Apple actually accepts.
Those constants look like this in the source:
ISO_21496_PRIMARY_APP2 = bytes.fromhex(
"ffe2002275726e3a69736f3a7374643a69736f3a74733a32313439363a2d310000000000"
)
ISO_21496_GAINMAP_APP2 = bytes.fromhex(
"ffe2005b75726e3a69736f3a7374643a69736f3a74733a32313439363a2d31"
"00000000004000000000000f42400028eb29000f4240ffffe317000f424000"
"28eb29000f4240000d5810000f42400000000a000f42400000000a000f4240"
)
The hex is the string urn:iso:std:iso:ts:21496:-1 followed by some numeric fields. I am not thrilled about shipping byte-literal constants that came out of a hex dump. But this is what the iPhone itself produces and what Preview.app accepts.
With those pieces, constructing the final file is mechanical. Insert the APP segments into the front of the primary JPEG, before the Start of Scan marker. Insert the matching APP segments into the gain-map JPEG. Concatenate. Patch the JFIF APP0 to include the four-byte AMPF marker that signals this is a multi-picture file. Yay, done.
A short sanity-check function I keep around for any file anyone sends me:
def analyze_ultrahdr(path):
with open(path, 'rb') as f:
data = f.read()
first_soi = data.find(b'\xff\xd8')
second_soi = data.find(b'\xff\xd8', first_soi + 2)
if second_soi == -1:
print("Not an Ultra HDR JPEG (no second image found)")
return
primary = data[:second_soi]
gainmap = data[second_soi:]
checks = {
'MPF in primary': b'MPF\x00' in primary,
'Container XMP': b'http://ns.google.com/photos/1.0/container/' in primary,
'hdrgm namespace': b'http://ns.adobe.com/hdr-gain-map/1.0/' in primary,
'ISO 21496-1 (primary)': b'urn:iso:std:iso:ts:21496:-1' in primary,
'ISO 21496-1 (gainmap)': b'urn:iso:std:iso:ts:21496:-1' in gainmap,
'ICC profile': b'ICC_PROFILE\x00' in primary,
}
for name, ok in checks.items():
print(f" {'OK ' if ok else 'NO '} {name}")
If all six are true, the files I generate render as HDR in both Chrome and Preview.
Where the gain map comes from
I have glossed over the interesting part. You have an edited SDR image, the gain map is gone, and you want a new gain map. How do you produce one?
You cannot reconstruct the exact original gain map because it encoded the camera pipeline's HDR rendition, information that is no longer present in the edited SDR image. But you can produce a plausible gain map: one that an HDR display can use to lift the right regions, and that looks like something the iPhone pipeline might have made for an image with those pixels.
The naive version is straightforward. Compute the luminance of each SDR pixel, stretch the bright end of the range, and call that the gain map. This is roughly what synthetic inverse-tone-mapping methods do. It kind of works, but the output is generic: every sky is boosted the same amount, every reflection is boosted the same amount, and the map does not know what a face is. Apply it uniformly and skin tones also get lifted. In the iPhone files I looked at, specular highlights and bright sources get pushed a lot, midtones get lifted a little, and skin and shadows are mostly left alone.
The less naive version is a neural network trained to produce a gain map. The one I landed on is GMNet, from the ICLR 2025 paper Learning Gain Map for Inverse Tone Mapping by Liao et al. It is small, it runs on CPU, and the authors published trained weights and both synthetic and real-world datasets.
GMNet has two branches. A global branch takes a 256×256 thumbnail of the input and extracts scene-level features. From those it produces three things: a small 3×3 kernel to apply dynamically to the local branch, a channel-attention vector, and a single scalar called qmax that is the ceiling on the boost. A local branch takes the full-resolution input and runs it through residual blocks. The two branches meet when the local features are convolved with the global-branch-produced kernel, the channel attention is applied, and the result is upsampled with pixel shuffle back to full resolution. Okay, that was messy, but at the end you have the gain map, along with qmax, which tells you the GainMapMax value to write into the metadata.
The global branch sees the whole scene and decides, for example, that this is a sunset, the sky should get a big boost, and the foreground should not. The local branch has enough resolution to paint that decision onto the right pixels. The fact that the boost kernel itself is network-produced, rather than fixed, is how the same model handles a noon beach shot and a candlelit dinner.
The output of the whole pipeline is the SDR image you uploaded, unchanged; a grayscale gain-map image derived from GMNet's prediction; and the gain-map metadata (min, max, gamma, offsets, capacity min, and capacity max) that goes into the Google XMP and ISO 21496-1 payload. Those three pieces get handed to the Ultra HDR packager, and out comes a file Chrome and Preview both understand.
Did you notice the HDR photos?
I was wondering which visitors to my sites were on devices that would actually render these correctly.
Browsers expose one direct signal: the CSS media query window.matchMedia('(dynamic-range: high)'), which reports whether the browser and output device say they support high peak brightness, high contrast, and more than 8 bits per color channel. It does not guarantee that HDR mode is currently active. I also collect the WebGL renderer string. That does not tell me whether the display is HDR, but it gives me a rough device and GPU class to help interpret the results.
Among my unique visitors, about 20% hit dynamic-range: high. If I count events rather than unique visitors, HDR-capable sessions account for 40%. The rest are on hardware that cannot go brighter than SDR, on browsers without the HDR rendering path enabled, or on OS versions that have not shipped gain-map rendering yet.
Apps and uploads are messier. Some preserve HDR on upload but only render it inside their native app, not in the web viewer. Some preserve it everywhere. Meta has written about HDR photo support in Instagram and Threads, and the state of the art there is better than it was a few years ago, but it is still platform- and device-specific.
Okay, thanks for reading!