Fix #152455: OpenEXR DWAA/DWAB saving leads to inf values

DWAA/DWAB lossy compression uses DCT-based encoding, where values near
HALF_MAX can overshoot to infinity after decompression, due to DCT ringing.

There are different ways to resolves it and it's not so clear what the ideal
solution would be and how it would be handled upstream. By default it
will already convert values > HALF_MAX to inf and it's up to the application
to clamp if they want to (like we do).

As a workaround, clamp half float values before writing with an empirically
determined value that takes into account that lower quality makes this
problem worse. The resulting files should then decompress without inf in
existing software.

Pull Request: https://projects.blender.org/blender/blender/pulls/154061
This commit is contained in:
Brecht Van Lommel 2026-01-31 12:44:56 +01:00 • committed by Gitea
parent c17eb901f7
commit fac8b300e0
15 changed files with 78 additions and 14 deletions

View file

@ -386,9 +386,9 @@ struct _RGBAZ {
using RGBAZ = _RGBAZ;
static half float_to_half_safe(const float value)
static half float_to_half_safe(const float value, const float max_val = HALF_MAX)
{
return half(clamp_f(value, -HALF_MAX, HALF_MAX));
return half(clamp_f(value, -max_val, max_val));
}
bool imb_is_a_openexr(const uchar *mem, const size_t size)
@ -400,7 +400,7 @@ bool imb_is_a_openexr(const uchar *mem, const size_t size)
return Imf::isImfMagic((const char *)mem);
}
static int openexr_jpg_like_quality_to_dwa_quality(int q)
static int openexr_jpg_like_quality_to_dwa_compression_level(int q)
{
q = math::clamp(q, 0, 100);
@ -413,6 +413,24 @@ static int openexr_jpg_like_quality_to_dwa_quality(int q)
return q;
}
static float compression_half_max(const int compression, const int quality)
{
if (ELEM(compression, R_IMF_EXR_CODEC_DWAA, R_IMF_EXR_CODEC_DWAB)) {
/* Empirically determined margin to prevent DWAA/DWAB lossy compression
* from overshooting to infinity. The DWA compression uses log2-luminance
* DCT, and overshoot increases with the compression level.
*
* Tested with randomized pixel patterns across various compression levels
* to find a tight bound. */
const int level = openexr_jpg_like_quality_to_dwa_compression_level(quality);
const float margin = (level <= 50) ? (2048.0f + 32.0f * level) :
(3624.0f + 128.0f * (level - 50));
return HALF_MAX - margin;
}
return HALF_MAX;
}
static void openexr_header_compression(Header *header, int compression, int quality)
{
switch (compression) {
@ -443,11 +461,11 @@ static void openexr_header_compression(Header *header, int compression, int qual
#if OPENEXR_VERSION_MAJOR > 2 || (OPENEXR_VERSION_MAJOR >= 2 && OPENEXR_VERSION_MINOR >= 2)
case R_IMF_EXR_CODEC_DWAA:
header->compression() = DWAA_COMPRESSION;
header->dwaCompressionLevel() = openexr_jpg_like_quality_to_dwa_quality(quality);
header->dwaCompressionLevel() = openexr_jpg_like_quality_to_dwa_compression_level(quality);
break;
case R_IMF_EXR_CODEC_DWAB:
header->compression() = DWAB_COMPRESSION;
header->dwaCompressionLevel() = openexr_jpg_like_quality_to_dwa_quality(quality);
header->dwaCompressionLevel() = openexr_jpg_like_quality_to_dwa_compression_level(quality);
break;
#endif
#if COMBINED_OPENEXR_VERSION >= 30400
@ -583,11 +601,13 @@ static bool imb_save_openexr_half(ImBuf *ibuf, const char *filepath, const int f
try {
Header header(width, height);
openexr_header_compression(
&header, ibuf->foptions.flag & OPENEXR_CODEC_MASK, ibuf->foptions.quality);
const int compression = ibuf->foptions.flag & OPENEXR_CODEC_MASK;
openexr_header_compression(&header, compression, ibuf->foptions.quality);
openexr_header_metadata_global(&header, ibuf->metadata, ibuf->ppm);
openexr_header_metadata_colorspace(&header, ibuf);
const float half_max_val = compression_half_max(compression, ibuf->foptions.quality);
/* create channels */
header.channels().insert("R", Channel(HALF));
header.channels().insert("G", Channel(HALF));
@ -627,10 +647,10 @@ static bool imb_save_openexr_half(ImBuf *ibuf, const char *filepath, const int f
from = ibuf->float_buffer.data + int64_t(channels) * i * width;
for (int j = ibuf->x; j > 0; j--) {
to->r = float_to_half_safe(from[0]);
to->g = float_to_half_safe((channels >= 2) ? from[1] : from[0]);
to->b = float_to_half_safe((channels >= 3) ? from[2] : from[0]);
to->a = float_to_half_safe((channels >= 4) ? from[3] : 1.0f);
to->r = float_to_half_safe(from[0], half_max_val);
to->g = float_to_half_safe((channels >= 2) ? from[1] : from[0], half_max_val);
to->b = float_to_half_safe((channels >= 3) ? from[2] : from[0], half_max_val);
to->a = float_to_half_safe((channels >= 4) ? from[3] : 1.0f, half_max_val);
to++;
from += channels;
}
@ -840,6 +860,7 @@ struct ExrHandle {
bool write_multipart = false;
bool has_layer_pass_names = false;
float half_max_val = HALF_MAX;
int tilex = 0, tiley = 0;
int width = 0, height = 0;
@ -1016,6 +1037,7 @@ bool IMB_exr_begin_write(ExrHandle *handle,
handle->height = height;
openexr_header_compression(&header, compress, quality);
handle->half_max_val = compression_half_max(compress, quality);
if (!handle->write_multipart) {
/* If we're writing single part, we can only add one colorspace even if there are
@ -1207,7 +1229,7 @@ void IMB_exr_write_channels(ExrHandle *handle)
const float *rect = echan.rect;
half *cur = current_rect_half;
for (size_t i = 0; i < num_pixels; i++, cur++) {
*cur = float_to_half_safe(rect[i * echan.xstride]);
*cur = float_to_half_safe(rect[i * echan.xstride], handle->half_max_val);
}
half *rect_to_write = current_rect_half + (handle->height - 1L) * handle->width;
frameBuffer.insert(

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b64c4b4db47b51e2f7c69d38ba93f30e69cb80ee0761a37a00cbd18361874cce
size 18359

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4800ee496a6d311b2aafb48cd9300caf43345afcfffdf5575f6a827bd2e5561d
size 10616

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9605dc81541c513aabf1980635ba9edd91cf8c2a0becc0b6cec76ed7f910670b
size 1735

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bdb609df4d64807ef380b4d3f169f519c7c7368e9af47d4ca3acf1e87062323c
size 1844

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62be1e70a478b0c1aecedcde396920e53a611bc88e0780a016160fb12b637502
size 2261

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24eca7e95e7ade8c4e7040fcdac9bc78fceb0f64e2b2f9bf4db4d073850e2188
size 3691

View file

@ -0,0 +1 @@
channels=4 is_float=True colorspace='Linear Rec.709' alpha_mode='PREMUL'

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:df5bfcb8ca2287dc43f2430212bd6f35653a2cd50d2bd61c0c70c0043ba3f0a0
size 4262

View file

@ -0,0 +1 @@
channels=4 is_float=True colorspace='Linear Rec.709' alpha_mode='PREMUL'

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f85eaa2e2fbbf38840373295d989aea2f29a11a14002215f63d69718685d65c0
size 4335

View file

@ -0,0 +1 @@
channels=4 is_float=True colorspace='Linear Rec.709' alpha_mode='PREMUL'

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:decd767856944419fae0b484d1f81bad691450cddddee3aaa1f25795e1f1ea6e
size 4092

View file

@ -0,0 +1 @@
channels=4 is_float=True colorspace='Linear Rec.709' alpha_mode='PREMUL'

View file

@ -18,6 +18,7 @@ args = None
TEMPLATE_RGBA08 = "template-rgba08.png"
TEMPLATE_RGBA32 = "template-rgba32.exr"
TEMPLATE_HIGH_VALUES = "extreme_values/template-high-values.exr"
class ImBufTest(AbstractImBufTest):
@ -31,7 +32,7 @@ class ImBufTest(AbstractImBufTest):
def _load_template_image(self, name, template_name):
image_path = str(self.test_dir.joinpath(template_name))
bpy.ops.image.open(filepath=image_path)
img = bpy.data.images[template_name]
img = bpy.data.images[pathlib.Path(template_name).name]
img.name = name
return img
@ -47,7 +48,7 @@ class ImBufTest(AbstractImBufTest):
name += str(settings[s]).rjust(2, '0') + "-"
# do not embed exr quality into test file name unless it is DWAA/DWAB
elif not (s == "quality" and ext == "exr" and
settings.get("exr_codec") not in ("DWAA", "DWAB")):
settings.get("exr_codec") not in ("DWAA", "DWAB")):
name += str(settings[s]) + "-"
setattr(image_settings, s, settings[s])
@ -60,6 +61,7 @@ class ImBufTest(AbstractImBufTest):
loaders = {
"rgba08": lambda name: self._load_template_image(name, TEMPLATE_RGBA08),
"rgba32": lambda name: self._load_template_image(name, TEMPLATE_RGBA32),
"high-values": lambda name: self._load_template_image(name, TEMPLATE_HIGH_VALUES),
}
# Load the template image and assign it the image name
@ -144,6 +146,15 @@ class ImBufSaveTest(ImBufTest):
self.check(src="rgba32", ext="exr", settings={"file_format": "OPEN_EXR", "color_mode": "RGB", "color_depth": "16", "exr_codec": "HTJ2K"})
self.check(src="rgba32", ext="exr", settings={"file_format": "OPEN_EXR", "color_mode": "RGBA", "color_depth": "32", "exr_codec": "HTJ2K"})
def test_save_exr_dwa_high_values(self):
self.skip_if_format_missing("OPENEXR")
# Tests for correct clamping of values that exceed HALF_MAX.
self.check(src="high-values", ext="exr", settings={"file_format": "OPEN_EXR", "color_mode": "RGB", "color_depth": "16", "exr_codec": "DWAA", "quality": 100})
self.check(src="high-values", ext="exr", settings={"file_format": "OPEN_EXR", "color_mode": "RGB", "color_depth": "16", "exr_codec": "DWAA", "quality": 90})
self.check(src="high-values", ext="exr", settings={"file_format": "OPEN_EXR", "color_mode": "RGB", "color_depth": "16", "exr_codec": "DWAA", "quality": 75})
self.check(src="high-values", ext="exr", settings={"file_format": "OPEN_EXR", "color_mode": "RGB", "color_depth": "16", "exr_codec": "DWAA", "quality": 50})
def test_save_hdr(self):
self.check(src="rgba08", ext="hdr", settings={"file_format": "HDR", "color_mode": "BW"})
self.check(src="rgba08", ext="hdr", settings={"file_format": "HDR", "color_mode": "RGB"})