Skip to content

Add libultrahdr - #4645

Merged
jcupitt merged 27 commits into
masterfrom
add-libultrahdr
Sep 2, 2025
Merged

Add libultrahdr#4645
jcupitt merged 27 commits into
masterfrom
add-libultrahdr

Conversation

@jcupitt

@jcupitt jcupitt commented Aug 23, 2025

Copy link
Copy Markdown
Member

Add uhdrload and udrsave.

libuhdr

Build with eg.:

    git clone https://github.com/google/libultrahdr.git

    cd libultrahdr
    mkdir build
    cd build
    cmake ../ \
            -G Ninja \
            -DCMAKE_INSTALL_PREFIX=/home/john/vips \
            -DCMAKE_BUILD_TYPE=Debug \
            -DUHDR_MAX_DIMENSION=65500
    ninja
    ninja install

This PR

  • add the gainmap as metadata on the output
  • support SDR and HDR decode
  • add shrink on load
  • use uhdr_dec_get_gainmap_image() etc. to do our own image decode
  • add some tests
  • add uhdrsave operation
  • streamed decode
  • exif and icc metadata load and save
  • save params to match jpegsave, eg. Q

Follow-up PR:

  • there must be a fast half -> float function in libultrahdr, we should use it
  • add a libvips operation for sdr + gainmap -> hdr
  • integrate into saveable system so we get eg. uhdr -> rad conversion
  • integrate into standard jpg decoder
  • other save params eg. multiband gain? not clear how useful they might be

@jcupitt
jcupitt marked this pull request as draft August 23, 2025 10:48
@jcupitt

jcupitt commented Aug 23, 2025

Copy link
Copy Markdown
Member Author

It seems to work -- I see:

$ VIPS_CONCURRENCY=1 /usr/bin/time -f %M:%e vips uhdrload ultra-hdr.jpg x.v
vips_foreign_load_uhdr_build:
vips_foreign_load_uhdr_header:
vips_foreign_load_uhdr_header: decoded image
	raw->fmt = UHDR_IMG_FMT_64bppRGBAHalfFloat
	raw->cg = UHDR_CG_BT_709
	raw->ct = UHDR_CT_LINEAR
	raw->range = UHDR_CR_UNSPECIFIED
	raw->w = 3840
	raw->h = 2160
	raw->planes[0] = 0x72f55ceb7010
	raw->planes[1] = (nil)
	raw->planes[2] = (nil)
	raw->stride[0] = 3840
	raw->stride[1] = 0
	raw->stride[2] = 0
vips_foreign_load_uhdr_header: gainmap image
	raw->fmt = UHDR_IMG_FMT_8bppYCbCr400
	raw->cg = UHDR_CG_UNSPECIFIED
	raw->ct = UHDR_CT_UNSPECIFIED
	raw->range = UHDR_CR_FULL_RANGE
	raw->w = 960
	raw->h = 540
	raw->planes[0] = 0x60eda394bd30
	raw->planes[1] = (nil)
	raw->planes[2] = (nil)
	raw->stride[0] = 960
	raw->stride[1] = 0
	raw->stride[2] = 0
118888:0.46

Then:

image

I can adjust the sliders to see detail in the exhaust:

image

Woo!

@jcupitt jcupitt mentioned this pull request Aug 23, 2025
@cromefire

cromefire commented Aug 23, 2025

Copy link
Copy Markdown

Cool that it already seems work (good enough for my use case), should scaling from UltraHDR -> UltraHDR already work? If so I'd like to give it a try if it works for previews and scaled images in Nextcloud although I'm sure how soon I can test it, given how I'll probably have to build up the stack from source and get it into docker somehow (IIRC it's libvips -> bimg -> imaginary -> docker image).

and attach the gainmap as metadata
@jcupitt

jcupitt commented Aug 23, 2025

Copy link
Copy Markdown
Member Author

@cromefire I think we'll probably need uhdrsave before we can do much testing.

Yes, building it will be a pain, you'll have to compile libultrahdr from source I think.

@jcupitt

jcupitt commented Aug 23, 2025

Copy link
Copy Markdown
Member Author

Also, this branch is just for testing, I doubt it'll get merged to libvips master. Right now at least, the memory behaviour is pretty bad.

@cromefire

cromefire commented Aug 23, 2025

Copy link
Copy Markdown

@cromefire I think we'll probably need uhdrsave before we can do much testing.

Yes, building it will be a pain, you'll have to compile libultrahdr from source I think.

So that means the upstream libraries would also have to build explicit support for scaling ultrahdr, right?

The building I can handle, just have to have a few hours time for it...

@PetrVys

PetrVys commented Aug 23, 2025

Copy link
Copy Markdown

FYI - it appears that a significant refactor of libultrahdr is on horizont, as Android 16 supports gainmaps also in HEIC files, and current library is pretty hardcoded to JPG. I haven’t seen any public mention of this yet, but it’ll probably be expanded work that you can see here: https://github.com/ittiam-systems/libultrahdr/tree/experimental

@jcupitt

jcupitt commented Aug 23, 2025

Copy link
Copy Markdown
Member Author

Tiny python test prog:

#!/usr/bin/env python

import sys
import pyvips

image = pyvips.Image.uhdrload(sys.argv[1])

gainmap_buffer = image.get('gainmap')
print(f"{len(gainmap_buffer)} bytes of gainmap data")
gainmap = pyvips.Image.jpegload_buffer(gainmap_buffer)
print(f"gainmap = {gainmap}")

profile = image.get('icc-profile-data')
print(f"{len(profile)} bytes of ICC profile data")
with open("profile.icc", "wb") as f:
    f.write(profile)
print(f"profile written to profile.icc")

for name in ["gainmap-max-content-boost",
             "gainmap-min-content-boost",
             "gainmap-gamma",
             "gainmap-offset-sdr",
             "gainmap-offset-hdr",
             "gainmap-hdr-capacity-min",
             "gainmap-hdr-capacity-max",
             "gainmap-use-base-cg"]:
    field = image.get(name)
    print(f"{name} = {field}")

I see:

$ ~/try/uhdr.py ultra-hdr.jpg 
vips_foreign_load_uhdr_build:
vips_foreign_load_uhdr_header:
31738 bytes of gainmap data
gainmap = <pyvips.Image 960x540 uchar, 1 bands, b-w>
588 bytes of ICC profile data
profile written to profile.icc
gainmap-max-content-boost = [100.00025177001953, 100.00025177001953, 100.00025177001953]
gainmap-min-content-boost = [1.0, 1.0, 1.0]
gainmap-gamma = [1.0, 1.0, 1.0]
gainmap-offset-sdr = [0.0, 0.0, 0.0]
gainmap-offset-hdr = [0.0, 0.0, 0.0]
gainmap-hdr-capacity-min = 1.0
gainmap-hdr-capacity-max = 100.00025177001953
gainmap-use-base-cg = 1

So it's correctly reading the SDR image, attaching the gainmap as a compressed JPG, and the gainmap metadata as a set of tags. It's able to stream SDR decode, so memory performance is OK in this case anyway.

You can set two options: shrink will perform shrink-on-load, and hdr will load as scRGB using libuhdr for the decode.

Comment thread meson.build Outdated

object_class->nickname = "uhdrload_buffer";
object_class->build = vips_foreign_load_uhdr_buffer_build;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we still need to declare ->is_a_buffer here that always returns FALSE. For example:

/* Compile with:
 *   gcc -g -Wall test.c `pkg-config vips --cflags --libs`
 */

#include <vips/vips.h>

int
main(int argc, char *argv[])
{
	if (VIPS_INIT(argv[0]))
		vips_error_exit(NULL);

	VipsSource *source;
	if (!(source = vips_source_new_from_file("/dev/null")))
		return 0;

	vips_foreign_find_load_source(source);
	vips_foreign_find_load_buffer(NULL, 0);

	return 0;
}
$ ./a.out 

(process:1903495): VIPS-WARNING **: 12:46:12.704: loader uhdrload_source has no is_a_source method

(process:1903495): VIPS-WARNING **: 12:46:12.705: loader uhdrload_buffer has no is_a_buffer method

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, good point, I'll fix this tonight.


object_class->nickname = "uhdrload_source";
object_class->build = vips_foreign_load_uhdr_source_build;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto, we should probably declare ->is_a_source here that always returns FALSE. Similar to csvload_source:

static gboolean
vips_foreign_load_csv_source_is_a_source(VipsSource *source)
{
/* Detecting CSV files automatically is tricky. Define this method to
* prevent a warning, but users will need to run the csv loader
* explicitly.
*/
return FALSE;
}

Comment thread meson_options.txt

option('uhdr',
type: 'feature',
value: 'auto',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for downstream packagers: libultrahdr is not yet widely available on the various distros.
libultrahdr packaging status

If you are building with -Dauto_features=enabled (see e.g. rule 3 from https://blogs.gnome.org/mcatanzaro/2022/07/15/best-practices-for-build-options/), you must either:

  • explicitly disable the feature by passing -Duhdr=disabled, or
  • ensure that libultrahdr is properly packaged.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a note to the README "dependencies" section for libuhdr.

@jcupitt

jcupitt commented Sep 2, 2025

Copy link
Copy Markdown
Member Author

Thank you for the review, @kleisauke !

Comment thread README.md Outdated
Co-authored-by: Kleis Auke Wolthuizen <github@kleisauke.nl>
@jcupitt
jcupitt merged commit db44003 into master Sep 2, 2025
14 checks passed
@jcupitt

jcupitt commented Sep 2, 2025

Copy link
Copy Markdown
Member Author

Thanks!

@gregbenz

gregbenz commented Sep 3, 2025

Copy link
Copy Markdown
Contributor

FYI - it appears that a significant refactor of libultrahdr is on horizon, as Android 16 supports gainmaps also in HEIC files, and current library is pretty hardcoded to JPG. I haven’t seen any public mention of this yet, but it’ll probably be expanded work that you can see here: https://github.com/ittiam-systems/libultrahdr/tree/experimental

Support for AVIF and HEIF is on the roadmap for libultra. Support for AVIF in libvips will be extremely useful, as this format is widely supported by all modern browsers, offers higher image quality, and files which are about half the size as JPG.

Support for decoding HEIF will be important to support transcoding from images captured on iPhone, though encoding to HEIF is more of a niche use case as support is primarily only in Safari / Apple software.

@gregbenz

gregbenz commented Sep 3, 2025

Copy link
Copy Markdown
Contributor

@jcupitt I'd be happy to help test. I'm very familiar with the gain map format and libultrahdr, but not libvips. Could you share a couple quick test commands to use via the CLI (or point to examples)?

I believe the following would be very helpful to ensure core transcoding operations work as expected:

  • resize an image
  • compress an image
  • crop an image (important to confirm metadata is not re-calculated - optimizing might seem attractive if you cut out a bright segment of an image, but it would result in mismatches under limited headroom if the intention was to split an image into segments which appear side by side).

All of these should be JPG gain map being transcoded to another JPG gain map with as much fidelity to the original as practical for a given transform and quality parameters.

@jcupitt

jcupitt commented Sep 3, 2025

Copy link
Copy Markdown
Member Author

Hi @gregbenz, thanks for the offer of help.

Support for AVIF in libvips will be extremely useful,

libvips has had HEIC and AVIF support since 2018, including high bit depth support. HDR-in-AVIF and -HEIC should just work if you have the right profile (I think). Gainmaps in HEIC and AVIF are not supported -- it sounds like the new libultrahdr release will help!

Could you share a couple quick test commands to use via the CLI (or point to examples)?

There are testing notes up the thread ^^^ #4645 (comment) with some sample python code.

Right now it's not an automatic process, you'll need to manipulate the gain map yourself, and explicitly load via the uhdrload and uhdrsave operations.

We're going to improve the integration with the libvips load and save system in a follow-up PR, so you don't need to call uhdrload and uhdrsave, and we're planning to leave most gainmap manipulation to higher level libraries, like sharp.

This is now merged to master (our dev branch), so if you find problems (you probably will!), please open a new issue.

@gregbenz

gregbenz commented Sep 3, 2025

Copy link
Copy Markdown
Contributor

@jcupitt Great to hear standalone HDR AVIF is already supported! Gain map AVIF will be very helpful and is the ideal way to share HDR (unless file size is a much higher priority than image quality). Gain maps are often misunderstood as a bit of a "hack" to enable HDR in the 8-bit JPG format. While they do that, the more important benefit is high-quality adaptation for displays lacking full headroom (anything requiring more than 1 stop of tone mapping tends to be low quality compared to a gain map created with artistic input). A gain map AVIF will offer much higher quality than AVIF without it (and avoids rendering issues in FireFox which shows HDR AVIF very dark, while the base SDR AVIF would render safely).

Here's a direct comparison of AVIF with and without a gain map: https://gregbenzphotography.com/hdr-photos/gain-maps-vs-tone-mapping-hdr/
And more info on the artistic value of gain maps: https://gregbenzphotography.com/hdr-photos/great-hdr-requires-a-great-sdr-in-the-gain-map/

Are there CLI commands which might be used for testing? I'm sure I could figure it out, but don't currently develop in Python. I should be able to build the binary if that's included in the output for the build process described above. I'm eager for the potential that WP media experiments should be able to leverage this for client-side WASM support to upload HDR gain maps to the WordPress media library, and though it would be ideal to do some testing at this level before the WASM is available in case there are some bugs. libultrahdr itself works very well, so for me it's just a question of confirming the new interaction between it and libvips.

@jcupitt

jcupitt commented Sep 3, 2025

Copy link
Copy Markdown
Member Author

Are there CLI commands which might be used for testing?

Yes, they are in that comment above, under "To test".

vipsdisp is very handy for testing, I'd try that too.

https://github.com/jcupitt/vipsdisp

Linux, mac and win binaries. If you load a uhdr image to .v format you can view it with vipsdisp and see individual pixel values, metadata. etc.

@gregbenz

gregbenz commented Sep 3, 2025

Copy link
Copy Markdown
Contributor

@jcupitt I see that round trip test, but am wondering about the best CLI test to validate resizing, compression, and cropping? Something that simulates deriving a thumbnail for an upload to WordPress would make for an ideal test.

My goal here is to transcode the JPG, I've got good tools to evaluate the output (the Adobe Gain Map Demo app is an excellent one to confirm a valid output and most map-specific metadata: https://helpx.adobe.com/camera-raw/using/gain-map.html#resources).

@jcupitt

jcupitt commented Sep 3, 2025

Copy link
Copy Markdown
Member Author

You could just about do a resize or crop at the CLI, but it'll be really awkward. I'd use python, it'd be much simpler.

You can test load and save at the CLI, both SDR and HDR, and compression settings (use the --Q switch).

The nice thing about vipsdisp is that you can see the intermediate image, so you can test uhdrload and uhdrsave separately.

@gregbenz

gregbenz commented Sep 3, 2025

Copy link
Copy Markdown
Contributor

@jcupitt Good to know, thanks, will take a look at that route.

@cromefire

cromefire commented Sep 3, 2025

Copy link
Copy Markdown

Currently loading a jpeg normally still discards the gainmap and you have to know it's an UltraHDR file and specifically load it, right? Once it's supported in normal JPEG load and save, I'd be interested to maybe see whether I can get UltraHDR support in 1-2 downstream libraries that affect me (if I'm able to do C lib stuff...). Mostly for Nextcloud, not sure whether WordPress also uses libvips in it's default config, not sure anymore whether it's was in PHP's image stack.

@jcupitt

jcupitt commented Sep 3, 2025

Copy link
Copy Markdown
Member Author

That's right, I'm planning to add a thing to the regular libvips jpeg loader that spots an UltraHDR image and transfers control to the uhdr loader instead. Same for save.

@gregbenz

gregbenz commented Sep 4, 2025

Copy link
Copy Markdown
Contributor

@jcupitt FYI that libultra has a probe to validate a gain map in unit tests (google/libultrahdr#201) and CLI (google/libultrahdr#368) which may help point to existing capabilities to leverage.

I haven't dug in to review the C++ here. If the library does not already have something you would consider sufficiently performant to determine when libvips should use libultrahdr vs another code path, may be good to raise a request in libultrahdr as I would expect similar concerns elsewhere (ImageMagick similarly segregates libultrahdr from other JPG processing to ensure no impact to existing workflows, edge cases, etc).

@gregbenz

gregbenz commented Sep 4, 2025

Copy link
Copy Markdown
Contributor

@cromefire WordPress has a few potential image pipelines for HDR support in the media library:

Limited workaround you can use now: share HDR gain maps via WordPress currently by using the "full" size in the media library, as this is just your original image. You won't have any derived sizes (thumbnails, etc) and you can't allow any optimization plugins that might alter the image. So it is not ideal, but there is some support as long as you do a unique upload for each size you need.

Several of these may require PHP 8.0+ in the hosting environment (Media Experiments does, and I believe AVIF support does too).

@jcupitt

jcupitt commented Sep 4, 2025

Copy link
Copy Markdown
Member Author

You can also use intervention:

https://github.com/Intervention/image

It's a general image handling module for php with backends for GD, imagick and php-vips. I think there are several wordpress things that let you use intervention for image handling.

There are some benchmarks of php-vips, imagick and gd here:

https://github.com/libvips/libvips/wiki/Speed-and-memory-use

(intervention will need some work to support libvips uhdr, but probably not too much)

@gregbenz

gregbenz commented Sep 4, 2025

Copy link
Copy Markdown
Contributor

@jcupitt One thought on leveraging libultrahdr. It can be compiled to output gain maps using the ISO standard (binary data in the aux image codestream), XMP metadata (per the Google/Android spec) or dual-encoded to use both. I recommend that libvips use ISO encoding, as XMP encoding is deprecated in favor of ISO - and libultrahdr will decode images with either regardless of which compiler options you choose for encoding new images. The ISO format is now well supported (all major browsers, Adobe software, Android, iOS, etc), and skipping XMP encoding reduces the file size by ~2K.

I don't recall the current defaults if you don't set the flags, but you can use something like:
cmake -DUHDR_WRITE_XMP=0 -DUHDR_WRITE_ISO=1 -DBUILD_SHARED_LIBS=0 -G "Unix Makefiles" ../

@suhailphotos

suhailphotos commented Oct 20, 2025

Copy link
Copy Markdown

Results from my test UltraHDR test harness (libvips + libultrahdr via pyvips) - Containerized

Hi all — I set up a small, reproducible test harness to exercise uhdrload/uhdrsave through libvips (master) with libultrahdr (main), using pyvips. It’s containerized (Ubuntu 24.04), so builds/pins are deterministic. Repo + full report:

What I tested

  • Inspect: verify UltraHDR metadata (embedded JPEG gain map, ICC profile, gain-map fields).
  • Recompress (Q-only): uhdrsave at lower quality.
  • Uniform resize: base image resized, no crop.
  • Crop: with and without an explicitly synchronized gain map.

Key observations

  1. Load/Save works and preserves metadata.

    • uhdrload() returns the SDR base image; the embedded gain map is exposed as a compressed JPEG buffer in the gainmap metadata field.
    • ICC profile is present and preserved by uhdrsave. In my samples: P3 ICC ~620 B; sRGB ICC ~3.1 KB.
  2. Recompress (quality-only) is safe.

    • Base and gain map re-saved intact; gain map byte size changes slightly (as expected for recompression).
    • All UltraHDR fields (max/min content boost, gamma, offsets, capacity) unchanged.
  3. Uniform resize (no crop) currently leaves the embedded gain map at original size.

    • In my simple script, base resized 1080→512 while the embedded gain map remained 1080×1080.
    • Visual checks looked fine in common viewers, but geometry no longer matches. Downstream pipelines that assume 1:1 geometry could be surprised.
  4. Cropping requires explicit gain-map alignment.

    • If you crop the base and do not crop the embedded gain map, you get a mismatch.
    • With a synchronized crop (crop the gain map proportionally, then re-embed), alignment is restored and HDR renders as expected.
    • Demo artifacts in the report show:
      • No-sync crop: base 640×640, gain map still 1080×1080 (intentional “bad” case).
      • Synced crop: base 640×640, gain map 640×640 (correct).

Why I’m posting

Given how the API is exposed today (gain map as a compressed buffer in metadata), this seems by design: libvips won’t automatically recompute or recrop the map, and implementers doing geometry changes (crop, non-uniform resize, rotate, etc.) must keep base + gain map aligned. That matches comments above about leaving most gain-map manipulation to higher-level libraries.

I wanted to share concrete, reproducible confirms + artifacts so downstream implementers (e.g., web thumbnailers, CMS pipelines) don’t accidentally ship mismatched crops.

Snippet: proportional gain-map crop (pyvips)

import pyvips

in_path  = "in.jpg"
out_path = "out.jpg"

img = pyvips.Image.uhdrload(in_path)
gm  = pyvips.Image.jpegload_buffer(img.get("gainmap"))

# crop box in base pixels
X, Y, W, H = 220, 220, 640, 640
img2 = img.crop(X, Y, W, H)

# map base->gainmap coordinates
Wb, Hb = img.width, img.height
Wg, Hg = gm.width, gm.height
xg = round(X * Wg / Wb);  yg = round(Y * Hg / Hb)
wg = max(1, round(W * Wg / Wb));  hg = max(1, round(H * Hg / Hb))
gm2 = gm.crop(xg, yg, wg, hg)

# (If you also resize the base, apply the same scaling to gm2 here.)

# re-embed and save
gm_bytes = gm2.jpegsave_buffer(Q=85)
img2.set("gainmap", gm_bytes)
pyvips.Operation.call("uhdrsave", img2, out_path, Q=85)

(For resize+crop, apply the same scale to the cropped gain map region before re-embedding.)

  • FYI per Greg’s note: libultrahdr can choose ISO vs XMP signaling for encoding; I stuck to defaults for these tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants