Manual creation of UltraHDR images

Inspired by this thread (Processing RAWs for HDR displays in Darktable), and by some UltraHDR pictures a friend took, I spent some time recently digging into existing tooling for generating these types of pictures, and wanted to share my progress in the hopes that people with more understanding can improve my process. I did a lot of searching around to see if there were any other tools available for manual creation of UltraHDR photos, and couldn’t find much, so hopefully this is helpful to some people!

Background

UltraHDR: a high dynamic range file format proposed by Google. My understanding is that this is similar to the proposed Adobe Gain Map spec, but I haven’t confirmed this. Put simply, this format stores two JPG images (an SDR rendition and a gain map) in a single file using the existing CIPA Multi-Picture Format spec. When displayed on an SDR display, or with a program that doesn’t support UltraHDR, the SDR rendition is used. The main source I’ve seen for this type of image is from Google Pixel phones, which has a toggle in the camera to create them.

Programs: I’ve been displaying these images on (a) an iPhone and a Pixel using the Google Photos app, and (b) an M-series MacBook Air in the Chrome browser. Your mileage may vary for display support. Greg Benz Photography has some good resources on support, and a testing page.

Tooling: Once you have an UltraHDR image file you may want to check parameters or other things. The embedded XML data is viewable in a plain text editor, you just need to scroll until you find it. You can also extract the gain map image using exiftool with the following: exiftool -b -MPImage2 ~/UltraHDRImage.jpg > GainMap.jpg.

Examples

(For the “screen” column, taking a screenshot on MacOS preserves the displayed brightness ratio in a SDR jpg, which is helpful for demonstration, although it doesn’t look exactly the same as it does in person.)

Attribution: First example is own work and licensed CC0. Bridge photo by chaimav from Take the High Road and licensed CC BY-NC-SA 4.0

Update: When viewing my post, the embedded HDR images don’t render correctly in-browser, probably due to metadata changes after uploading. Here are the original HDR image files for those that are interested.
hdr_examples.zip (1.4 MB)

Merge Process

To generate these photos, I used libultrahdr-wasm, a WebAssembly build of libultrahdr. I was able to find a built version of the library on NPM in @monogrid/gainmap-js. I then wrote a small script which allows me to manually specify the HDR parameters. The only dependency here is Node. The libraries are licensed under Apache 2.0, my example code is too simple for a license, but 0BSD if it matters. This is also a “Works on My Machine” situation, best of luck!

Here’s the step-by-step:

  1. Download libultrahdr-esm.wasm and libultrahdr-esm.js from @monogrid/gainmap-js/libultrahdr-wasm/build/ in the linked NPM repository.
  2. Rename libultrahdr-esm.js to libultrahdr-esm.mjs.
  3. Create a new file hdr.mjs with the contents of the script below.
  4. Edit imgName, gainName, outName, width, and height for your files.
  5. Run using: node hdr.mjs
hdr.mjs Script
import { readFile, writeFile } from 'fs/promises';
import libultrahdr from "./libultrahdr-esm.mjs"

const libraryInstance = await libultrahdr()

const imgName = './DSCF4682_sdr.jpg'
const gainName = './DSCF4682_gain.jpg'
const outName = './DSCF4682_hdr.jpg'

const width = 1749
const height = 1080

const img = new Uint8Array(await readFile(imgName));
const gain = new Uint8Array(await readFile(gainName));

const metadata = {
    "gainMapMax": 1.4888443464573364,
    "gainMapMin": 0,
    "gamma": 1,
    "hdrCapacityMax": 1.4888443464573364,
    "hdrCapacityMin": 0,
    "offsetHdr": 0.015625,
    "offsetSdr": 0.015625,
}

const result = libraryInstance.appendGainMap(
    width, height,
    img, img.length,
    gain, gain.length,
    metadata.gainMapMax, metadata.gainMapMin,
    metadata.gamma, metadata.offsetSdr, metadata.offsetHdr,
    metadata.hdrCapacityMin, metadata.hdrCapacityMax
)

await writeFile(outName, Buffer.from(result))

Gain Map Generation

For photos I’ve been taking a very manual approach. I create the SDR rendition in Darktable as I normally would. Then I create a duplicate, and tweak the contrast and exposure until I’m happy. I sometimes use tone equalizer, and usually make it black and white (although the spec allows for colored gain maps, which create an interesting effect).

I’m sure I could create a preset or similar to make this faster, I also imagine there’s a more technically correct way of generating these. I’d be very interested in suggestions or ideas on how to create a more automated workflow. There’s a lot in the UltraHDR spec on how to generate values that I haven’t gone through in detail.

Conclusion

For future work, it would be cool to build a little web tool to merge an SDR and gain map together, with sliders for the different values. The processing is fairly quick, and all local, so I imagine it could update the preview fairly quickly. This isn’t something I’m as familiar with, but it certainly seems possible. Also, a darktable preset for generating gain maps combined with a better CLI wrapper could allow for a nice workflow with darktable-cli. It’s also possible store the gain map in a lower resolution than the main image, but I haven’t tried this yet.

So far, it seems a bit like a novelty to me, and I probably won’t start exporting all of my photographs in UltraHDR. Visually, the main indicator is that it makes the highlights of the photo brighter than the system UI white. When the effect is high, it can be quite striking, a real “turn it up to 11” type of look.

Some high contrast photos (in particular of lit up buildings at night) are very cool, but for most of the pictures I take, it’s a pretty minor change. I really only notice the difference when my SDR images are in a shared album next to somebody else’s Pixel phone photos. When you swipe from an HDR to an SDR photo, it makes the latter look very dull in comparison.

7 Likes

I turned it off on my phone…I only have SDR monitors and i got images like you showed with blown highlights… so there was no benefit for me at this point anyway…

It is in fact an implementation of the Adobe spec, but with an extra GContainer that so far does exactly the same thing as the MPF, so could just as well be left out.

I find it curious that they seem to almost go out of their way to not mention Adobe, whether in the spec or in PR releases. Only real indication is the xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" line in the GContainer XMP and the fact that they use the term gain map.

Perhaps because this is to become an ISO standard and Google (and others) probably contributed to it…

P.S. Looks like it’ll also get support in libheif, libavif and a libjxl down the line…

1 Like

Thanks for this thread, @Bordwall!

I dug a little bit, and it turns out that libultrahdr (and that includes the wasm fork that was created months ago) currently has a bug that makes it output invalid UltraHDR images if any of the source images had an EXIF/JFIF/XPF metadata in them. They are invalid in the sense that the package ordering in the output file makes the file an invalid JFIF image, which in turn, e.g. disables the UltraHDR detection in Google Photos.

What I noticed works is stripping all the metadata, and re-adding it afterwards with exiftool. I’m using the example ultrahdr_app from upstream libultrahdr, but perhaps this approach works with the old wasm fork too. The whole script is as follows:

#!/usr/bin/env bash
# Create an UltraHDR file from a gainmap and an SDR jpegs
# https://developer.android.com/media/platform/hdr-image-format
usage() { 
    echo "Usage: $0 -i <input.jpg> -g <gainmap.jpg> -o <ultrahdr_output.jpg>" 1>&2; exit 1; 
}

while getopts ":i:g:o:" o; do
    case "${o}" in
        i)
            input=${OPTARG}
            ;;
        g)
            gainmap=${OPTARG}
            ;;
        o)
            output=${OPTARG}
            ;;
        *)
            usage
            ;;
    esac
done
shift $((OPTIND-1))

if [ -z "${input}" ] || [ -z "${gainmap}" ] || [ -z "${output}" ]; then
    usage
fi

SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
TMP=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir')

echo "Stripping all metadata from $gainmap..."
# Metadata in gainmap blocks UltraHDR rendering in Google Photos
exiftool -all= "$gainmap" -o "$TMP/gainmap.jpg"
echo "Stripping all metadata from $input..."
exiftool -all= "$input" -o "$TMP/input.jpg"
echo "Merging to UltraHDR image..."
$SCRIPT_DIR/ultrahdr_app -m 0 -i "$TMP/input.jpg" -g "$TMP/gainmap.jpg" -f "$SCRIPT_DIR/metadata.cfg" -z $TMP/out.jpg
echo "Copy $input metadata to UltraHDR image..."
# Reintegrate the metadata back into the merged file
exiftool -tagsfromfile "$input" -all>all $TMP/out.jpg
mv $TMP/out.jpg "$output"
rm -rf $TMP
echo "$output created."

The output file renders as UltraHDR in Google Photos, Chrome, and likely all the other places supporting UltraHDR. One downside is that you need to build libultrahdr yourself, but I expect soon we might have it in actual distros and Homebrew?

I’m trying out now whether it’s possible to have a usable workflow for exporting UltraHDR from base + gainmap images from Darktable with Lua. I’m guessing something based on the hugin script might work.

2 Likes

Added UltraHDR export plugin to generate UltraHDR JPEG images. by koto · Pull Request #502 · darktable-org/lua-scripts · GitHub works on this end of the keyboard. Now the images like https://photos.app.goo.gl/XKaDmbhSA6NCYpUx8 can be created straight from Darktable :slight_smile:

1 Like

I will likely need to view this on my phone to see the impact as my monitor is not an HDR device correct??

Yes, that’s correct. HDR downgrades to regular SDR on non-HDR displays.

1 Like

Thanks …if I build libultrahdr on windows which I think I can do from the instructions then were should those files be located??

Sounds great!

Right now, I get “Created 0 UltraHDR image(s) in /Users/jl/Pictures/Scratch” and no output is produced, but I’ll try to debug it, starting by doing the procedure manually.

Do you mean ultrahdr_app? It will be in the out directory of libultrahdr. metadata.cfg is in the examples dir.

In case you mean the Lua script, GitHub - darktable-org/lua-scripts has the instructions. Since this script is not merged, you could e.g. copy https://raw.githubusercontent.com/koto/lua-scripts/ultrahdr/contrib/ultrahdr.lua manually to the contrib directory and then enable the it in the script manager UI in Darktable.

The plugin assumes you export pairs of images generated from the same source image. What I do is:

  1. Create a regular base SDR image in DT (crop, filmic, color balance rgb, or whatever other modules you use usually)
  2. Create its another copy in duplicate manager - this will be the gainmap
  3. Apply monochrome module, drop the exposure a lot, and use tone equalizer to make the gainmap mostly black, apart from the parts you’d like to brighten in HDR. Try to boost those highlights a lot.
  4. Select both images, and export them using the “Ultra HDR” storage option.

If you get 0 images, it’s most likely because the module didn’t find two images that were generated from the same source raw.

1 Like

Indeed, I was only selecting the HDR version of the image before exporting. If I select both, it starts merging them!

However, I am now hitting another error “Error: File not found - /private/var/folders/<shortened_path>/_DSC9617_hdr.jpg”. Looking at the logs, I see a suspicious “unsupported option -z” and then the usage tips for ultrahdr_app are printed. So I suspect that this option is not supported in my version of ultrahdr_app (current main with commit bddf8da), and this causes the HDR version of the JPG file to not be produced.

Which version of libultrahdr are you targetting?

EDIT: solved, the new executable wasn’t copied properly, so I was using an old version.

I don’t think I know what I mean ? :slight_smile: But I will review everything tonight and take a run at it to see if I can cobble it all together…

Thanks for the information

I built at 4ef6913d25d37d53634cc6f8a31a70198e334008 from a few days ago, but the changes in between don’t seem to deprecate -z. In any case, it’s just the output filename, since it defaults to `out.jpeg’, so it would be trivial to workaround.

1 Like

Thanks! More likely something went wrong on my side. I’ll do a clean build and experiment with the app. (EDIT: make install was copying the library in the right place, but not the executable, so I was using an old, incompatible version of the latter…)

One last question: if one wants to manually export and merge the SDR and HDR files, which colorspace (and transfer function?) should they use?

¯_(ツ)_/¯ Sadly, I don’t know much about HDR beyond what I’ve read on Create and edit true HDR (High Dynamic Range) images - Greg Benz Photography. I just use sRGB in DT export settings for these HDRs, and didn’t notice any differences when using e.g. Display P3, or other wider gamuts. But I literally started looking into the subject a few days ago only.

1 Like

After solving my build issues, I can confirm that the lua plugin works on my side of the keyboard too. Thank a lot @Krzysztof_Kotowicz for putting it together!

1 Like

After playing a bit with the plugin, I can notice a small shift in colors, and the UltraHDR image is slightly too bright compared to the HDR one. It also doesn’t have the same dynamic range as an HDR image exported to JPEG XL (but that’s expected since it is only 8 bit, vs. 12 bit).

I’ll keep experimenting with the code, and try to find the parameters that minimize the color shift.

2 Likes

I experimented a bit more with libultrahdr today, and I think I started narrowing down the settings required for color accuracy. I used the binary ultrahdr_app directly, since it makes it easier to iterate on the settings.

I successfully ran the app in “mode 2”, i.e. by providing both a “SDR intent” and a “HDR intent”, as well as in “mode 3”, by providing the finished (“compressed”) SDR JPEG as well as the “HDR intent”.

Now, about those SDR/HDR “intents”: ultrahdr_app requires raw RGB images as inputs. I was unfamiliar with the formats, but it seems that those are just simple binary image formats, following a specific layout in memory. They can be easily generated using the ffmpeg program.

For the HDR intent, I first export my HDR edit in Darktable to a JPEG-XL image, using the P3 primaries and the PQ EOTF (I chose P3 since UltraHDR mostly targets recent consumer displays). In practice, I chose a 10-bit depth, quality 99% or 100% (lossless, but some viewers don’t support it so I can’t compare with the end result), and profile “PQ P3 RGB”. Make sure that the width and height of the image are even, by cropping if needed. I then generate the RAW image using:

# HDR intent
ffmpeg -i image.jxl -pix_fmt p010le -f rawvideo image-hdr.raw

(see this issue for the choice of p010le)

For the SDR intent, I proceed similarly, and export my SDR edit to either a finished JPEG or to a lossless PNG/TIFF. Here I use the profile “Display P3 RGB” since we don’t want the PQ EOTF for our SDR intent. If I chose the lossless route, I then have to generate the raw RGB image using:

# SDR intent
ffmpeg -i image.png -pix_fmt rgba -f rawvideo image-sdr.raw

We can now combine the SDR and HDR intents using the ultrahdr_app program. If we already have the finished JPEG, the command is:

ultrahdr_app -m 0 -i image.jpg -p image-hdr.raw \
    -a 0 -c 1 -C 1 -t 2 -M 1 -s 1 -Q 100 -D 1 \
    -w <width> -h <height> -z image-ultrahdr.jpg

If, instead, our SDR intent is a raw RGB image:

ultrahdr_app -m 0 -y image-sdr.raw -p image-hdr.raw \
    -a 0 -b 3 -c 1 -C 1 -t 2 -M 1 -s 1 -q 100 -Q 100 -D 1
    -w <width> -h <height> -z image-ultrahdr.jpg

I used the best quality settings above, but of course you will most likely want to tune them according to your use case.

Let’s briefly dive into the settings:

Settings
  • -i image.jpg: use the finished SDR image image.jpg as the base.
  • -y image-sdr.raw: use the raw SDR image image-sdr.raw as the SDR intent.
  • -p image-hdr.raw: use the raw HDR image image-hdr.raw as the HDR intent. The gain map will then be automatically computed from the HDR and SDR intents.
  • -a 0: the memory layout of the raw HDR image is P010.
  • -b 3: the memory layout of the raw SDR image is RGB8888.
  • -c 1 -C 1: both the SDR and HDR intents use the P3 primaries.
  • -t 2: the EOTF of the HDR intent is the PQ curve.
  • -M 1: the gain map is multi-channel (RGB instead of grayscale).
  • -s 1: the gain map downsampling factor. 1 means that it has the same resolution is the initial image.
  • -q 100: the JPEG quality for the SDR intent (if the JPEG is not used as input with -i).
  • -Q 100: the (JPEG?) quality for the compressed gain map itself.
  • -D 1: use slower but higher-quality encoding.
  • -w: the image width in pixels (must be even).
  • -h: the image height in pixels (must be even).
  • -z: the path to the output UltraHDR file.

These settings give me very decent results. In particular, I get consistent colors, contrast and brightness in the “SDR part” of the image.

Here is a .zip containing a SDR JPEG, a HDR JPEG-XL, and the corresponding UltraHDR JPEG (generated with a higher-quality JPEG-XL than the one uploaded here).
ultrahdr.zip (6.0 MB)

Although this is a significant improvement compared to my previous attempts from two weeks ago, there are still a few issues with this approach:

  • Although I get consistent colors in the shadows and midtones, I observe that the highlights are clearly oversaturated when using a RGB gain map, and I don’t understand why. Conversely, they are undersaturated when using a greyscale gain map (but that makes more sense).
  • ultrahdr_app doesn’t seem to handle noisy images very well when using a RGB gain map: the noise can be greatly enhanced (while the HDR intent looked fine), and the gain map itself looks noisy. This happens even when the SDR/HDR intents are lossless and the quality of the UltraHDR image (and its gain map) are set to 100.
  • I didn’t try to address the EXIF/Google Photos bug.

Let me know if you obtain similar results or if you find a solution to the noisy and oversaturated highlights!

Meanwhile, it seems that things are moving on the Darktable side, with some recent issue proposing to add first-party gain map support.