Skip to content

Async image pipeline with SKBitmap filters, thread safety, and performance fixes - #36

Merged
emosaru merged 5 commits into
avaloniafrom
claude/hungry-jones
Apr 13, 2026
Merged

Async image pipeline with SKBitmap filters, thread safety, and performance fixes#36
emosaru merged 5 commits into
avaloniafrom
claude/hungry-jones

Conversation

@emosaru

@emosaru emosaru commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Complete overhaul of the image resolution pipeline for performance, correctness, and thread safety.

Image processing performance

  • SKBitmap filter pipeline: Replace per-pixel GetPixel/SetPixel loops with Skia native color matrix operations (grayscale, dim, brightness, saturation) for ~10-50x speedup
  • Eliminate PNG round-trips: Keep images as SKBitmap through the entire filter chain instead of encoding/decoding PNG between each step
  • Unsafe pixel access: Use uint* pointer access for color-key and alpha mask computation
  • Strong-reference source cache: Cache decoded base SKBitmaps in ConcreteImageReferenceResolver to avoid redundant decoding

Async background resolution

  • Background worker thread resolves images off the UI thread with a priority queue
  • Sized transparent placeholders maintain correct layout while images load
  • Instance tracking (mPendingInstances) fans out ResolvedImage to all ImageReference objects sharing an equality key

Thread safety

  • ZipPackageSource: Add lock around Open()/Files for concurrent archive access from UI and background threads
  • sAlphaMasks/sPngCache: Convert to ConcurrentDictionary for safe background worker writes
  • InputMaskingImage: Default to click-through when alpha mask is unavailable (prevents input blocking during async load)

SuspendRefresh ref-counting

  • Replace boolean SuspendRefresh with ref-counted Push/Pop pattern via SuspendRefreshScope
  • Fixes click handler UI spike: LuaItem.OnLeftClick previously set SuspendRefresh = false inside the outer LeftClickCommand scope, releasing the suspend before transaction callbacks fired — causing ~97 full RefreshAccessibility walks per click instead of 1 batched refresh
  • Underflow detection with error logging and Debug.Fail for dangling suspend bugs
  • All 6 callsites converted from direct true/false toggle to scoped Push/Pop

Filter correctness

  • Fix ApplyColorMatrix: clear destination bitmap and use SKBlendMode.Src to prevent corruption of semi-transparent pixels via SrcOver on uninitialized buffer data
  • Fix BT601 grayscale weights: original code divided by 8 (>>3), not 6

Package Manager game images

  • Bind game banners to Game.Image.ResolvedImage for live updates instead of one-shot converter
  • Bridge HTTP image download system with ResolvedImage property in OnHttpImageLoaded

Files changed

File Changes
IconUtility.cs SKBitmap filter pipeline, unsafe pixel access, color matrix ops
ImageReferenceService.cs Background worker, instance tracking, priority queue
ConcreteImageReferenceResolver.cs SKBitmap source cache, filter pipeline integration
FilterImageReferenceResolver.cs SKBitmap pipeline for filter refs
LayeredImageReferenceResolver.cs SKBitmap compositing for layered refs
LocationDatabase.cs Ref-counted SuspendRefresh with Push/Pop
LuaItem.cs SuspendRefreshScope for click handlers
TrackableItemControl.axaml.cs SuspendRefreshScope for click commands
ItemDatabase.cs SuspendRefreshScope for incremental load
ZipPackageSource.cs Archive lock for thread safety
InputMaskingImage.cs Click-through default for missing masks
ImageReference.cs NotifyCreated for external URIs
ApplicationModel.cs HTTP image → ResolvedImage bridge
PackageManagerWindow.axaml Live binding for game banner images

Test plan

  • Load ALTTP pack — all item and location images display correctly
  • Click items — no UI spike/freeze, accessibility updates batched
  • Rapid pack switching — no stale images, no crashes
  • Package Manager — game banner images appear after HTTP download
  • Filter rendering matches original (grayscale, dim, overlays)
  • Semi-transparent image edges render correctly (no corruption)

🤖 Generated with Claude Code

Replace synchronous on-demand image resolution with a background worker
thread that processes a priority-sorted work queue. Images are queued
automatically during pack load via ImageReference.OnImageReferenceCreated
callback, and UI bindings use path-through ResolvedImage property for
automatic updates when images resolve asynchronously.

Key changes:
- ImageReferenceService: background Thread with priority queue
  (SortedDictionary + ConcurrentDictionary cache), ManualResetEventSlim
  for sleep/wake, priority boosting for UI-requested images
- ImageReference: added ResolvedImage property and static
  OnImageReferenceCreated callback for auto-queuing during pack load
- XAML bindings: migrated from IValueConverter to path-through binding
  (e.g. Icon.ResolvedImage) across 5 control files (15 bindings)
- UI gets 1x1 transparent placeholder immediately, replaced dynamically
  when background resolution completes via Dispatcher.UIThread.Post

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emosaru
emosaru requested a review from a team April 13, 2026 04:43
EmoSaru and others added 4 commits April 12, 2026 22:04
Add --no-async-images command-line flag that disables the background
image worker and forces synchronous on-demand resolution (pre-refactor
behavior) for A/B comparison testing of layout issues.

- ApplicationSettings: parse --no-async-images flag
- ImageReferenceService: SyncMode property gates worker thread startup;
  sync mode hooks OnImageReferenceCreated to resolve immediately;
  ResolveImageReference sets ResolvedImage in sync mode for
  path-through bindings; QueueCount/CacheCount properties exposed
- MCP: get_image_queue_status tool reports syncMode, queueCount,
  cacheCount for runtime diagnostics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sized placeholders: Read image dimensions from PNG/BMP/JPEG headers
at ImageReference creation time and store as SourceWidth/SourceHeight.
The image service creates correctly-sized transparent placeholder
bitmaps (cached by dimension) instead of the universal 1×1 placeholder,
so Avalonia's layout system measures controls at the right size before
real images resolve. FilterImageReference inherits dimensions from its
source; LayeredImageReference inherits from its first layer.

Source image cache: ConcreteImageReferenceResolver now caches decoded
base images (before filter application) in a WeakReference dictionary
keyed by pack-relative path. Multiple ConcreteImageReferences pointing
to the same source file with different filters share the decoded base
image, avoiding redundant zip extraction and decoding. Cache is cleared
on pack unload.

Layout invalidation: When the background worker drains its queue, it
posts a layout invalidation to the main window at Background priority
so any controls that were measured with placeholder dimensions get a
chance to re-measure with final image sizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add FallbackValue={x:Null} to all multi-segment .ResolvedImage
bindings (e.g. Icon.ResolvedImage, Image.ResolvedImage). When the
intermediate property (Icon, Image, Thumbnail, etc.) is null on some
data items, Avalonia logs a binding error because it can't traverse
the path. FallbackValue silently returns null instead, matching the
previous converter-based behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ef-counting, and missing images

Image pipeline overhaul:
- Replace per-pixel GetPixel/SetPixel filter loops with Skia color matrix operations
  (grayscale, dim, brightness) for ~10-50x speedup
- Eliminate PNG encode/decode round-trips between filter steps by keeping images as
  SKBitmap through the entire chain
- Add unsafe pixel buffer access for color-key and alpha mask computation
- Fix ApplyColorMatrix: clear destination bitmap and use SKBlendMode.Src to avoid
  corrupting semi-transparent pixels via SrcOver on uninitialized data
- Fix BT601 grayscale weights: original divided by 8 (>>3), not 6
- Add strong-reference source image cache in ConcreteImageReferenceResolver

Thread safety and missing images:
- Add lock around ZipPackageSource.Open/Files for concurrent archive access
- Convert sAlphaMasks/sPngCache to ConcurrentDictionary for background worker writes
- Fix duplicate ImageReference instances: add instance tracking (mPendingInstances)
  so PostResolvedImage fans out ResolvedImage to ALL objects sharing an equality key
- Add FromExternalURI NotifyCreated call so HTTP images enter the resolution pipeline
- Fix InputMaskingImage: default to click-through when alpha mask unavailable

SuspendRefresh ref-counting:
- Replace boolean SuspendRefresh with int ref-count (Push/Pop pattern)
- SuspendRefreshScope increments on open, decrements on close; refresh fires at zero
- Add underflow detection with error logging and debug assert
- Convert all 6 callsites from direct toggle to scope/Push/Pop
- Fix click handler UI spike: LuaItem.OnLeftClick previously set SuspendRefresh=false
  inside outer LeftClickCommand scope, releasing suspend before transaction callbacks
  fired (~97 full RefreshAccessibility walks per click → 1 batched refresh)

Package Manager game images:
- Bind game banner to Game.Image.ResolvedImage instead of one-shot converter
- Add Game property to PackageGroup for live binding support
- Bridge HTTP image downloads with ResolvedImage in OnHttpImageLoaded

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emosaru emosaru changed the title Refactor image resolution to async background worker with priority queue Async image pipeline with SKBitmap filters, thread safety, and performance fixes Apr 13, 2026
@emosaru
emosaru merged commit 641ff19 into avalonia Apr 13, 2026
3 checks passed
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.

1 participant