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.
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/PKG-INFO +28 -3
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/README.md +27 -2
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/plugin.py +30 -0
- flimkit_zstack_explorer-0.2.0/flimkit_zstack_explorer/viewer.py +152 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/zarr_io.py +10 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/PKG-INFO +28 -3
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/pyproject.toml +1 -1
- flimkit_zstack_explorer-0.2.0/tests/test_plugin_guard.py +101 -0
- flimkit_zstack_explorer-0.2.0/tests/test_viewer.py +133 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/tests/test_zarr_io.py +16 -0
- flimkit_zstack_explorer-0.1.0/flimkit_zstack_explorer/viewer.py +0 -79
- flimkit_zstack_explorer-0.1.0/tests/test_plugin_guard.py +0 -52
- flimkit_zstack_explorer-0.1.0/tests/test_viewer.py +0 -20
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/LICENSE +0 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/__init__.py +0 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/volumes.py +0 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/SOURCES.txt +0 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/dependency_links.txt +0 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/entry_points.txt +0 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/requires.txt +0 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer.egg-info/top_level.txt +0 -0
- {flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/setup.cfg +0 -0
- {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.
|
|
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
|
|
{flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/plugin.py
RENAMED
|
@@ -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()
|
{flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/zarr_io.py
RENAMED
|
@@ -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.
|
|
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
|
|
|
@@ -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))
|
|
File without changes
|
{flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/__init__.py
RENAMED
|
File without changes
|
{flimkit_zstack_explorer-0.1.0 → flimkit_zstack_explorer-0.2.0}/flimkit_zstack_explorer/volumes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|