Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7a66c42
Add bounded feature cache module (LRU + disk spillover)
hinderling Jul 12, 2026
082e08b
Feature extractors: cacheable pyramid representation
hinderling Jul 12, 2026
3b7af04
ConvpaintModel: content-addressed feature caching in the extraction path
hinderling Jul 12, 2026
4123db5
Widget: feature-cache controls and cache-first stack prediction
hinderling Jul 12, 2026
871b837
Widget: skip redundant re-normalization when training
hinderling Jul 12, 2026
c4b8679
Widget layout mechanics: scrollable tabs, tight header, napari density
hinderling Jul 13, 2026
adfd94d
Widget content polish: compact rows, section conventions, theming
hinderling Jul 13, 2026
2b2a735
FeatureCache: thread-safety via re-entrant lock on public methods
hinderling Jul 13, 2026
2a74ce2
Utils: skip identity resizes in rescale_outputs/rescale_class_labels;…
hinderling Jul 19, 2026
f41e46d
Feature pyramid: keep NN features on-device end-to-end, incl. cache hits
hinderling Jul 19, 2026
6ac892f
Merge feature-caching (on-device pyramid + cache perf fixes) into gui…
hinderling Jul 19, 2026
f01235f
Widget: output layers no longer steal the active-layer selection
hinderling Jul 19, 2026
59248fe
Widget: flat solid-color tabs instead of napari's gradient
hinderling Jul 27, 2026
ab20cd3
Classes tab rework: explicit label values, selection-aware controls, …
hinderling Jul 27, 2026
6aed050
features_from_cacheable: note the device=None CPU fallback at the lif…
hinderling Jul 28, 2026
7a98bc5
Merge feature-caching (device fallback note) into gui-polish
hinderling Jul 28, 2026
229c537
Classes: cap label values at 255 (uint8 data)
hinderling Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 347 additions & 0 deletions src/napari_convpaint/_tests/test_feature_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
"""Tests for the bounded feature cache (storage/eviction/budget logic)."""
import numpy as np

from napari_convpaint.feature_cache import FeatureCache


def _arr(mb):
"""A float32 array of approximately `mb` megabytes."""
n = int(mb * 1e6 / 4)
return np.zeros(n, dtype=np.float32)


def test_hit_and_miss():
c = FeatureCache(max_bytes=100 * 10**6, headroom_frac=0.0)
assert c.get(("img", 0, "sig")) is None
payload = _arr(1)
c.put(("img", 0, "sig"), payload)
got = c.get(("img", 0, "sig"))
assert got is payload
assert c.stats()["hits"] == 1
assert c.stats()["misses"] == 1


def test_lru_eviction_by_cap():
# Cap ~2.5 MB; each entry ~1 MB -> at most 2 fit, oldest evicted.
c = FeatureCache(max_bytes=int(2.5 * 10**6), headroom_frac=0.0)
c.put(("a",), _arr(1))
c.put(("b",), _arr(1))
assert len(c) == 2
c.put(("c",), _arr(1)) # evicts "a" (LRU)
assert len(c) == 2
assert c.get(("a",)) is None
assert c.get(("b",)) is not None
assert c.get(("c",)) is not None


def test_lru_touch_on_get_protects_entry():
c = FeatureCache(max_bytes=int(2.5 * 10**6), headroom_frac=0.0)
c.put(("a",), _arr(1))
c.put(("b",), _arr(1))
assert c.get(("a",)) is not None # touch "a" -> now "b" is LRU
c.put(("c",), _arr(1)) # should evict "b", not "a"
assert c.get(("a",)) is not None
assert c.get(("b",)) is None


def test_single_oversize_payload_is_not_cached():
c = FeatureCache(max_bytes=1 * 10**6, headroom_frac=0.0)
c.put(("big",), _arr(5)) # 5 MB into a 1 MB cap -> skipped, not cached
assert len(c) == 0
assert c.get(("big",)) is None


def test_overwrite_updates_size():
c = FeatureCache(max_bytes=100 * 10**6, headroom_frac=0.0)
c.put(("k",), _arr(1))
b0 = c.nbytes
c.put(("k",), _arr(3)) # replace with a bigger payload
assert c.nbytes > b0
assert len(c) == 1


def test_clear():
c = FeatureCache(max_bytes=100 * 10**6, headroom_frac=0.0)
c.put(("a",), _arr(1))
c.put(("b",), _arr(1))
c.clear()
assert len(c) == 0
assert c.nbytes == 0


def test_disabled_cache_is_noop():
c = FeatureCache(max_bytes=100 * 10**6, headroom_frac=0.0, enabled=False)
c.put(("a",), _arr(1))
assert c.get(("a",)) is None
assert len(c) == 0


def test_list_payload_size_accounted():
c = FeatureCache(max_bytes=int(2.5 * 10**6), headroom_frac=0.0)
c.put(("a",), [_arr(1), _arr(1)]) # ~2 MB as a list of arrays
assert len(c) == 1
c.put(("b",), _arr(1)) # pushes over 2.5 MB -> evicts "a"
assert c.get(("a",)) is None


def test_model_feature_cache_identical_and_reuses():
"""With the cache on, a re-extraction of the same image reuses features and
produces bit-identical output vs the cache off."""
import warnings
from napari_convpaint.convpaint_model import ConvpaintModel

rng = np.random.default_rng(0)
img = rng.random((64, 64), dtype=np.float32)
annot = np.zeros((64, 64), dtype=np.uint8)
annot[10:20, 10:20] = 1
annot[40:50, 40:50] = 2

def run(enable):
m = ConvpaintModel(fe_name="gaussian_features")
m.set_params(channel_mode="single")
if enable:
m.enable_feature_cache(True)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
m.train(img, annot)
seg1 = np.asarray(m.segment(img))
seg2 = np.asarray(m.segment(img))
return m, seg1, seg2

m_off, off1, off2 = run(False)
m_on, on1, on2 = run(True)
assert np.array_equal(off1, on1)
assert np.array_equal(off2, on2)
# cache actually stored and served something
assert m_on._feature_cache.stats()["hits"] >= 1


def test_disk_spillover_serves_ram_evicted_entries():
"""RAM-evicted entries spill to disk and are served from there (bit-identical)."""
c = FeatureCache(max_bytes=int(2.5 * 10**6), headroom_frac=0.0,
disk_max_bytes=100 * 10**6)
a = _arr(1); b = _arr(1); d = _arr(1)
c.put(("a",), a); c.put(("b",), b) # RAM full (2 entries)
c.put(("c",), d) # evicts "a" from RAM -> spills to disk
assert len(c) == 2 and c.stats()["disk_entries"] == 1
got = c.get(("a",)) # RAM miss -> disk hit
assert got is not None and np.array_equal(got, a) # round-trips bit-identical
assert c.stats()["disk_hits"] == 1


def test_disk_lru_eviction_and_total_miss():
c = FeatureCache(max_bytes=int(1.5 * 10**6), headroom_frac=0.0,
disk_max_bytes=int(1.5 * 10**6)) # RAM holds 1, disk holds 1
c.put(("a",), _arr(1)); c.put(("b",), _arr(1)) # a -> disk, b in RAM
c.put(("c",), _arr(1)) # b -> disk (evicts a from disk), c in RAM
assert c.get(("a",)) is None # a fell off disk entirely -> recompute
assert c.get(("b",)) is not None # b on disk
assert c.get(("c",)) is not None # c in RAM


def test_disk_disabled_by_default():
c = FeatureCache(max_bytes=int(1.5 * 10**6), headroom_frac=0.0) # no disk
c.put(("a",), _arr(1)); c.put(("b",), _arr(1)) # a evicted, dropped (no disk)
assert c.get(("a",)) is None and c.stats()["disk_entries"] == 0


def test_clear_removes_disk_tier_and_tempdir():
import os
c = FeatureCache(max_bytes=int(1.5 * 10**6), headroom_frac=0.0,
disk_max_bytes=100 * 10**6)
c.put(("a",), _arr(1)); c.put(("b",), _arr(1)) # a on disk
disk_dir = c._disk_dir
assert disk_dir is not None and os.path.isdir(disk_dir)
c.clear()
assert c.stats()["disk_entries"] == 0 and c.disk_nbytes == 0
c.close()
assert not os.path.isdir(disk_dir) # temp dir removed


def test_disk_bytes_never_exceeds_cap():
"""Stress: many puts must never push the disk tier over its byte cap."""
cap = int(3.5 * 10**6) # ~3 entries of 1 MB
c = FeatureCache(max_bytes=int(1.5 * 10**6), headroom_frac=0.0, disk_max_bytes=cap)
for i in range(20):
c.put((i,), _arr(1))
assert c.disk_nbytes <= cap # invariant holds after every put
c.close()


# --- integration with the model-level cache protocol -----------------------

def test_oversized_payload_goes_to_disk_tier():
from napari_convpaint.feature_cache import FeatureCache
c = FeatureCache(max_bytes=1024 * 1024, headroom_frac=0.0,
disk_max_bytes=64 * 1024 * 1024)
try:
payload = np.zeros(2 * 1024 * 1024, dtype=np.uint8) # 2 MB > 1 MB RAM cap
c.put(('big',), payload)
assert len(c) == 0
assert c.stats()['disk_entries'] == 1
got = c.get(('big',))
assert got is not None and got.nbytes == payload.nbytes
finally:
c.close()


def test_spill_ok_false_never_touches_disk():
from napari_convpaint.feature_cache import FeatureCache
c = FeatureCache(max_bytes=1024 * 1024, headroom_frac=0.0,
disk_max_bytes=64 * 1024 * 1024)
try:
c.put(('a',), np.zeros(600 * 1024, dtype=np.uint8), spill_ok=False)
c.put(('b',), np.zeros(600 * 1024, dtype=np.uint8)) # evicts 'a' -> dropped
assert c.stats()['disk_entries'] == 0
assert c.get(('a',)) is None
c.put(('huge',), np.zeros(2 * 1024 * 1024, dtype=np.uint8), spill_ok=False)
assert c.get(('huge',)) is None
assert c.stats()['disk_entries'] == 0
finally:
c.close()


def test_hookmodel_opts_out_of_disk_spill():
from napari_convpaint.feature_extractor import FeatureExtractor
assert FeatureExtractor.cache_spill_to_disk(object()) is True
from napari_convpaint.feature_extractors.nnlayers import Hookmodel
assert Hookmodel.cache_spill_to_disk(object()) is False


def test_cache_key_includes_fe_instance_state():
from napari_convpaint.convpaint_model import ConvpaintModel
cp = ConvpaintModel('gaussian')
sig_before = cp._fe_cache_signature(cp._param)
cp.fe_model.sigma = cp.fe_model.sigma + 1
assert cp._fe_cache_signature(cp._param) != sig_before
# generic hook: any change in reported extra state must change the key
orig = cp.fe_model.cache_extra_state
cp.fe_model.cache_extra_state = lambda: ('jafar_scalings', (1, 8))
try:
assert cp._fe_cache_signature(cp._param) != sig_before
finally:
cp.fe_model.cache_extra_state = orig


def test_cached_prediction_bit_identical_and_hits():
import warnings as _w
from napari_convpaint.convpaint_model import ConvpaintModel
rng = np.random.RandomState(0)
img = rng.rand(1, 96, 96).astype(np.float32)
annot = np.zeros((1, 96, 96), dtype=np.uint8)
annot[0, :12, :12] = 1
annot[0, -12:, -12:] = 2
with _w.catch_warnings():
_w.simplefilter('ignore')
cp = ConvpaintModel('gaussian')
fc = cp.enable_feature_cache(max_bytes=64 * 1024 * 1024)
cp.train(img, annot)
seg_first = cp.segment(img)
hits_before = fc.stats()['hits']
seg_second = cp.segment(img)
assert fc.stats()['hits'] > hits_before # second pass hits
assert np.array_equal(seg_first, seg_second)
# peek semantics
assert cp._predict(rng.rand(1, 96, 96).astype(np.float32), cache_only=True) is None
assert cp._predict(img, cache_only=True) is not None
# uncached model produces the identical segmentation
cp2 = ConvpaintModel('gaussian')
cp2.train(img, annot)
assert np.array_equal(seg_second, cp2.segment(img))


def test_thread_safety_under_concurrent_use():
"""Hammer the cache from worker threads while the "GUI" thread clears it and
changes limits (exactly what the napari widget does during a threaded op).
Correctness bar: no exceptions and consistent bookkeeping afterwards."""
import threading

c = FeatureCache(max_bytes=int(3 * 10**6), headroom_frac=0.0,
disk_max_bytes=int(5 * 10**6))
errors = []
start = threading.Barrier(5)

def worker(tid):
try:
start.wait()
for i in range(200):
key = ("img", tid, i % 7)
if c.get(key) is None:
c.put(key, _arr(0.1))
len(c), c.stats()
except Exception as e: # pragma: no cover - only on regression
errors.append(e)

def gui():
try:
start.wait()
for i in range(100):
c.set_max_bytes(int((2 + i % 3) * 10**6))
c.set_disk_max_bytes(int((i % 2) * 5 * 10**6))
c.stats()
if i % 10 == 0:
c.clear()
except Exception as e: # pragma: no cover - only on regression
errors.append(e)

threads = [threading.Thread(target=worker, args=(t,)) for t in range(4)]
threads.append(threading.Thread(target=gui))
for t in threads:
t.start()
for t in threads:
t.join()

assert errors == []
# Bookkeeping must be consistent: recompute sizes from the stores.
assert c.nbytes == sum(item[1] for item in c._store.values())
assert c.disk_nbytes == sum(item[1] for item in c._disk_store.values())
assert c.nbytes <= c.stats()["max_bytes"]
c.close()


def test_nn_fe_cache_hit_matches_fresh_and_uses_torch_payload():
"""NN FEs keep their native features on-device (torch); the cache payload
is cast to numpy for storage but remembers it was torch, so hits are
lifted back and reconstructed with the SAME torch backend as fresh
extractions. Guards against a hit/miss backend split (skimage vs torch)
which would make cache-enabled extraction both slow (CPU rescale) and
potentially non-identical to fresh results."""
import warnings as _w
from napari_convpaint.convpaint_model import ConvpaintModel
rng = np.random.RandomState(0)
img = rng.rand(1, 64, 64).astype(np.float32)
with _w.catch_warnings():
_w.simplefilter('ignore')
cp = ConvpaintModel(fe_name='vgg16')
cp.set_params(fe_scalings=[1, 2])
feat_off = cp.get_feature_image(img) # cache disabled: fresh
fc = cp.enable_feature_cache(max_bytes=512 * 10**6)
feat_miss = cp.get_feature_image(img) # miss: fills cache
feat_hit = cp.get_feature_image(img) # hit: from payload
assert fc.stats()['hits'] >= 1
assert np.array_equal(feat_off, feat_miss), "cache-on (miss) differs from cache-off"
assert np.array_equal(feat_miss, feat_hit), "cache hit differs from miss"
payload = next(iter(fc._store.values()))[0]
assert payload['was_torch'] is True
for features, _, _ in payload['scales']: # stored form is numpy
assert all(isinstance(f, np.ndarray) for f in features)


def test_numpy_fe_payload_stays_numpy_and_identical():
"""Numpy-native FEs (e.g. gaussian) must NOT be lifted to torch on a hit —
their fresh path is skimage, and hit/miss must keep sharing it."""
import warnings as _w
from napari_convpaint.convpaint_model import ConvpaintModel
rng = np.random.RandomState(0)
img = rng.rand(1, 96, 96).astype(np.float32)
with _w.catch_warnings():
_w.simplefilter('ignore')
cp = ConvpaintModel(fe_name='gaussian_features')
feat_off = cp.get_feature_image(img)
fc = cp.enable_feature_cache(max_bytes=256 * 10**6)
feat_miss = cp.get_feature_image(img)
feat_hit = cp.get_feature_image(img)
assert np.array_equal(feat_off, feat_miss)
assert np.array_equal(feat_miss, feat_hit)
payload = next(iter(fc._store.values()))[0]
assert payload['was_torch'] is False
Loading
Loading