Skip to content

Widget layout & styling overhaul (depends on #73) - #74

Open
hinderling wants to merge 17 commits into
guiwitz:mainfrom
hinderling:gui-polish
Open

Widget layout & styling overhaul (depends on #73)#74
hinderling wants to merge 17 commits into
guiwitz:mainfrom
hinderling:gui-polish

Conversation

@hinderling

Copy link
Copy Markdown
Contributor

Summary

Widget layout and styling overhaul, aimed at making the plugin usable on small screens and consistent with napari's own panels. Two commits on top of #73.

⚠️ Depends on #73 (feature caching) — merge that first. This branch contains #73's commits; once it is merged, this PR's diff reduces to the last two commits (Widget layout mechanics … and Widget content polish …), which are the only ones to review here.

Commit 1 — layout mechanics

  • Every tab is scrollable: vertical scrolling on small screens, and no tab imposes a width floor on the dock anymore. Previously the widest tab (Multifile, ~500px) dictated the plugin's minimum width even if the user never opened it; now content wider than the dock gets a horizontal scrollbar on demand instead of clipping. A fresh widget always opens on the Home tab (regression-tested).
  • Tab bar: left-aligned tabs rendered as one segmented control (square shared inner corners — rounded ones leave notches that expose tab-colored nubs when the bar is squeezed), scroll buttons on narrow docks, no pane frame. Note for future maintenance, learned the hard way: do not reparent the tab bar into a custom header row — QTabWidget keeps managing its bar's geometry and re-centers it on every resize, fighting any outside layout.
  • Spacing matched to napari: 6px outer margins (was ~20), 4px above/below the tab bar, pixel-exact alignment of tab headers with the group boxes below (measured, not eyeballed), and 4px grid spacing inside all group boxes.

Commit 2 — content polish

Driven by measured per-tab minimum widths; the widest rows were restructured or abbreviated. Floors before → after: Home 399→319, Classes 418→289, Advanced 420→362, Multifile 500→388 px (Home being the priority, since that's where users spend most of their time).

  • Home: tile checkboxes stacked; radio labels abbreviated with the right column hugging the divider; shortcut hints compacted; model summary word-wraps; the Documentation link moved from the tab-bar corner to an underlined "documentation" link above the shortcut hints.
  • Advanced: sections reordered and consolidated (the one-checkbox Appearance and Prediction sections were dissolved); the cache section is now Performance, hosting both the caching controls and the Dask toggle behind a dashed divider.
  • Classes: export/import stacked, dashed dividers sectioning the button stack, and the four copy-pasted button-placement blocks consolidated into one helper.
  • Multifile: three-button rows → 2+1, and the file list restyled flat in the active napari theme's colors (rounded corners, plugin font), re-applied automatically on dark ↔ light switches. Palette roles can't be used for this: napari themes via stylesheet only, so the Qt palette keeps the platform's default light look (the previous Win95-style header bevel).
  • Section titles follow one convention: & joins grouped topics ("Image type & Normalization"), / joins complementary action pairs ("Train / Segment").

Tests

New test_widget_layout.py pins the Home-tab default and the scroll wrapping. Full suite passes on top of #73. All changes were iterated visually in napari through the real plugin path (npe2 docking), on both wide and squeezed docks.

Generic, FE-agnostic cache for the interactive annotate→predict loop:
stores opaque per-image payloads keyed by content hash + FE signature,
bounded by a RAM budget with live headroom (never OOMs — degrades to
recomputation), LRU eviction, and an optional disk tier that RAM-evicted
or oversized payloads spill to (per-entry opt-out via spill_ok for
payloads cheaper to recompute than to pickle).
Split extract_features_pyramid into _pyramid_native (expensive per-scale
native features, device-independent CPU numpy — the cacheable payload)
and _pyramid_reconstruct (cheap rescale+concat), keeping the combined
output bit-identical. Add the cache protocol hooks on the base class
(supports_feature_cache / cacheable_repr / features_from_cacheable /
cacheable_nbytes / cache_spill_to_disk / cache_extra_state) and the FE
overrides: Hookmodel keeps its huge CNN payloads out of the disk tier,
ComboFeatures opts out (it bypasses the pyramid split), and JAFAR/gaussian
report their instance state (jafar_scalings, sigma) for the cache key.

Includes two micro-fixes the split exposed: features_per_layer now
defaults to None on the base class (fe_use_min_features degrades with a
warning instead of AttributeError), and supported_devices no longer
returns a nested list for the CPU entry.
enable_feature_cache() attaches a FeatureCache; _get_features then routes
per-image pyramid extraction through _extract_pyramid_cached, which keys
the FE's native payload by (content hash of the prepared image, FE
signature). The signature covers the train-reset param set plus
image_downsample, patch size, and FE instance state (cache_extra_state),
so any setting that changes feature values misses instead of serving
stale features. With the cache disabled (default) behaviour is unchanged;
enabled, outputs are bit-identical (the pyramid split is exact).

cache_only=True threads a peek through _predict/_predict_image/
_get_features: return the prediction only if all needed features are
already cached, else None — lets a stack prediction serve cached slices
first, before a sequential scan evicts them (used by the widget).
GUI wiring for the cache: enable checkbox + RAM/disk budget settings,
live size/hit-rate label, (re)creating the cache when the model changes,
and clearing it only when the last user image layer is removed (plugin-
owned probability/feature layers — including renamed backups — never wipe
it; content-addressing handles staleness otherwise).

Predict-all now runs cache-first: a cheap peek pass serves all slices
whose features are already cached before the compute pass runs — a plain
sequential scan over a stack larger than the cache would evict exactly
the slices it is about to need (LRU thrash). Extended the cache tests
with the model-level protocol (disk routing, spill opt-out, FE-state
keys, bit-identical cached predictions).
The widget already normalizes the full stack (image_stack_norm) before
training, and prediction passes skip_norm=True on the same pre-normalized
data — but training passed skip_norm=False, so the model ran a second
normalization pass. That pass never changed values meaningfully (verified
per mode: the imagenet guard returns out-of-[0,1] data unchanged with a
spurious warning; default z-scoring and the percentile stretch are both
idempotent to float32 noise), but it wasted a full-stack pass, emitted a
bogus warning for imagenet FEs, and left train/predict arrays differing
at the float-noise level — which breaks feature-cache sharing, since
cache keys are content hashes and need byte-identical inputs. Passing
skip_norm=True makes train and predict prepare exactly the same arrays.
The multi-file training path still passes skip_norm=False — it feeds raw,
not-yet-normalized images.
- Every tab is wrapped in a scroll area: vertical scrolling on small
  screens, and no tab imposes a width floor on the dock — content wider
  than the dock gets a horizontal scrollbar on demand instead of clipping
  (previously the widest tab, Multifile at ~500px, dictated the plugin's
  minimum width). A fresh widget always opens on the Home tab (the
  remove/insert wrapping dance otherwise leaves the current index on an
  arbitrary tab) — regression-tested in test_widget_layout.py.
- Tab bar cleanup: left-aligned tabs rendered as one segmented control
  (square shared inner corners — rounded ones leave notches that expose
  tab-colored nubs of the neighbor when the bar is squeezed; only the
  outer corners stay rounded), scroll buttons when the dock is narrow,
  and no pane frame. The Documentation corner button is removed here and
  returns as a link on the Home tab in the next commit. (Note for future
  changes: do NOT reparent the tab bar into a custom header row —
  QTabWidget keeps managing its bar's geometry and re-centers it on every
  resize, fighting any outside layout.)
- Spacing matched to napari's own panels: 6px outer margins (was ~20),
  4px above/below the tab bar, zero page side margins (the first tab
  starts at x=0, so any left page margin reads as misalignment between
  tab headers and content — measured), 6px gap to the vertical scrollbar,
  and 4px grid spacing (both axes) inside all group boxes.
Driven by measured per-tab minimum widths (the width below which content
would need horizontal scrolling); the widest rows were restructured or
abbreviated. Floors before -> after: Home 399->319, Classes 418->289,
Advanced 420->362, Multifile 500->388 px.

- Home: tile checkboxes stacked; radio labels abbreviated ('… img',
  'Norm. over stack/by plane') with the right radio column hugging the
  divider (column stretch); shortcut hints abbreviated and tightened;
  model summaries word-wrap; 'Annotations layer' capitalized; docs link
  ('Information and tutorials in documentation') above the shortcut
  hints, styled like them.
- Models: feature-extractor description styled as info text (italic,
  dimmed).
- Advanced: sections reordered (note, layers/appearance incl. tooltips
  checkbox behind a dashed divider, training, input, output,
  unsupervised, performance); auto-select and add-annotation-layers on
  their own rows with a clearer button label; training counts compacted
  ('N px / N imgs / N labels') with the class-distribution button below;
  switch-axes button full width; the cache section renamed 'Performance'
  hosting the Dask toggle ('Use Dask' + explanatory note) behind a dashed
  divider; disk label without the '0 = RAM only' hint.
- Classes: export/import stacked with dashed dividers sectioning the
  button stack; the four copy-pasted button-placement blocks share one
  _place_class_buttons helper.
- Multifile: both three-button rows -> 2+1 ('Train on annot.'); the file
  list styled flat in the ACTIVE napari theme's colors with rounded
  corners and the widget's font, re-applied on dark<->light switches
  (palette roles can't be used — napari themes via stylesheet only, so
  the Qt palette keeps the platform's Win95 look).
- Section titles follow one convention: '&' joins grouped topics
  ('Image type & Normalization', 'Acceleration & Post-processing',
  'Layers handling & Appearance'), ' / ' joins complementary action
  pairs ('Train / Segment', 'Clear / Close', 'Export / Import').
- ConvPaint -> Convpaint capitalization in comments/docstrings.
@hinderling
hinderling marked this pull request as ready for review July 13, 2026 15:57
@codecov-commenter

codecov-commenter commented Jul 13, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 44.47215% with 668 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.06%. Comparing base (b1e1ddd) to head (229c537).

Files with missing lines Patch % Lines
src/napari_convpaint/_tests/test_feature_cache.py 0.00% 254 Missing ⚠️
src/napari_convpaint/convpaint_widget.py 65.98% 166 Missing ⚠️
src/napari_convpaint/_tests/test_widget_layout.py 0.00% 150 Missing ⚠️
src/napari_convpaint/feature_cache.py 56.65% 88 Missing ⚠️
src/napari_convpaint/convpaint_model.py 87.50% 6 Missing ⚠️
src/napari_convpaint/utils.py 33.33% 4 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #74      +/-   ##
==========================================
- Coverage   63.20%   60.06%   -3.15%     
==========================================
  Files          32       35       +3     
  Lines        6515     7500     +985     
==========================================
+ Hits         4118     4505     +387     
- Misses       2397     2995     +598     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A worker thread can now extract/cache while the GUI thread clears the
cache or changes limits (needed for the threaded-cancellation branch,
and already correct for API users sharing a model across threads).
… tensor-aware pad_to_shape

skimage.transform.resize computes a full resize even when the target shape
equals the input shape — for the common patch_size=1 / image_downsample=1
prediction path that was ~17 ms/slice of pure waste (rescale_features
already had this guard). The class-labels guard keeps the uint8 contract.

pad_to_shape gains a torch branch (same symmetric convention) so it works
for on-device feature tensors.
The pyramid split cast NN features to CPU numpy inside _pyramid_native,
which silently moved the whole multi-scale rescale from torch-on-device
onto CPU skimage (rescale_features dispatches on array type): ~2-4x
slower stack prediction than main on GPU, cache on or off.

- _pyramid_native keeps features in their native form (torch tensors on
  the extraction device for NN FEs); _pyramid_reconstruct rescales and
  concatenates on-device with a single host transfer at the end.
- The cache payload is device-independent CPU numpy (_native_to_payload)
  and records was_torch; features_from_cacheable lifts such payloads back
  onto the device, so cache HITS use the same torch backend as fresh
  extractions: a hit is now faster than a recompute (it saves the forward
  pass) instead of ~5x slower, and hit/miss results stay identical.
- On a miss, _extract_pyramid_cached reconstructs from the on-device
  native form via cacheable_repr_and_features (one extraction pass) and
  stores only the numpy payload, so the cache-enabled path is as fast as
  the cache-off path.
- Numpy-native FEs (gaussian etc.) are untouched: their payloads stay
  numpy and reconstruct via skimage on hit and miss alike.
- Caching surface trimmed while at it: cacheable_repr and
  cacheable_nbytes dropped (cacheable_repr_and_features is the single
  extension point; the cache sizes payloads itself), invalidate(predicate)
  folded into clear() (content-addressed entries never go stale),
  disk_dir ctor param dropped, cache_extra_state takes no Param, the
  widget's three cache-settings handlers collapse into
  _apply_feature_cache, and _predict_all's two loops merge into one
  cache-first pair.

Benchmarks (VGG16, scalings [1,2,4], MPS): main 34 ms/slice; before this
fix 125 (cache on or off); after: 18.3 off / 19.8 miss / 15.4 hit —
bit-identical across all paths and devices.

Tests: hit == miss == cache-off equality for an NN FE (vgg16) and a
numpy FE, payload-form assertions, plus a threaded cache hammer.
napari selects a newly added layer, so creating the segmentation/
probabilities/features layers (e.g. on auto-segment after training)
silently moved the user's brush off the annotations layer — the next
scribble landed in the segmentation. The output-layer creators now
restore the previously active layer.
napari main re-enabled the QSS tab gradient (napari #8961), which
clashes with the joined segmented-control tab bar. _style_tabs applies
the tab stylesheet with the active theme's colors as solid fills
(foreground for tabs, current for the selected one) and re-applies on
viewer.events.theme, so dark <-> light switches restyle.
…placeholders

Class identity now lives in an explicit per-row label VALUE (shown left
of the color swatch) instead of the row position. Rows may be sparse
(classes 1, 2, 7) and are kept sorted by value.

- The selected class (the annotations layer's selected label) is
  outlined in the list; icon clicks, Shift+Q/W/E/R and napari's label
  controls all move the outline. Selection has widget-side memory, so
  classes stay selectable/removable when no annotations layer exists.
- Remove class removes the SELECTED class; other classes keep their
  values — no renumbering. The button grays out whenever the selection
  is not a real class.
- No class-count floor: classes can be removed down to zero. Training's
  own >=2-painted-classes check is the real constraint. The widget
  starts with NO classes; PLACEHOLDER slots pad the display to two:
  pure UI (striped swatch matching real swatch size, ghost value,
  italic 'add class' name field), no layer interaction. Typing a name
  into a placeholder creates the real class in place (focus is deferred
  past the placeholder widget's deletion so typing continues
  seamlessly).
- Add class assigns max+1; 'Add classes from annotations' adds exactly
  the painted values (no gap filling). Without a labels colormap the
  swatches show the striped placeholder with an explanatory tooltip.
- CSV export writes the true values; import honors explicit indices as
  values (sparse preserved), name-only rows get sequential values.
- Layer properties stay dense internally (index = label value) with
  placeholder names for value gaps.
…t site

The docstring already documents the device contract; add an inline marker at
the actual fallback (hoisted into lift_device) so the caveat is visible where
the hit is reconstructed. No behavior change.
Annotation layers and the predicted segmentation are uint8, so class
values above 255 would wrap. Enforce the limit in one place
(MAX_CLASS_VALUE): reject too-high values in _on_add_class, gray out
'Add class' when the next value would exceed the limit, and make CSV
import raise before touching the current classes. Also drop the stale
two-class-floor note from the import docstring.
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.

2 participants