flimkit-zstack-explorer 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/PKG-INFO +28 -3
  2. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/README.md +27 -2
  3. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/plugin.py +30 -0
  4. flimkit_zstack_explorer-0.2.0/flimkit_zstack_explorer/viewer.py +152 -0
  5. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/zarr_io.py +10 -0
  6. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/PKG-INFO +28 -3
  7. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/pyproject.toml +1 -1
  8. flimkit_zstack_explorer-0.2.0/tests/test_plugin_guard.py +101 -0
  9. flimkit_zstack_explorer-0.2.0/tests/test_viewer.py +133 -0
  10. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/tests/test_zarr_io.py +16 -0
  11. flimkit_zstack_explorer-0.1.0/flimkit_zstack_explorer/viewer.py +0 -79
  12. flimkit_zstack_explorer-0.1.0/tests/test_plugin_guard.py +0 -52
  13. flimkit_zstack_explorer-0.1.0/tests/test_viewer.py +0 -20
  14. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/LICENSE +0 -0
  15. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/__init__.py +0 -0
  16. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/volumes.py +0 -0
  17. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/SOURCES.txt +0 -0
  18. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/dependency_links.txt +0 -0
  19. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/entry_points.txt +0 -0
  20. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/requires.txt +0 -0
  21. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/top_level.txt +0 -0
  22. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/setup.cfg +0 -0
  23. {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flimkit-zstack-explorer
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: 3D intensity and FLIM volume explorer for FLIMKit z-stacks
5
5
  Author-email: Alex Hunt <alexander.hunt@ed.ac.uk>
6
6
  License-Expression: MIT
@@ -33,21 +33,46 @@ and opens a popout 3D viewer with the two volumes side by side.
33
33
  display code (the same path used when you drag the z-slider), so the
34
34
  volume matches what's on screen slice by slice.
35
35
  - Saves a 2-channel OME-Zarr store (channel 0 = intensity, channel 1 =
36
- lifetime in ns, NaN where a slice had no per-pixel fit).
36
+ lifetime in ns, NaN where a slice had no per-pixel fit). Saving to a path
37
+ that already has a store from a previous run overwrites it.
37
38
  - Opens a [PyVista](https://pyvista.org) volume viewer in its own process —
38
39
  intensity on the left, FLIM lifetime on the right, camera-linked — so it
39
40
  doesn't compete with FLIMKit's Tkinter UI for the main thread.
41
+ - Intensity is clipped to its 99th percentile before rendering, the same
42
+ clip FLIMKit's own 2D intensity view uses, so a few hot pixels don't wash
43
+ out the whole volume.
44
+ - The FLIM pane has live sliders for the lifetime min/max color range
45
+ (defaulting to the 2nd/98th percentile, like FLIMKit's "Auto" scale
46
+ button) and a colormap picker with the same palette as FLIMKit's 2D FLIM
47
+ view (hsv, viridis, cool, hot, twilight).
48
+ - **Tools > Open Saved 3D Volume...** reopens a `.zarr` store from a
49
+ previous run — no z-stack needs to be loaded for this one, it just opens
50
+ the viewer against whatever store you point it at.
40
51
 
41
52
  ## Install
42
53
 
43
54
  ```
44
- pip install flimkit-zstack-explorer[gui]
55
+ pip install 'flimkit-zstack-explorer[gui]'
45
56
  ```
46
57
 
58
+ Quote it — in zsh (macOS's default shell), an unquoted `[gui]` is a glob
59
+ pattern and the command fails with "no matches found" without installing
60
+ anything.
61
+
47
62
  `[gui]` adds PyVista, which does the actual 3D rendering. Without it, the
48
63
  volume-building and OME-Zarr saving still work, but the viewer step will
49
64
  tell you it's missing and how to install it.
50
65
 
66
+ Install it into the same Python environment FLIMKit itself runs from —
67
+ a separate environment or a different `python`/`pip` on your `PATH` will
68
+ install cleanly but leave FLIMKit unable to see it. Verify with:
69
+
70
+ ```bash
71
+ python -c "import flimkit_zstack_explorer, pyvista; print('ok')"
72
+ ```
73
+
74
+ run from the same terminal you launch FLIMKit from.
75
+
51
76
  FLIMKit discovers this through the `flimkit.plugins` entry point, so there's
52
77
  nothing else to wire up. On start it appears under Tools.
53
78
 
@@ -14,21 +14,46 @@ and opens a popout 3D viewer with the two volumes side by side.
14
14
  display code (the same path used when you drag the z-slider), so the
15
15
  volume matches what's on screen slice by slice.
16
16
  - Saves a 2-channel OME-Zarr store (channel 0 = intensity, channel 1 =
17
- lifetime in ns, NaN where a slice had no per-pixel fit).
17
+ lifetime in ns, NaN where a slice had no per-pixel fit). Saving to a path
18
+ that already has a store from a previous run overwrites it.
18
19
  - Opens a [PyVista](https://pyvista.org) volume viewer in its own process —
19
20
  intensity on the left, FLIM lifetime on the right, camera-linked — so it
20
21
  doesn't compete with FLIMKit's Tkinter UI for the main thread.
22
+ - Intensity is clipped to its 99th percentile before rendering, the same
23
+ clip FLIMKit's own 2D intensity view uses, so a few hot pixels don't wash
24
+ out the whole volume.
25
+ - The FLIM pane has live sliders for the lifetime min/max color range
26
+ (defaulting to the 2nd/98th percentile, like FLIMKit's "Auto" scale
27
+ button) and a colormap picker with the same palette as FLIMKit's 2D FLIM
28
+ view (hsv, viridis, cool, hot, twilight).
29
+ - **Tools > Open Saved 3D Volume...** reopens a `.zarr` store from a
30
+ previous run — no z-stack needs to be loaded for this one, it just opens
31
+ the viewer against whatever store you point it at.
21
32
 
22
33
  ## Install
23
34
 
24
35
  ```
25
- pip install flimkit-zstack-explorer[gui]
36
+ pip install 'flimkit-zstack-explorer[gui]'
26
37
  ```
27
38
 
39
+ Quote it — in zsh (macOS's default shell), an unquoted `[gui]` is a glob
40
+ pattern and the command fails with "no matches found" without installing
41
+ anything.
42
+
28
43
  `[gui]` adds PyVista, which does the actual 3D rendering. Without it, the
29
44
  volume-building and OME-Zarr saving still work, but the viewer step will
30
45
  tell you it's missing and how to install it.
31
46
 
47
+ Install it into the same Python environment FLIMKit itself runs from —
48
+ a separate environment or a different `python`/`pip` on your `PATH` will
49
+ install cleanly but leave FLIMKit unable to see it. Verify with:
50
+
51
+ ```bash
52
+ python -c "import flimkit_zstack_explorer, pyvista; print('ok')"
53
+ ```
54
+
55
+ run from the same terminal you launch FLIMKit from.
56
+
32
57
  FLIMKit discovers this through the `flimkit.plugins` entry point, so there's
33
58
  nothing else to wire up. On start it appears under Tools.
34
59
 
@@ -47,6 +47,36 @@ def open_3d_explorer(app):
47
47
  _run_build_and_view(app, parent, out_path)
48
48
 
49
49
 
50
+ @tool(id='zstack_3d_open_saved', label='Open Saved 3D Volume...', menu='Tools', order=851)
51
+ def open_saved_volume(app):
52
+ from tkinter import filedialog, messagebox
53
+
54
+ parent = _parent(app)
55
+
56
+ if not _deps_ok():
57
+ messagebox.showerror(
58
+ 'Missing dependency',
59
+ 'Opening a saved 3D volume needs zarr and ome-zarr.\n\n'
60
+ 'Install with:\n\n pip install flimkit-zstack-explorer',
61
+ parent=parent)
62
+ return
63
+
64
+ path = filedialog.askdirectory(
65
+ parent=parent, title='Open a saved 3D volume (OME-Zarr)', mustexist=True)
66
+ if not path:
67
+ return
68
+
69
+ from ome_zarr.io import parse_url
70
+ if parse_url(path) is None:
71
+ messagebox.showerror(
72
+ 'Not an OME-Zarr store',
73
+ f"{path}\n\ndoesn't look like an OME-Zarr store saved by this tool.",
74
+ parent=parent)
75
+ return
76
+
77
+ _launch_viewer(path, parent)
78
+
79
+
50
80
  def _run_build_and_view(app, parent, out_path):
51
81
  import threading
52
82
  from flimkit.UI.progress_window import ProgressWindow
@@ -0,0 +1,152 @@
1
+ """Standalone popout 3D viewer. Runs in its own process (see plugin.py),
2
+ never imports tkinter/flimkit, so it doesn't fight FLIMKit's Tk mainloop
3
+ for the main thread.
4
+
5
+ Run directly with: python -m flimkit_zstack_explorer.viewer --zarr <path>
6
+ """
7
+ from __future__ import annotations
8
+ import argparse
9
+ import sys
10
+
11
+ import numpy as np
12
+
13
+ # Same names, same order, as flimkit.utils.display.COLORMAPS, so the FLIM
14
+ # volume offers the same palette as FLIMKit's own 2D lifetime view.
15
+ COLORMAPS = ['hsv', 'viridis', 'cool', 'hot', 'twilight']
16
+
17
+
18
+ def _missing_pyvista():
19
+ sys.stderr.write(
20
+ 'flimkit-zstack-explorer: pyvista is required for the 3D viewer.\n'
21
+ 'Install it with:\n\n'
22
+ ' pip install "flimkit-zstack-explorer[gui]"\n\n')
23
+ sys.exit(1)
24
+
25
+
26
+ def _set_lifetime_colormap(actor, name):
27
+ import pyvista as pv
28
+ lut = pv.LookupTable(cmap=name)
29
+ lut.apply_opacity('linear')
30
+ actor.mapper.lookup_table = lut
31
+
32
+
33
+ def _masked_grid(values, mask, name, dimensions, spacing):
34
+ """Build an ImageData for `values` and drop every cell where `mask` is
35
+ False, via a threshold filter.
36
+
37
+ A volume mapper's own opacity/NaN handling doesn't reliably drop
38
+ individual voxels (tested: NaN cells with nan_opacity=0 still render as
39
+ solid fill), so masked-out voxels are removed from the geometry outright
40
+ rather than made transparent.
41
+ """
42
+ import pyvista as pv
43
+ grid = pv.ImageData()
44
+ grid.dimensions = dimensions
45
+ grid.spacing = spacing
46
+ grid.cell_data[name] = np.where(mask, values, 0).flatten(order='F')
47
+ grid.cell_data['_mask'] = mask.astype(np.float32).flatten(order='F')
48
+ return grid.threshold(0.5, scalars='_mask')
49
+
50
+
51
+ def show(intensity_stack: np.ndarray, lifetime_stack: np.ndarray, *,
52
+ voxel_size_um=(1.0, 1.0, 1.0), title: str = 'FLIMKit 3D Z-stack Explorer'):
53
+ try:
54
+ import pyvista as pv
55
+ except ImportError:
56
+ _missing_pyvista()
57
+ return
58
+
59
+ spacing = tuple(float(v) for v in voxel_size_um)[::-1]
60
+ dimensions = tuple(np.array(intensity_stack.shape)[::-1] + 1)
61
+
62
+ # Clipped to the 99th percentile, same as FLIMKit's own 2D intensity
63
+ # view (flimkit/UI/fov_preview.py), so a handful of hot pixels don't
64
+ # wash out the whole volume's opacity ramp. Zero-intensity voxels (no
65
+ # signal) are dropped from the geometry entirely.
66
+ intensity_clip = np.percentile(intensity_stack, 99) if intensity_stack.size else 1.0
67
+ intensity_clipped = np.clip(intensity_stack, 0, intensity_clip)
68
+ intensity_grid = _masked_grid(
69
+ intensity_clipped, intensity_stack > 0, 'intensity', dimensions, spacing)
70
+
71
+ # Voxels with no per-pixel fit (NaN lifetime) are dropped the same way.
72
+ lifetime_grid = _masked_grid(
73
+ lifetime_stack, np.isfinite(lifetime_stack), 'lifetime_ns', dimensions, spacing)
74
+
75
+ valid_lifetime = lifetime_stack[np.isfinite(lifetime_stack)]
76
+ if valid_lifetime.size:
77
+ lt_min, lt_max = float(valid_lifetime.min()), float(valid_lifetime.max())
78
+ # 2nd/98th percentile default, same as FLIMKit's "Auto" scale button.
79
+ vmin = float(np.percentile(valid_lifetime, 2))
80
+ vmax = float(np.percentile(valid_lifetime, 98))
81
+ else:
82
+ lt_min, lt_max, vmin, vmax = 0.0, 1.0, 0.0, 1.0
83
+ if lt_max <= lt_min:
84
+ lt_max = lt_min + 1.0
85
+ if vmax <= vmin:
86
+ vmin, vmax = lt_min, lt_max
87
+
88
+ plotter = pv.Plotter(shape=(1, 2), title=title)
89
+
90
+ plotter.subplot(0, 0)
91
+ if intensity_grid.n_cells:
92
+ plotter.add_volume(intensity_grid, scalars='intensity', cmap='inferno',
93
+ opacity='linear', name='intensity')
94
+ else:
95
+ plotter.add_text('No intensity signal', font_size=10, color='grey')
96
+ plotter.add_text('Intensity', font_size=12)
97
+ plotter.add_axes()
98
+
99
+ plotter.subplot(0, 1)
100
+ if lifetime_grid.n_cells:
101
+ lifetime_actor = plotter.add_volume(
102
+ lifetime_grid, scalars='lifetime_ns', cmap='viridis',
103
+ opacity='linear', name='lifetime', show_scalar_bar=False)
104
+ lifetime_actor.mapper.scalar_range = (vmin, vmax)
105
+ plotter.add_scalar_bar('lifetime (ns)', mapper=lifetime_actor.mapper)
106
+
107
+ def set_vmin(value):
108
+ _, hi = lifetime_actor.mapper.scalar_range
109
+ lo = min(float(value), hi)
110
+ lifetime_actor.mapper.scalar_range = (lo, hi)
111
+ plotter.update_scalar_bar_range((lo, hi))
112
+
113
+ def set_vmax(value):
114
+ lo, _ = lifetime_actor.mapper.scalar_range
115
+ hi = max(float(value), lo)
116
+ lifetime_actor.mapper.scalar_range = (lo, hi)
117
+ plotter.update_scalar_bar_range((lo, hi))
118
+
119
+ def set_cmap(name):
120
+ _set_lifetime_colormap(lifetime_actor, name)
121
+
122
+ plotter.add_slider_widget(
123
+ set_vmin, rng=(lt_min, lt_max), value=vmin, title='lifetime min (ns)',
124
+ pointa=(0.52, 0.92), pointb=(0.98, 0.92), style='modern')
125
+ plotter.add_slider_widget(
126
+ set_vmax, rng=(lt_min, lt_max), value=vmax, title='lifetime max (ns)',
127
+ pointa=(0.52, 0.80), pointb=(0.98, 0.80), style='modern')
128
+ plotter.add_text_slider_widget(
129
+ set_cmap, data=COLORMAPS, value=COLORMAPS.index('viridis'),
130
+ pointa=(0.52, 0.68), pointb=(0.98, 0.68), style='modern')
131
+ else:
132
+ plotter.add_text('No per-pixel fit data', font_size=10, color='grey')
133
+ plotter.add_text('FLIM (lifetime)', font_size=12)
134
+ plotter.add_axes()
135
+
136
+ plotter.link_views()
137
+ plotter.show()
138
+
139
+
140
+ def main(argv=None):
141
+ parser = argparse.ArgumentParser(description=__doc__)
142
+ parser.add_argument('--zarr', required=True, help='Path to an OME-Zarr store '
143
+ 'written by flimkit_zstack_explorer.zarr_io.save_ome_zarr')
144
+ args = parser.parse_args(argv)
145
+
146
+ from flimkit_zstack_explorer.zarr_io import load_ome_zarr
147
+ intensity, lifetime = load_ome_zarr(args.zarr)
148
+ show(intensity, lifetime, title=f'FLIMKit 3D Z-stack Explorer - {args.zarr}')
149
+
150
+
151
+ if __name__ == '__main__':
152
+ main()
@@ -1,4 +1,6 @@
1
1
  from __future__ import annotations
2
+ import os
3
+ import shutil
2
4
  from typing import Optional, Sequence
3
5
 
4
6
  import numpy as np
@@ -32,6 +34,14 @@ def save_ome_zarr(
32
34
  from ome_zarr.io import parse_url
33
35
  from ome_zarr.writer import write_image
34
36
 
37
+ # ome-zarr's 'w' mode doesn't clear an existing store, so writing to the
38
+ # same path twice (the save dialog defaults to the same filename every
39
+ # run) hits leftover array nodes from the previous write.
40
+ if os.path.isdir(path):
41
+ shutil.rmtree(path)
42
+ elif os.path.exists(path):
43
+ os.remove(path)
44
+
35
45
  data = np.stack([
36
46
  np.nan_to_num(intensity_stack, nan=0.0).astype(np.float32),
37
47
  lifetime_stack.astype(np.float32),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flimkit-zstack-explorer
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: 3D intensity and FLIM volume explorer for FLIMKit z-stacks
5
5
  Author-email: Alex Hunt <alexander.hunt@ed.ac.uk>
6
6
  License-Expression: MIT
@@ -33,21 +33,46 @@ and opens a popout 3D viewer with the two volumes side by side.
33
33
  display code (the same path used when you drag the z-slider), so the
34
34
  volume matches what's on screen slice by slice.
35
35
  - Saves a 2-channel OME-Zarr store (channel 0 = intensity, channel 1 =
36
- lifetime in ns, NaN where a slice had no per-pixel fit).
36
+ lifetime in ns, NaN where a slice had no per-pixel fit). Saving to a path
37
+ that already has a store from a previous run overwrites it.
37
38
  - Opens a [PyVista](https://pyvista.org) volume viewer in its own process —
38
39
  intensity on the left, FLIM lifetime on the right, camera-linked — so it
39
40
  doesn't compete with FLIMKit's Tkinter UI for the main thread.
41
+ - Intensity is clipped to its 99th percentile before rendering, the same
42
+ clip FLIMKit's own 2D intensity view uses, so a few hot pixels don't wash
43
+ out the whole volume.
44
+ - The FLIM pane has live sliders for the lifetime min/max color range
45
+ (defaulting to the 2nd/98th percentile, like FLIMKit's "Auto" scale
46
+ button) and a colormap picker with the same palette as FLIMKit's 2D FLIM
47
+ view (hsv, viridis, cool, hot, twilight).
48
+ - **Tools > Open Saved 3D Volume...** reopens a `.zarr` store from a
49
+ previous run — no z-stack needs to be loaded for this one, it just opens
50
+ the viewer against whatever store you point it at.
40
51
 
41
52
  ## Install
42
53
 
43
54
  ```
44
- pip install flimkit-zstack-explorer[gui]
55
+ pip install 'flimkit-zstack-explorer[gui]'
45
56
  ```
46
57
 
58
+ Quote it — in zsh (macOS's default shell), an unquoted `[gui]` is a glob
59
+ pattern and the command fails with "no matches found" without installing
60
+ anything.
61
+
47
62
  `[gui]` adds PyVista, which does the actual 3D rendering. Without it, the
48
63
  volume-building and OME-Zarr saving still work, but the viewer step will
49
64
  tell you it's missing and how to install it.
50
65
 
66
+ Install it into the same Python environment FLIMKit itself runs from —
67
+ a separate environment or a different `python`/`pip` on your `PATH` will
68
+ install cleanly but leave FLIMKit unable to see it. Verify with:
69
+
70
+ ```bash
71
+ python -c "import flimkit_zstack_explorer, pyvista; print('ok')"
72
+ ```
73
+
74
+ run from the same terminal you launch FLIMKit from.
75
+
51
76
  FLIMKit discovers this through the `flimkit.plugins` entry point, so there's
52
77
  nothing else to wire up. On start it appears under Tools.
53
78
 
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'flimkit-zstack-explorer'
7
- version = '0.1.0'
7
+ version = '0.2.0'
8
8
  description = '3D intensity and FLIM volume explorer for FLIMKit z-stacks'
9
9
  readme = 'README.md'
10
10
  requires-python = '>=3.12'
@@ -0,0 +1,101 @@
1
+ """Verifies the Tools menu entry is a *guarded* action (per the approved
2
+ design: always visible, but refuses to run without a loaded z-stack)
3
+ rather than actually disabled/hidden."""
4
+ import pytest
5
+
6
+ flimkit = pytest.importorskip('flimkit', reason='requires a FLIMKit install')
7
+
8
+ from flimkit_zstack_explorer.plugin import open_3d_explorer, open_saved_volume
9
+
10
+
11
+ class NoZStackApp:
12
+ root = None
13
+ _fov_preview = None
14
+
15
+
16
+ class EmptyZStackApp:
17
+ root = None
18
+
19
+ class _fov_preview:
20
+ _zstack = []
21
+
22
+
23
+ def test_guard_shows_message_when_no_zstack(monkeypatch):
24
+ calls = []
25
+ monkeypatch.setattr('tkinter.messagebox.showinfo',
26
+ lambda *a, **k: calls.append((a, k)))
27
+
28
+ open_3d_explorer(NoZStackApp())
29
+
30
+ assert len(calls) == 1
31
+ assert 'z-stack' in calls[0][0][0].lower() or 'z-stack' in calls[0][0][1].lower()
32
+
33
+
34
+ def test_guard_shows_message_when_zstack_empty(monkeypatch):
35
+ calls = []
36
+ monkeypatch.setattr('tkinter.messagebox.showinfo',
37
+ lambda *a, **k: calls.append((a, k)))
38
+
39
+ open_3d_explorer(EmptyZStackApp())
40
+
41
+ assert len(calls) == 1
42
+
43
+
44
+ def test_does_not_prompt_for_save_path_without_zstack(monkeypatch):
45
+ monkeypatch.setattr('tkinter.messagebox.showinfo', lambda *a, **k: None)
46
+
47
+ def fail_if_called(*a, **k):
48
+ raise AssertionError('should not prompt for a save path without a z-stack')
49
+
50
+ monkeypatch.setattr('tkinter.filedialog.asksaveasfilename', fail_if_called)
51
+
52
+ open_3d_explorer(NoZStackApp())
53
+
54
+
55
+ def test_open_saved_volume_rejects_non_zarr_dir(tmp_path, monkeypatch):
56
+ plain_dir = tmp_path / 'not_a_store'
57
+ plain_dir.mkdir()
58
+ monkeypatch.setattr('tkinter.filedialog.askdirectory', lambda **k: str(plain_dir))
59
+
60
+ errors = []
61
+ monkeypatch.setattr('tkinter.messagebox.showerror',
62
+ lambda *a, **k: errors.append((a, k)))
63
+
64
+ launched = []
65
+ monkeypatch.setattr('subprocess.Popen', lambda *a, **k: launched.append((a, k)))
66
+
67
+ open_saved_volume(NoZStackApp())
68
+
69
+ assert len(errors) == 1
70
+ assert not launched
71
+
72
+
73
+ def test_open_saved_volume_launches_viewer_for_a_real_store(tmp_path, monkeypatch):
74
+ from flimkit_zstack_explorer.zarr_io import save_ome_zarr
75
+ import numpy as np
76
+
77
+ store = tmp_path / 'volume.zarr'
78
+ save_ome_zarr(str(store), np.zeros((2, 4, 4), dtype=np.float32),
79
+ np.zeros((2, 4, 4), dtype=np.float32))
80
+
81
+ monkeypatch.setattr('tkinter.filedialog.askdirectory', lambda **k: str(store))
82
+
83
+ launched = []
84
+ monkeypatch.setattr('subprocess.Popen', lambda args, **k: launched.append(args))
85
+
86
+ open_saved_volume(NoZStackApp())
87
+
88
+ assert len(launched) == 1
89
+ assert '--zarr' in launched[0]
90
+ assert str(store) in launched[0]
91
+
92
+
93
+ def test_open_saved_volume_no_op_when_dialog_cancelled(monkeypatch):
94
+ monkeypatch.setattr('tkinter.filedialog.askdirectory', lambda **k: '')
95
+
96
+ launched = []
97
+ monkeypatch.setattr('subprocess.Popen', lambda *a, **k: launched.append((a, k)))
98
+
99
+ open_saved_volume(NoZStackApp())
100
+
101
+ assert not launched
@@ -0,0 +1,133 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ pytest.importorskip('pyvista', reason='requires the [gui] extra')
5
+
6
+ import pyvista as pv
7
+
8
+ from flimkit_zstack_explorer.viewer import (
9
+ COLORMAPS, _masked_grid, _set_lifetime_colormap, show,
10
+ )
11
+
12
+
13
+ def test_masked_grid_drops_exact_cell_count():
14
+ shape = (10, 20, 24)
15
+ dims = tuple(np.array(shape)[::-1] + 1)
16
+ rng = np.random.default_rng(0)
17
+
18
+ intensity = rng.poisson(50, size=shape).astype(np.float32)
19
+ zero_idx = rng.choice(intensity.size, 500, replace=False)
20
+ intensity.flat[zero_idx] = 0
21
+ grid = _masked_grid(intensity, intensity > 0, 'intensity', dims, (1.0, 1.0, 1.0))
22
+ assert grid.n_cells == intensity.size - 500
23
+
24
+ lifetime = rng.uniform(1.5, 3.5, size=shape).astype(np.float32)
25
+ lifetime[3] = np.nan # a whole slice with no per-pixel fit
26
+ grid = _masked_grid(lifetime, np.isfinite(lifetime), 'lifetime_ns', dims, (1.0, 1.0, 1.0))
27
+ assert grid.n_cells == lifetime.size - shape[1] * shape[2]
28
+
29
+
30
+ def test_masked_grid_all_dropped_yields_empty_grid():
31
+ shape = (2, 4, 4)
32
+ dims = tuple(np.array(shape)[::-1] + 1)
33
+ values = np.zeros(shape, dtype=np.float32)
34
+ grid = _masked_grid(values, values > 0, 'intensity', dims, (1.0, 1.0, 1.0))
35
+ assert grid.n_cells == 0
36
+
37
+
38
+ def test_show_handles_all_zero_intensity_and_all_nan_lifetime(monkeypatch):
39
+ # Must not crash even when every voxel is masked out in one or both
40
+ # volumes (add_volume errors on an empty grid, so show() has to skip it).
41
+ pv.OFF_SCREEN = True
42
+ monkeypatch.setattr(pv.Plotter, 'show', lambda self, *a, **k: None)
43
+
44
+ intensity = np.zeros((3, 8, 8), dtype=np.float32)
45
+ lifetime = np.full((3, 8, 8), np.nan, dtype=np.float32)
46
+
47
+ show(intensity, lifetime)
48
+
49
+
50
+ def _spy_on_add_volume(monkeypatch):
51
+ calls = []
52
+ original = pv.Plotter.add_volume
53
+
54
+ def spy(self, grid, **kwargs):
55
+ actor = original(self, grid, **kwargs)
56
+ calls.append((grid, kwargs, actor))
57
+ return actor
58
+
59
+ monkeypatch.setattr(pv.Plotter, 'add_volume', spy)
60
+ return calls
61
+
62
+
63
+ def test_show_builds_offscreen_without_error(monkeypatch):
64
+ pv.OFF_SCREEN = True
65
+ monkeypatch.setattr(pv.Plotter, 'show', lambda self, *a, **k: None)
66
+
67
+ rng = np.random.default_rng(0)
68
+ intensity = rng.poisson(50, size=(4, 12, 16)).astype(np.float32)
69
+ lifetime = rng.uniform(1.5, 3.5, size=(4, 12, 16)).astype(np.float32)
70
+ lifetime[0] = np.nan
71
+
72
+ show(intensity, lifetime, voxel_size_um=(2.0, 0.5, 0.5))
73
+
74
+
75
+ def test_intensity_is_clipped_to_99th_percentile(monkeypatch):
76
+ pv.OFF_SCREEN = True
77
+ monkeypatch.setattr(pv.Plotter, 'show', lambda self, *a, **k: None)
78
+ calls = _spy_on_add_volume(monkeypatch)
79
+
80
+ rng = np.random.default_rng(1)
81
+ intensity = rng.poisson(50, size=(3, 20, 20)).astype(np.float32)
82
+ expected_clip = float(np.percentile(intensity, 99))
83
+ intensity[0, 0, 0] = 1_000_000.0 # a hot pixel outlier
84
+ lifetime = np.full((3, 20, 20), 2.0, dtype=np.float32)
85
+
86
+ show(intensity, lifetime)
87
+
88
+ intensity_grid = calls[0][0]
89
+ assert intensity_grid.cell_data['intensity'].max() <= expected_clip + 1e-3
90
+ assert intensity_grid.cell_data['intensity'].min() >= 0
91
+
92
+
93
+ def test_lifetime_scalar_range_defaults_to_2nd_98th_percentile(monkeypatch):
94
+ pv.OFF_SCREEN = True
95
+ monkeypatch.setattr(pv.Plotter, 'show', lambda self, *a, **k: None)
96
+ calls = _spy_on_add_volume(monkeypatch)
97
+
98
+ rng = np.random.default_rng(2)
99
+ intensity = np.full((3, 16, 16), 10.0, dtype=np.float32)
100
+ lifetime = rng.uniform(1.0, 4.0, size=(3, 16, 16)).astype(np.float32)
101
+ expected_vmin = float(np.percentile(lifetime, 2))
102
+ expected_vmax = float(np.percentile(lifetime, 98))
103
+
104
+ show(intensity, lifetime)
105
+
106
+ lifetime_actor = calls[1][2]
107
+ lo, hi = lifetime_actor.mapper.scalar_range
108
+ assert lo == pytest.approx(expected_vmin, abs=1e-3)
109
+ assert hi == pytest.approx(expected_vmax, abs=1e-3)
110
+
111
+
112
+ def test_set_lifetime_colormap_swaps_colors_and_keeps_opacity_ramp():
113
+ grid = pv.ImageData()
114
+ grid.dimensions = (5, 5, 5)
115
+ grid.spacing = (1, 1, 1)
116
+ grid.cell_data['lifetime_ns'] = np.linspace(0, 1, 64).astype(np.float32)
117
+
118
+ plotter = pv.Plotter(off_screen=True)
119
+ actor = plotter.add_volume(grid, scalars='lifetime_ns', cmap='viridis',
120
+ opacity='linear')
121
+ original_colors = actor.mapper.lookup_table.values.copy()
122
+
123
+ for name in COLORMAPS:
124
+ _set_lifetime_colormap(actor, name)
125
+ assert actor.mapper.lookup_table is not None
126
+ # Opacity ramps from transparent to opaque across the table, same
127
+ # shape 'linear' produces for every colormap.
128
+ alpha = actor.mapper.lookup_table.values[:, -1]
129
+ assert alpha[0] < alpha[-1]
130
+
131
+ _set_lifetime_colormap(actor, 'hot')
132
+ assert not np.array_equal(actor.mapper.lookup_table.values, original_colors)
133
+ plotter.close()
@@ -24,6 +24,22 @@ def test_roundtrip(tmp_path):
24
24
  assert np.allclose(l2[1:], lifetime[1:])
25
25
 
26
26
 
27
+ def test_saving_to_same_path_twice_overwrites(tmp_path):
28
+ # The save dialog defaults to the same filename every run, so re-running
29
+ # the tool against a path that already has a store must overwrite it
30
+ # rather than hitting ome-zarr's ContainsArrayError on leftover nodes.
31
+ intensity = np.full((3, 8, 8), 10.0, dtype=np.float32)
32
+ lifetime = np.full((3, 8, 8), 2.0, dtype=np.float32)
33
+ path = str(tmp_path / 'zstack_volume.zarr')
34
+
35
+ save_ome_zarr(path, intensity, lifetime)
36
+ save_ome_zarr(path, intensity * 2, lifetime + 1)
37
+
38
+ i2, l2 = load_ome_zarr(path)
39
+ assert np.allclose(i2, 20.0)
40
+ assert np.allclose(l2, 3.0)
41
+
42
+
27
43
  def test_shape_mismatch_raises(tmp_path):
28
44
  intensity = np.zeros((3, 4, 4), dtype=np.float32)
29
45
  lifetime = np.zeros((3, 5, 5), dtype=np.float32)
@@ -1,79 +0,0 @@
1
- """Standalone popout 3D viewer. Runs in its own process (see plugin.py),
2
- never imports tkinter/flimkit, so it doesn't fight FLIMKit's Tk mainloop
3
- for the main thread.
4
-
5
- Run directly with: python -m flimkit_zstack_explorer.viewer --zarr <path>
6
- """
7
- from __future__ import annotations
8
- import argparse
9
- import sys
10
-
11
- import numpy as np
12
-
13
-
14
- def _missing_pyvista():
15
- sys.stderr.write(
16
- 'flimkit-zstack-explorer: pyvista is required for the 3D viewer.\n'
17
- 'Install it with:\n\n'
18
- ' pip install "flimkit-zstack-explorer[gui]"\n\n')
19
- sys.exit(1)
20
-
21
-
22
- def show(intensity_stack: np.ndarray, lifetime_stack: np.ndarray, *,
23
- voxel_size_um=(1.0, 1.0, 1.0), title: str = 'FLIMKit 3D Z-stack Explorer'):
24
- try:
25
- import pyvista as pv
26
- except ImportError:
27
- _missing_pyvista()
28
- return
29
-
30
- spacing = tuple(float(v) for v in voxel_size_um)
31
-
32
- intensity_grid = pv.ImageData()
33
- intensity_grid.dimensions = np.array(intensity_stack.shape)[::-1] + 1
34
- intensity_grid.spacing = spacing[::-1]
35
- intensity_grid.cell_data['intensity'] = intensity_stack.flatten(order='F')
36
-
37
- # Background (no-fit / no-signal) voxels are pinned to 0, which is below
38
- # any real lifetime in the data, so the 'linear' opacity transfer
39
- # function used below fades them out automatically without needing a
40
- # second per-voxel opacity field (PyVista's add_volume only accepts an
41
- # opacity transfer function keyed by the volume's own scalar range).
42
- lifetime_grid = pv.ImageData()
43
- lifetime_grid.dimensions = np.array(lifetime_stack.shape)[::-1] + 1
44
- lifetime_grid.spacing = spacing[::-1]
45
- finite_lifetime = np.where(np.isfinite(lifetime_stack), lifetime_stack, 0.0)
46
- lifetime_grid.cell_data['lifetime_ns'] = finite_lifetime.flatten(order='F')
47
-
48
- plotter = pv.Plotter(shape=(1, 2), title=title)
49
-
50
- plotter.subplot(0, 0)
51
- plotter.add_volume(intensity_grid, scalars='intensity', cmap='inferno',
52
- opacity='linear', name='intensity')
53
- plotter.add_text('Intensity', font_size=12)
54
- plotter.add_axes()
55
-
56
- plotter.subplot(0, 1)
57
- plotter.add_volume(lifetime_grid, scalars='lifetime_ns', cmap='turbo',
58
- opacity='linear', name='lifetime')
59
- plotter.add_scalar_bar('lifetime (ns)')
60
- plotter.add_text('FLIM (lifetime)', font_size=12)
61
- plotter.add_axes()
62
-
63
- plotter.link_views()
64
- plotter.show()
65
-
66
-
67
- def main(argv=None):
68
- parser = argparse.ArgumentParser(description=__doc__)
69
- parser.add_argument('--zarr', required=True, help='Path to an OME-Zarr store '
70
- 'written by flimkit_zstack_explorer.zarr_io.save_ome_zarr')
71
- args = parser.parse_args(argv)
72
-
73
- from flimkit_zstack_explorer.zarr_io import load_ome_zarr
74
- intensity, lifetime = load_ome_zarr(args.zarr)
75
- show(intensity, lifetime, title=f'FLIMKit 3D Z-stack Explorer - {args.zarr}')
76
-
77
-
78
- if __name__ == '__main__':
79
- main()
@@ -1,52 +0,0 @@
1
- """Verifies the Tools menu entry is a *guarded* action (per the approved
2
- design: always visible, but refuses to run without a loaded z-stack)
3
- rather than actually disabled/hidden."""
4
- import pytest
5
-
6
- flimkit = pytest.importorskip('flimkit', reason='requires a FLIMKit install')
7
-
8
- from flimkit_zstack_explorer.plugin import open_3d_explorer
9
-
10
-
11
- class NoZStackApp:
12
- root = None
13
- _fov_preview = None
14
-
15
-
16
- class EmptyZStackApp:
17
- root = None
18
-
19
- class _fov_preview:
20
- _zstack = []
21
-
22
-
23
- def test_guard_shows_message_when_no_zstack(monkeypatch):
24
- calls = []
25
- monkeypatch.setattr('tkinter.messagebox.showinfo',
26
- lambda *a, **k: calls.append((a, k)))
27
-
28
- open_3d_explorer(NoZStackApp())
29
-
30
- assert len(calls) == 1
31
- assert 'z-stack' in calls[0][0][0].lower() or 'z-stack' in calls[0][0][1].lower()
32
-
33
-
34
- def test_guard_shows_message_when_zstack_empty(monkeypatch):
35
- calls = []
36
- monkeypatch.setattr('tkinter.messagebox.showinfo',
37
- lambda *a, **k: calls.append((a, k)))
38
-
39
- open_3d_explorer(EmptyZStackApp())
40
-
41
- assert len(calls) == 1
42
-
43
-
44
- def test_does_not_prompt_for_save_path_without_zstack(monkeypatch):
45
- monkeypatch.setattr('tkinter.messagebox.showinfo', lambda *a, **k: None)
46
-
47
- def fail_if_called(*a, **k):
48
- raise AssertionError('should not prompt for a save path without a z-stack')
49
-
50
- monkeypatch.setattr('tkinter.filedialog.asksaveasfilename', fail_if_called)
51
-
52
- open_3d_explorer(NoZStackApp())
@@ -1,20 +0,0 @@
1
- import numpy as np
2
- import pytest
3
-
4
- pytest.importorskip('pyvista', reason='requires the [gui] extra')
5
-
6
- import pyvista as pv
7
-
8
- from flimkit_zstack_explorer.viewer import show
9
-
10
-
11
- def test_show_builds_offscreen_without_error(monkeypatch):
12
- pv.OFF_SCREEN = True
13
- monkeypatch.setattr(pv.Plotter, 'show', lambda self, *a, **k: None)
14
-
15
- rng = np.random.default_rng(0)
16
- intensity = rng.poisson(50, size=(4, 12, 16)).astype(np.float32)
17
- lifetime = rng.uniform(1.5, 3.5, size=(4, 12, 16)).astype(np.float32)
18
- lifetime[0] = np.nan
19
-
20
- show(intensity, lifetime, voxel_size_um=(2.0, 0.5, 0.5))