Visitar URL original
perf(imaging): Read a BitmapSource's size without decoding it by mikernet · Pull Request #25561 · unoplatform/uno · GitHub
Skip to content

perf(imaging): Read a BitmapSource's size without decoding it - #25561

Open
mikernet wants to merge 2 commits into
unoplatform:masterfrom
mikernet:dev/mikernet/bitmap-source-read-size
Open

mikernet wants to merge 2 commits into
unoplatform:masterfrom
mikernet:dev/mikernet/bitmap-source-read-size

Conversation

@mikernet

@mikernet mikernet commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

GitHub Issue: closes #25556

PR Type:

🐞 Bugfix (a performance regression from 6.8, and a source that doesn't reload)

What changed? 🚀

BitmapSource.SetSource fully decoded the image on the UI thread to read its size, then threw the pixels away; 6.8 read only the header. Now:

  • IImageEncoderDecoder gains TryGetSize(Stream, out int pixelWidth, out int pixelHeight): the size TryDecode produces with no target size, EXIF orientation applied. It is a default interface method, like IFontProvider.LoadFontFile, so it isn't breaking: the default decodes, as SetSource did.
  • Skia reads the header with SKCodec.Create and swaps width and height for the quarter-turn EXIF origins (5–8), as TryDecode does.
  • SetSource reads the size with it, still synchronously, and disposes the stream clone it reads; WriteableBitmap.SetSource takes the size from the decode that fills its buffer.

Setting a stream now also reloads the source for whatever shows it, as WinUI's CImageSource::SetSource does. This was wrong in 6.8 too:

  • An Image showing the bitmap, or shown later, gets the new image instead of the previous one. SetSource now invalidates the source, which since fix(imaging): Release a decoded image when nothing displays it #25124 also cancels a previous open still in flight, with its size and events.
  • SetSourceAsync used to subscribe and then invalidate, which opened the source twice when nothing else subscribed, and completed with the previous image when one was open. It now only subscribes.

The managed decoder keeps the default here; reading its headers is #25566.

Measurements

A 4000x2667 JPEG (2.4 MB), Release builds:

master this PR
SetSource, UI thread, desktop (median) 49–83 ms¹ 0.25–1.1 ms
SetSource, UI thread, WebAssembly (median) 68–76 ms¹ 0.6 ms
Decodes: BitmapImage.SetSource (nothing showing it) 1 0
Decodes: BitmapImage.SetSourceAsync, desktop 2–3 1
Decodes: WriteableBitmap.SetSource/SetSourceAsync 2 1

¹ Medians of several runs of master's SetSource steps on this branch, measured before #25124 merged; it didn't change the cost of the SetSource path.

Tests

Given_BitmapSource:

  • When_SetSource_Then_PixelSize_Is_Read_With_TryGetSize: JPEG, PNG, an EXIF-rotated JPEG, an animated GIF, WebP and BMP get their size, and SetSource calls no TryDecode.
  • When_SetSource_Then_Skia_Reads_Only_The_Header: for a JPEG, a PNG, a BMP and a GIF, reading the size reads under a quarter of the file.
  • When_SetSource_Exif_Oriented_Then_PixelSize_Matches_Decode (each of the 8 orientations) and When_SetSource_Webp_Then_PixelSize_Matches_Decode (VP8L, VP8 and VP8X): the size matches the decode's.
  • When_SetSource_Undecodable_Then_PixelSize_Is_Zero: a text file and a truncated PNG don't throw and leave the size at 0. A file whose header reads but whose pixels don't decode keeps the header's size until its open fails and resets it, as in 6.8.
  • When_SetSource_With_Codec_Without_TryGetSize_Then_PixelSize_Is_Read_From_Decode: the default.
  • When_SetSource_On_Shown_Source_Then_Image_Shows_New_Source, When_SetSource_On_Opened_Source_Then_Image_Shown_Later_Gets_New_Source, When_SetSource_While_Previous_Source_Opens_Unseen_Then_Its_Result_Is_Dropped and When_SetSourceAsync_On_Opened_Source_Then_Completes_Once_New_Source_Opens.

Given_WriteableBitmap: When_SetSource_Then_Stream_Is_Decoded_Once, When_SetSourceAsync_Then_Source_Is_Opened_Once and When_SetSourceAsync_Opens_And_Fails_For_A_Subscriber_Then_It_Completes.

Taken out one at a time on desktop, each of these fails a test: master's BitmapSource and WriteableBitmap; the Skia override (the default reads the whole file), its EXIF swap and its null check; the default's decode; the invalidation in SetSource; the SetSourceAsync error guard. The cancel of an unobserved open and the check that drops a superseded BitmapImage stream open, which this PR used to add, came with #25124.

Runs of the image test classes (Given_BitmapSource, Given_WriteableBitmap, Given_BitmapImage, Given_Image, Given_Image_Release, Given_ImageSource, Given_ImageBrush) on current master: Skia desktop 159 passed / 1 failed. Before the update, WebAssembly: 106 passed / 1 failed. The failure, When_WriteableBitmap_Assigned_With_Data_Present, fails the same on master without this PR (125% scaling).

PR Checklist ✅

Copilot AI balanced review requested due to automatic review settings October 17, 2026 02:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Cross-platform loading, cancellation, and async-completion changes warrant final human review despite the added regression coverage.

No open issues 🎉

What changed in this PR

Addresses #25556 by reducing synchronous image decoding and correcting stream-source reload behavior in Uno’s imaging pipeline.

Changes:

  • Adds header-based size reading for Skia, with a compatible decoding fallback.
  • Removes redundant decoding and updates source invalidation, cancellation, and async completion.
  • Adds regression tests for image formats, orientation, decoding counts, and reload behavior.
File Description
src/​Uno.UI/​UI/​Xaml/​Media/​Imaging/​WriteableBitmap.cs Takes dimensions from the buffer decode.
src/​Uno.UI/​UI/​Xaml/​Media/​Imaging/​BitmapSource.cs Reads dimensions and reloads replaced sources.
src/​Uno.UI/​UI/​Xaml/​Media/​Imaging/​BitmapImage.cs Discards cancelled stream-load results.
src/​Uno.UI/​UI/​Xaml/​Media/​ImageSource.crossruntime.cs Cancels obsolete opens without subscribers.
src/​Uno.UI.RuntimeTests/​Microsoft/​UI/​Xaml/​Media/​Imaging/​Given_WriteableBitmap.cs Tests decode counts and async completion.
src/​Uno.UI.RuntimeTests/​Microsoft/​UI/​Xaml/​Media/​Imaging/​Given_BitmapSource.cs Tests size reading, orientation, and reloads.
src/​Uno.UI.RuntimeTests/​Helpers/​CountingImageCodec.cs Adds codec instrumentation for tests.
src/​Uno.UI.Composition.Skia/​Composition/​Uno/​Drawing/​SkiaImageDecoderBackend.cs Exposes Skia size reading.
src/​Uno.UI.Composition.Skia/​Composition/​Uno/​Drawing/​SkiaImageDecoder.cs Reads header dimensions with orientation applied.
src/​Uno.UI.Composition.Drawing/​IImageEncoderDecoder.cs Adds the default size-reading contract.

Review effort: Balanced

mikernet added a commit to Singulink/uno that referenced this pull request Oct 17, 2026
Port of the reload part of unoplatform#25561 (9f2f289).

SetSource kept the image opened from the previous stream: an Image
already showing the bitmap kept its picture (or showed nothing, if the
bitmap had no stream yet), and an Image shown later got the old image.
WinUI's CImageSource::SetSource cancels the current decode and drops the
current image.
- SetSourceCore now invalidates the source the same way.
- SetSourceAsync subscribed, then invalidated, which opened the source
  twice when nothing else subscribed, and completed at once with data
  the source still held. It now only subscribes, after SetSourceCore's
  invalidation, and returns when that invalidation opened the source and
  failed synchronously, since no other result follows.
- Tests: When_SetSourceAsync_On_Opened_Source_Then_Completes_Once_New_Source_Opens,
  When_SetSource_On_Shown_Source_Then_Image_Shows_New_Source,
  When_SetSource_On_Opened_Source_Then_Image_Shown_Later_Gets_New_Source,
  When_SetSource_While_Previous_Source_Opens_Unseen_Then_Its_Result_Is_Dropped
  (Given_BitmapSource) and
  When_SetSourceAsync_Opens_And_Fails_For_A_Subscriber_Then_It_Completes
  (Given_WriteableBitmap).

6.8 differences:
- Not ported: the size read without decoding (7ba72ff), as 6.8
  already reads only the codec header, and the test that counts opens
  through the codec seam 6.8 doesn't have.
- With the unoplatform#25124 port, ImageSource.ReleaseImageData already cancels an
  open in flight whenever the source is invalidated, so upstream's
  cancel in InvalidateSource has nothing left to do. BitmapImage's
  Publish already drops a stream open superseded while it decoded, and
  releases its surface.
- After the unoplatform#25124 port, an error left by SetSourceCore's invalidation
  can only come from that open failing synchronously, so ForceLoad keeps
  upstream's check of the data's kind. Checking for no open in flight
  and no data would also return when nothing subscribes, before
  anything opens the source.
@mergify

mergify Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue). If it does not match the queue conditions yet, it enters the queue once it does.

  • Queue this pull request

SetSource set PixelWidth and PixelHeight from a full decode of the
image on the UI thread (every frame of an animated one) and threw the
pixels away; the image is decoded again when it opens, and a
WriteableBitmap decoded it a second time into its pixel buffer. For a
4000x2667 JPEG that is a 43 MB decode, about 75 ms per call on desktop.
6.8 read only the header with SKCodec, but the backend-neutral codec
seam had no way to do that.

IImageEncoderDecoder gains TryGetSize. It is a default interface method,
so a codec an app registers keeps working: the default decodes and
reads the first frame, as SetSource did. The Skia codec reads the
header and applies the EXIF origin, as TryDecode does. SetSource now
also disposes the stream clone it reads the size from.

WriteableBitmap takes its size from the decode that fills its buffer
instead, so the buffer is sized from the frame copied into it.
SetSource kept the image opened from the previous stream: an Image
already showing the bitmap kept its picture (or showed nothing, if the
bitmap had no stream yet), and an Image shown later got the old image.
WinUI's CImageSource::SetSource cancels the current decode and drops
the current image. SetSourceCore now invalidates the source the same
way, which also cancels an open of the previous stream still in flight
and gives the source its new version.

SetSourceAsync subscribed, then invalidated. With no other subscriber
that opened the source twice: a BitmapImage could decode twice on
desktop (the first open is only cancelled if it hasn't started), and a
WriteableBitmap copied its pixels into an image twice. When the source
still held opened data, subscribing completed at once with it, so the
await returned before the new source opened. It now only subscribes,
after SetSourceCore's invalidation, and returns when that invalidation
opened the source and failed synchronously, since no other result
follows.
@mikernet
mikernet force-pushed the dev/mikernet/bitmap-source-read-size branch from 9f2f289 to f5ac544 Compare November 12, 2026 22:04
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.

[Skia] BitmapSource.SetSource decodes the whole image on the UI thread to read its size

2 participants