Skip to content

PGM/PPM images with a 16-bit maxval other than 65535 decode to only a few gray levels #3239

Open

Description

@jafin

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 DEBUG and RELEASE mode
  • 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions