Commit 9ef7b5dd for libheif

commit 9ef7b5ddb80afd20037bba00834d6799544d94ae
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Sep 20 15:09:29 2026 +0200

    Composite the alpha of a luma-only image directly on its Y plane

    Op_flatten_alpha_plane converts its input to planar RGB, composites the
    alpha plane onto the colour planes, and converts back to the source
    colorspace. There is no operator from RGB back to a luma-only image, so
    compositing a monochrome image with alpha failed with "unsupported color
    conversion" for monochrome and RGB targets (the planner flattens first
    because that is the cheapest way to drop the alpha plane) and only worked
    for a YCbCr target, where the planner converts before it flattens. This
    predates the per-plane ColorState.

    A luma-only image (colorspace monochrome, or YCbCr with chroma
    monochrome) is now composited directly on its Y plane against the BT.601
    luma of the background colour, which is exact for the grey backgrounds
    this feature is used with. The per-plane composite loop is shared between
    the luma and the RGB path. Direct callers get errors instead of undefined
    behaviour when no composition mode is set, the image has no alpha plane,
    the depths do not match the 'Pixel' type, or the alpha plane has a
    different size. Bayer images are declined at plan time.

    Tests: monochrome to monochrome at 8 and 10 bits with exact values,
    monochrome to interleaved RGB, a checkerboard, and a coloured background
    that checks the BT.601 weighting.

diff --git a/libheif/color-conversion/alpha.cc b/libheif/color-conversion/alpha.cc
index e981e53f..b585a7e3 100644
--- a/libheif/color-conversion/alpha.cc
+++ b/libheif/color-conversion/alpha.cc
@@ -152,7 +152,10 @@ Op_flatten_alpha_plane<Pixel>::state_after_conversion(const ColorState& input_st

   // only drop alpha plane if it is not needed in output

-  if ((input_state.chroma != heif_chroma_monochrome &&
+  // Bayer images (colorspace filter_array, chroma planar == monochrome) are not composited;
+  // there is no way back from RGB to a filter array.
+  if (input_state.colorspace == heif_colorspace_filter_array ||
+      (input_state.chroma != heif_chroma_monochrome &&
        input_state.chroma != heif_chroma_420 &&
        input_state.chroma != heif_chroma_422 &&
        input_state.chroma != heif_chroma_444) ||
@@ -180,6 +183,59 @@ Op_flatten_alpha_plane<Pixel>::state_after_conversion(const ColorState& input_st
 }


+// Composites one plane onto a background: out = (in * a + bkg * (alpha_max - a)) >> bpp_alpha.
+// With 'checkerboard_square_size' == 0 the background is 'bkg1' everywhere; otherwise the
+// squares alternate between 'bkg1' and 'bkg2'. All three planes are stored with sizeof(Pixel)
+// bytes per sample; the strides are in samples.
+template<class Pixel>
+static void composite_plane(const Pixel* p_in, size_t stride_in,
+                            const Pixel* p_alpha, size_t stride_alpha,
+                            Pixel* p_out, size_t stride_out,
+                            uint32_t width, uint32_t height,
+                            int bpp_alpha, Pixel bkg1, Pixel bkg2, uint32_t checkerboard_square_size)
+{
+  // The composite is a weighted sum bounded by (2^bpp-1) * (2^bpp_alpha-1). For 8-bit samples
+  // that fits a plain int; for wider samples it can exceed a signed 32-bit int (65535*65535
+  // overflows), so accumulate in an unsigned 32-bit integer, which is exact as long as both
+  // the colour and alpha samples are <= 16 bits (bound < 2^32). Using uint32_t rather than a
+  // 64-bit type keeps the hot path efficient on non-64-bit architectures. The caller rejects
+  // anything wider.
+  using composite_t = std::conditional_t<(sizeof(Pixel) > 1), uint32_t, int>;
+
+  const Pixel alpha_max = static_cast<Pixel>((1 << bpp_alpha) - 1);
+
+  if (checkerboard_square_size == 0) {
+    for (uint32_t y = 0; y < height; y++)
+      for (uint32_t x = 0; x < width; x++) {
+        int a = p_alpha[y * stride_alpha + x];
+        composite_t composite = static_cast<composite_t>(p_in[y * stride_in + x]) * a
+                                + static_cast<composite_t>(bkg1) * (alpha_max - a);
+        p_out[y * stride_out + x] = static_cast<Pixel>(composite >> bpp_alpha);
+      }
+  }
+  else {
+    for (uint32_t y = 0; y < height; y++)
+      for (uint32_t x = 0; x < width; x++) {
+        uint8_t parity = (x / checkerboard_square_size + y / checkerboard_square_size) % 2;
+        Pixel bkg = parity ? bkg1 : bkg2;
+
+        int a = p_alpha[y * stride_alpha + x];
+        composite_t composite = static_cast<composite_t>(p_in[y * stride_in + x]) * a
+                                + static_cast<composite_t>(bkg) * (alpha_max - a);
+        p_out[y * stride_out + x] = static_cast<Pixel>(composite >> bpp_alpha);
+      }
+  }
+}
+
+
+// Luma of a background colour given as 16-bit RGB, with BT.601 weights. Used to composite
+// onto a luma-only image.
+static uint16_t background_luma16(uint16_t r, uint16_t g, uint16_t b)
+{
+  return static_cast<uint16_t>((299u * r + 587u * g + 114u * b + 500u) / 1000u);
+}
+
+
 template<class Pixel>
 Result<std::shared_ptr<HeifPixelImage>>
 Op_flatten_alpha_plane<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixelImage>& input_raw,
@@ -189,23 +245,102 @@ Op_flatten_alpha_plane<Pixel>::convert_colorspace(const std::shared_ptr<const He
                                                   const heif_color_conversion_options_ext& options_ext,
                                                   const heif_security_limits* limits) const
 {
-  std::shared_ptr<const HeifPixelImage> input = input_raw;
+  // The guards up to the bit-depth check are only reachable by a direct caller;
+  // state_after_conversion() does not offer this operation in these cases.
+
+  if (options_ext.alpha_composition_mode == heif_alpha_composition_mode_none) {
+    return Error{heif_error_Usage_error,
+                 heif_suberror_Unspecified,
+                 "Op_flatten_alpha_plane: no alpha composition mode"};
+  }

-  // The colour planes are converted at, and the alpha plane is composited at, one common depth.
-  // state_after_conversion() only offers this operation when the planes agree; a direct caller
-  // may not have checked.
+  if (!input_raw->has_channel(heif_channel_Alpha)) {
+    return Error{heif_error_Usage_error,
+                 heif_suberror_Unspecified,
+                 "Op_flatten_alpha_plane: image has no alpha plane"};
+  }
+
+  // The colour planes are converted at, and the alpha plane is composited at, one common depth
+  // through the 'Pixel' type of this instance.
   int color_bpp = input_state.get_uniform_color_bits_per_pixel();
-  if (color_bpp == 0) {
+  int bpp_alpha = input_raw->get_bits_per_pixel(heif_channel_Alpha);
+
+  if (color_bpp == 0 ||
+      bpp_alpha != color_bpp ||
+      bytes_per_sample_for_bit_depth(color_bpp) != static_cast<int>(sizeof(Pixel))) {
     return Error{heif_error_Unsupported_feature,
                  heif_suberror_Unsupported_color_conversion,
-                 "Op_flatten_alpha_plane: colour planes with differing bit depths"};
+                 "Op_flatten_alpha_plane: colour and alpha planes must share one bit depth"};
   }

+  // See composite_plane(): the composite accumulates in 32 bits, which is exact for samples
+  // of up to 16 bits. Reject anything wider instead of overflowing.
+  if (color_bpp > 16) {
+    return Error{heif_error_Unsupported_feature,
+                 heif_suberror_Unsupported_bit_depth,
+                 "Alpha compositing is not supported for images with more than 16 bits per sample."};
+  }
+
+  // The background colours are given as 16-bit values. A checkerboard with a square size of 0
+  // is a solid background.
+  const int shift = 16 - color_bpp;
+  const uint32_t square_size = (options_ext.alpha_composition_mode == heif_alpha_composition_mode_checkerboard) ?
+                               options_ext.checkerboard_square_size : 0;
+
+  // --- luma-only image: composite the Y plane directly
+
+  bool luma_only = (input_raw->get_colorspace() == heif_colorspace_monochrome ||
+                    (input_raw->get_colorspace() == heif_colorspace_YCbCr &&
+                     input_raw->get_chroma_format() == heif_chroma_monochrome));
+
+  if (luma_only) {
+    // There is no operator that converts RGB back to a luma-only image, and for a grey
+    // background the round trip through RGB would be an identity anyway. Composite the luma
+    // plane against the luma of the background colour instead.
+    uint32_t width = input_raw->get_width(heif_channel_Y);
+    uint32_t height = input_raw->get_height(heif_channel_Y);
+
+    if (input_raw->get_width(heif_channel_Alpha) != width ||
+        input_raw->get_height(heif_channel_Alpha) != height) {
+      return Error::InternalError;
+    }
+
+    auto outimg = std::make_shared<HeifPixelImage>();
+    outimg->create(input_raw->get_width(), input_raw->get_height(),
+                   input_raw->get_colorspace(), input_raw->get_chroma_format());
+
+    if (Error err = outimg->add_channel(heif_channel_Y, width, height, color_bpp, limits)) {
+      return err;
+    }
+
+    Pixel bkg1 = static_cast<Pixel>(background_luma16(options_ext.background_red,
+                                                      options_ext.background_green,
+                                                      options_ext.background_blue) >> shift);
+    Pixel bkg2 = static_cast<Pixel>(background_luma16(options_ext.secondary_background_red,
+                                                      options_ext.secondary_background_green,
+                                                      options_ext.secondary_background_blue) >> shift);
+
+    size_t stride_in, stride_alpha, stride_out;
+    const Pixel* p_in = (const Pixel*) input_raw->get_channel_memory(heif_channel_Y, &stride_in);
+    const Pixel* p_alpha = (const Pixel*) input_raw->get_channel_memory(heif_channel_Alpha, &stride_alpha);
+    Pixel* p_out = (Pixel*) outimg->get_channel_memory(heif_channel_Y, &stride_out);
+
+    composite_plane<Pixel>(p_in, stride_in / sizeof(Pixel),
+                           p_alpha, stride_alpha / sizeof(Pixel),
+                           p_out, stride_out / sizeof(Pixel),
+                           width, height, bpp_alpha, bkg1, bkg2, square_size);
+
+    return outimg;
+  }
+
+  // --- colour image: convert to planar RGB, composite each colour plane, convert back
+
   heif_color_conversion_options_ext options_ext_skip_alpha = options_ext;
   options_ext_skip_alpha.alpha_composition_mode = heif_alpha_composition_mode_none;

-  if (options_ext.alpha_composition_mode != heif_alpha_composition_mode_none) {
-    Result<std::shared_ptr<const HeifPixelImage>> convInput = ::convert_colorspace(input,
+  std::shared_ptr<const HeifPixelImage> input;
+  {
+    Result<std::shared_ptr<const HeifPixelImage>> convInput = ::convert_colorspace(input_raw,
                                                                                    heif_colorspace_RGB,
                                                                                    heif_chroma_444,
                                                                                    input_state.nclx,
@@ -215,149 +350,79 @@ Op_flatten_alpha_plane<Pixel>::convert_colorspace(const std::shared_ptr<const He
     if (!convInput) {
       return convInput.error();
     }
-    else {
-      input = *convInput;
-    }
+
+    input = *convInput;
   }

   uint32_t width = input->get_width();
   uint32_t height = input->get_height();

-  auto outimg = std::make_shared<HeifPixelImage>();
-
-  outimg->create(width, height,
-                 input->get_colorspace(),
-                 input->get_chroma_format());
-
-  for (heif_channel channel : {heif_channel_R,
-                               heif_channel_G,
-                               heif_channel_B}) {
-    // 'input' was converted to planar RGB above, so its planes carry the depth we composite
-    // at. Do not take the depth from target_state: this operation keeps the source
-    // colorspace, so for a YCbCr or monochrome target the R/G/B fields there are 0 (plane
-    // absent). add_channel() refuses a zero depth and the loops below would then write
-    // through a null plane pointer.
-    if (Error err = outimg->add_channel(channel, width, height, input->get_bits_per_pixel(channel), limits)) {
-      return err;
-    }
+  // The conversion above was asked for 'color_bpp' and carries the alpha plane through.
+  if (!input->has_channel(heif_channel_Alpha) ||
+      input->get_bits_per_pixel(heif_channel_Alpha) != bpp_alpha ||
+      input->get_width(heif_channel_Alpha) != width ||
+      input->get_height(heif_channel_Alpha) != height) {
+    return Error::InternalError;
+  }

-    const Pixel* p_alpha;
-    size_t stride_alpha;
-    p_alpha = (const Pixel*)input->get_channel_memory(heif_channel_Alpha, &stride_alpha);
-    int bpp_alpha = input->get_bits_per_pixel(heif_channel_Alpha);
-    Pixel alpha_max = (Pixel)((1 << bpp_alpha) - 1);
-
-    // The composite below (p_in*a + bkg*(alpha_max-a)) is a weighted sum bounded by
-    // (2^bpp-1) * (2^bpp_alpha-1). For 8-bit samples that fits a plain int; for
-    // wider samples it can exceed a signed 32-bit int (65535*65535 overflows), so
-    // accumulate in an unsigned 32-bit integer, which is exact as long as both the
-    // colour and alpha samples are <= 16 bits (bound < 2^32). Using uint32_t rather
-    // than a 64-bit type keeps the hot path efficient on non-64-bit architectures.
-    // Reject anything wider instead of overflowing.
-    if (bpp_alpha > 16 || input->get_bits_per_pixel(channel) > 16) {
-      return Error{heif_error_Unsupported_feature,
-                   heif_suberror_Unsupported_bit_depth,
-                   "Alpha compositing is not supported for images with more than 16 bits per sample."};
-    }
-    using composite_t = std::conditional_t<(sizeof(Pixel) > 1), uint32_t, int>;
+  auto outimg = std::make_shared<HeifPixelImage>();
+  outimg->create(width, height, input->get_colorspace(), input->get_chroma_format());

-    const Pixel* p_in;
-    size_t stride_in;
-    p_in = (const Pixel*)input->get_channel_memory(channel, &stride_in);
+  size_t stride_alpha;
+  const Pixel* p_alpha = (const Pixel*) input->get_channel_memory(heif_channel_Alpha, &stride_alpha);
+  stride_alpha /= sizeof(Pixel);

-    Pixel* p_out;
-    size_t stride_out;
-    p_out = (Pixel*)outimg->get_channel_memory(channel, &stride_out);
-
-    stride_alpha /= sizeof(Pixel);
-    stride_in /= sizeof(Pixel);
-    stride_out /= sizeof(Pixel);
-
-    if (options_ext.alpha_composition_mode == heif_alpha_composition_mode_solid_color ||
-        (options_ext.alpha_composition_mode == heif_alpha_composition_mode_checkerboard && options_ext.checkerboard_square_size == 0)) {
-      uint16_t bkg16;
-
-      switch (channel) {
-        case heif_channel_R:
-          bkg16 = options_ext.background_red;
-          break;
-        case heif_channel_G:
-          bkg16 = options_ext.background_green;
-          break;
-        case heif_channel_B:
-          bkg16 = options_ext.background_blue;
-          break;
-        default:
-          assert(false);
-          bkg16 = 0;
-      }
+  struct PlaneBackground
+  {
+    heif_channel channel;
+    uint16_t bkg1;
+    uint16_t bkg2;
+  };

-      Pixel bkg = static_cast<Pixel>(bkg16 >> (16 - input->get_bits_per_pixel(channel)));
+  const PlaneBackground planes[3] = {
+      {heif_channel_R, options_ext.background_red, options_ext.secondary_background_red},
+      {heif_channel_G, options_ext.background_green, options_ext.secondary_background_green},
+      {heif_channel_B, options_ext.background_blue, options_ext.secondary_background_blue}};

-      for (uint32_t y = 0; y < height; y++)
-        for (uint32_t x = 0; x < width; x++) {
-          int a = p_alpha[y * stride_alpha + x];
-          composite_t composite = static_cast<composite_t>(p_in[y * stride_in + x]) * a
-                                  + static_cast<composite_t>(bkg) * (alpha_max - a);
-          p_out[y * stride_out + x] = static_cast<Pixel>(composite >> bpp_alpha);
-        }
+  for (const PlaneBackground& plane : planes) {
+    // 'input' was converted to planar RGB above, so its planes carry the depth we composite
+    // at. Do not take the depth from target_state: this operation keeps the source
+    // colorspace, so for a YCbCr target the R/G/B fields there are 0 (plane absent), and
+    // add_channel() would refuse the zero depth.
+    if (input->get_bits_per_pixel(plane.channel) != color_bpp) {
+      return Error::InternalError;
     }
-    else {
-      uint16_t bkg16_1, bkg16_2;
-
-      switch (channel) {
-        case heif_channel_R:
-          bkg16_1 = options_ext.background_red;
-          bkg16_2 = options_ext.secondary_background_red;
-          break;
-        case heif_channel_G:
-          bkg16_1 = options_ext.background_green;
-          bkg16_2 = options_ext.secondary_background_green;
-          break;
-        case heif_channel_B:
-          bkg16_1 = options_ext.background_blue;
-          bkg16_2 = options_ext.secondary_background_blue;
-          break;
-        default:
-          assert(false);
-          bkg16_1 = bkg16_2 = 0;
-      }
-
-      Pixel bkg1 = static_cast<Pixel>(bkg16_1 >> (16 - input->get_bits_per_pixel(channel)));
-      Pixel bkg2 = static_cast<Pixel>(bkg16_2 >> (16 - input->get_bits_per_pixel(channel)));
-
-      for (uint32_t y = 0; y < height; y++)
-        for (uint32_t x = 0; x < width; x++) {
-          uint8_t parity = (x / options_ext.checkerboard_square_size + y / options_ext.checkerboard_square_size) % 2;
-          Pixel bkg = parity ? bkg1 : bkg2;

-          int a = p_alpha[y * stride_alpha + x];
-          composite_t composite = static_cast<composite_t>(p_in[y * stride_in + x]) * a
-                                  + static_cast<composite_t>(bkg) * (alpha_max - a);
-          p_out[y * stride_out + x] = static_cast<Pixel>(composite >> bpp_alpha);
-        }
+    if (Error err = outimg->add_channel(plane.channel, width, height, color_bpp, limits)) {
+      return err;
     }

+    size_t stride_in, stride_out;
+    const Pixel* p_in = (const Pixel*) input->get_channel_memory(plane.channel, &stride_in);
+    Pixel* p_out = (Pixel*) outimg->get_channel_memory(plane.channel, &stride_out);
+
+    composite_plane<Pixel>(p_in, stride_in / sizeof(Pixel),
+                           p_alpha, stride_alpha,
+                           p_out, stride_out / sizeof(Pixel),
+                           width, height, bpp_alpha,
+                           static_cast<Pixel>(plane.bkg1 >> shift),
+                           static_cast<Pixel>(plane.bkg2 >> shift),
+                           square_size);
   }

-  if (options_ext.alpha_composition_mode != heif_alpha_composition_mode_none) {
-    Result<std::shared_ptr<HeifPixelImage>> convOutput = ::convert_colorspace(outimg,
-                                                                              input_raw->get_colorspace(),
-                                                                              input_raw->get_chroma_format(),
-                                                                              input_state.nclx,
-                                                                              color_bpp,
-                                                                              options, &options_ext_skip_alpha,
-                                                                              limits);
-    if (!convOutput) {
-      return convOutput.error();
-    }
-    else {
-      return convOutput;
-    }
-  }
-  else {
-    return outimg;
+  // Back to the colorspace and chroma of the source image.
+  Result<std::shared_ptr<HeifPixelImage>> convOutput = ::convert_colorspace(outimg,
+                                                                            input_raw->get_colorspace(),
+                                                                            input_raw->get_chroma_format(),
+                                                                            input_state.nclx,
+                                                                            color_bpp,
+                                                                            options, &options_ext_skip_alpha,
+                                                                            limits);
+  if (!convOutput) {
+    return convOutput.error();
   }
+
+  return convOutput;
 }

 template class Op_flatten_alpha_plane<uint8_t>;
diff --git a/tests/conversion.cc b/tests/conversion.cc
index f2f16e3f..8d6a95f1 100644
--- a/tests/conversion.cc
+++ b/tests/conversion.cc
@@ -1172,6 +1172,18 @@ static std::shared_ptr<HeifPixelImage> make_ycbcr_alpha_image(heif_chroma chroma
   return img;
 }

+static std::shared_ptr<HeifPixelImage> make_mono_alpha_image(uint32_t width, uint32_t height,
+                                                             int bpp, uint16_t luma, uint16_t alpha,
+                                                             const nclx_profile& nclx)
+{
+  auto img = std::make_shared<HeifPixelImage>();
+  img->create(width, height, heif_colorspace_monochrome, heif_chroma_monochrome);
+  img->fill_new_channel(heif_channel_Y, luma, width, height, bpp, nullptr);
+  img->fill_new_channel(heif_channel_Alpha, alpha, width, height, bpp, nullptr);
+  img->set_color_profile_nclx(nclx);
+  return img;
+}
+
 // The operator computes (p * a + bkg * (alpha_max - a)) >> bpp_alpha. With neutral chroma
 // and a full-range BT.601 matrix, the RGB gray level equals the luma, so the composited
 // luma is predictable up to the rounding of the two matrix conversions.
@@ -1300,29 +1312,124 @@ TEST_CASE("Alpha compositing into a YCbCr or monochrome target", "[heif_image]")
     CHECK(std::abs(p[2] - expected) <= 2);
   }

+  // A luma-only image is composited directly on its Y plane against the BT.601 luma of the
+  // background colour (there is no RGB to monochrome operator for the round trip the colour
+  // path takes). Nothing but integer arithmetic is involved, so the results are exact.
+
   SECTION("8-bit monochrome -> monochrome with a solid background") {
-    // The crash also covered a monochrome target (bits_per_pixel_R/G/B are 0 there too).
-    // Compositing a monochrome image is not actually supported: the operator converts
-    // the input to RGB, composites, and has no operator to convert back to monochrome,
-    // so the request fails with Unsupported_color_conversion (unchanged from v1.23.4).
-    // Pin that it declines cleanly instead of crashing; if monochrome compositing is
-    // implemented, replace the error check by a check of the composited luma.
-    auto img = std::make_shared<HeifPixelImage>();
-    img->create(width, height, heif_colorspace_monochrome, heif_chroma_monochrome);
-    img->fill_new_channel(heif_channel_Y, 100, width, height, 8, nullptr);
-    img->fill_new_channel(heif_channel_Alpha, 128, width, height, 8, nullptr);
-    img->set_color_profile_nclx(nclx);
+    auto img = make_mono_alpha_image(width, height, 8, 100, 128, nclx);

     auto result = convert_colorspace(img, heif_colorspace_monochrome, heif_chroma_monochrome,
                                      nclx, 0, options, &options_ext,
                                      heif_get_disabled_security_limits());
-    if (result) {
-      CHECK(!(*result)->has_channel(heif_channel_Alpha));
-    }
-    else {
-      CHECK(result.error().error_code == heif_error_Unsupported_feature);
-      CHECK(result.error().sub_error_code == heif_suberror_Unsupported_color_conversion);
-    }
+    REQUIRE(result);
+    auto out = *result;
+
+    CHECK(out->get_colorspace() == heif_colorspace_monochrome);
+    CHECK(!out->has_channel(heif_channel_Alpha));
+    REQUIRE(out->has_channel(heif_channel_Y));
+    CHECK(out->get_bits_per_pixel(heif_channel_Y) == 8);
+
+    size_t stride;
+    const uint8_t* p_y = out->get_channel_memory(heif_channel_Y, &stride);
+    REQUIRE(p_y != nullptr);
+
+    int expected = expected_composited_luma(100, 128, 255, 8);
+    CHECK(p_y[0] == expected);
+    CHECK(p_y[(height - 1) * stride + (width - 1)] == expected);
+  }
+
+  SECTION("10-bit monochrome -> monochrome (16-bit operator)") {
+    auto img = make_mono_alpha_image(width, height, 10, 400, 512, nclx);
+
+    auto result = convert_colorspace(img, heif_colorspace_monochrome, heif_chroma_monochrome,
+                                     nclx, 0, options, &options_ext,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(result);
+    auto out = *result;
+
+    CHECK(!out->has_channel(heif_channel_Alpha));
+    REQUIRE(out->has_channel(heif_channel_Y));
+    CHECK(out->get_bits_per_pixel(heif_channel_Y) == 10);
+
+    size_t stride;
+    const uint8_t* p_y = out->get_channel_memory(heif_channel_Y, &stride);
+    REQUIRE(p_y != nullptr);
+    const uint16_t* y16 = reinterpret_cast<const uint16_t*>(p_y);
+
+    int expected = expected_composited_luma(400, 512, 1023, 10);
+    CHECK(y16[0] == expected);
+    CHECK(y16[(height - 1) * (stride / 2) + (width - 1)] == expected);
+  }
+
+  SECTION("8-bit monochrome -> interleaved RGB") {
+    // The planner flattens the monochrome image first (cheapest) and converts afterwards.
+    auto img = make_mono_alpha_image(width, height, 8, 100, 128, nclx);
+
+    auto result = convert_colorspace(img, heif_colorspace_RGB, heif_chroma_interleaved_RGB,
+                                     nclx, 0, options, &options_ext,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(result);
+    auto out = *result;
+
+    CHECK(out->get_chroma_format() == heif_chroma_interleaved_RGB);
+
+    size_t stride;
+    const uint8_t* p = out->get_channel_memory(heif_channel_interleaved, &stride);
+    REQUIRE(p != nullptr);
+
+    int expected = expected_composited_luma(100, 128, 255, 8);
+    CHECK(p[0] == expected);
+    CHECK(p[1] == expected);
+    CHECK(p[2] == expected);
+  }
+
+  SECTION("8-bit monochrome with a checkerboard") {
+    heif_color_conversion_options_ext checker = options_ext;
+    checker.alpha_composition_mode = heif_alpha_composition_mode_checkerboard;
+    checker.secondary_background_red = 0;
+    checker.secondary_background_green = 0;
+    checker.secondary_background_blue = 0;
+    checker.checkerboard_square_size = 8;
+
+    auto img = make_mono_alpha_image(width, height, 8, 100, 128, nclx);
+
+    auto result = convert_colorspace(img, heif_colorspace_monochrome, heif_chroma_monochrome,
+                                     nclx, 0, options, &checker,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(result);
+    auto out = *result;
+
+    size_t stride;
+    const uint8_t* p_y = out->get_channel_memory(heif_channel_Y, &stride);
+    REQUIRE(p_y != nullptr);
+
+    // parity = (x/8 + y/8) % 2 selects the secondary (black) square at (0,0) and the
+    // primary (white) square at (8,0).
+    CHECK(p_y[0] == expected_composited_luma(100, 128, 0, 8));
+    CHECK(p_y[8] == expected_composited_luma(100, 128, 255, 8));
+  }
+
+  SECTION("8-bit monochrome with a coloured background uses its BT.601 luma") {
+    heif_color_conversion_options_ext red = options_ext;
+    red.background_red = 0xFFFF;
+    red.background_green = 0;
+    red.background_blue = 0;
+
+    // Fully transparent, so the output is the background's luma: 0.299 * 65535 = 19595
+    // as a 16-bit value, 76 at 8 bits.
+    auto img = make_mono_alpha_image(width, height, 8, 100, 0, nclx);
+
+    auto result = convert_colorspace(img, heif_colorspace_monochrome, heif_chroma_monochrome,
+                                     nclx, 0, options, &red,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(result);
+    auto out = *result;
+
+    size_t stride;
+    const uint8_t* p_y = out->get_channel_memory(heif_channel_Y, &stride);
+    REQUIRE(p_y != nullptr);
+    CHECK(p_y[0] == expected_composited_luma(100, 0, 76, 8));
   }
 }