Color Management: Save and load HDR images with 203 nits

Based on extensive testing, this gives matching HDR brighness across most
application, with both PNG and AVIF. Video remains at 100 nits as that
appears to tbe convention there.

This is implemented by adding two modified PQ and HLG color spaces to the
OCIO config on startup, and for the specific cases of image save and loaded
these will replace the regular PQ and HLG color spaces.

This was chosen rather than adding them as color spaces in the OCIO config,
so that it can work for any config with appropriate interop IDs, including
the ACES config. Additionally, it would be unclear how to make this work with
view + display transforms, we wouldn't want to burden the users with having
to pick a different display depending if they are saving images or video.

Ref #145855, #144911

Pull Request: https://projects.blender.org/blender/blender/pulls/146888
This commit is contained in:
Brecht Van Lommel
2025-09-06 16:31:56 +02:00
parent ae2034e6c5
commit df12a448ba
12 changed files with 138 additions and 9 deletions

View File

@@ -37,8 +37,10 @@ enum ColorManagedDisplaySpace {
/* Convert to display space for drawing. This will included emulation of the
* chosen display for an extended sRGB buffer. */
DISPLAY_SPACE_DRAW,
/* Convert to display space for file output. */
DISPLAY_SPACE_FILE_OUTPUT,
/* Convert to display space for file output. Note image and video have different
* conventions for HDR brightness, so there is a distinction. */
DISPLAY_SPACE_IMAGE_OUTPUT,
DISPLAY_SPACE_VIDEO_OUTPUT,
/* Convert to display space for inspecting color values as text in the UI. */
DISPLAY_SPACE_COLOR_INSPECTION,
};

View File

@@ -34,4 +34,4 @@ const ColorSpace *colormanage_colorspace_get_named(const char *name);
const ColorSpace *colormanage_colorspace_get_roled(int role);
void colormanage_imbuf_set_default_spaces(ImBuf *ibuf);
void colormanage_imbuf_make_linear(ImBuf *ibuf, const char *from_colorspace);
void colormanage_imbuf_make_linear(ImBuf *ibuf, const char *from_colorspace, bool video);

View File

@@ -819,6 +819,7 @@ static std::shared_ptr<const ocio::CPUProcessor> get_display_buffer_processor(
display_parameters.use_hdr_buffer = GPU_hdr_support();
display_parameters.use_hdr_display = IMB_colormanagement_display_is_hdr(&display_settings,
view_transform);
display_parameters.is_image_output = (target == DISPLAY_SPACE_IMAGE_OUTPUT);
display_parameters.use_display_emulation = (target == DISPLAY_SPACE_DRAW) ?
get_display_emulation(display_settings) :
false;
@@ -870,7 +871,7 @@ void colormanage_imbuf_set_default_spaces(ImBuf *ibuf)
ibuf->byte_buffer.colorspace = g_config->get_color_space(global_role_default_byte);
}
void colormanage_imbuf_make_linear(ImBuf *ibuf, const char *from_colorspace)
void colormanage_imbuf_make_linear(ImBuf *ibuf, const char *from_colorspace, bool video)
{
const ColorSpace *colorspace = g_config->get_color_space(from_colorspace);
@@ -887,6 +888,14 @@ void colormanage_imbuf_make_linear(ImBuf *ibuf, const char *from_colorspace)
IMB_free_byte_pixels(ibuf);
}
if (!video) {
const ColorSpace *image_colorspace = g_config->get_color_space_for_hdr_image(
from_colorspace);
if (image_colorspace) {
from_colorspace = image_colorspace->name().c_str();
}
}
IMB_colormanagement_transform_float(ibuf->float_buffer.data,
ibuf->x,
ibuf->y,
@@ -2720,7 +2729,9 @@ ImBuf *IMB_colormanagement_imbuf_for_write(ImBuf *ibuf,
colormanagement_imbuf_make_display_space(colormanaged_ibuf,
&image_format->view_settings,
&image_format->display_settings,
DISPLAY_SPACE_FILE_OUTPUT,
image_format->media_type == MEDIA_TYPE_VIDEO ?
DISPLAY_SPACE_VIDEO_OUTPUT :
DISPLAY_SPACE_IMAGE_OUTPUT,
byte_output);
if (colormanaged_ibuf->float_buffer.data) {
@@ -2751,6 +2762,14 @@ ImBuf *IMB_colormanagement_imbuf_for_write(ImBuf *ibuf,
const char *to_colorspace = image_format->linear_colorspace_settings.name;
/* to_colorspace may need to modified to compensate for 100 vs 203 nits conventions. */
if (image_format->media_type != MEDIA_TYPE_VIDEO) {
const ColorSpace *image_colorspace = g_config->get_color_space_for_hdr_image(to_colorspace);
if (image_colorspace) {
to_colorspace = image_colorspace->name().c_str();
}
}
/* TODO: can we check with OCIO if color spaces are the same but have different names? */
if (to_colorspace[0] == '\0' || STREQ(from_colorspace, to_colorspace)) {
/* No conversion needed, but may still need to allocate byte buffer for output. */

View File

@@ -117,7 +117,7 @@ static void imb_handle_colorspace_and_alpha(ImBuf *ibuf,
}
}
colormanage_imbuf_make_linear(ibuf, new_colorspace);
colormanage_imbuf_make_linear(ibuf, new_colorspace, false);
}
ImBuf *IMB_load_image_from_memory(const uchar *mem,

View File

@@ -1329,7 +1329,7 @@ static ImBuf *ffmpeg_fetchibuf(MovieReader *anim, int position, IMB_Timecode_Typ
* It might not be the most optimal thing to do from the playback performance in the
* sequencer perspective, but it ensures that other areas in Blender do not run into obscure
* color space mismatches. */
colormanage_imbuf_make_linear(cur_frame_final, anim->colorspace);
colormanage_imbuf_make_linear(cur_frame_final, anim->colorspace, true);
}
}
else {

View File

@@ -34,6 +34,8 @@ struct DisplayParameters {
bool use_hdr_buffer = false;
/* Chosen display is HDR. */
bool use_hdr_display = false;
/* Display transform is being used for image output. */
bool is_image_output = false;
/* Rather than outputting colors for the specified display, output extended
* sRGB colors emulating the specified display. */
bool use_display_emulation = false;
@@ -133,6 +135,12 @@ class Config {
*/
virtual const ColorSpace *get_color_space_by_interop_id(StringRefNull interop_id) const = 0;
/**
* Get colorspace to be used for saving and loading HDR image files, which
* may need adjustments compared to the colorspace as chosen by the user.
**/
virtual const ColorSpace *get_color_space_for_hdr_image(StringRefNull name) const = 0;
/** \} */
/* -------------------------------------------------------------------- */

View File

@@ -113,6 +113,11 @@ const ColorSpace *FallbackConfig::get_color_space_by_interop_id(StringRefNull in
return nullptr;
}
const ColorSpace *FallbackConfig::get_color_space_for_hdr_image(StringRefNull name) const
{
return get_color_space(name);
}
/** \} */
/* -------------------------------------------------------------------- */

View File

@@ -50,6 +50,7 @@ class FallbackConfig : public Config {
const ColorSpace *get_color_space_by_index(int index) const override;
const ColorSpace *get_sorted_color_space_by_index(int index) const override;
const ColorSpace *get_color_space_by_interop_id(StringRefNull interop_id) const override;
const ColorSpace *get_color_space_for_hdr_image(StringRefNull name) const override;
/* Working space API. */
void set_scene_linear_role(StringRefNull name) override;

View File

@@ -80,6 +80,7 @@ LibOCIOConfig::LibOCIOConfig(const OCIO_NAMESPACE::ConstConfigRcPtr &ocio_config
initialize_active_color_spaces();
initialize_inactive_color_spaces();
initialize_hdr_color_spaces();
initialize_looks();
initialize_displays();
}
@@ -366,6 +367,62 @@ const ColorSpace *LibOCIOConfig::get_color_space_by_interop_id(StringRefNull int
/** \} */
/* -------------------------------------------------------------------- */
/** \name HDR image API
* \{ */
const ColorSpace *LibOCIOConfig::get_color_space_for_hdr_image(StringRefNull name) const
{
/* Based on emperical testing, ideo works with 100 nits diffuse white, while
* images need 203 nits diffuse whites to show matching results. */
const ColorSpace *colorspece = get_color_space(name);
if (colorspece->interop_id() == "pq_rec2020_display") {
return get_color_space("blender:pq_rec2020_display_203nits");
}
if (colorspece->interop_id() == "hlg_rec2020_display") {
return get_color_space("blender:hlg_rec2020_display_203nits");
}
return nullptr;
}
void LibOCIOConfig::initialize_hdr_color_spaces()
{
for (StringRefNull interop_id : {"pq_rec2020_display", "hlg_rec2020_display"}) {
const auto *colorspace = static_cast<const LibOCIOColorSpace *>(
get_color_space_by_interop_id(interop_id));
if (!colorspace || !colorspace->is_display_referred()) {
continue;
}
/* Create colorspace that uses 203 nits diffuse white instead of 100 nits. */
const auto hdr_100_colorspace = ocio_config_->getColorSpace(colorspace->name().c_str());
const auto hdr_colorspace = OCIO_NAMESPACE::ColorSpace::Create(
OCIO_NAMESPACE::REFERENCE_SPACE_DISPLAY);
const auto group = OCIO_NAMESPACE::GroupTransform::Create();
hdr_colorspace->setName(("blender:" + interop_id + "_203nits").c_str());
const auto to_203_nits = OCIO_NAMESPACE::MatrixTransform::Create();
to_203_nits->setMatrix(double4x4(double3x3::diagonal(203.0 / 100.0)).base_ptr());
group->appendTransform(to_203_nits);
const auto to_display = hdr_100_colorspace
->getTransform(OCIO_NAMESPACE::COLORSPACE_DIR_FROM_REFERENCE)
->createEditableCopy();
group->appendTransform(to_display);
hdr_colorspace->setTransform(group, OCIO_NAMESPACE::COLORSPACE_DIR_FROM_REFERENCE);
OCIO_NAMESPACE::Config *mutable_ocio_config = const_cast<OCIO_NAMESPACE::Config *>(
ocio_config_.get());
mutable_ocio_config->addColorSpace(hdr_colorspace);
inactive_color_spaces_.append_as(inactive_color_spaces_.size(), ocio_config_, hdr_colorspace);
}
}
/** \} */
/* -------------------------------------------------------------------- */
/** \name Working space API
* \{ */

View File

@@ -60,6 +60,7 @@ class LibOCIOConfig : public Config {
const ColorSpace *get_color_space_by_index(int index) const override;
const ColorSpace *get_sorted_color_space_by_index(int index) const override;
const ColorSpace *get_color_space_by_interop_id(StringRefNull interop_id) const override;
const ColorSpace *get_color_space_for_hdr_image(StringRefNull name) const override;
/* Working space API. */
void set_scene_linear_role(StringRefNull name) override;
@@ -103,6 +104,7 @@ class LibOCIOConfig : public Config {
* OpenColorIO configuration. */
void initialize_active_color_spaces();
void initialize_inactive_color_spaces();
void initialize_hdr_color_spaces();
void initialize_looks();
void initialize_displays();
};

View File

@@ -96,6 +96,36 @@ static OCIO_NAMESPACE::TransformRcPtr create_extended_srgb_transform(
return to_ui;
}
static void adjust_for_hdr_image_file(const LibOCIOConfig &config,
OCIO_NAMESPACE::GroupTransformRcPtr &group,
StringRefNull display_name,
StringRefNull view_name)
{
/* Convert HDR PQ and HLG images from 100 nits to 203 nits convention. */
const LibOCIODisplay *display = static_cast<const LibOCIODisplay *>(
config.get_display_by_name(display_name));
const LibOCIOView *view = (display) ? static_cast<const LibOCIOView *>(
display->get_view_by_name(view_name)) :
nullptr;
const LibOCIOColorSpace *display_colorspace = static_cast<const LibOCIOColorSpace *>(
view->display_colorspace());
if (display_colorspace == nullptr || !display_colorspace->is_display_referred()) {
return;
}
const ColorSpace *image_display_colorspace = config.get_color_space_for_hdr_image(
display_colorspace->name());
if (image_display_colorspace == nullptr || image_display_colorspace == display_colorspace) {
return;
}
auto to_display_linear = OCIO_NAMESPACE::ColorSpaceTransform::Create();
to_display_linear->setSrc(display_colorspace->name().c_str());
to_display_linear->setDst(image_display_colorspace->name().c_str());
group->appendTransform(to_display_linear);
}
static void display_as_extended_srgb(const LibOCIOConfig &config,
OCIO_NAMESPACE::GroupTransformRcPtr &group,
StringRefNull display_name,
@@ -417,6 +447,11 @@ OCIO_NAMESPACE::ConstProcessorRcPtr create_ocio_display_processor(
group->appendTransform(et);
}
if (display_parameters.is_image_output) {
adjust_for_hdr_image_file(
config, group, display_parameters.display, display_parameters.view);
}
/* Convert to extended sRGB to match the system graphics buffer. */
if (display_parameters.use_display_emulation) {
display_as_extended_srgb(config,

View File

@@ -156,7 +156,7 @@ class ConvertToDisplayOperation : public NodeOperation {
{
const NodeConvertToDisplay &nctd = node_storage(bnode());
ColormanageProcessor *color_processor = IMB_colormanagement_display_processor_new(
&nctd.view_settings, &nctd.display_settings, DISPLAY_SPACE_FILE_OUTPUT, do_inverse());
&nctd.view_settings, &nctd.display_settings, DISPLAY_SPACE_VIDEO_OUTPUT, do_inverse());
Result &input_image = get_input("Image");
@@ -181,7 +181,7 @@ class ConvertToDisplayOperation : public NodeOperation {
{
const NodeConvertToDisplay &nctd = node_storage(bnode());
ColormanageProcessor *color_processor = IMB_colormanagement_display_processor_new(
&nctd.view_settings, &nctd.display_settings, DISPLAY_SPACE_FILE_OUTPUT, do_inverse());
&nctd.view_settings, &nctd.display_settings, DISPLAY_SPACE_VIDEO_OUTPUT, do_inverse());
Result &input_image = get_input("Image");
float4 color = input_image.get_single_value<float4>();