From 5952c20b1d710b2632b2ca21a05117160da0001a Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 14 Sep 2026 13:23:34 +1000 Subject: [PATCH] Fix TIFF decoding of legacy 8-bit color maps --- .../PaletteTiffColor{TPixel}.cs | 43 +++++++++++++------ .../Formats/Tiff/TiffDecoderTests.cs | 11 +++++ tests/ImageSharp.Tests/TestImages.cs | 2 + .../Tiff/Issues/Issue3182ColorMap16Bit.tiff | 3 ++ .../Tiff/Issues/Issue3182ColorMap8Bit.tiff | 3 ++ 5 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 tests/Images/Input/Tiff/Issues/Issue3182ColorMap16Bit.tiff create mode 100644 tests/Images/Input/Tiff/Issues/Issue3182ColorMap8Bit.tiff diff --git a/src/ImageSharp/Formats/Tiff/PhotometricInterpretation/PaletteTiffColor{TPixel}.cs b/src/ImageSharp/Formats/Tiff/PhotometricInterpretation/PaletteTiffColor{TPixel}.cs index 52315a02b4..114a3205ea 100644 --- a/src/ImageSharp/Formats/Tiff/PhotometricInterpretation/PaletteTiffColor{TPixel}.cs +++ b/src/ImageSharp/Formats/Tiff/PhotometricInterpretation/PaletteTiffColor{TPixel}.cs @@ -26,8 +26,6 @@ internal class PaletteTiffColor : TiffBaseColorDecoder private readonly bool hasAlpha; private Color[]? paletteColors; - private const float InvMax = 1f / 65535f; - /// /// Initializes a new instance of the class. /// @@ -42,8 +40,15 @@ public PaletteTiffColor(TiffBitsPerSample bitsPerSample, ushort[] colorMap, Tiff int colorCount = 1 << this.bitsPerSample0; + // Match libtiff's legacy palette detection: use 8-bit scaling only when every RGB entry + // fits in 0-255. The runtime vectorizes this range check without allocating. + // Normalize by the selected channel range, not the observed maximum, to preserve brightness. + float colorScale = colorMap.AsSpan().ContainsAnyExceptInRange((ushort)0, (ushort)255) + ? 1f / 65535f + : 1f / 255f; + // TIFF PaletteColor uses ColorMap (tag 320 / 0x0140) which is RGB-only (no alpha). - this.vectorPallete = GenerateVectorPalette(colorMap, colorCount); + this.vectorPallete = GenerateVectorPalette(colorMap, colorCount, colorScale); // ExtraSamples (tag 338 / 0x0152) describes extra per-pixel samples stored in the image data stream. // For PaletteColor, any alpha is per pixel (stored alongside the index), not per palette entry. @@ -61,7 +66,7 @@ public PaletteTiffColor(TiffBitsPerSample bitsPerSample, ushort[] colorMap, Tiff else { // Pre-generate pixel palette for non-alpha case for performance. - this.pixelPalette = GeneratePixelPalette(colorMap, colorCount); + this.pixelPalette = GeneratePixelPalette(colorMap, colorCount, colorScale); } } @@ -131,7 +136,14 @@ public override void Decode(ReadOnlySpan data, Buffer2D pixels, in } } - private static Vector4[] GenerateVectorPalette(ushort[] colorMap, int colorCount) + /// + /// Converts the RGB color map to normalized vectors with opaque alpha. + /// + /// The planar RGB color map. + /// The number of palette colors. + /// The reciprocal of the detected channel range maximum. + /// The normalized vector palette. + private static Vector4[] GenerateVectorPalette(ushort[] colorMap, int colorCount, float colorScale) { Vector4[] palette = new Vector4[colorCount]; @@ -141,16 +153,23 @@ private static Vector4[] GenerateVectorPalette(ushort[] colorMap, int colorCount for (int i = 0; i < palette.Length; i++) { - float r = colorMap[rOffset + i] * InvMax; - float g = colorMap[gOffset + i] * InvMax; - float b = colorMap[bOffset + i] * InvMax; + float r = colorMap[rOffset + i] * colorScale; + float g = colorMap[gOffset + i] * colorScale; + float b = colorMap[bOffset + i] * colorScale; palette[i] = new Vector4(r, g, b, 1f); } return palette; } - private static TPixel[] GeneratePixelPalette(ushort[] colorMap, int colorCount) + /// + /// Converts the RGB color map to pixels with opaque alpha. + /// + /// The planar RGB color map. + /// The number of palette colors. + /// The reciprocal of the detected channel range maximum. + /// The pixel palette. + private static TPixel[] GeneratePixelPalette(ushort[] colorMap, int colorCount, float colorScale) { TPixel[] palette = new TPixel[colorCount]; @@ -160,9 +179,9 @@ private static TPixel[] GeneratePixelPalette(ushort[] colorMap, int colorCount) for (int i = 0; i < palette.Length; i++) { - float r = colorMap[rOffset + i] * InvMax; - float g = colorMap[gOffset + i] * InvMax; - float b = colorMap[bOffset + i] * InvMax; + float r = colorMap[rOffset + i] * colorScale; + float g = colorMap[gOffset + i] * colorScale; + float b = colorMap[bOffset + i] * colorScale; palette[i] = TPixel.FromUnassociatedScaledVector4(new Vector4(r, g, b, 1f)); } diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs index d23eedcfee..e5ecb50f5c 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs @@ -202,6 +202,17 @@ public void TiffDecoder_CanDecode_Planar_128Bit(TestImageProvider(TestImageProvider provider) where TPixel : unmanaged, IPixel => TestTiffDecoder(provider); + /// + /// Palette TIFFs with LZW compression and horizontal prediction preserve colors for both palette ranges. + /// + /// The pixel type. + /// The TIFF image provider. + [Theory] + [WithFile(Issue3182ColorMap16Bit, PixelTypes.Rgba64)] + [WithFile(Issue3182ColorMap8Bit, PixelTypes.Rgba32)] + public void TiffDecoder_CanDecode_PaletteWithLzwAndPredictor(TestImageProvider provider) + where TPixel : unmanaged, IPixel => TestTiffDecoder(provider); + [Theory] [WithFile(Rgb4BitPalette, PixelTypes.Rgba32)] [WithFile(Flower4BitPalette, PixelTypes.Rgba32)] diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index d1e0d64ae6..03937310f5 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -1210,6 +1210,8 @@ public static class Tiff public const string Issue2909 = "Tiff/Issues/Issue2909.tiff"; public const string Issue2983 = "Tiff/Issues/Issue2983.tiff"; + public const string Issue3182ColorMap8Bit = "Tiff/Issues/Issue3182ColorMap8Bit.tiff"; + public const string Issue3182ColorMap16Bit = "Tiff/Issues/Issue3182ColorMap16Bit.tiff"; public static readonly string[] Multiframes = [MultiframeDeflateWithPreview, MultiframeLzwPredictor /*, MultiFrameDifferentSize, MultiframeDifferentSizeTiled, MultiFrameDifferentVariants,*/ ]; diff --git a/tests/Images/Input/Tiff/Issues/Issue3182ColorMap16Bit.tiff b/tests/Images/Input/Tiff/Issues/Issue3182ColorMap16Bit.tiff new file mode 100644 index 0000000000..d32b399459 --- /dev/null +++ b/tests/Images/Input/Tiff/Issues/Issue3182ColorMap16Bit.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a0553c34175e647a027d9878b63d3229b686159ac5ee863046e35ab94aee8b4 +size 4704 diff --git a/tests/Images/Input/Tiff/Issues/Issue3182ColorMap8Bit.tiff b/tests/Images/Input/Tiff/Issues/Issue3182ColorMap8Bit.tiff new file mode 100644 index 0000000000..312f385931 --- /dev/null +++ b/tests/Images/Input/Tiff/Issues/Issue3182ColorMap8Bit.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b664b087ad2ecab109d100a907180bc4aa2ace573c347424c47009c37360eb59 +size 4704