Skip to content

Replace SixLabors.ImageSharp with a self-contained image decoder - #358

Merged
matt-edmondson merged 5 commits into
mainfrom
claude/imguiapp-issue-354-7ftjju
Sep 8, 2026
Merged

Replace SixLabors.ImageSharp with a self-contained image decoder#358
matt-edmondson merged 5 commits into
mainfrom
claude/imguiapp-issue-354-7ftjju

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closes #354.

The answer to the question

Yes to the second option. ktsu.ImGui.App now decodes images itself and has no imaging dependency at all.

The dependency was only ever doing three things: decode a file to RGBA8, crop, and scale. ImageSharp moved to a split licence at 4.0, which broke the build for anyone letting the version float (#230) and left the package pinned to 3.1.x indefinitely — a pin nobody can lift. Swapping in another package (StbImageSharp, ImageMagick bindings) trades one licence and one supply-chain surface for another; writing the ~600 lines of decoding this library actually uses removes the question. The repo already has the precedent: ImGui.App.Testing/Bitmap32.cs hand-rolls PNG encoding over ZLibStream for exactly this reason.

What's here

ImGui.App/Images/ decodes to ImagePixels — a tightly packed, straight-alpha RGBA8 buffer with no row padding, which is exactly what UploadTextureRGBA wants.

Format Covered Not covered
PNG All five colour types, bit depths 1/2/4/8/16, palettes, tRNS in all three forms, Adam7 interlacing, all five scanline filters Nothing in the base format
JPEG Baseline and extended sequential (SOF0/SOF1) and progressive (SOF2) Huffman, any sampling factors, restart intervals, greyscale and colour, the Adobe transform flag Arithmetic coding, lossless and hierarchical modes, CMYK/YCCK, 12-bit samples
BMP Core and info headers of every version, 1/4/8/16/24/32 bit, top-down and bottom-up, BI_RGB and BI_BITFIELDS BI_RLE4/BI_RLE8, embedded PNG/JPEG payloads
TGA Colour-mapped, true-colour and greyscale at 8/15/16/24/32 bit, raw and run-length encoded, the descriptor's origin and attribute-bit flags Nothing in common use

The format comes from the file's own leading bytes, never its extension, so a mislabelled file still loads. Anything unrecognised or malformed raises InvalidImageDataException naming what was found. Inflation uses System.IO.Compression.ZLibStream from the BCL, so nothing new is pulled in.

ImageResampler replaces the Welch resize behind SetWindowIcon: a separable Lanczos-3 filter whose support widens by the reduction factor when downscaling, so shrinking averages rather than point-samples. It runs on premultiplied alpha and unpremultiplies afterwards, so the colour of fully transparent pixels does not bleed into their visible neighbours — which the old path did not do.

Verification

Decoder output was compared against libpng/libjpeg (through Pillow) over a matrix of encodings:

  • PNG — RGBA8, RGB8, greyscale 1/8/16-bit, greyscale+alpha, palette, palette+tRNS, Adam7 interlaced: bit-exact, all of them.
  • BMP — 24-bit, 32-bit, 8-bit palette, 1-bit: bit-exact. (The one intentional difference: a 32-bit BI_RGB BMP's alpha is honoured, with an all-zero channel treated as opaque, matching stb_image. Pillow discards it. Checked against the original RGBA source, our output is bit-exact and Pillow's is the lossy one.)
  • TGA — 24/32-bit, raw and RLE, greyscale: bit-exact.
  • JPEG — 4:4:4, 4:2:2, 4:2:0, greyscale, progressive, restart markers, odd dimensions, high-frequency noise: mean channel error 0.01–0.26, max 3. That is the range reference decoders differ by among themselves; chroma is upsampled by bilinear interpolation on half-offset sample centres, which for the usual 2x factors is the same triangle filter libjpeg applies.

New tests live in tests/ImGui.App.Tests/Images/ (76 cases). TestImageBuilder encodes PNG, BMP and TGA in memory so the decoders can be driven over their whole feature matrix — every colour type, every bit depth, every scanline filter, both row orders, both TGA packet kinds — without checking binary fixtures into the repo. The JPEG cases, which need a real encoder, are small base64 constants.

CI on 78dde73: 1563 tests, 0 failures, all 18 test projects green across Linux, Windows and macOS, plus a green iOS build. That run is the one that matters most here: the six demo UI suites decode the repository's real PNGs through the actual render path, so the new decoder is exercised against icon.png, ktsu.png and the trevor-*.png set rather than only against synthetic fixtures. SonarQube's quality gate passed at 83.9% coverage on new code and 0% duplication.

Performance

Measured on a 2048×2048 image:

before after
PNG decode 305 ms 75 ms
JPEG decode 396 ms
SetWindowIcon icon set 318 ms

PNG got faster than ImageSharp's path here because scanline expansion writes straight into the destination span with a memcpy fast path for 8-bit truecolour. JPEG is slower than libjpeg (floating-point IDCT, no SIMD) but well inside what a texture load can absorb; a DC-only block fast path covers the flat regions that dominate real photographs.

Breaking change

ImGuiApp.UseImageBytes(Image<Rgba32>, Action<byte[]>) is now UseImageBytes(ImagePixels, Action<byte[]>). That parameter was the only place the dependency reached the public surface, so removing the dependency cannot be done compatibly. Callers passing an Image<Rgba32> should load through ImageDecoder instead. Nothing else in the public API changed, and GetOrLoadTexture, TryGetTexture, SetWindowIcon and the texture cache are untouched from a caller's point of view.

Also worth stating plainly: GIF, WebP and TIFF are gone. ImageSharp decoded them and this does not. They do not appear in any icon or texture in this repo or its examples, and a file in one of those formats now fails with a message that names the format rather than silently — but if anyone is loading them, this is the regression to know about.

Docs

CLAUDE.md gains an "Image decoding" section with the support matrix and the three behaviours worth knowing before changing any of it (16-bit truncation, the all-zero-alpha heuristic, and JPEG's whole-image coefficient buffer). ImGui.App/README.md gains an "Image Loading" section with the same matrix and a standalone usage example. ImageSharp is removed from every dependency list.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DCFaH3HSDMKHTmrQB7LcSP

ImageSharp moved to a split licence at 4.0, which broke the build for
anyone letting the version float (#230) and left this package pinned to
3.1.x indefinitely. Issue #354 asked whether the dependency could be
replaced with something more permissive, or with code that only does what
this library needs. This is that code: ktsu.ImGui.App now has no imaging
dependency at all.

ImGui.App/Images/ decodes to ImagePixels, a tightly packed straight-alpha
RGBA8 buffer, which is exactly what UploadTextureRGBA wants:

- PNG: all five colour types, bit depths 1 to 16, palettes, tRNS in all
  three forms, Adam7 interlacing, all five scanline filters
- JPEG: baseline, extended sequential and progressive Huffman, any
  sampling factors, restart intervals, greyscale and colour
- BMP: core and info headers of every version, 1/4/8/16/24/32 bit,
  top-down and bottom-up, BI_RGB and BI_BITFIELDS
- TGA: colour-mapped, true-colour and greyscale, raw and run-length
  encoded, 8/15/16/24/32 bit

The format is chosen from the file's own bytes rather than its extension,
and anything unrecognised or malformed raises InvalidImageDataException
naming what was found. Inflation uses ZLibStream from the base class
library, so nothing new is pulled in.

ImageResampler replaces the Welch resize behind SetWindowIcon: separable
Lanczos-3 whose support widens by the reduction factor when downscaling,
run on premultiplied alpha so transparent pixels do not bleed colour into
their neighbours. SetWindowIcon reduces a large source once before
deriving its ten sizes rather than filtering the full image ten times.

Verified against libjpeg and libpng through Pillow: PNG, BMP and TGA
decode bit-exactly, and JPEG lands within three units per channel across
4:4:4, 4:2:2, 4:2:0, greyscale, progressive and restart-marker codings —
the range reference decoders differ by among themselves.

BREAKING: ImGuiApp.UseImageBytes now takes ImagePixels rather than
ImageSharp's Image<Rgba32>. That type was the only place the dependency
reached the public surface, so removing it cannot be done compatibly.
Callers passing an Image<Rgba32> should load through ImageDecoder instead.

Also not carried over: GIF, WebP and TIFF, which ImageSharp supported and
this does not; run-length encoded BMP; and arithmetic-coded, lossless or
CMYK JPEG. None appear in the icons and textures this library loads, and
each is rejected with a message saying so rather than silently.

Closes #354

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCFaH3HSDMKHTmrQB7LcSP
Comment thread ImGui.App/Images/JpegDecoder.cs Fixed
Comment thread ImGui.App/Images/JpegDecoder.cs Fixed
Comment thread tests/ImGui.App.Tests/Images/ImageDecoderTests.cs Fixed
…-354-7ftjju

#355 restructured ImGui.App/README.md, moving Versioning, Support and
Acknowledgments above Contributing and dropping the duplicate copy at the
end of the file. The conflict was that duplicate: this branch had edited
its Acknowledgments list to drop ImageSharp, and main deleted the block
outright. Took main's structure and dropped the ImageSharp entry from the
surviving Acknowledgments list instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCFaH3HSDMKHTmrQB7LcSP
Neither can misbehave as written — the IDCT's (2x+1)*u peaks at 105 for an
8x8 block, and a GUID-and-extension filename is never rooted, so nothing
overflows and no earlier argument is dropped. Both fixes are free, though,
and an integer product feeding a cosine is worth not writing regardless.

- InverseDct.BuildBasis computes the cosine argument in double from the
  first operand.
- The extension-sniffing test joins its temp path with Path.Join.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCFaH3HSDMKHTmrQB7LcSP
The three new null guards used ArgumentNullException.ThrowIfNull, which the
SDK analyzer rejects because it is .NET 6 and later only; Polyfill's
Ensure.NotNull is what the rest of ImGui.App uses and what compiles on every
target. The iOS job caught it — its Roslyn is new enough to load the SDK
analyzers, which the container this was written in is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCFaH3HSDMKHTmrQB7LcSP
…-354-7ftjju

#356 and #357 landed: ImGuiNodeEditor was renamed to ImGui.NodeEditor, and
tokenizing was split out of ImGui.SyntaxHighlighting into its own
SyntaxHighlighting library. Neither touches the image decoder.

The only conflict was CLAUDE.md's Libraries list, where main rewrote both
the ImGui.App and ImGui.Widgets entries. Took main's text for both and
re-applied just this branch's addition: the sentence on ImGui.App pointing
at the self-contained image decoding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCFaH3HSDMKHTmrQB7LcSP
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

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.

Can ImageSharp be replaced with something more permissive, or replaced with self-serviced code that only does what we need?

2 participants