Repository navigation
PGM/PPM images with a 16-bit maxval other than 65535 decode to only a few gray levels #3239
Description
Prerequisites
- I have written a descriptive issue title
- I have verified that I am running the latest version of ImageSharp
- I have verified if the problem exist in both
DEBUGandRELEASEmode - I have searched open and closed issues to ensure it has not already been reported
ImageSharp version
main @ a73c43d (also present in v4.1.3, same code)
Other ImageSharp packages and versions
None
Environment (Operating system, version and so on)
Windows 11 Pro 10.0.22631, x64
.NET Framework version
.NET 10 (net10.0)
Description
In the Netpbm formats, a sample value is in the range [0, maxval], and maxval maps to full intensity. A decoder that returns 8-bit pixels should give approximately round(sample * 255 / maxval).
When maxval is above 255 but below 65535 (for example 1000), samples are 16-bit. If the image is decoded to an 8-bit pixel type, such as Rgb24 (the default for Image.Load), most of the precision is lost. With maxval 1000:
- sample 7 decodes to 0 (expected 2)
- sample 500 decodes to 131 (expected 128)
- all 1001 possible sample values decode to only five gray levels
This affects P2, P3, P5 and P6.
Cause: PbmDecoderCore scales in two steps. It first converts the raw samples to the destination pixel type, as if they used the full 0-65535 range. Then it runs a Brightness(65535 / maxval) pass over the whole image. The first step narrows a sample such as 500 to 500 / 65535 * 255 ≈ 2 before any scaling, and the brightness pass then multiplies that rounded value. A maxval of 255 or less does not show this loss, because those samples are 8-bit and only the brightness pass scales them.
A fix is proposed in #3227.
Steps to Reproduce
using System.Text;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
int[] samples = [0, 7, 500, 999, 1000];
// P5 (binary graymap), maxval 1000: two bytes per sample, most significant byte first.
byte[] header = Encoding.ASCII.GetBytes($"P5\n{samples.Length} 1\n1000\n");
byte[] payload = [.. samples.SelectMany(s => new[] { (byte)(s >> 8), (byte)s })];
using Image<Rgb24> binary = Image.Load<Rgb24>([.. header, .. payload]);
// P2 (plain graymap), same samples.
using Image<Rgb24> plain = Image.Load<Rgb24>(
Encoding.ASCII.GetBytes($"P2\n{samples.Length} 1\n1000\n{string.Join(' ', samples)}\n"));
for (int x = 0; x < samples.Length; x++)
{
int expected = (int)Math.Round(samples[x] * 255.0 / 1000);
Console.WriteLine($"{samples[x],4} -> P5 {binary[x, 0].R,3}, P2 {plain[x, 0].R,3} (expected {expected})");
}Actual output:
0 -> P5 0, P2 0 (expected 0)
7 -> P5 0, P2 0 (expected 2)
500 -> P5 131, P2 131 (expected 128)
999 -> P5 255, P2 255 (expected 255)
1000 -> P5 255, P2 255 (expected 255)
Expected output: each value within one step of round(sample * 255 / maxval). The same happens for P3 and P6 (color), where each channel is affected in the same way.
Images
No images needed; the repro builds the input in memory.
Activity