Skip to content

DW-40: Carry original BitsPerPixel through derived AnyBitmap operations and preserve depth on resize#154

Open
Sawraz-IS wants to merge 1 commit into
developfrom
DW-40-carry-original-bpp-through-derived-apis
Open

DW-40: Carry original BitsPerPixel through derived AnyBitmap operations and preserve depth on resize#154
Sawraz-IS wants to merge 1 commit into
developfrom
DW-40-carry-original-bpp-through-derived-apis

Conversation

@Sawraz-IS

@Sawraz-IS Sawraz-IS commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Description

Follow-up to DW-9. DW-9 made AnyBitmap.BitsPerPixel report a TIFF's true source color depth (for example 1 for black and white) instead of the decoded 32bpp value, but only for the object returned directly by AnyBitmap.FromFile / FromStream. Several public APIs build a new AnyBitmap from an already in-memory image and did not carry that captured depth over, so they silently fell back to reporting 32.

This PR carries the original source depth through those derived operations, and fixes the resize path so it no longer flattens every image to 32bpp.

nuspec release notes (not yet pushed). The NuGet/IronSoftware.Drawing.nuspec release notes update is intentionally not part of this branch yet. To be added when the change set is finalized:

<releaseNotes>- AnyBitmap now carries the original source color depth (BitsPerPixel) through derived operations - GetAllFrames (per page), Clone(Rectangle), RotateFlip and Redact - instead of reporting the decoded 32bpp value.
- Resizing an AnyBitmap now preserves the source pixel depth (e.g. 24bpp and 64bpp are no longer flattened to 32bpp, avoiding 64bpp precision loss). Resampling a sub-8bpp source (e.g. 1bpp) honestly reports its decoded depth.
- Removed explicit System.Memory dependency to resolve .NET Framework assembly version conflict.</releaseNotes>

Fixes DW-40

Root cause. DW-9 stores the captured depth in a private _originalBitsPerPixel field that is only populated inside LoadImage. BitsPerPixel returns _originalBitsPerPixel ?? InMemoryBitsPerPixel. Each derived API constructs its result through an internal constructor (or the ImageSharp cast operator) that never sets _originalBitsPerPixel, so the getter fell back to the in memory decoded depth (32). The resize path additionally hard coded Image.Load<Rgba32>, which forced 32bpp for all inputs.

Changes in AnyBitmap.cs:

  • Clone(Rectangle), RotateFlip(...), Redact(...): these operations preserve the source color depth, so the result now copies _originalBitsPerPixel from the source. Propagation is done explicitly in each API (not in the shared AnyBitmap(Image) constructor, because that same constructor is the ImageSharp cast operator, where there is no original source depth to assume).
  • GetAllFrames: now reports each frame's own source depth. A multi page TIFF can legally mix depths per page (for example page 1 bilevel, page 2 color), so a single frame-0 value would be wrong for later pages. Per frame depth is captured during InternalLoadTiff inside the same loop that decodes frames and skips thumbnails, guaranteeing index alignment with the decoded frames. Frames only receive an original depth when the object itself captured one (a TIFF loaded preserving its original format); otherwise they report the in memory depth as before.
  • Resize constructor AnyBitmap(AnyBitmap, int, int) (LoadAndResizeImage): now resizes a clone of the already decoded image, preserving its pixel type, instead of re-decoding as Rgba32. This fixes two problems: 24bpp is no longer upsampled to 32bpp, and 64bpp (Rgba64) is no longer flattened to 32bpp with loss of color precision (16 bits per channel down to 8). Resizing resamples (blends) pixels, so a source whose original depth is below 8bpp (for example 1bpp, which has no in memory representation below 8bpp) is honestly reported at its decoded depth rather than claiming to still be 1bpp. The original low depth is intentionally not carried over for resize.
  • Refactor: extracted ReadDirectoryBitsPerPixel(Tiff), shared by ReadTiffMetadataFast (frame 0) and the new per frame capture in InternalLoadTiff.

Documentation: updated the resize constructor XML doc to state that resizing resamples and may change BitsPerPixel, consistent with the existing BitsPerPixel / Stride / Scan0 decoupling notes added in DW-9.

Type of change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • 📚 This change requires a documentation update

How Has This Been Tested?

Added six unit tests in AnyBitmapFunctionality.cs, run on net48 and net8.0:

  • DW_40_GetAllFrames_ShouldReturnOriginalBitsPerPixelPerFrame: a single frame 1bpp TIFF reports 1, and a mixed depth multi page TIFF reports [1, 8, 24] per frame.
  • DW_40_CloneRectangle_ShouldPreserveOriginalBitsPerPixel: cropping a 1bpp TIFF reports 1.
  • DW_40_RotateFlip_ShouldPreserveOriginalBitsPerPixel: rotating a 1bpp TIFF reports 1.
  • DW_40_Redact_ShouldPreserveOriginalBitsPerPixel: redacting a 1bpp TIFF reports 1.
  • DW_40_Resize_ShouldReportHonestDepthForSubEightBppSource: resizing a 1bpp TIFF honestly reports 32.
  • DW_40_Resize_ShouldPreserveDepthForRepresentableFormats: resizing a 24bpp image reports 24, and resizing a 64bpp image reports 64.

New test asset Data/DW-40 MixedDepthMultiPage.tif: a synthesized 3 page TIFF (page 0 = 1bpp, page 1 = 8bpp grayscale, page 2 = 24bpp RGB) so the per frame GetAllFrames behavior is verified against genuinely mixed depths.

Results: full test project passes locally, 414 of 414 on net8.0 and 400 of 400 on 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
  • I have made corresponding changes to the documentation
  • 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 summary for a source loaded preserving its original format:

Operation Before After
GetAllFrames (per page) 32 for every frame each frame's true source depth (for example 1 / 8 / 24)
Clone(Rectangle) 32 source depth (for example 1)
RotateFlip(...) 32 source depth (for example 1)
Redact(...) 32 source depth (for example 1)
Resize, 1bpp source 32 32 (honest, resampling creates intermediate tones)
Resize, 24bpp source 32 24 (depth preserved)
Resize, 64bpp source 32 (precision loss) 64 (depth and precision preserved)

Design decision (resize). Resize was intentionally kept honest for sub 8bpp sources rather than propagating the original depth, because resampling blends black and white into gray, so the resized image genuinely is no longer bilevel. For 8/24/32/64bpp the depth is preserved by resizing at the native pixel type.

Durability note. As with BitsPerPixel on the loaded object, a carried original depth is an in memory label. Saving through the default encoder (BMP at 32bpp) or calling GetBytes() re-encodes and the reduced depth is not preserved in the saved bytes. This is unchanged behavior and is already documented on BitsPerPixel.

Comment on lines +3446 to +3457
// Resize a clone of the already-decoded image so its pixel type (and therefore its color
// depth) is preserved.
var image = original.GetFirstInternalImage().Clone(img => img.Resize(width, height));

_lazyImage = new Lazy<IReadOnlyList<Image>>(() =>
//update Binary
using (var memoryStream = new MemoryStream())
{

var image = Image.Load<Rgba32>(Binary);
image.Mutate(img => img.Resize(width, height));

//update Binary
using var memoryStream = new MemoryStream();
image.Save(memoryStream, GetDefaultImageEncoder(image.Width, image.Height));
Binary = memoryStream.ToArray();
}

return [image];
});
_lazyImage = new Lazy<IReadOnlyList<Image>>(() => [image]);

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.

actually we should move this code to be inside Lazy
//but this method call ForceLoadLazyImage(); anyway so it's not a deal breaker

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.

Good call, thanks. I have moved the clone, resize and encode work back inside the Lazy so the lazy-loading pattern is preserved. To keep it safe against the original's lifetime (what the old Binary = original.Binary line guarded), I capture the source's decoded image reference up front and then do the work inside the Lazy. Pushed. All AnyBitmapFunctionality tests still pass (123/123 net8.0, 121/121 net48).

@Sawraz-IS Sawraz-IS force-pushed the DW-40-carry-original-bpp-through-derived-apis branch from abbf129 to b08be16 Compare July 9, 2026 09:53
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