diff --git a/CMakeLists.txt b/CMakeLists.txt index 2346db8d4..af15a8181 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ else() set(ALLOW_DOWNLOADING_PUGIXML OFF CACHE BOOL "If pugixml src tree is not found in location specified by PUGIXML_PATH, do fetch the archive from internet" FORCE) endif() option(WITH_JPEG "Enable JPEG support for DNG Lossy JPEG support" ON) +option(WITH_JPEGXL "Enable JPEG XL support for DNG 1.7 JPEG XL compression" ON) option(WITH_ZLIB "Enable ZLIB support for DNG deflate support" ON) if(WITH_ZLIB) option(USE_BUNDLED_ZLIB "Build and use zlib in-tree" OFF) diff --git a/cmake/src-dependencies.cmake b/cmake/src-dependencies.cmake index a9a887e6d..59619ea8e 100644 --- a/cmake/src-dependencies.cmake +++ b/cmake/src-dependencies.cmake @@ -184,6 +184,29 @@ else() endif() add_feature_info("Lossy JPEG decoding" HAVE_JPEG "used for DNG Lossy JPEG compression decoding") +unset(HAVE_JPEGXL) +if(WITH_JPEGXL) + message(STATUS "Looking for JPEG XL (libjxl)") + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(libjxl IMPORTED_TARGET libjxl) + endif() + if(NOT libjxl_FOUND) + message(SEND_ERROR "Did not find libjxl! Either install jpeg-xl, or pass -DWITH_JPEGXL=OFF to disable JPEG XL.") + else() + message(STATUS "Looking for JPEG XL - found (${libjxl_VERSION})") + set(HAVE_JPEGXL 1) + target_link_libraries(rawspeed PRIVATE PkgConfig::libjxl) + set_package_properties(libjxl PROPERTIES + TYPE RECOMMENDED + DESCRIPTION "JPEG XL reference codec library" + PURPOSE "Used for decoding DNG JPEG XL (DNG 1.7) compression") + endif() +else() + message(STATUS "JPEG XL is disabled, DNG JPEG XL (DNG 1.7) support won't be available.") +endif() +add_feature_info("JPEG XL decoding" HAVE_JPEGXL "used for DNG JPEG XL (DNG 1.7) compression decoding") + unset(HAVE_ZLIB) if (WITH_ZLIB) message(STATUS "Looking for ZLIB") diff --git a/src/config.h.in b/src/config.h.in index 623d14174..0e42746e9 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -65,6 +65,8 @@ static_assert(RAWSPEED_LARGEPAGESIZE >= RAWSPEED_PAGESIZE, #cmakedefine HAVE_JPEG #cmakedefine HAVE_JPEG_MEM_SRC +#cmakedefine HAVE_JPEGXL + #cmakedefine HAVE_CXX_THREAD_LOCAL #cmakedefine HAVE_GCC_THREAD_LOCAL diff --git a/src/librawspeed/decoders/CMakeLists.txt b/src/librawspeed/decoders/CMakeLists.txt index 23214ac43..065b601bd 100644 --- a/src/librawspeed/decoders/CMakeLists.txt +++ b/src/librawspeed/decoders/CMakeLists.txt @@ -16,6 +16,7 @@ FILE(GLOB SOURCES "DcsDecoder.h" "DngDecoder.cpp" "DngDecoder.h" + "DngDeinterleave.h" "ErfDecoder.cpp" "ErfDecoder.h" "IiqDecoder.cpp" diff --git a/src/librawspeed/decoders/DngDecoder.cpp b/src/librawspeed/decoders/DngDecoder.cpp index ecd119898..9251184b4 100644 --- a/src/librawspeed/decoders/DngDecoder.cpp +++ b/src/librawspeed/decoders/DngDecoder.cpp @@ -31,6 +31,7 @@ #include "common/DngOpcodes.h" #include "common/RawImage.h" #include "decoders/AbstractTiffDecoder.h" +#include "decoders/DngDeinterleave.h" #include "decoders/RawDecoderException.h" #include "decompressors/AbstractDngDecompressor.h" #include "io/Buffer.h" @@ -45,7 +46,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -120,7 +123,10 @@ void DngDecoder::dropUnsuportedChunks(std::vector* data) { #ifdef HAVE_JPEG case 0x884c: // lossy JPEG #endif - // no change, if supported, then is still supported. +#ifdef HAVE_JPEGXL + case 52546: // JPEG XL (DNG 1.7) +#endif + // no change, if supported, then is still supported. break; #ifndef HAVE_ZLIB @@ -140,6 +146,15 @@ void DngDecoder::dropUnsuportedChunks(std::vector* data) { "chunk, but the jpeg support was " "disabled at build!"); [[clang::fallthrough]]; +#endif +#ifndef HAVE_JPEGXL + case 52546: // JPEG XL (DNG 1.7) +#pragma message \ + "JPEG XL is not present! DNG JPEG XL compression will not be supported!" + writeLog(DEBUG_PRIO::WARNING, "DNG Decoder: found JPEG XL-encoded " + "chunk, but JPEG XL support was " + "disabled at build!"); + [[clang::fallthrough]]; #endif default: supported = false; @@ -443,6 +458,67 @@ void DngDecoder::decodeData(const TiffIFD* raw, uint32_t sample_format) const { mRaw->createData(); slices.decompress(); + + // DNG 1.7 may store the (already assembled) frame with its color-plane + // fields stacked, signalled by Row/ColumnInterleaveFactor. This is a + // whole-frame post-pass over the fully assembled buffer, applied BEFORE + // ActiveArea/DefaultCropOrigin (handleMetadata) crop the image. A single + // (e.g. JXL) tile can straddle a field boundary, so this can NOT be done + // per-tile. + deinterleaveFields(raw); +} + +void DngDecoder::deinterleaveFields(const TiffIFD* raw) const { + uint32_t rowFactor = 1; + if (raw->hasEntry(TiffTag::ROWINTERLEAVEFACTOR)) + rowFactor = raw->getEntry(TiffTag::ROWINTERLEAVEFACTOR)->getU32(); + + uint32_t colFactor = 1; + if (raw->hasEntry(TiffTag::COLUMNINTERLEAVEFACTOR)) + colFactor = raw->getEntry(TiffTag::COLUMNINTERLEAVEFACTOR)->getU32(); + + if (rowFactor == 0 || colFactor == 0) + ThrowRDE("Invalid interleave factor (%u, %u)", rowFactor, colFactor); + + // Fast path: nothing to do. + if (rowFactor == 1 && colFactor == 1) + return; + + const int storedH = mRaw->dim.y; + const int storedW = mRaw->dim.x; + + if (rowFactor > static_cast(storedH) || + colFactor > static_cast(storedW)) + ThrowRDE("Interleave factor (%u, %u) larger than image dimensions (%i, %i)", + rowFactor, colFactor, storedW, storedH); + + // stored-row -> final-row and stored-col -> final-col lookup tables. + const std::vector rowMap = + dngDeinterleaveFieldMap(storedH, implicit_cast(rowFactor)); + const std::vector colMap = + dngDeinterleaveFieldMap(storedW, implicit_cast(colFactor)); + + // Operate on raw bytes so the same scatter works for both UINT16 and F32 + // buffers. `bpp` is the size of one whole pixel (all channels) in bytes; the + // byte Array2DRef indexes columns in bytes. + const int bpp = implicit_cast(mRaw->getBpp()); + const Array2DRef img = mRaw->getByteDataAsUncroppedArray2DRef(); + + // A temporary copy of the assembled (stored-order) frame to scatter from. + std::vector tmp(static_cast(storedH) * storedW * bpp); + const Array2DRef src(tmp.data(), storedW * bpp, storedH); + for (int sy = 0; sy < storedH; ++sy) + std::memcpy(&src(sy, 0), &img(sy, 0), static_cast(storedW) * bpp); + + // Scatter src(sy,sx) -> img(fy,fx), one whole pixel (bpp bytes) at a time. + for (int sy = 0; sy < storedH; ++sy) { + const int fy = rowMap[static_cast(sy)]; + for (int sx = 0; sx < storedW; ++sx) { + const int fx = colMap[static_cast(sx)]; + std::memcpy(&img(fy, bpp * fx), &src(sy, bpp * sx), + static_cast(bpp)); + } + } } RawImage DngDecoder::decodeRawInternal() { diff --git a/src/librawspeed/decoders/DngDecoder.h b/src/librawspeed/decoders/DngDecoder.h index 4558be552..c3fea09c9 100644 --- a/src/librawspeed/decoders/DngDecoder.h +++ b/src/librawspeed/decoders/DngDecoder.h @@ -53,6 +53,7 @@ class DngDecoder final : public AbstractTiffDecoder { void parseWhiteBalance() const; DngTilingDescription getTilingDescription(const TiffIFD* raw) const; void decodeData(const TiffIFD* raw, uint32_t sample_format) const; + void deinterleaveFields(const TiffIFD* raw) const; void handleMetadata(const TiffIFD* raw); bool decodeMaskedAreas(const TiffIFD* raw) const; bool decodeBlackLevels(const TiffIFD* raw) const; diff --git a/src/librawspeed/decoders/DngDeinterleave.h b/src/librawspeed/decoders/DngDeinterleave.h new file mode 100644 index 000000000..27021c087 --- /dev/null +++ b/src/librawspeed/decoders/DngDeinterleave.h @@ -0,0 +1,77 @@ +/* + RawSpeed - RAW file decoder. + + Copyright (C) 2026 Mayk Thewessen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#pragma once + +#include +#include +#include + +namespace rawspeed { + +// Pixel de-interleave for DNG 1.7 RowInterleaveFactor (0xC71F) / +// ColumnInterleaveFactor (0xCD43). +// +// A CFA DNG may store its mosaic as `R` x `C` stacked "fields" (color-plane +// subimages), each of which compresses far better under lossy JPEG XL than the +// raw mosaic. De-interleaving REORDERS pixels only; it never resizes, so the +// final dimensions equal the stored (decoded) dimensions. +// +// Field f's rows scatter to final rows f, f+R, f+2R, ... i.e. the forward map +// stored->final is `fy = within_field_row * R + field_row_index`, and +// symmetrically for columns with C. Non-divisible sizes are handled exactly +// like the dng_sdk reference (dng_read_image.cpp): earlier fields absorb the +// remainder. +// +// This matches the Adobe DNG 1.7.1.0 specification and the dng_sdk reference +// implementation. + +// Build the stored-row -> final-row (or stored-col -> final-col) lookup table. +// +// `total` is the stored extent (height for rows, width for columns) and +// `factor` is the corresponding interleave factor (R or C, >= 1). +// +// Returns a vector `map` of size `total` such that the pixel stored at index +// `s` belongs at final index `map[s]`. +[[nodiscard]] inline std::vector dngDeinterleaveFieldMap(int total, + int factor) { + assert(total >= 0); + assert(factor >= 1); + + std::vector map(static_cast(total)); + + int acc = 0; + for (int f = 0; f < factor; ++f) { + // Number of rows/cols in this field. Earlier fields absorb the remainder, + // matching dng_sdk: rows[f] = (total - f + factor - 1) / factor. + const int fieldExtent = (total - f + factor - 1) / factor; + for (int within = 0; within < fieldExtent; ++within) { + const int storedIdx = acc + within; + assert(storedIdx < total); + map[static_cast(storedIdx)] = within * factor + f; + } + acc += fieldExtent; + } + assert(acc == total); + + return map; +} + +} // namespace rawspeed diff --git a/src/librawspeed/decompressors/AbstractDngDecompressor.cpp b/src/librawspeed/decompressors/AbstractDngDecompressor.cpp index b828a4fe2..81156cb82 100644 --- a/src/librawspeed/decompressors/AbstractDngDecompressor.cpp +++ b/src/librawspeed/decompressors/AbstractDngDecompressor.cpp @@ -49,6 +49,10 @@ #include "decompressors/JpegDecompressor.h" #endif +#ifdef HAVE_JPEGXL +#include "decompressors/JpegXlDecompressor.h" +#endif + namespace rawspeed { template <> void AbstractDngDecompressor::decompressThread<1>() const noexcept { @@ -201,6 +205,36 @@ void AbstractDngDecompressor::decompressThread<0x884c>() const noexcept { } #endif +#ifdef HAVE_JPEGXL +template <> +void AbstractDngDecompressor::decompressThread<52546>() const noexcept { +#ifdef HAVE_OPENMP +#pragma omp for schedule(static) +#endif + for (const auto& e : + Array1DRef(slices.data(), implicit_cast(slices.size()))) { + try { + // WhiteLevel, not mBps: it is the tag that says which range the samples + // are on, and DngDecoder::decodeData() has already set it by now (the + // same reason VC5 may rely on it). 0 means "unknown", which leaves + // libjxl's default scaling in place. + const uint32_t whiteLevel = + implicit_cast(std::max(0, mRaw->whitePoint.value_or(0))); + JpegXlDecompressor j(e.bs.peekBuffer(e.bs.getRemainSize()), mRaw, + whiteLevel); + j.decode(e.offX, e.offY); + } catch (const RawDecoderException& err) { + mRaw->setError(err.what()); + } catch (const IOException& err) { + mRaw->setError(err.what()); + } catch (...) { + // We should not get any other exception type here. + __builtin_unreachable(); + } + } +} +#endif + void AbstractDngDecompressor::decompressThread() const noexcept { invariant(mRaw->dim.x > 0); invariant(mRaw->dim.y > 0); @@ -232,6 +266,14 @@ void AbstractDngDecompressor::decompressThread() const noexcept { #else #pragma message "JPEG is not present! Lossy JPEG DNG will not be supported!" mRaw->setError("jpeg support is disabled."); +#endif + } else if (compression == 52546) { + /* JPEG XL (DNG 1.7) */ +#ifdef HAVE_JPEGXL + decompressThread<52546>(); +#else +#pragma message "JPEG XL is not present! DNG JPEG XL will not be supported!" + mRaw->setError("JPEG XL support is disabled."); #endif } else { mRaw->setError("AbstractDngDecompressor: Unknown compression"); diff --git a/src/librawspeed/decompressors/CMakeLists.txt b/src/librawspeed/decompressors/CMakeLists.txt index 8933a3ad1..65923ab3f 100644 --- a/src/librawspeed/decompressors/CMakeLists.txt +++ b/src/librawspeed/decompressors/CMakeLists.txt @@ -26,6 +26,9 @@ FILE(GLOB SOURCES "JpegDecompressor.cpp" "JpegDecompressor.h" "JpegMarkers.h" + "JpegXlDecompressor.cpp" + "JpegXlDecompressor.h" + "JpegXlTileLayout.h" "KodakDecompressor.cpp" "KodakDecompressor.h" "LJpegDecoder.cpp" @@ -82,4 +85,8 @@ if(WITH_JPEG AND TARGET JPEG::JPEG) target_link_libraries(rawspeed_decompressors PUBLIC JPEG::JPEG) endif() +if(WITH_JPEGXL AND TARGET PkgConfig::libjxl) + target_link_libraries(rawspeed_decompressors PUBLIC PkgConfig::libjxl) +endif() + target_link_libraries(rawspeed PRIVATE rawspeed_decompressors) diff --git a/src/librawspeed/decompressors/JpegXlDecompressor.cpp b/src/librawspeed/decompressors/JpegXlDecompressor.cpp new file mode 100644 index 000000000..057e5e38b --- /dev/null +++ b/src/librawspeed/decompressors/JpegXlDecompressor.cpp @@ -0,0 +1,200 @@ +/* + RawSpeed - RAW file decoder. + + Copyright (C) 2026 darktable developers + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "rawspeedconfig.h" // IWYU pragma: keep + +#ifdef HAVE_JPEGXL + +#include "adt/Array2DRef.h" +#include "adt/Point.h" +#include "common/RawImage.h" +#include "decoders/RawDecoderException.h" +#include "decompressors/JpegXlDecompressor.h" +#include "decompressors/JpegXlTileLayout.h" +#include +#include +#include +#include +#include + +namespace rawspeed { + +namespace { +// RAII wrapper so JxlDecoderDestroy runs on every exit path (incl. throws). +struct JxlDecoderGuard final { + JxlDecoder* dec; + explicit JxlDecoderGuard(JxlDecoder* d) : dec(d) {} + JxlDecoderGuard(const JxlDecoderGuard&) = delete; + JxlDecoderGuard(JxlDecoderGuard&&) = delete; + JxlDecoderGuard& operator=(const JxlDecoderGuard&) = delete; + JxlDecoderGuard& operator=(JxlDecoderGuard&&) = delete; + ~JxlDecoderGuard() { JxlDecoderDestroy(dec); } +}; + +// Copy the decoded, interleaved JXL tile into the raw buffer at (offX,offY). +// Templated on the sample type so the integer (uint16_t) and float (float) +// output paths share one implementation. +template +void copyTile(const Array2DRef out, const T* pixels, uint32_t jxl_w, + uint32_t copy_w, uint32_t copy_h, uint32_t cpp, uint32_t offX, + uint32_t offY) { + for (uint32_t row = 0; row < copy_h; ++row) { + for (uint32_t col = 0; col < cpp * copy_w; ++col) { + out(static_cast(row + offY), static_cast(cpp * offX + col)) = + pixels[(static_cast(row) * jxl_w * cpp) + col]; + } + } +} +} // namespace + +void JpegXlDecompressor::decode(uint32_t offX, uint32_t offY) { + JxlDecoder* dec = JxlDecoderCreate(nullptr); + if (dec == nullptr) + ThrowRDE("JXL: JxlDecoderCreate failed"); + JxlDecoderGuard guard(dec); + + if (JXL_DEC_SUCCESS != + JxlDecoderSubscribeEvents(dec, JXL_DEC_BASIC_INFO | JXL_DEC_FULL_IMAGE)) + ThrowRDE("JXL: JxlDecoderSubscribeEvents failed"); + + // rawspeed/darktable handle orientation themselves; do not auto-rotate. + if (JXL_DEC_SUCCESS != JxlDecoderSetKeepOrientation(dec, JXL_TRUE)) + ThrowRDE("JXL: JxlDecoderSetKeepOrientation failed"); + + if (JXL_DEC_SUCCESS != + JxlDecoderSetInput(dec, input.begin(), input.getSize())) + ThrowRDE("JXL: JxlDecoderSetInput failed"); + JxlDecoderCloseInput(dec); + + const uint32_t cpp = mRaw->getCpp(); + // Float DNGs (e.g. linear raw float) store an F32 buffer; integer DNGs store + // uint16. Decode JXL directly into whichever sample type mRaw expects, so the + // tile lands in the matching typed view below. + const bool isFloat = mRaw->getDataType() == RawImageType::F32; + const size_t sampleSize = isFloat ? sizeof(float) : sizeof(uint16_t); + const JxlPixelFormat fmt = { + /*num_channels=*/cpp, + /*data_type=*/isFloat ? JXL_TYPE_FLOAT : JXL_TYPE_UINT16, + /*endianness=*/JXL_LITTLE_ENDIAN, + /*align=*/0}; + + JpegXlStreamProps props; + // Type-agnostic byte buffer; libjxl reports the required size in bytes. + std::vector pixels; + + for (;;) { + const JxlDecoderStatus status = JxlDecoderProcessInput(dec); + if (status == JXL_DEC_ERROR) + ThrowRDE("JXL: decoding error"); + if (status == JXL_DEC_NEED_MORE_INPUT) + ThrowRDE("JXL: needs more input (truncated tile?)"); + if (status == JXL_DEC_BASIC_INFO) { + JxlBasicInfo info = {}; + if (JXL_DEC_SUCCESS != JxlDecoderGetBasicInfo(dec, &info)) + ThrowRDE("JXL: JxlDecoderGetBasicInfo failed"); + props = {/*width=*/info.xsize, + /*height=*/info.ysize, + /*bitsPerSample=*/info.bits_per_sample, + /*numColorChannels=*/info.num_color_channels, + /*numExtraChannels=*/info.num_extra_channels}; + if (const JpegXlStreamCheck check = + checkJpegXlStream(props, cpp, whiteLevel, isFloat); + check != JpegXlStreamCheck::Ok) { + ThrowRDE("JXL: %s (codestream %ux%u, %u bit, %u color + %u extra " + "channels; DNG cpp %u, WhiteLevel %u)", + toString(check), props.width, props.height, + props.bitsPerSample, props.numColorChannels, + props.numExtraChannels, cpp, whiteLevel); + } + continue; + } + if (status == JXL_DEC_NEED_IMAGE_OUT_BUFFER) { + size_t buf_size = 0; + if (JXL_DEC_SUCCESS != JxlDecoderImageOutBufferSize(dec, &fmt, &buf_size)) + ThrowRDE("JXL: JxlDecoderImageOutBufferSize failed"); + // copyTile indexes the buffer as a tightly-packed jxl_w * cpp row stride; + // make libjxl's own accounting confirm that before trusting it. + if (const size_t needed = + jpegXlTileBufferBytes(props.width, props.height, cpp, sampleSize); + buf_size < needed) { + ThrowRDE("JXL: output buffer of %zu bytes is short of the %zu needed " + "for a %ux%u tile of %u channels", + buf_size, needed, props.width, props.height, cpp); + } + pixels.resize(buf_size); + if (JXL_DEC_SUCCESS != + JxlDecoderSetImageOutBuffer(dec, &fmt, pixels.data(), buf_size)) + ThrowRDE("JXL: JxlDecoderSetImageOutBuffer failed"); + // Must follow SetImageOutBuffer -- that ordering is the libjxl API + // contract. + // + // libjxl's default (JXL_BIT_DEPTH_FROM_PIXEL_FORMAT) stretches the + // codestream to fill uint16. Whether that is right depends entirely on + // which range the DNG's WhiteLevel is expressed in, so let + // jpegXlBitDepthMode decide from WhiteLevel rather than assuming either + // way; both kinds of file exist. Only override when we want the + // codestream's own range, since the default needs no call at all. + if (jpegXlBitDepthMode(props, whiteLevel, isFloat) == + JpegXlBitDepthMode::FromCodestream) { + const JxlBitDepth bitDepth = {/*type=*/JXL_BIT_DEPTH_FROM_CODESTREAM, + /*bits_per_sample=*/0, + /*exponent_bits_per_sample=*/0}; + if (JXL_DEC_SUCCESS != JxlDecoderSetImageOutBitDepth(dec, &bitDepth)) + ThrowRDE("JXL: JxlDecoderSetImageOutBitDepth failed"); + } + continue; + } + if (status == JXL_DEC_FULL_IMAGE) + continue; // image now in `pixels` + if (status == JXL_DEC_SUCCESS) + break; + ThrowRDE("JXL: unexpected decoder status %d", static_cast(status)); + } + + if (pixels.empty()) + ThrowRDE("JXL: no pixel data decoded"); + + const JpegXlTileExtent copy = clipJpegXlTile( + static_cast(mRaw->dim.x), static_cast(mRaw->dim.y), + offX, offY, props.width, props.height); + if (copy.empty()) + ThrowRDE("JXL: tile origin (%u,%u) lies outside the %ix%i raw image", offX, + offY, mRaw->dim.x, mRaw->dim.y); + + if (isFloat) { + copyTile(mRaw->getF32DataAsUncroppedArray2DRef(), + reinterpret_cast(pixels.data()), props.width, + copy.w, copy.h, cpp, offX, offY); + } else { + copyTile(mRaw->getU16DataAsUncroppedArray2DRef(), + reinterpret_cast(pixels.data()), + props.width, copy.w, copy.h, cpp, offX, offY); + } +} + +} // namespace rawspeed + +#else + +#pragma message \ + "JPEG XL is not present! DNG JPEG XL (DNG 1.7) compression will not be " \ + "supported!" + +#endif diff --git a/src/librawspeed/decompressors/JpegXlDecompressor.h b/src/librawspeed/decompressors/JpegXlDecompressor.h new file mode 100644 index 000000000..6e86bb7fa --- /dev/null +++ b/src/librawspeed/decompressors/JpegXlDecompressor.h @@ -0,0 +1,61 @@ +/* + RawSpeed - RAW file decoder. + + Copyright (C) 2026 darktable developers + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#pragma once + +#include "rawspeedconfig.h" + +#ifdef HAVE_JPEGXL + +#include "common/RawImage.h" +#include "decompressors/AbstractDecompressor.h" +#include "io/Buffer.h" +#include +#include + +namespace rawspeed { + +// Decodes a single DNG tile whose data is a self-contained JPEG XL codestream +// (TIFF Compression tag 52546, as used by Apple ProRAW on iPhone 16 / DNG 1.7). +class JpegXlDecompressor final : public AbstractDecompressor { + Buffer input; + RawImage mRaw; + // The DNG's WhiteLevel for this IFD, or 0 if unknown. This is the tag that + // says what range the sample values are on, so it decides whether the + // codestream is taken as-is or rescaled. Not BitsPerSample: real files + // declare a BitsPerSample their codestream does not match. + uint32_t whiteLevel; + +public: + JpegXlDecompressor(Buffer bs, RawImage img, uint32_t whiteLevel_) + : input(bs), mRaw(std::move(img)), whiteLevel(whiteLevel_) {} + + void decode(uint32_t offsetX, uint32_t offsetY); +}; + +} // namespace rawspeed + +#else + +#pragma message \ + "JPEG XL is not present! DNG JPEG XL (DNG 1.7) compression will not be " \ + "supported!" + +#endif diff --git a/src/librawspeed/decompressors/JpegXlTileLayout.h b/src/librawspeed/decompressors/JpegXlTileLayout.h new file mode 100644 index 000000000..21fe5d244 --- /dev/null +++ b/src/librawspeed/decompressors/JpegXlTileLayout.h @@ -0,0 +1,194 @@ +/* + RawSpeed - RAW file decoder. + + Copyright (C) 2026 Mayk Thewessen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#pragma once + +#include +#include +#include + +namespace rawspeed { + +// Geometry and validation helpers for the DNG 1.7 JPEG XL tile path (TIFF +// Compression 52546), factored out of JpegXlDecompressor. +// +// These are deliberately pure and free of any dependency, so that the +// index arithmetic and the codestream-vs-DNG agreement rules can be unit-tested +// on every build, with or without libjxl, and without needing a sample file. + +// The subset of a decoded JXL codestream's properties the tile path has to +// reason about. Mirrors the relevant JxlBasicInfo fields. +struct JpegXlStreamProps final { + uint32_t width = 0; + uint32_t height = 0; + uint32_t bitsPerSample = 0; + uint32_t numColorChannels = 0; + uint32_t numExtraChannels = 0; +}; + +enum class JpegXlStreamCheck { + Ok, + EmptyImage, + ColorChannelMismatch, + ExtraChannelsUnsupported, + BitDepthUnsupported, + WhiteLevelUnrepresentable, +}; + +[[nodiscard]] constexpr const char* toString(JpegXlStreamCheck check) noexcept { + switch (check) { + case JpegXlStreamCheck::Ok: + return "ok"; + case JpegXlStreamCheck::EmptyImage: + return "codestream has zero width or height"; + case JpegXlStreamCheck::ColorChannelMismatch: + return "codestream color channel count does not match DNG SamplesPerPixel"; + case JpegXlStreamCheck::ExtraChannelsUnsupported: + return "codestream has extra (non-color) channels"; + case JpegXlStreamCheck::BitDepthUnsupported: + return "codestream bit depth does not fit the uint16 output"; + case JpegXlStreamCheck::WhiteLevelUnrepresentable: + return "DNG WhiteLevel exceeds the uint16 output range"; + } + return "unknown"; +} + +// Largest sample value an integer codestream of `bitsPerSample` bits can hold. +// Only meaningful for 1..16; callers reject anything else first. +[[nodiscard]] constexpr uint32_t +jpegXlCodestreamMax(uint32_t bitsPerSample) noexcept { + return (uint32_t{1} << bitsPerSample) - 1; +} + +// Widest value the integer output path (JXL_TYPE_UINT16) can carry. +constexpr uint32_t JpegXlMaxOutputValue = 65535; + +// Decide whether a decoded codestream may be copied into the DNG tile. +// +// `cpp` is the raw image's samples-per-pixel and `whiteLevel` the DNG's +// WhiteLevel (tag 0xC61D, or the BitsPerSample-derived default) for this IFD. +// Pass 0 when it is unknown. +// +// Deliberately NOT checked: the codestream's bitsPerSample against the DNG's +// BitsPerSample. Those two legitimately disagree in the wild, because +// BitsPerSample is not what the sample values are scaled to: +// - Apple ProRAW (iPhone 17 Pro) declares BitsPerSample = 10 while embedding +// a 16-bit codestream, with WhiteLevel = 65535. +// - Panasonic declares BitsPerSample = 16 while embedding a 12-bit +// codestream, with WhiteLevel = 63232 = 3952 * 16. +// Rejecting on disagreement would refuse both of those real files. WhiteLevel +// is the tag that describes the intended range, so it is what the scaling +// decision is keyed to (see jpegXlBitDepthMode) and a depth disagreement is not +// by itself an error. What IS worth refusing is a depth the output path cannot +// represent at all, and a WhiteLevel no uint16 scaling could reach. +[[nodiscard]] constexpr JpegXlStreamCheck +checkJpegXlStream(const JpegXlStreamProps& props, uint32_t cpp, + uint32_t whiteLevel, bool isFloat) noexcept { + if (props.width == 0 || props.height == 0) + return JpegXlStreamCheck::EmptyImage; + if (props.numColorChannels != cpp) + return JpegXlStreamCheck::ColorChannelMismatch; + // Requesting `cpp` channels would make libjxl drop any extra channel. Raw + // mosaic/linear tiles have no meaning for one, so a codestream carrying one + // is not a file we understand; do not silently discard part of it. + if (props.numExtraChannels != 0) + return JpegXlStreamCheck::ExtraChannelsUnsupported; + // The integer path decodes as JXL_TYPE_UINT16, so a deeper codestream has no + // representation, and 0 is not a valid depth. Float tiles decode as + // JXL_TYPE_FLOAT, where bitsPerSample is paired with an exponent field and + // neither bound applies. + if (!isFloat && (props.bitsPerSample == 0 || props.bitsPerSample > 16)) + return JpegXlStreamCheck::BitDepthUnsupported; + if (!isFloat && whiteLevel > JpegXlMaxOutputValue) + return JpegXlStreamCheck::WhiteLevelUnrepresentable; + return JpegXlStreamCheck::Ok; +} + +enum class JpegXlBitDepthMode { + // libjxl's default (JXL_BIT_DEPTH_FROM_PIXEL_FORMAT): the codestream is + // rescaled to fill the pixel format's range, so a b-bit codestream is + // stretched to fill uint16. + PixelFormatDefault, + // JXL_BIT_DEPTH_FROM_CODESTREAM: samples come out in the codestream's own + // range, unscaled. + FromCodestream, +}; + +// Pick the libjxl output bit depth so decoded values land on the scale the +// DNG's WhiteLevel describes. +// +// If WhiteLevel fits inside the codestream's own range, then that range is the +// scale the DNG's levels are keyed to and the samples must not be touched. If +// WhiteLevel is larger than the codestream can express, it can only be +// describing the stretched full-uint16 range, and libjxl's default rescale is +// exactly what makes the two agree (Panasonic: 12-bit codestream, WhiteLevel +// 63232 = 3952 * 16). For a 16-bit codestream the two modes coincide. +// +// An unknown WhiteLevel (0) keeps libjxl's default, which is the behaviour that +// predates this decision. +[[nodiscard]] constexpr JpegXlBitDepthMode +jpegXlBitDepthMode(const JpegXlStreamProps& props, uint32_t whiteLevel, + bool isFloat) noexcept { + // Float output supports nothing but the default. + if (isFloat || whiteLevel == 0 || props.bitsPerSample == 0 || + props.bitsPerSample > 16) + return JpegXlBitDepthMode::PixelFormatDefault; + if (whiteLevel <= jpegXlCodestreamMax(props.bitsPerSample)) + return JpegXlBitDepthMode::FromCodestream; + return JpegXlBitDepthMode::PixelFormatDefault; +} + +struct JpegXlTileExtent final { + uint32_t w = 0; + uint32_t h = 0; + + [[nodiscard]] constexpr bool empty() const noexcept { + return w == 0 || h == 0; + } +}; + +// Clip a decoded tile placed at (offX, offY) against the raw image bounds. +// +// Tiles on the right/bottom edge are legitimately larger than the image area +// left to fill, so the copy is truncated to fit. An offset at or beyond the +// image bounds is rejected explicitly rather than left to unsigned subtraction, +// which would wrap to ~4e9 and turn a bad tile offset into a wild write. +[[nodiscard]] constexpr JpegXlTileExtent +clipJpegXlTile(uint32_t imgW, uint32_t imgH, uint32_t offX, uint32_t offY, + uint32_t jxlW, uint32_t jxlH) noexcept { + if (offX >= imgW || offY >= imgH) + return {}; + return {std::min(imgW - offX, jxlW), std::min(imgH - offY, jxlH)}; +} + +// Bytes libjxl must hand back for a fully interleaved jxlW x jxlH tile of `cpp` +// samples of `sampleSize` bytes each (JxlPixelFormat align = 0, so rows are +// tightly packed). +// +// Widened to size_t before multiplying: the product of plausible tile +// dimensions overflows 32 bits well before it overflows the buffer. +[[nodiscard]] constexpr size_t +jpegXlTileBufferBytes(uint32_t jxlW, uint32_t jxlH, uint32_t cpp, + size_t sampleSize) noexcept { + return static_cast(jxlW) * static_cast(jxlH) * + static_cast(cpp) * sampleSize; +} + +} // namespace rawspeed diff --git a/src/librawspeed/tiff/TiffTag.h b/src/librawspeed/tiff/TiffTag.h index b4d20750a..8a8163cc8 100644 --- a/src/librawspeed/tiff/TiffTag.h +++ b/src/librawspeed/tiff/TiffTag.h @@ -327,6 +327,7 @@ enum class TiffTag : uint16_t { ORIGINALRAWFILEDIGEST = 0xC71D, SUBTILEBLOCKSIZE = 0xC71E, ROWINTERLEAVEFACTOR = 0xC71F, + COLUMNINTERLEAVEFACTOR = 0xCD43, PROFILELOOKTABLEDIMS = 0xC725, PROFILELOOKTABLEDATA = 0xC726, OPCODELIST1 = 0xC740, diff --git a/test/librawspeed/CMakeLists.txt b/test/librawspeed/CMakeLists.txt index fe5234773..19c0b3dc1 100644 --- a/test/librawspeed/CMakeLists.txt +++ b/test/librawspeed/CMakeLists.txt @@ -25,6 +25,8 @@ add_subdirectory(adt) add_subdirectory(bitstreams) add_subdirectory(codes) add_subdirectory(common) +add_subdirectory(decoders) +add_subdirectory(decompressors) add_subdirectory(io) add_subdirectory(metadata) add_subdirectory(test) diff --git a/test/librawspeed/decoders/CMakeLists.txt b/test/librawspeed/decoders/CMakeLists.txt new file mode 100644 index 000000000..2cb182180 --- /dev/null +++ b/test/librawspeed/decoders/CMakeLists.txt @@ -0,0 +1,7 @@ +FILE(GLOB RAWSPEED_TEST_SOURCES + "DngDeinterleaveTest.cpp" +) + +foreach(SRC ${RAWSPEED_TEST_SOURCES}) + add_rs_test("${SRC}") +endforeach() diff --git a/test/librawspeed/decoders/DngDeinterleaveTest.cpp b/test/librawspeed/decoders/DngDeinterleaveTest.cpp new file mode 100644 index 000000000..1c8b5c33f --- /dev/null +++ b/test/librawspeed/decoders/DngDeinterleaveTest.cpp @@ -0,0 +1,134 @@ +/* + RawSpeed - RAW file decoder. + + Copyright (C) 2026 Mayk Thewessen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "decoders/DngDeinterleave.h" +#include +#include + +using rawspeed::dngDeinterleaveFieldMap; + +namespace rawspeed_test { + +namespace { + +// Apply the row/col field maps to a stored (row-major, single-channel) image, +// mirroring DngDecoder::deinterleaveFields for verification of the index math. +std::vector applyDeinterleave(const std::vector& stored, int storedH, + int storedW, int rowFactor, int colFactor) { + const std::vector rowMap = dngDeinterleaveFieldMap(storedH, rowFactor); + const std::vector colMap = dngDeinterleaveFieldMap(storedW, colFactor); + + std::vector out(stored.size()); + for (int sy = 0; sy < storedH; ++sy) { + const int fy = rowMap[sy]; + for (int sx = 0; sx < storedW; ++sx) { + const int fx = colMap[sx]; + out[fy * storedW + fx] = stored[sy * storedW + sx]; + } + } + return out; +} + +} // namespace + +// Identity: factor 1 must leave the map untouched. +TEST(DngDeinterleaveTest, FactorOneIsIdentity) { + const std::vector map = dngDeinterleaveFieldMap(7, 1); + ASSERT_EQ(map.size(), 7U); + for (int i = 0; i < 7; ++i) + EXPECT_EQ(map[i], i); +} + +// The exact stored->final row map for R=2 on 4 rows. +// Field 0 occupies stored rows {0,1} -> final {0,2}. +// Field 1 occupies stored rows {2,3} -> final {1,3}. +TEST(DngDeinterleaveTest, RowMap2x4) { + const std::vector map = dngDeinterleaveFieldMap(4, 2); + const std::vector expected = {0, 2, 1, 3}; + EXPECT_EQ(map, expected); +} + +// Non-divisible case: earlier fields absorb the remainder (dng_sdk rule). +// total=5, factor=2: field0 has rows {0,1,2}, field1 has rows {3,4}. +// stored 0 -> 0, 1 -> 2, 2 -> 4 (field 0, *2+0) +// stored 3 -> 1, 4 -> 3 (field 1, *2+1) +TEST(DngDeinterleaveTest, NonDivisibleRemainder) { + const std::vector map = dngDeinterleaveFieldMap(5, 2); + const std::vector expected = {0, 2, 4, 1, 3}; + EXPECT_EQ(map, expected); +} + +// total=7, factor=3: field0 {0,1,2}(3), field1 {3,4}(2), field2 {5,6}(2). +// field0: 0->0, 1->3, 2->6 +// field1: 3->1, 4->4 +// field2: 5->2, 6->5 +TEST(DngDeinterleaveTest, NonDivisibleThreeFields) { + const std::vector map = dngDeinterleaveFieldMap(7, 3); + const std::vector expected = {0, 3, 6, 1, 4, 2, 5}; + EXPECT_EQ(map, expected); +} + +// The full VERIFIED 4x4, R=C=2 worked example. +// A stored buffer whose 4 quadrants are constant (TL=10 "R", TR=20 "G", +// BL=30 "G", BR=40 "B") must de-interleave to the RGGB-phase mosaic: +// 10 20 10 20 / 30 40 30 40 / 10 20 10 20 / 30 40 30 40 +TEST(DngDeinterleaveTest, WorkedExample4x4Quadrants) { + // clang-format off + const std::vector stored = { + 10, 10, 20, 20, + 10, 10, 20, 20, + 30, 30, 40, 40, + 30, 30, 40, 40, + }; + const std::vector expected = { + 10, 20, 10, 20, + 30, 40, 30, 40, + 10, 20, 10, 20, + 30, 40, 30, 40, + }; + // clang-format on + + const std::vector out = + applyDeinterleave(stored, /*storedH=*/4, /*storedW=*/4, + /*rowFactor=*/2, /*colFactor=*/2); + EXPECT_EQ(out, expected); +} + +// Per-pixel stored->final mapping from the spec, every pixel of the 4x4. +TEST(DngDeinterleaveTest, WorkedExample4x4PerPixelMap) { + const std::vector rowMap = dngDeinterleaveFieldMap(4, 2); + const std::vector colMap = dngDeinterleaveFieldMap(4, 2); + + // (stored_y, stored_x) -> (final_y, final_x) + const int expected[4][4][2] = { + {{0, 0}, {0, 2}, {0, 1}, {0, 3}}, + {{2, 0}, {2, 2}, {2, 1}, {2, 3}}, + {{1, 0}, {1, 2}, {1, 1}, {1, 3}}, + {{3, 0}, {3, 2}, {3, 1}, {3, 3}}, + }; + for (int sy = 0; sy < 4; ++sy) { + for (int sx = 0; sx < 4; ++sx) { + EXPECT_EQ(rowMap[sy], expected[sy][sx][0]) << "sy=" << sy << " sx=" << sx; + EXPECT_EQ(colMap[sx], expected[sy][sx][1]) << "sy=" << sy << " sx=" << sx; + } + } +} + +} // namespace rawspeed_test diff --git a/test/librawspeed/decompressors/CMakeLists.txt b/test/librawspeed/decompressors/CMakeLists.txt new file mode 100644 index 000000000..a1f981cea --- /dev/null +++ b/test/librawspeed/decompressors/CMakeLists.txt @@ -0,0 +1,7 @@ +FILE(GLOB RAWSPEED_TEST_SOURCES + "JpegXlTileLayoutTest.cpp" +) + +foreach(SRC ${RAWSPEED_TEST_SOURCES}) + add_rs_test("${SRC}") +endforeach() diff --git a/test/librawspeed/decompressors/JpegXlTileLayoutTest.cpp b/test/librawspeed/decompressors/JpegXlTileLayoutTest.cpp new file mode 100644 index 000000000..5aae548cd --- /dev/null +++ b/test/librawspeed/decompressors/JpegXlTileLayoutTest.cpp @@ -0,0 +1,349 @@ +/* + RawSpeed - RAW file decoder. + + Copyright (C) 2026 Mayk Thewessen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "decompressors/JpegXlTileLayout.h" +#include +#include +#include + +using rawspeed::checkJpegXlStream; +using rawspeed::clipJpegXlTile; +using rawspeed::JpegXlBitDepthMode; +using rawspeed::jpegXlBitDepthMode; +using rawspeed::JpegXlStreamCheck; +using rawspeed::JpegXlStreamProps; +using rawspeed::jpegXlTileBufferBytes; +using rawspeed::JpegXlTileExtent; + +namespace rawspeed_test { + +namespace { + +// The Samsung Galaxy S23 Expert RAW layout reported on rawspeed#971: +// 4000x3000, 16-bit, three-channel LinearRaw. +constexpr JpegXlStreamProps s23LinearRaw() { + return {/*width=*/4000, /*height=*/3000, /*bitsPerSample=*/16, + /*numColorChannels=*/3, /*numExtraChannels=*/0}; +} + +} // namespace + +// +// checkJpegXlStream: what actually disqualifies a codestream +// + +TEST(JpegXlStreamCheckTest, S23LinearRawIsAccepted) { + EXPECT_EQ(checkJpegXlStream(s23LinearRaw(), /*cpp=*/3, + /*whiteLevel=*/65535, /*isFloat=*/false), + JpegXlStreamCheck::Ok); +} + +// A single-channel CFA mosaic tile whose WhiteLevel sits inside its own depth. +TEST(JpegXlStreamCheckTest, MonochromeCfaTileIsAccepted) { + const JpegXlStreamProps props = {/*width=*/256, /*height=*/256, + /*bitsPerSample=*/14, + /*numColorChannels=*/1, + /*numExtraChannels=*/0}; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/1, /*whiteLevel=*/16383, + /*isFloat=*/false), + JpegXlStreamCheck::Ok); +} + +// The regression this file exists for, measured on a real file: +// `iPhone 17 Pro RAW IMG_0031.DNG` declares BitsPerSample = 10, yet jxlinfo +// reports its tiles as 16-bit RGB 2016x2016, with WhiteLevel = 65535. An +// earlier revision compared the codestream depth against BitsPerSample and +// refused the file outright. BitsPerSample is not the range indicator, so this +// must decode. +TEST(JpegXlStreamCheckTest, AppleProRawSixteenBitCodestreamInTenBitDngIsOk) { + const JpegXlStreamProps props = {/*width=*/2016, /*height=*/2016, + /*bitsPerSample=*/16, + /*numColorChannels=*/3, + /*numExtraChannels=*/0}; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/65535, + /*isFloat=*/false), + JpegXlStreamCheck::Ok); +} + +// The mirror image of the Apple case: Panasonic declares BitsPerSample = 16 but +// embeds a 12-bit codestream, with WhiteLevel = 63232 = 3952 * 16, i.e. keyed +// to the stretched range. Also must not be refused for the depth disagreement. +TEST(JpegXlStreamCheckTest, PanasonicTwelveBitCodestreamIsOk) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 12; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/63232, + /*isFloat=*/false), + JpegXlStreamCheck::Ok); +} + +// A depth the uint16 output path cannot represent is a different matter: there +// is no scaling that recovers it, so it is refused. +TEST(JpegXlStreamCheckTest, DepthDeeperThanUint16IsRejected) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 17; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/65535, + /*isFloat=*/false), + JpegXlStreamCheck::BitDepthUnsupported); + + props.bitsPerSample = 32; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/65535, + /*isFloat=*/false), + JpegXlStreamCheck::BitDepthUnsupported); +} + +TEST(JpegXlStreamCheckTest, ZeroDepthIsRejected) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 0; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/65535, + /*isFloat=*/false), + JpegXlStreamCheck::BitDepthUnsupported); +} + +// Float tiles decode as JXL_TYPE_FLOAT, where bitsPerSample is paired with an +// exponent field, so neither integer bound applies. +TEST(JpegXlStreamCheckTest, FloatPathIgnoresDepthBounds) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 32; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/1, + /*isFloat=*/true), + JpegXlStreamCheck::Ok); +} + +// No uint16 output can reach a WhiteLevel above 65535. +TEST(JpegXlStreamCheckTest, WhiteLevelAboveUint16IsRejected) { + EXPECT_EQ(checkJpegXlStream(s23LinearRaw(), /*cpp=*/3, /*whiteLevel=*/65536, + /*isFloat=*/false), + JpegXlStreamCheck::WhiteLevelUnrepresentable); +} + +// An unknown WhiteLevel is not an error; it just means the default scaling. +TEST(JpegXlStreamCheckTest, UnknownWhiteLevelIsAccepted) { + EXPECT_EQ(checkJpegXlStream(s23LinearRaw(), /*cpp=*/3, /*whiteLevel=*/0, + /*isFloat=*/false), + JpegXlStreamCheck::Ok); +} + +TEST(JpegXlStreamCheckTest, ColorChannelCountMustMatchCpp) { + const JpegXlStreamProps props = s23LinearRaw(); // 3 color channels + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/1, /*whiteLevel=*/65535, + /*isFloat=*/false), + JpegXlStreamCheck::ColorChannelMismatch); +} + +// Requesting cpp channels would make libjxl silently drop the extra one. +TEST(JpegXlStreamCheckTest, ExtraChannelsAreRejectedNotDropped) { + JpegXlStreamProps props = s23LinearRaw(); + props.numExtraChannels = 1; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/65535, + /*isFloat=*/false), + JpegXlStreamCheck::ExtraChannelsUnsupported); +} + +TEST(JpegXlStreamCheckTest, ZeroSizedCodestreamIsRejected) { + JpegXlStreamProps props = s23LinearRaw(); + props.width = 0; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/65535, + /*isFloat=*/false), + JpegXlStreamCheck::EmptyImage); + + props = s23LinearRaw(); + props.height = 0; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/65535, + /*isFloat=*/false), + JpegXlStreamCheck::EmptyImage); +} + +// The checks are ordered so that the most structural problem is reported first. +TEST(JpegXlStreamCheckTest, EmptinessOutranksOtherFaults) { + const JpegXlStreamProps props = {/*width=*/0, /*height=*/0, + /*bitsPerSample=*/32, + /*numColorChannels=*/4, + /*numExtraChannels=*/2}; + EXPECT_EQ(checkJpegXlStream(props, /*cpp=*/3, /*whiteLevel=*/70000, + /*isFloat=*/false), + JpegXlStreamCheck::EmptyImage); +} + +// +// jpegXlBitDepthMode: which range the decoded samples must land on +// + +// Apple: WhiteLevel 65535 fits a 16-bit codestream exactly, so take it as-is. +// (For a 16-bit codestream the two modes coincide anyway.) +TEST(JpegXlBitDepthModeTest, AppleFullRangeUsesCodestreamRange) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 16; + EXPECT_EQ(jpegXlBitDepthMode(props, /*whiteLevel=*/65535, /*isFloat=*/false), + JpegXlBitDepthMode::FromCodestream); +} + +// Panasonic: WhiteLevel 63232 cannot be expressed in 12 bits (max 4095), so it +// must be describing the stretched range. Keep libjxl's rescale, which lands +// 3952 on 63232. +TEST(JpegXlBitDepthModeTest, WhiteLevelBeyondCodestreamRangeStretches) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 12; + EXPECT_EQ(jpegXlBitDepthMode(props, /*whiteLevel=*/63232, /*isFloat=*/false), + JpegXlBitDepthMode::PixelFormatDefault); +} + +// A 12-bit codestream whose WhiteLevel is also 12-bit is already on the right +// scale; stretching it would make it ~16x too bright. +TEST(JpegXlBitDepthModeTest, MatchingTwelveBitUsesCodestreamRange) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 12; + EXPECT_EQ(jpegXlBitDepthMode(props, /*whiteLevel=*/4095, /*isFloat=*/false), + JpegXlBitDepthMode::FromCodestream); +} + +// Adobe's 03_jxl_bayer_raw_integer.dng: 16-bit codestream, WhiteLevel 16383. +// WhiteLevel below the codestream max is normal (data simply does not reach +// full scale) and must not trigger a rescale. +TEST(JpegXlBitDepthModeTest, WhiteLevelBelowCodestreamMaxIsNotRescaled) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 16; + props.numColorChannels = 1; + EXPECT_EQ(jpegXlBitDepthMode(props, /*whiteLevel=*/16383, /*isFloat=*/false), + JpegXlBitDepthMode::FromCodestream); +} + +// Nothing to key the decision to; keep the libjxl default. +TEST(JpegXlBitDepthModeTest, UnknownWhiteLevelKeepsDefault) { + EXPECT_EQ(jpegXlBitDepthMode(s23LinearRaw(), /*whiteLevel=*/0, + /*isFloat=*/false), + JpegXlBitDepthMode::PixelFormatDefault); +} + +// libjxl supports only the default for float output. +TEST(JpegXlBitDepthModeTest, FloatKeepsDefault) { + EXPECT_EQ(jpegXlBitDepthMode(s23LinearRaw(), /*whiteLevel=*/1, + /*isFloat=*/true), + JpegXlBitDepthMode::PixelFormatDefault); +} + +// An out-of-range depth never reaches the scaling decision (checkJpegXlStream +// refuses it first), but the mode must still be well-defined for it. +TEST(JpegXlBitDepthModeTest, OutOfRangeDepthKeepsDefault) { + JpegXlStreamProps props = s23LinearRaw(); + props.bitsPerSample = 17; + EXPECT_EQ(jpegXlBitDepthMode(props, /*whiteLevel=*/65535, /*isFloat=*/false), + JpegXlBitDepthMode::PixelFormatDefault); + + props.bitsPerSample = 0; + EXPECT_EQ(jpegXlBitDepthMode(props, /*whiteLevel=*/65535, /*isFloat=*/false), + JpegXlBitDepthMode::PixelFormatDefault); +} + +// +// clipJpegXlTile: tile placement geometry +// + +TEST(JpegXlTileExtentTest, InteriorTileIsNotClipped) { + const JpegXlTileExtent e = clipJpegXlTile(/*imgW=*/4000, /*imgH=*/3000, + /*offX=*/512, /*offY=*/256, + /*jxlW=*/256, /*jxlH=*/256); + EXPECT_EQ(e.w, 256U); + EXPECT_EQ(e.h, 256U); + EXPECT_FALSE(e.empty()); +} + +// 4000 is not a multiple of 256, so the last column of tiles overhangs. +TEST(JpegXlTileExtentTest, RightEdgeTileIsTruncated) { + const JpegXlTileExtent e = clipJpegXlTile(/*imgW=*/4000, /*imgH=*/3000, + /*offX=*/3840, /*offY=*/0, + /*jxlW=*/256, /*jxlH=*/256); + EXPECT_EQ(e.w, 160U); // 4000 - 3840 + EXPECT_EQ(e.h, 256U); +} + +TEST(JpegXlTileExtentTest, BottomEdgeTileIsTruncated) { + const JpegXlTileExtent e = clipJpegXlTile(/*imgW=*/4000, /*imgH=*/3000, + /*offX=*/0, /*offY=*/2816, + /*jxlW=*/256, /*jxlH=*/256); + EXPECT_EQ(e.w, 256U); + EXPECT_EQ(e.h, 184U); // 3000 - 2816 +} + +TEST(JpegXlTileExtentTest, CornerTileIsTruncatedInBothAxes) { + const JpegXlTileExtent e = clipJpegXlTile(/*imgW=*/4000, /*imgH=*/3000, + /*offX=*/3840, /*offY=*/2816, + /*jxlW=*/256, /*jxlH=*/256); + EXPECT_EQ(e.w, 160U); + EXPECT_EQ(e.h, 184U); +} + +// A single-tile image whose codestream exactly fills it. +TEST(JpegXlTileExtentTest, SingleTileCoveringWholeImage) { + const JpegXlTileExtent e = clipJpegXlTile(/*imgW=*/4000, /*imgH=*/3000, + /*offX=*/0, /*offY=*/0, + /*jxlW=*/4000, /*jxlH=*/3000); + EXPECT_EQ(e.w, 4000U); + EXPECT_EQ(e.h, 3000U); +} + +// The regression: `imgW - offX` on uint32_t wraps to ~4e9 when offX >= imgW, +// which would make the copy loop run far past the end of the raw buffer. +TEST(JpegXlTileExtentTest, OffsetAtImageEdgeDoesNotUnderflow) { + const JpegXlTileExtent e = clipJpegXlTile(/*imgW=*/4000, /*imgH=*/3000, + /*offX=*/4000, /*offY=*/0, + /*jxlW=*/256, /*jxlH=*/256); + EXPECT_TRUE(e.empty()); + EXPECT_EQ(e.w, 0U); +} + +TEST(JpegXlTileExtentTest, OffsetPastImageDoesNotUnderflow) { + const JpegXlTileExtent x = clipJpegXlTile(/*imgW=*/4000, /*imgH=*/3000, + /*offX=*/9999, /*offY=*/0, + /*jxlW=*/256, /*jxlH=*/256); + EXPECT_TRUE(x.empty()); + + const JpegXlTileExtent y = clipJpegXlTile(/*imgW=*/4000, /*imgH=*/3000, + /*offX=*/0, /*offY=*/9999, + /*jxlW=*/256, /*jxlH=*/256); + EXPECT_TRUE(y.empty()); +} + +// +// jpegXlTileBufferBytes: required output buffer size +// + +TEST(JpegXlTileBufferTest, InterleavedTileSize) { + // 256x256, 3 channels, uint16. + EXPECT_EQ(jpegXlTileBufferBytes(256, 256, 3, sizeof(uint16_t)), + size_t{256} * 256 * 3 * 2); + // Single-channel float. + EXPECT_EQ(jpegXlTileBufferBytes(256, 256, 1, sizeof(float)), + size_t{256} * 256 * 4); +} + +TEST(JpegXlTileBufferTest, S23FullFrameSize) { + EXPECT_EQ(jpegXlTileBufferBytes(4000, 3000, 3, sizeof(uint16_t)), + size_t{72'000'000}); +} + +// The product must be computed in size_t: 65535*65535*3*2 overflows uint32_t. +TEST(JpegXlTileBufferTest, LargeTileDoesNotOverflow) { + constexpr size_t expected = size_t{65535} * 65535 * 3 * 2; + static_assert(expected > size_t{UINT32_MAX}, + "test is meaningless if it fits in 32 bits"); + EXPECT_EQ(jpegXlTileBufferBytes(65535, 65535, 3, sizeof(uint16_t)), expected); +} + +} // namespace rawspeed_test