Skip to content

DW-9: Load image using original BPP and support changing color depth#149

Merged
Sawraz-IS merged 2 commits into
developfrom
DW-9-load-image-using-original-bpp
Jun 19, 2026
Merged

DW-9: Load image using original BPP and support changing color depth#149
Sawraz-IS merged 2 commits into
developfrom
DW-9-load-image-using-original-bpp

Conversation

@Sawraz-IS

@Sawraz-IS Sawraz-IS commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Description

AnyBitmap.BitsPerPixel previously reported 32 bpp for every TIFF, including 1‑bpp black & white scans, because TIFFs are decoded into a 32‑bpp Rgba32 image (via LibTiff / ImageSharp) and the property simply returned the in‑memory pixel depth. SixLabors.ImageSharp has no pixel format below 8 bpp, so the original color depth was lost.

This PR makes BitsPerPixel report the original color depth of the source image when it is loaded preserving its original format (the FromFile default), matching what System.Drawing.Bitmap reports (e.g. Format1bppIndexed → 1).

What changed (AnyBitmap.cs):

  • Added an _originalBitsPerPixel field, populated during LoadImage for TIFFs (when preserveOriginalFormat == true) by reading BitsPerSample × SamplesPerPixel straight from the TIFF metadata via a new lightweight GetTiffBitsPerPixelFast() helper (modeled on the existing GetTiffFrameCountFast() — no full decode required).
  • BitsPerPixel now returns _originalBitsPerPixel ?? InMemoryBitsPerPixel. Non‑TIFF formats and preserveOriginalFormat == false are unchanged (still report the decoded depth, e.g. 32).
  • GetStride() now uses the in‑memory pixel depth rather than the reported BitsPerPixel, so stride stays consistent with the 32‑bpp pixel data exposed by GetFirstPixelData().
  • Added ChangeBitsPerPixel(int) — the System.Drawing ChangeBpp equivalent. Returns a new AnyBitmap converted to 8 (grayscale), 24 (RGB) or 32 (RGBA); throws NotSupportedException for unsupported depths.

Type of change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)

How Has This Been Tested?

Added unit tests in AnyBitmapFunctionality.cs using the attached sample TIFFs (added to the test Data folder):

  • DW_9_LoadImage_ShouldReturnOriginalBitsPerPixelScanDev_BW.tif→1, ScanDev_Gray.tif→8, ScanDev_Color.tif→24
  • DW_9_LoadBlackAndWhiteTiff_ShouldReturnOriginalBitsPerPixel_AndAllowChangingBpp — reproduces the exact reported scenario with tifimg.tif: BitsPerPixel == 1, cross‑checked against new System.Drawing.Bitmap(path).PixelFormat == Format1bppIndexed, plus ChangeBitsPerPixel(24) → 24
  • DW_9_LoadImage_NotPreservingOriginalFormat_ShouldReturn32BitsPerPixelpreserveOriginalFormat: false → 32
  • DW_9_ChangeBitsPerPixel_ShouldReturnRequestedColorDepth — 8 / 24 / 32
  • DW_9_ChangeBitsPerPixel_WithUnsupportedDepth_ShouldThrowNotSupportedException

Local results: full AnyBitmapFunctionality suite passes — 103/103 (net8.0) and 101/101 (net48), no regressions.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have successfully run all unit tests on Windows
  • I have successfully run all unit tests on Linux

Additional Context

Behavior change for BitsPerPixel on TIFFs loaded with preserveOriginalFormat = true (the FromFile default):

File Before After
tifimg.tif (B&W) 32 1
ScanDev_BW.tif 32 1
ScanDev_Gray.tif 32 8
ScanDev_Color.tif 32 24

@Sawraz-IS Sawraz-IS marked this pull request as ready for review June 1, 2026 08:29

@mee-ironsoftware mee-ironsoftware left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — the metadata-reading core is sound and well-modeled on the existing GetTiffFrameCountFast, and decoupling GetStride from the reported BitsPerPixel shows the seam was considered. A few things to address before/after merge (none blocking):

Findings outside the diff

  • AnyBitmap.cs:1319 — the Scan0 XML doc still states it returns "the first 32bpp BGRA pixel data". After this PR that directly contradicts BitsPerPixel reporting 1 for a B&W TIFF. Update the doc and the release notes to call out that BitsPerPixel is now decoupled from Scan0/Stride, and grep the downstream consumers (IronOCR/IronPDF) for .BitsPerPixel paired with Scan0 buffer math.
  • Checklist: "run all unit tests on Linux" is unchecked, and the headline repro test is [IgnoreOnUnixFact]. The non-Unix-ignored DW_9_LoadImage_ShouldReturnOriginalBitsPerPixel theory should be confirmed green on Linux/LibTiff before merge.

Verdict: fix-then-ship.

return 4 * (((Width * BitsPerPixel) + 31) / 32);
// Use the in-memory pixel depth (not the reported original BitsPerPixel) so the
// stride stays consistent with the decoded pixel data exposed by GetFirstPixelData.
return 4 * (((Width * InMemoryBitsPerPixel) + 31) / 32);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: This decoupling is correct internally (Stride stays 32-based, consistent with the 32bpp BGRA buffer from GetFirstPixelData), but it creates a public-API inconsistency. After this PR a 1bpp TIFF reports BitsPerPixel == 1 while Stride and Scan0 still describe 32bpp data — System.Drawing never does that (a Format1bppIndexed bitmap has 1bpp BitsPerPixel, Stride, and Scan0, all consistent). Any external caller sizing a Scan0 buffer as Height * Width * BitsPerPixel / 8 now under-allocates 32x. Either expose a separate OriginalBitsPerPixel and leave BitsPerPixel reporting in-memory depth, or document the divergence explicitly as a breaking change and audit downstream consumers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented the divergence explicitly on BitsPerPixel, Stride, and Scan0 XML docs (including "use Stride, not BitsPerPixel, for buffer math"). Did not take your suggested alternative (revert BitsPerPixel to in-memory depth + add OriginalBitsPerPixel) because the DW-9 acceptance criteria explicitly require BitsPerPixel == 1.

/// <paramref name="bitsPerPixel"/>.</returns>
/// <exception cref="NotSupportedException">Thrown when <paramref name="bitsPerPixel"/>
/// is not one of the supported values.</exception>
public AnyBitmap ChangeBitsPerPixel(int bitsPerPixel)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: The converted depth only lives in the in-memory PixelType; it is not durable across serialization. new AnyBitmap(converted) has no Binary, so the first Binary/SaveAs/GetBytes access re-encodes via GetDefaultImageExportEncoderGetDefaultImageEncoder → a 32bpp BmpEncoder (AnyBitmap.cs:3335). So bmp.ChangeBitsPerPixel(8).SaveAs("x.bmp") produces a 32bpp file. For a feature billed as the System.Drawing ChangeBpp equivalent (where the point is to save at that depth), either make the exporter honor the converted pixel type, or document clearly that this only affects the in-memory representation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified empirically (PNG is durable → 8; default BMP/GetBytes → 32). Documented the durability behavior precisely in the XML doc and locked it with a round-trip test.

/// is not one of the supported values.</exception>
public AnyBitmap ChangeBitsPerPixel(int bitsPerPixel)
{
Image source = GetFirstInternalImage();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: GetFirstInternalImage() converts only frame 0, so calling this on a multi-page TIFF (the very format this PR targets) silently drops frames 2..N. Document the single-frame behavior or map over all frames.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented single-frame behavior in the XML doc + inline comment.

/// Reads the original bits per pixel of the first frame of the loaded TIFF directly from
/// its metadata (BitsPerSample x SamplesPerPixel), without fully decoding the image.
/// </summary>
/// <returns>The original bits per pixel, or <c>null</c> if it cannot be determined.</returns>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: For every TIFF load this is now a second Tiff.ClientOpen over the same Binary (plus a redundant static SetErrorHandler), in addition to GetTiffFrameCountFast and InternalLoadTiff. Cheap (metadata-only) but trivially mergeable — one open could return both the directory count and frame-0 bits.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged GetTiffFrameCountFast + GetTiffBitsPerPixelFast into one ReadTiffMetadataFast() that returns (FrameCount, BitsPerPixel) from a single open.


var converted = bitmap.ChangeBitsPerPixel(targetBitsPerPixel);

Assert.Equal(targetBitsPerPixel, converted.BitsPerPixel);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Happy-path only. ChangeBitsPerPixel(32) here asserts a clone is 32bpp (it already was — proves nothing), and no test round-trips a converted bitmap through SaveAs/GetBytes and reloads — which is exactly why the non-durable-depth issue above is invisible. Suggest adding a round-trip test (it will currently fail, documenting the gap) and a Stride/Scan0-vs-BitsPerPixel consistency assertion so the intentional decoupling is locked against future re-coupling.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added DW_9_BitsPerPixel_IsIntentionallyDecoupledFromStrideAndScan0 (locks the decoupling) and DW_9_ChangeBitsPerPixel_DurabilityDependsOnEncoder (PNG→8, BMP/GetBytes→32).

@Sawraz-IS Sawraz-IS merged commit d7c1983 into develop Jun 19, 2026
9 checks passed
@Sawraz-IS Sawraz-IS deleted the DW-9-load-image-using-original-bpp branch June 19, 2026 02:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants