Commit 49a6b678 for libheif

commit 49a6b6786d4c94eaaa243efb312601f352133348
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Sep 20 15:04:44 2026 +0200

    Decline Bayer images in Op_to_hdr_planes and Op_to_sdr_planes

    A Bayer image has colorspace filter_array with chroma planar, which has
    the same value as heif_chroma_monochrome, so both operators accepted it at
    plan time and declared a converted filter-array plane in their output
    state. Their runtime, however, only handles the Y/Cb/Cr/R/G/B/alpha planes
    and returned an image without any plane. Since the planner preferred that
    two-step route over demosaicing first, decoding an 8-bit Bayer 'unci' to
    16-bit interleaved RGB and a 12-bit one to 8-bit interleaved RGB failed
    with "Invalid bit depth" from the Bayer operator, and a native decode of a
    12-bit Bayer image with convert_hdr_to_8bit returned success with an image
    that had no planes at all. This predates the per-plane ColorState.

    Both operators now decline the filter-array colorspace. The Bayer operator
    demosaics first and the depth change is applied to its RGB output; the
    in-place 8-bit request for a Bayer image is refused cleanly.

    The test builds 8-bit and 12-bit Bayer files through the public API and
    checks the two repaired routes, the two direct routes, and that the
    in-place request never yields an image without planes.

diff --git a/libheif/color-conversion/hdr_sdr.cc b/libheif/color-conversion/hdr_sdr.cc
index 0738c7f4..85251d25 100644
--- a/libheif/color-conversion/hdr_sdr.cc
+++ b/libheif/color-conversion/hdr_sdr.cc
@@ -35,6 +35,14 @@ Op_to_hdr_planes::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  // A Bayer image (colorspace filter_array with chroma planar, which has the same value as
+  // heif_chroma_monochrome) passes the chroma test above, but the loop below only handles
+  // the Y/Cb/Cr/R/G/B/alpha planes and would return an image without any plane. Decline it,
+  // so that the Bayer operator demosaics first and the depth change applies to its RGB result.
+  if (input_state.colorspace == heif_colorspace_filter_array) {
+    return {};
+  }
+
   // Every plane, alpha included, is widened from 8 bits, so all of them must be 8 bits
   // (get_uniform_bits_per_pixel() is 0 when the planes differ).
   if (input_state.get_uniform_bits_per_pixel() != 8) { // TODO: support for <8 bpp
@@ -150,6 +158,14 @@ Op_to_sdr_planes::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  // A Bayer image (colorspace filter_array with chroma planar, which has the same value as
+  // heif_chroma_monochrome) passes the chroma test above, but the loop below only handles
+  // the Y/Cb/Cr/R/G/B/alpha planes and would return an image without any plane. Decline it,
+  // so that the Bayer operator demosaics first and the depth change applies to its RGB result.
+  if (input_state.colorspace == heif_colorspace_filter_array) {
+    return {};
+  }
+
   // Nothing to do when every colour plane is already 8 bits. The planes may differ from each
   // other ('unci' declares a depth per component); the loop below handles each plane on its
   // own, so a mixed image such as 8/8/16 is equalized to 8/8/8 here. For such an image
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 4d499f85..4608196f 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -156,6 +156,7 @@ if (WITH_UNCOMPRESSED_CODEC)
     add_libheif_test(uncompressed_mixed_chroma_depth_colorconv)
     add_libheif_test(uncompressed_alpha_composite_depth_mismatch)
     add_libheif_test(uncompressed_mixed_rgb_depth_to_sdr)
+    add_libheif_test(uncompressed_bayer_depth_conversion)
     add_libheif_test(uncompressed_mixed_chroma_depth_encode)
     add_libheif_test(uncompressed_wide_component_colorconv)
     add_libheif_test(uncompressed_block_pixel_overpacked)
diff --git a/tests/uncompressed_bayer_depth_conversion.cc b/tests/uncompressed_bayer_depth_conversion.cc
new file mode 100644
index 00000000..2575c5db
--- /dev/null
+++ b/tests/uncompressed_bayer_depth_conversion.cc
@@ -0,0 +1,196 @@
+/*
+  libheif unit tests
+
+  MIT License
+
+  Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in all
+  copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+  SOFTWARE.
+*/
+
+// Regression test: Op_to_hdr_planes and Op_to_sdr_planes accepted Bayer images at plan
+// time (colorspace filter_array uses chroma planar, which has the same value as
+// heif_chroma_monochrome) but their runtime only handles the Y/Cb/Cr/R/G/B/alpha planes,
+// so they produced an image without any plane. The planner preferred that two-step route
+// over demosaicing first: decoding an 8-bit Bayer 'unci' to 16-bit interleaved RGB and a
+// 12-bit one to 8-bit interleaved RGB failed with "Invalid bit depth" from the Bayer
+// operator, and a native decode of a 12-bit Bayer image with convert_hdr_to_8bit returned
+// success with an image that had no planes at all. Both operators now decline the
+// filter-array colorspace, so the Bayer operator runs first and the depth change is
+// applied to its RGB output.
+//
+// The files are built through the public API and the uncompressed encoder.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "libheif/heif_properties.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <string>
+
+namespace {
+
+constexpr uint32_t W = 64;
+constexpr uint32_t H = 64;
+
+std::string write_bayer_unci(int depth, const char* filename)
+{
+  heif_image* image = nullptr;
+  heif_error err = heif_image_create(W, H, heif_colorspace_filter_array, heif_chroma_planar, &image);
+  REQUIRE(err.code == heif_error_Ok);
+
+  uint32_t fa_id = 0;
+  err = heif_image_add_component(image, W, H, heif_cmpd_component_type_filter_array,
+                                 heif_component_datatype_unsigned_integer, depth, &fa_id);
+  REQUIRE(err.code == heif_error_Ok);
+
+  size_t stride = 0;
+  uint8_t* fa = heif_image_get_component(image, fa_id, &stride);
+  REQUIRE(fa != nullptr);
+  for (uint32_t y = 0; y < H; y++) {
+    for (uint32_t x = 0; x < W; x++) {
+      uint32_t v = (x * 31 + y * 17) & ((1u << depth) - 1);
+      if (depth <= 8) {
+        fa[y * stride + x] = static_cast<uint8_t>(v);
+      }
+      else {
+        reinterpret_cast<uint16_t*>(fa + y * stride)[x] = static_cast<uint16_t>(v);
+      }
+    }
+  }
+
+  uint32_t r_id = 0, g_id = 0, b_id = 0;
+  err = heif_image_add_bayer_component(image, heif_cmpd_component_type_red, &r_id);
+  REQUIRE(err.code == heif_error_Ok);
+  err = heif_image_add_bayer_component(image, heif_cmpd_component_type_green, &g_id);
+  REQUIRE(err.code == heif_error_Ok);
+  err = heif_image_add_bayer_component(image, heif_cmpd_component_type_blue, &b_id);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_bayer_pattern_pixel pattern[4] = {{r_id, 1.0f}, {g_id, 1.0f}, {g_id, 1.0f}, {b_id, 1.0f}};
+  err = heif_image_set_bayer_pattern(image, fa_id, 2, 2, pattern);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_context* ctx = heif_context_alloc();
+  heif_encoder* encoder = nullptr;
+  err = heif_context_get_encoder_for_format(ctx, heif_compression_uncompressed, &encoder);
+  REQUIRE(err.code == heif_error_Ok);
+
+  err = heif_context_encode_image(ctx, image, encoder, nullptr, nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  std::string path = get_tests_output_file_path(filename);
+  err = heif_context_write_to_file(ctx, path.c_str());
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_encoder_release(encoder);
+  heif_image_release(image);
+  heif_context_free(ctx);
+  return path;
+}
+
+// Decodes to an interleaved RGB target and checks that a plane of the expected depth exists.
+void decode_to_interleaved(const std::string& path, heif_chroma chroma, int expected_range_bits)
+{
+  heif_context* ctx = heif_context_alloc();
+  heif_error err = heif_context_read_from_file(ctx, path.c_str(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+  heif_image_handle* handle = get_primary_image_handle(ctx);
+
+  heif_image* img = nullptr;
+  err = heif_decode_image(handle, &img, heif_colorspace_RGB, chroma, nullptr);
+  INFO("decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(img != nullptr);
+
+  CHECK(heif_image_get_chroma_format(img) == chroma);
+  CHECK(heif_image_get_bits_per_pixel_range(img, heif_channel_interleaved) == expected_range_bits);
+  CHECK(heif_image_get_width(img, heif_channel_interleaved) == static_cast<int>(W));
+  CHECK(heif_image_get_height(img, heif_channel_interleaved) == static_cast<int>(H));
+
+  size_t stride = 0;
+  const uint8_t* p = heif_image_get_plane_readonly2(img, heif_channel_interleaved, &stride);
+  CHECK(p != nullptr);
+
+  heif_image_release(img);
+  heif_image_handle_release(handle);
+  heif_context_free(ctx);
+}
+
+} // namespace
+
+TEST_CASE("Bayer unci depth conversion goes through the Bayer operator")
+{
+  if (!heif_have_decoder_for_format(heif_compression_uncompressed)) {
+    SKIP("uncompressed decoder not available");
+  }
+  if (!heif_have_encoder_for_format(heif_compression_uncompressed)) {
+    SKIP("uncompressed encoder not available");
+  }
+
+  std::string bayer8 = write_bayer_unci(8, "bayer_8bit.heif");
+  std::string bayer12 = write_bayer_unci(12, "bayer_12bit.heif");
+
+  SECTION("direct routes (unchanged)") {
+    decode_to_interleaved(bayer8, heif_chroma_interleaved_RGB, 8);
+    decode_to_interleaved(bayer12, heif_chroma_interleaved_RRGGBB_LE, 12);
+  }
+
+  SECTION("8-bit Bayer to 16-bit interleaved RGB (was to_hdr on the filter array)") {
+    // An interleaved RRGGBB target without a requested depth is 10 bits.
+    decode_to_interleaved(bayer8, heif_chroma_interleaved_RRGGBB_LE, 10);
+  }
+
+  SECTION("12-bit Bayer to 8-bit interleaved RGB (was to_sdr on the filter array)") {
+    decode_to_interleaved(bayer12, heif_chroma_interleaved_RGB, 8);
+  }
+
+  SECTION("12-bit Bayer, native layout with convert_hdr_to_8bit, never yields an image without planes") {
+    heif_context* ctx = heif_context_alloc();
+    heif_error err = heif_context_read_from_file(ctx, bayer12.c_str(), nullptr);
+    REQUIRE(err.code == heif_error_Ok);
+    heif_image_handle* handle = get_primary_image_handle(ctx);
+
+    heif_decoding_options* options = heif_decoding_options_alloc();
+    REQUIRE(options != nullptr);
+    options->convert_hdr_to_8bit = true;
+
+    heif_image* img = nullptr;
+    err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, options);
+    INFO("decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+
+    if (err.code == heif_error_Ok) {
+      // Lowering a Bayer image to 8 bits in place is not implemented today. If it is one
+      // day, the result has to carry the filter-array plane.
+      REQUIRE(img != nullptr);
+      CHECK(heif_image_has_channel(img, heif_channel_filter_array));
+      heif_image_release(img);
+    }
+    else {
+      CHECK(err.code == heif_error_Unsupported_feature);
+      CHECK(err.subcode == heif_suberror_Unsupported_color_conversion);
+      CHECK(img == nullptr);
+    }
+
+    heif_decoding_options_free(options);
+    heif_image_handle_release(handle);
+    heif_context_free(ctx);
+  }
+}