Commit 5ff01779 for libheif
commit 5ff0177944aad5f3dc72176aacd8db313a4dae2f
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Sun Aug 30 13:44:39 2026 +0200
FFmpeg decoder: return high bit depth JPEG / JPEG 2000 at the coded bit depth
FFmpeg's MJPEG and JPEG 2000 decoders have no exact-depth pixel format for
every coded bit depth: 12-bit grayscale JPEG is returned as GRAY16, 10/12-bit
grayscale JPEG 2000 as GRAY16, >8-bit YCbCr JPEG as YUV4xxP16 and >8-bit
4:4:4 JPEG 2000 as packed RGB48, each with the samples shifted left to fill
the 16-bit container. The plugin passed these through as 16-bit planes, so
the decoded image reported 16 bits while the handle (SOF / SIZ / pixi)
reported 10 or 12, and the samples were scaled by the difference.
Use bits_per_raw_sample, which both decoders set to the coded precision, to
shift the samples back and create the planes at the coded bit depth. Also
handle rgb48le in the JPEG 2000 packed-RGB path (previously >8-bit 4:4:4
JPEG 2000 was rejected) and name the pixel format in the "not implemented"
error. HEVC/AVC/VVC/AV1 always use exact-depth formats and are unaffected.
Add a test that encodes 8/10/12/16-bit monochrome and 4:4:4 JPEG 2000 with
the OpenJPEG encoder and checks that decoding with decoder_id "ffmpeg"
yields the coded bit depth and the original sample values.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAo5hPipqTKvaQDDpSEtxT
diff --git a/libheif/plugins/decoder_ffmpeg.cc b/libheif/plugins/decoder_ffmpeg.cc
index 53d521f4..78d8ea58 100644
--- a/libheif/plugins/decoder_ffmpeg.cc
+++ b/libheif/plugins/decoder_ffmpeg.cc
@@ -38,6 +38,7 @@
extern "C"
{
#include <libavcodec/avcodec.h>
+ #include <libavutil/pixdesc.h>
}
@@ -464,12 +465,22 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
*out_user_data = av_frame->pts;
}
- // FFmpeg's JPEG2000 decoder hands back the codestream's 3 components as packed
- // "rgb24", but libheif stores them as sYCC (the codestream is tagged sYCC).
- // De-interleave into YCbCr-444 planes so the container's nclx color conversion
- // is applied correctly.
- if (av_dec_ctx->pix_fmt == AV_PIX_FMT_RGB24 &&
+ // FFmpeg's JPEG2000 decoder hands back a 3-component 4:4:4 codestream as packed
+ // "rgb24" (8 bit) or "rgb48le" (9-16 bit), but libheif stores them as sYCC (the
+ // codestream is tagged sYCC). De-interleave into YCbCr-444 planes so the container's
+ // nclx color conversion is applied correctly. For rgb48le, FFmpeg scales the samples
+ // up to 16 bit; undo this so that the image has the coded bit depth (see below).
+ if ((av_dec_ctx->pix_fmt == AV_PIX_FMT_RGB24 || av_dec_ctx->pix_fmt == AV_PIX_FMT_RGB48LE) &&
decoder->av_codec->id == AV_CODEC_ID_JPEG2000) {
+ const bool packed16 = (av_dec_ctx->pix_fmt == AV_PIX_FMT_RGB48LE);
+ int bpp = packed16 ? 16 : 8;
+ int shift = 0;
+ int coded_bpp = av_dec_ctx->bits_per_raw_sample;
+ if (coded_bpp > 0 && coded_bpp < bpp) {
+ shift = bpp - coded_bpp;
+ bpp = coded_bpp;
+ }
+
heif_error err = heif_image_create(av_frame->width, av_frame->height,
heif_colorspace_YCbCr, heif_chroma_444, image);
if (err.code) {
@@ -478,7 +489,7 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
heif_channel planes[3] = {heif_channel_Y, heif_channel_Cb, heif_channel_Cr};
uint8_t* dst[3]; size_t dst_stride[3];
for (int c = 0; c < 3; c++) {
- err = heif_image_add_plane_safe(*image, planes[c], av_frame->width, av_frame->height, 8, limits);
+ err = heif_image_add_plane_safe(*image, planes[c], av_frame->width, av_frame->height, bpp, limits);
if (err.code) {
decoder->error_message = err.message;
err.message = decoder->error_message.c_str();
@@ -492,9 +503,17 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
for (int y = 0; y < av_frame->height; y++) {
const uint8_t* row = src + static_cast<size_t>(y) * src_stride;
for (int x = 0; x < av_frame->width; x++) {
- dst[0][y * dst_stride[0] + x] = row[x * 3 + 0]; // Y <- "R"
- dst[1][y * dst_stride[1] + x] = row[x * 3 + 1]; // Cb <- "G"
- dst[2][y * dst_stride[2] + x] = row[x * 3 + 2]; // Cr <- "B"
+ // Y <- "R", Cb <- "G", Cr <- "B"
+ for (int c = 0; c < 3; c++) {
+ uint16_t v = packed16 ? reinterpret_cast<const uint16_t*>(row)[x * 3 + c] : row[x * 3 + c];
+ v = static_cast<uint16_t>(v >> shift);
+ if (bpp > 8) {
+ reinterpret_cast<uint16_t*>(dst[c] + y * dst_stride[c])[x] = v;
+ }
+ else {
+ dst[c][y * dst_stride[c] + x] = static_cast<uint8_t>(v);
+ }
+ }
}
}
return heif_error_success;
@@ -522,18 +541,34 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
int nPlanes = is_mono ? 1 : 3;
- for (int channel = 0; channel < nPlanes; channel++) {
- int bpp = get_ffmpeg_format_bpp(av_dec_ctx->pix_fmt);
- if (bpp == 0) {
- heif_image_release(*image);
- err = {
- heif_error_Decoder_plugin_error,
- heif_suberror_Unsupported_color_conversion,
- "Pixel format not implemented"
- };
- return err;
- }
+ int format_bpp = get_ffmpeg_format_bpp(av_dec_ctx->pix_fmt);
+ if (format_bpp == 0) {
+ heif_image_release(*image);
+ err = {
+ heif_error_Decoder_plugin_error,
+ heif_suberror_Unsupported_color_conversion,
+ "Pixel format not implemented"
+ };
+ return err;
+ }
+
+ // FFmpeg's MJPEG and JPEG 2000 decoders do not have an exact-depth pixel format for
+ // every coded bit depth. A 12-bit grayscale JPEG is returned as GRAY16, a 10- or 12-bit
+ // grayscale JPEG 2000 as GRAY16LE, a 12-bit YCbCr JPEG as YUV4xxP16, etc. In these cases
+ // FFmpeg shifts the samples left so that they fill the container (the low bits are zero)
+ // and reports the coded precision in bits_per_raw_sample. Undo that scaling so that the
+ // decoded image has the bit depth declared in the file (SOF / SIZ / pixi) and the
+ // original sample values. For HEVC/AVC/VVC/AV1 the pixel format depth always equals the
+ // coded depth, so nothing is shifted there.
+ int bpp = format_bpp;
+ int shift = 0;
+ int coded_bpp = av_dec_ctx->bits_per_raw_sample;
+ if (coded_bpp > 0 && coded_bpp < format_bpp) {
+ shift = format_bpp - coded_bpp;
+ bpp = coded_bpp;
+ }
+ for (int channel = 0; channel < nPlanes; channel++) {
int stride = av_frame->linesize[channel];
const uint8_t* data = av_frame->data[channel];
@@ -562,20 +597,47 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
size_t dst_stride;
uint8_t* dst_mem = heif_image_get_plane2(*image, channel2plane[channel], &dst_stride);
- int bytes_per_pixel = (bpp + 7) / 8;
+ int src_bytes_per_pixel = (format_bpp + 7) / 8;
+ int dst_bytes_per_pixel = (bpp + 7) / 8;
for (int y = 0; y < h; y++) {
- memcpy(dst_mem + y * dst_stride, data + static_cast<size_t>(y) * stride, static_cast<size_t>(w) * bytes_per_pixel);
+ const uint8_t* src_row = data + static_cast<size_t>(y) * stride;
+ uint8_t* dst_row = dst_mem + y * dst_stride;
+
+ if (shift == 0) {
+ memcpy(dst_row, src_row, static_cast<size_t>(w) * src_bytes_per_pixel);
+ }
+ else if (src_bytes_per_pixel == 2) {
+ const uint16_t* src16 = reinterpret_cast<const uint16_t*>(src_row);
+ if (dst_bytes_per_pixel == 2) {
+ uint16_t* dst16 = reinterpret_cast<uint16_t*>(dst_row);
+ for (int x = 0; x < w; x++) {
+ dst16[x] = static_cast<uint16_t>(src16[x] >> shift);
+ }
+ }
+ else {
+ for (int x = 0; x < w; x++) {
+ dst_row[x] = static_cast<uint8_t>(src16[x] >> shift);
+ }
+ }
+ }
+ else {
+ for (int x = 0; x < w; x++) {
+ dst_row[x] = static_cast<uint8_t>(src_row[x] >> shift);
+ }
+ }
}
}
return heif_error_success;
}
else {
+ const char* fmt_name = av_get_pix_fmt_name(av_dec_ctx->pix_fmt);
+ decoder->error_message = std::string("Pixel format not implemented: ") + (fmt_name ? fmt_name : "unknown");
return {
heif_error_Unsupported_feature,
heif_suberror_Unsupported_color_conversion,
- "Pixel format not implemented"
+ decoder->error_message.c_str()
};
}
}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 5dfdb722..8056f1b4 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -92,6 +92,7 @@ add_libheif_test(tai)
add_libheif_test(text)
add_libheif_test(cxx_wrapper)
add_libheif_test(component_descriptions)
+add_libheif_test(ffmpeg_decode_bitdepth)
if (WITH_OPENJPH_ENCODER AND SUPPORTS_J2K_HT_ENCODING)
add_libheif_test(encode_htj2k)
diff --git a/tests/ffmpeg_decode_bitdepth.cc b/tests/ffmpeg_decode_bitdepth.cc
new file mode 100644
index 00000000..9dd6957e
--- /dev/null
+++ b/tests/ffmpeg_decode_bitdepth.cc
@@ -0,0 +1,236 @@
+/*
+ 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.
+*/
+
+// FFmpeg's JPEG 2000 (and MJPEG) decoders have no exact-depth pixel format for every
+// coded bit depth. A 10- or 12-bit grayscale codestream is returned as GRAY16 with the
+// samples shifted left to fill the 16-bit container. The FFmpeg decoder plugin has to
+// undo this so that the decoded image has the bit depth declared in the file and the
+// original sample values. These tests encode high bit depth JPEG 2000 images with the
+// OpenJPEG encoder and decode them again explicitly with the FFmpeg decoder.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <cstring>
+#include <string>
+#include <vector>
+
+static const int W = 64;
+static const int H = 48;
+
+static bool have_decoder(heif_compression_format format, const char* id)
+{
+ int n = heif_get_decoder_descriptors(format, nullptr, 0);
+ std::vector<const heif_decoder_descriptor*> descs(n);
+ n = heif_get_decoder_descriptors(format, descs.data(), n);
+ for (int i = 0; i < n; i++) {
+ const char* name = heif_decoder_descriptor_get_id_name(descs[i]);
+ if (name && strcmp(name, id) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+
+// Deterministic test pattern that covers the full value range of the bit depth.
+static uint16_t pattern(int x, int y, int bpp)
+{
+ uint32_t maxval = (1u << bpp) - 1;
+ if (x == 0 && y == 0) return static_cast<uint16_t>(maxval);
+ if (x == 1 && y == 0) return 0;
+ return static_cast<uint16_t>((static_cast<uint32_t>(x) * 37 + static_cast<uint32_t>(y) * 101 + x * y) & maxval);
+}
+
+
+static void fill_plane(heif_image* img, heif_channel channel, int w, int h, int bpp)
+{
+ heif_error err = heif_image_add_plane(img, channel, w, h, bpp);
+ REQUIRE(err.code == heif_error_Ok);
+
+ size_t stride;
+ uint8_t* p = heif_image_get_plane2(img, channel, &stride);
+ REQUIRE(p != nullptr);
+
+ for (int y = 0; y < h; y++) {
+ for (int x = 0; x < w; x++) {
+ uint16_t v = pattern(x, y, bpp);
+ if (bpp > 8) {
+ reinterpret_cast<uint16_t*>(p + y * stride)[x] = v;
+ }
+ else {
+ p[y * stride + x] = static_cast<uint8_t>(v);
+ }
+ }
+ }
+}
+
+
+static void check_plane(const heif_image* img, heif_channel channel, int w, int h, int bpp)
+{
+ REQUIRE(heif_image_has_channel(img, channel));
+ CHECK(heif_image_get_bits_per_pixel_range(img, channel) == bpp);
+ CHECK(heif_image_get_width(img, channel) == w);
+ CHECK(heif_image_get_height(img, channel) == h);
+
+ size_t stride;
+ const uint8_t* p = heif_image_get_plane_readonly2(img, channel, &stride);
+ REQUIRE(p != nullptr);
+
+ int mismatches = 0;
+ for (int y = 0; y < h; y++) {
+ for (int x = 0; x < w; x++) {
+ uint16_t expected = pattern(x, y, bpp);
+ uint16_t actual;
+ if (bpp > 8) {
+ actual = reinterpret_cast<const uint16_t*>(p + y * stride)[x];
+ }
+ else {
+ actual = p[y * stride + x];
+ }
+ if (actual != expected) {
+ if (mismatches < 3) {
+ INFO("channel " << channel << " at (" << x << "," << y << "): expected " << expected << ", got " << actual);
+ CHECK(actual == expected);
+ }
+ mismatches++;
+ }
+ }
+ }
+ CHECK(mismatches == 0);
+}
+
+
+static std::string encode_j2k_lossless(heif_image* img, const char* filename)
+{
+ heif_encoder* encoder = get_encoder_or_skip_test(heif_compression_JPEG2000);
+
+ heif_context* ctx = heif_context_alloc();
+
+ heif_error err = heif_encoder_set_lossless(encoder, 1);
+ REQUIRE(err.code == heif_error_Ok);
+
+ heif_encoding_options* options = heif_encoding_options_alloc();
+ options->macOS_compatibility_workaround = false;
+ options->macOS_compatibility_workaround_no_nclx_profile = true;
+
+ heif_image_handle* handle;
+ err = heif_context_encode_image(ctx, img, encoder, options, &handle);
+ REQUIRE(err.code == heif_error_Ok);
+ heif_image_handle_release(handle);
+
+ 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_encoding_options_free(options);
+ heif_encoder_release(encoder);
+ heif_context_free(ctx);
+
+ return path;
+}
+
+
+static heif_image* decode_with_ffmpeg(const std::string& path, heif_colorspace colorspace, heif_chroma chroma, int expected_bpp)
+{
+ heif_context* ctx = get_context_for_local_file(path);
+ heif_image_handle* handle = get_primary_image_handle(ctx);
+
+ // The declared bit depth comes from the codestream header (SIZ), independent of the decoder.
+ CHECK(heif_image_handle_get_luma_bits_per_pixel(handle) == expected_bpp);
+
+ heif_decoding_options* options = heif_decoding_options_alloc();
+ options->decoder_id = "ffmpeg";
+
+ heif_image* img;
+ heif_error err = heif_decode_image(handle, &img, colorspace, chroma, options);
+ INFO("decode error: " << err.message);
+ REQUIRE(err.code == heif_error_Ok);
+
+ heif_decoding_options_free(options);
+ heif_image_handle_release(handle);
+ heif_context_free(ctx);
+
+ return img;
+}
+
+
+TEST_CASE("ffmpeg decodes high bit depth monochrome JPEG 2000 at the coded bit depth")
+{
+ if (!have_decoder(heif_compression_JPEG2000, "ffmpeg")) {
+ SKIP("FFmpeg decoder not available, skipping test");
+ }
+
+ // FFmpeg returns 10- and 12-bit grayscale as GRAY16 with left-shifted samples; 8- and 16-bit
+ // have exact formats (GRAY8 / GRAY16) and must be passed through unchanged.
+ int bpp = GENERATE(8, 10, 12, 16);
+ INFO("bit depth " << bpp);
+
+ heif_image* input;
+ heif_error err = heif_image_create(W, H, heif_colorspace_monochrome, heif_chroma_monochrome, &input);
+ REQUIRE(err.code == heif_error_Ok);
+ fill_plane(input, heif_channel_Y, W, H, bpp);
+
+ std::string filename = "ffmpeg_j2k_mono" + std::to_string(bpp) + ".heif";
+ std::string path = encode_j2k_lossless(input, filename.c_str());
+ heif_image_release(input);
+
+ heif_image* decoded = decode_with_ffmpeg(path, heif_colorspace_monochrome, heif_chroma_monochrome, bpp);
+ check_plane(decoded, heif_channel_Y, W, H, bpp);
+ heif_image_release(decoded);
+}
+
+
+TEST_CASE("ffmpeg decodes high bit depth YCbCr 4:4:4 JPEG 2000 at the coded bit depth")
+{
+ if (!have_decoder(heif_compression_JPEG2000, "ffmpeg")) {
+ SKIP("FFmpeg decoder not available, skipping test");
+ }
+
+ // FFmpeg returns a 3-component 4:4:4 codestream with more than 8 bits as packed RGB48
+ // with left-shifted samples (8 bit as packed RGB24). The libheif OpenJPEG encoder always
+ // writes 4:4:4, so subsampled codestreams cannot be generated here.
+ int bpp = GENERATE(8, 10, 12, 16);
+ INFO("bit depth " << bpp);
+
+ heif_image* input;
+ heif_error err = heif_image_create(W, H, heif_colorspace_YCbCr, heif_chroma_444, &input);
+ REQUIRE(err.code == heif_error_Ok);
+ fill_plane(input, heif_channel_Y, W, H, bpp);
+ fill_plane(input, heif_channel_Cb, W, H, bpp);
+ fill_plane(input, heif_channel_Cr, W, H, bpp);
+
+ std::string filename = "ffmpeg_j2k_yuv444_" + std::to_string(bpp) + ".heif";
+ std::string path = encode_j2k_lossless(input, filename.c_str());
+ heif_image_release(input);
+
+ heif_image* decoded = decode_with_ffmpeg(path, heif_colorspace_YCbCr, heif_chroma_444, bpp);
+ check_plane(decoded, heif_channel_Y, W, H, bpp);
+ check_plane(decoded, heif_channel_Cb, W, H, bpp);
+ check_plane(decoded, heif_channel_Cr, W, H, bpp);
+ heif_image_release(decoded);
+}