Widget layout & styling overhaul (depends on #73) - #74
Open
hinderling wants to merge 17 commits into
Open
Conversation
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
marked this pull request as ready for review
July 13, 2026 15:57
|
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Commit 1 — layout mechanics
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).
&joins grouped topics ("Image type & Normalization"),/joins complementary action pairs ("Train / Segment").Tests
New
test_widget_layout.pypins 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.