[Enhancement]: Grid paint and scroll costs at 125k images (delegate re-scaling, unbounded pixmap cache, filmstrip re-decode) #192
Labels
No labels
Kind/Bug
Kind/Feature
Priority/High
Priority/Medium
Reviewed/Confirmed
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
ai-collab/bulk-image-organizer#192
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Affected area
ui/thumbnail_delegate.py,ui/thumbnail_model.py(_pixmap_cache,patch_tag_display,clear_thumbnail_cache),ui/image_viewer.py(_thumb_for_session_index),ui/workbench_grid.py(QListViewlayout mode).Background
Split out of #191 at the app owner's direction, to keep that branch focused on open/scan/hydrate sequencing and threading. These are per-paint and per-scroll costs rather than startup costs, and they are what remains between the #191 work and a genuinely smooth 125k-image grid.
Findings
1. The delegate re-scales every visible thumbnail on every repaint.
ThumbnailDelegate.paint()callspixmap.scaled(thumb_rect.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)on every paint, with no cache of the scaled result. Display size only changes when the slider or a resize fires, but every scroll re-does a smooth resample of every visible cell. A cache keyed by(pixmap.cacheKey(), width, height)would make regenerated thumbnails miss naturally; bound it (~512 entries, LRU) and clear it inset_thumb_edge/set_cell_size.2. Per-paint allocations that could be cached.
Each
paint()builds a freshQFont(option.font), aQFontMetricsinside_elide_filename, and aQPainterPathper tag badge;_tag_badge_color()runshashlib.md5()per uncoloured badge per paint. Cache the derived font/metrics keyed byoption.font.key(), and@lru_cachethe badge colour by tag name.3.
ImageListModel._pixmap_cacheis unbounded.A plain
dict[str, QPixmap]with no eviction. At 256px/32bpp a cached pixmap is roughly 256 KB, so scrolling a 125k-image workspace end to end can accumulate tens of GB. Replace with anOrderedDictplusmove_to_endon hit and a cap (GRID_PIXMAP_CACHE_MAX, ~1200 ≈ 300 MB, still many viewports deep). Worth framing incore/constants.pyagainst DR-009's memory/CPU trade-off.4. The filmstrip re-decodes JPEGs on every repaint.
ImageViewer._thumb_for_session_index()checks the full-resolution_display_cache(usually a miss for thumbnails), then unconditionallyQPixmap().loadFromData(blob), and only then falls back to the model's decoration role — which is the model's already-decoded_pixmap_cache. Reordering those two lookups removes most redundant decodes for free. A small bounded thumb cache (~256) would cover the remainder; invalidate it alongside_display_cacheand on_on_thumbnails_readyfor the updated paths.5.
patch_tag_display()is O(n) per tag rename/recolour.for i, item in enumerate(self._images)scans every row regardless of how many carry the tag. At 125k that is a full scan on every rename or colour change. A tag-name → rows index would make it proportional to matches.6.
clear_thumbnail_cache()emits onedataChangedspanning the whole model.Called after a save-resolution change. It clears
_pixmap_cachefirst, so every visible cell then synchronously re-decodes on the next paint. Bounded by viewport size rather than row count, but still a burst worth measuring.7.
setLayoutMode(Batched)above a row threshold — needs manual verification.QListViewdefaults toSinglePasswithbatchSize=100; neithersetLayoutModenorsetBatchSizeis called anywhere in the repo. WithsetResizeMode(Adjust)on 125k uniform items, everyscheduleDelayedItemsLayout()lays out all of them in one main-thread pass.Batchedfixes that, but makesscrollTo(),setCurrentIndex()to not-yet-laid-out rows, and scrollbar range approximate until layout completes — which_select_proxy_row, the viewer↔grid round trip,GridSelectionFilterandGridWasdNavFilterall depend on. Suggest adopting it only above a threshold (e.g.rowCount() > 20_000) and verifying scroll/selection manually at scale, since headless tests cannot substitute for that.Note:
setViewportUpdateModewas considered and ruled out — it is aQGraphicsViewAPI, not available onQListView.Acceptance criteria
setLayoutMode(Batched)adoption is verified manually for scroll position, selection, and viewer round-trip at 100k+ rows.QPixmap.scaledcalled once for two paints at the same size).