001 Notes

ClusterLens UI Runtime: Virtualized PyQt, Workers, And Generated State

A local ML image app is not just an algorithm. It is a runtime.

It has to scan folders, decode images, compute perceptual hashes, generate thumbnails, run embedding models, build vector matrices, cluster images, render large result sets, and let the user move or inspect files without freezing the window.

That is the hard part. The clustering algorithm can be correct and the product can still feel broken if the UI blocks, the thumbnail cache grows invisibly, old worker results overwrite new state, or file actions are too easy to trigger accidentally.

This article explains the runtime design: virtualized PyQt views, lazy thumbnail generation, worker-job contracts, stale-result protection, generated-state management, safe file actions, CPU/GPU runtime policy, and shutdown discipline.

1. Why Naive Galleries Fail

The naive gallery implementation is straightforward:

for every image in folder:
  load full image from disk
  decode image
  resize image
  create QLabel or custom widget
  add widget to grid

This works for a demo folder. It fails for real image libraries.

The failure is not one bottleneck. It is a stack of problems:

ProblemWhy it hurts
one widget per imagewidget creation, layout, style, and event cost explode
eager image decodingstartup waits on disk and CPU work
full-size image loadingmemory pressure spikes
resize during paintscrolling stutters
main-thread file I/Othe app stops responding
unbounded thumbnail workold scroll positions keep wasting CPU
no cache policymemory grows until the OS intervenes

With 50 images, a naive grid may look fine.

With 50,000 images, it becomes pathological:

50,000 widgets
50,000 image decodes
50,000 layout items
50,000 paint targets

The user experiences it as:

slow startup
blank window
frozen scroll
high memory usage
fan noise
crashes on large folders

The fix is not “optimize the loop.” The fix is to stop doing work for things the user cannot see.

2. Virtualization

Virtualization means the UI represents the whole dataset but only renders the visible slice.

large image list
-> model stores rows and metadata
-> view asks for visible rows
-> delegate paints visible cells
-> cache supplies thumbnails
-> workers fill missing thumbnails

The mental model:

flowchart LR
  A[All image records] --> B[Model]
  B --> C[View viewport]
  C --> D[Visible indexes]
  D --> E[Delegate paint]
  E --> F[Thumbnail cache]
  F --> G{Thumbnail ready?}
  G -- yes --> H[Paint thumbnail]
  G -- no --> I[Paint placeholder]
  I --> J[Enqueue worker job]

In PyQt, this usually means using model/view/delegate patterns instead of one-widget-per-image.

ComponentResponsibility
modelrow count, metadata, roles, selection state, cluster membership
viewscrolling, viewport, selection mechanics, keyboard/mouse behavior
delegatepaint visible cells/cards
workerdecode, resize, hash, embed, cluster, search
cacheavoid recomputing thumbnails and vectors

The key design change:

Do not create UI objects for every image.
Create data records for every image.
Only paint visible records.

3. PyQt Model/View Pattern

In PyQt, the model should answer small questions quickly.

Common methods:

rowCount()
columnCount()
data(index, role)
flags(index)
headerData()

The model is queried frequently. It must not perform expensive work from inside those methods.

Bad model behavior:

data(index, role):
  open image from disk
  decode JPEG
  resize thumbnail
  create QPixmap
  return pixmap

This is bad because the view calls data() during painting, scrolling, selection updates, hover changes, and invalidation. Any blocking work there becomes UI jank.

Better behavior:

data(index, role):
  if role asks for thumbnail:
    if thumbnail exists in cache:
      return cached thumbnail
    enqueue thumbnail job if not already queued
    return placeholder

  if role asks for text:
    return precomputed metadata

  if role asks for status:
    return cheap in-memory state

Then the worker finishes later:

worker decodes image
worker resizes to thumbnail
worker stores result in cache
worker emits thumbnail_ready(image_id)
model emits dataChanged(index, index, [thumbnail_role])
view repaints that cell

The rule is simple:

data() returns what is already available.
data() may request work.
data() must not do heavy work itself.

4. Delegate Painting

A delegate should paint from prepared state.

It can draw:

thumbnail pixmap
placeholder rectangle
filename
cluster badge
selection border
loading indicator
warning marker

It should not:

read files
run image decoding
query a database repeatedly
compute embeddings
perform layout-heavy widget creation
block on locks

The delegate receives a rectangle. It paints inside it.

Conceptual delegate flow:

paint(painter, option, index):
  record = model record for index
  thumbnail = model data for thumbnail role

  draw background
  draw thumbnail or placeholder
  draw metadata badges
  draw selection/focus state

This keeps the view lightweight. The expensive preparation happens elsewhere.

5. The QPixmap And QImage Boundary

In Qt, QPixmap is GUI-resource oriented. It is usually safer to create and use it on the GUI thread.

For worker thumbnail generation, a common pattern is:

worker thread:
  load image
  decode into QImage or PIL image
  resize
  emit image bytes or QImage result

UI thread:
  convert QImage to QPixmap if needed
  store QPixmap in UI cache
  notify view

The important distinction:

worker prepares image data
UI thread owns GUI resources

If the implementation uses PIL for decoding, the worker can emit raw thumbnail bytes or a neutral image object. The UI thread then performs the final conversion into something the delegate paints.

The principle is the same regardless of exact library:

Do CPU/disk work away from the main thread.
Keep GUI object mutation on the GUI thread.

6. Lazy Thumbnail Flow

Thumbnail generation should be demand-driven.

sequenceDiagram
  participant V as Viewport
  participant M as Model
  participant Q as Queue
  participant W as Worker
  participant C as Cache
  V->>M: visible indexes changed
  M->>C: thumbnail exists?
  C-->>M: miss
  M->>Q: enqueue visible thumbnail job
  M-->>V: placeholder
  W->>Q: take job
  W->>W: decode and resize
  W->>C: store thumbnail data
  W-->>M: thumbnail ready
  M-->>V: dataChanged(index)

Visible work should be prioritized:

visible rows first
near-future prefetch rows second
old offscreen work last or cancelled

A good prefetch policy:

visible_start = first visible row
visible_end = last visible row
prefetch_start = visible_start - margin
prefetch_end = visible_end + margin

The margin should be bounded. If the user scrolls quickly from row 100 to row 20,000, the queue should not spend the next minute generating thumbnails for rows 101 through 800.

Useful queue policies:

deduplicate jobs by image id and thumbnail size
drop stale offscreen jobs when the viewport jumps
limit concurrent workers
prioritize current viewport
avoid generating thumbnails larger than needed

7. Cache Layers

A responsive image app usually needs multiple caches.

CachePurpose
in-memory pixmap cacheinstant repaint of visible/recent thumbnails
in-memory image cachereusable decoded/resized image data
disk thumbnail cacheavoid regenerating thumbnails between sessions
embedding cacheavoid rerunning model inference
perceptual hash indexavoid recomputing duplicate fingerprints
vector/search indexavoid rebuilding search state

Each cache needs an invalidation key.

For thumbnails:

image path or id
file mtime
file size
thumbnail size
decode options

For embeddings:

image path or id
file mtime
file size
model name
model signature
preprocess signature
runtime options if output can differ

For clustering results:

ordered image set fingerprint
embedding model
similarity mode
backend
backend parameters
PCA settings
outlier policy

Cache correctness matters more than cache hit rate. A stale cache makes the app look haunted.

8. Worker Jobs

Heavy work belongs off the main UI thread:

folder discovery
metadata scan
perceptual hash sync
thumbnail generation
embedding inference
vector index building
clustering
semantic search
duplicate candidate search
cache cleanup
file operations
model warm-up

Every worker job needs a contract:

FieldWhy it matters
job ididentify stale completions
input snapshotfreeze folder/query/settings for the run
progress callbackshow useful status
cancellation flagstop wasting work
result objectapply state safely on UI thread
error payloadshow actionable failure
cleanup hookrelease resources on finish/cancel

The input snapshot is especially important.

Bad pattern:

worker reads live UI state while running

Better pattern:

UI gathers current settings
UI creates immutable request object
worker only reads request object

That makes the job reproducible and prevents mid-run UI changes from corrupting the result.

9. Stale Job Protection

Desktop apps often run into stale-result bugs.

Example:

1. User selects folder A.
2. Clustering job A starts.
3. User quickly selects folder B.
4. Clustering job B starts.
5. Job A finishes after job B.
6. UI accidentally displays folder A results.

The fix is job identity.

current_job_id = 42

worker result:
  job_id = 41

UI:
  if result.job_id != current_job_id:
    ignore result

This applies to:

thumbnails
search results
cluster results
selection detail panels
AI summaries
warm-up jobs
cache cleanup status

The UI should treat late results as normal, not exceptional. Concurrency makes late results expected.

10. Progress And Cancellation

Long local workflows should not show a single spinner.

Useful stages:

scanning files
syncing perceptual hashes
loading cached embeddings
computing missing embeddings
building vector matrix
running clustering backend
building review model
generating thumbnails
writing result cache

Progress should be honest. If total work is unknown, show indeterminate progress with a specific stage:

Scanning folders...

If total work is known, show count-based progress:

Embedding 1,240 / 8,000 images

Cancellation should be cooperative:

for batch in batches:
  raise_if_cancelled()
  process batch
  report progress

Do not make cancellation wait until the whole model run finishes if the workload can be batched. The user should feel that cancel means something.

11. Main Thread Discipline

The main UI thread should do:

input handling
painting
layout
light state updates
signal handling
small model notifications

It should not do:

image decoding
directory walking
database scans
embedding inference
clustering
network calls
large JSON parsing
bulk file moves
cache deletion

Danger zones:

paintEvent
resizeEvent
data()
scroll handlers
selection changed handlers
timer callbacks
search text callbacks

If a handler can fire frequently, it must be cheap. Expensive work should be debounced, batched, or moved to a worker.

12. Generated State

Local ML apps create generated data:

thumbnail cache
embedding database
perceptual hash index
vector index files
cluster result cache
model downloads
face/object index files if used
runtime temp files
logs
settings file

This data belongs to the user. The app should show:

what exists
why it exists
where it is stored
how much disk it uses
whether it is rebuildable
what deleting it will do

A good generated-storage panel separates categories:

CategoryUser action
thumbnailsclear and regenerate
embeddingsclear and recompute
hash indexrebuild duplicate index
vector indexrebuild search index
model downloadsremove model assets
logsdelete logs
temp filesclear runtime leftovers
settingsopen/change from UI

Hidden generated gigabytes are a product bug. If the app creates data, the app should expose cleanup.

13. Settings And Runtime Visibility

Runtime state should be visible near where it matters.

Examples:

current folder
active model
embedding cache enabled/disabled
selected clustering backend
CPU/GPU runtime mode
thumbnail size
worker count/profile
cache location
generated storage usage
current job status

This helps debugging and user trust.

Bad UI:

Processing...

Better UI:

Computing CLIP embeddings on CPU: 812 / 4,200 images
Cache hits: 3,388
Backend: MiniBatchKMeans

Users do not need every internal detail all the time, but the app should make the current runtime understandable.

14. Safe File Actions

Image tools are dangerous because they operate on user files.

Safe actions:

open containing folder
copy path
show file properties
move candidates to review folder
export candidate list
mark as not duplicate
clear generated cache
rebuild index

Risky actions:

delete source images
bulk move images
overwrite metadata
rename many files
auto-delete near duplicates

Near-duplicate evidence should create a review group, not a destructive action.

Safer delete flow:

show selected files
show evidence
show destination or deletion mode
require explicit confirmation
prefer move-to-trash over permanent delete
log action result
allow user to inspect failures

The product should distinguish:

delete generated cache
delete downloaded model
move image file
delete image file

Those are different risk levels and should not share vague wording like “clean up.”

15. CPU, GPU, CUDA, And Packaging

GPU support is runtime policy, not just a speed checkbox.

Modes should be explicit:

ModeProduct meaning
CPU defaultportable baseline, easiest support
ONNX/native accelerationoptional speed path where available
Torch CUDA buildfaster embedding inference on compatible NVIDIA systems
custom CUDA kernelsonly claim if implemented, shipped, and benchmarked

CUDA can help:

embedding inference
large tensor batches
matrix operations
nearest-neighbor search if a GPU backend is present

CUDA complicates:

package size
driver compatibility
startup warm-up
memory pressure
fallback behavior
bug reports
release matrix

The UI should expose the selected runtime:

Runtime: CPU
Runtime: CUDA
Runtime: ONNX CPU
Runtime: ONNX GPU provider

If warm-up fails, the app should fall back cleanly:

GPU warm-up failed. Continuing on CPU.
Reason: incompatible runtime or unavailable device.

Do not silently fall back if runtime matters for performance expectations. Silence creates confusion.

16. Model Warm-Up

Model warm-up is useful because the first inference call often pays extra cost:

load weights
initialize kernels
allocate buffers
compile/prepare runtime paths
populate caches

But warm-up should not freeze startup.

Better policy:

start app quickly
show UI
warm selected model in background if allowed
show warm-up status
allow cancellation
skip warm-up if model is not available locally

Warm-up needs the same stale-job rules as clustering. If the user changes model or runtime mode, the old warm-up result may no longer be relevant.

17. Shutdown Discipline

Desktop ML apps often leave work behind if shutdown is casual.

Clean shutdown should:

stop accepting new jobs
request cancellation on active workers
wait briefly for workers
flush pending cache writes
close database connections
release model/runtime resources
write final logs
avoid orphan processes

The app should handle:

close during clustering
close during embedding
close during thumbnail generation
close during cache cleanup
close during warm-up

If the shutdown waits, the UI should say why:

Finishing safe shutdown: cancelling clustering worker...

The user should not have to inspect system processes after closing the app.

18. Failure Reporting

Failures should be actionable.

Bad:

Failed.

Better:

Could not decode 14 images.
The rest of the scan completed.
Open error report?

Common failure classes:

unreadable image
permission denied
missing file after scan
model unavailable
GPU runtime unavailable
cache database corrupted
disk full
cancelled by user

The product should distinguish partial success from total failure. In local tools, partial success is common because folders contain messy real-world files.

19. What Was Done And Why

Virtualized views are used because one-widget-per-image does not scale. The UI should store many records but only paint visible records.

Lazy thumbnails are used because decoding every image upfront makes startup and scrolling slow. The UI should request thumbnails on demand and show placeholders while workers catch up.

Worker jobs are used because folder scans, image decoding, hashing, embedding, clustering, and cleanup are too expensive for the main thread.

Job IDs and input snapshots are used because users change state while work is running. Late worker results must not overwrite current UI state.

Generated-storage controls are used because local ML apps create caches, indexes, logs, model files, and temp files. Users should be able to see and remove generated data from the GUI.

CPU-first runtime policy is used because portability and fallback matter. GPU acceleration is valuable, but the app must remain usable without it.

Safe file-action design is used because near-duplicate evidence is not deletion proof. Review comes before destructive action.

20. What Was Used, Removed, Or Deferred

Used:

virtualized model/view thinking
delegate painting
cheap data() contract
lazy thumbnail generation
bounded worker queues
progress and cancellation
stale job protection
generated-storage visibility
cache cleanup controls
CPU-first runtime behavior
optional GPU acceleration wording
safe file-action policy
shutdown discipline

Removed or avoided:

one widget per image
image decoding in paint/data paths
main-thread embedding or clustering
unbounded thumbnail queues
hidden generated storage
destructive duplicate cleanup
silent GPU fallback
claiming custom CUDA kernels without shipped evidence

Deferred:

custom GPU thumbnail pipeline
custom CUDA clustering kernels
complex distributed indexing
automatic destructive file cleanup
global cache frameworks before simple cleanup controls

The core lesson is that UI performance is mostly about not doing work. Do not create what is not visible. Do not decode what is not needed. Do not block the main thread. Do not hide generated state. Do not let old worker results overwrite current user intent.

Comments