flimkit-zstack-explorer 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FLIMKit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: flimkit-zstack-explorer
3
+ Version: 0.1.0
4
+ Summary: 3D intensity and FLIM volume explorer for FLIMKit z-stacks
5
+ Author-email: Alex Hunt <alexander.hunt@ed.ac.uk>
6
+ License-Expression: MIT
7
+ Keywords: FLIM,fluorescence,lifetime,z-stack,3D,zarr,pyvista
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy
12
+ Requires-Dist: zarr>=2.16
13
+ Requires-Dist: ome-zarr>=0.9
14
+ Provides-Extra: gui
15
+ Requires-Dist: pyvista>=0.44; extra == "gui"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # flimkit-zstack-explorer
21
+
22
+ 3D intensity and FLIM volume explorer for FLIMKit z-stacks. Takes a z-stack
23
+ you've already loaded or fitted in FLIMKit, stacks its per-slice intensity
24
+ and lifetime maps into `(Z, Y, X)` volumes, saves them as an OME-Zarr store,
25
+ and opens a popout 3D viewer with the two volumes side by side.
26
+
27
+ ## What it does
28
+
29
+ - Adds **Tools > 3D Z-stack Explorer...** to FLIMKit.
30
+ - Only does anything once a z-stack is loaded (FOV mode > Z-stack); otherwise
31
+ it tells you to load one first.
32
+ - Walks every slice in the loaded z-stack through the FOV preview panel's own
33
+ display code (the same path used when you drag the z-slider), so the
34
+ volume matches what's on screen slice by slice.
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).
37
+ - Opens a [PyVista](https://pyvista.org) volume viewer in its own process —
38
+ intensity on the left, FLIM lifetime on the right, camera-linked — so it
39
+ doesn't compete with FLIMKit's Tkinter UI for the main thread.
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install flimkit-zstack-explorer[gui]
45
+ ```
46
+
47
+ `[gui]` adds PyVista, which does the actual 3D rendering. Without it, the
48
+ volume-building and OME-Zarr saving still work, but the viewer step will
49
+ tell you it's missing and how to install it.
50
+
51
+ FLIMKit discovers this through the `flimkit.plugins` entry point, so there's
52
+ nothing else to wire up. On start it appears under Tools.
53
+
54
+ ## From code
55
+
56
+ ```python
57
+ from flimkit_zstack_explorer import build_zstack_volumes, save_ome_zarr, load_ome_zarr
58
+
59
+ # app is FLIMKit's running App instance, with a z-stack loaded in its
60
+ # FOV preview panel
61
+ intensity_stack, lifetime_stack = build_zstack_volumes(app)
62
+ save_ome_zarr('volume.zarr', intensity_stack, lifetime_stack,
63
+ voxel_size_um=(2.0, 0.5, 0.5))
64
+
65
+ intensity_stack, lifetime_stack = load_ome_zarr('volume.zarr')
66
+ ```
67
+
68
+ To view a store outside FLIMKit:
69
+
70
+ ```
71
+ python -m flimkit_zstack_explorer.viewer --zarr volume.zarr
72
+ ```
73
+
74
+ ## Why a separate process for the viewer
75
+
76
+ FLIMKit's UI is Tkinter; PyVista's is VTK. Running VTK's render loop on a
77
+ background thread inside a Tkinter process is unreliable, especially on
78
+ macOS, where GUI toolkits generally expect to own the main thread. Building
79
+ the volume and saving to disk happens inside FLIMKit; the popout viewer is a
80
+ plain `python -m flimkit_zstack_explorer.viewer` subprocess reading the
81
+ saved store, so it gets its own main thread and doesn't touch Tkinter at
82
+ all.
83
+
84
+ ## Development
85
+
86
+ ```
87
+ pip install -e .[gui,test]
88
+ pytest
89
+ ```
90
+
91
+ `tests/test_volumes.py` exercises the z-stack-to-array logic against a fake
92
+ FOV preview panel, so it runs without Tkinter or a real FLIMKit install.
93
+ `tests/test_zarr_io.py` round-trips real arrays through zarr/ome-zarr.
@@ -0,0 +1,74 @@
1
+ # flimkit-zstack-explorer
2
+
3
+ 3D intensity and FLIM volume explorer for FLIMKit z-stacks. Takes a z-stack
4
+ you've already loaded or fitted in FLIMKit, stacks its per-slice intensity
5
+ and lifetime maps into `(Z, Y, X)` volumes, saves them as an OME-Zarr store,
6
+ and opens a popout 3D viewer with the two volumes side by side.
7
+
8
+ ## What it does
9
+
10
+ - Adds **Tools > 3D Z-stack Explorer...** to FLIMKit.
11
+ - Only does anything once a z-stack is loaded (FOV mode > Z-stack); otherwise
12
+ it tells you to load one first.
13
+ - Walks every slice in the loaded z-stack through the FOV preview panel's own
14
+ display code (the same path used when you drag the z-slider), so the
15
+ volume matches what's on screen slice by slice.
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).
18
+ - Opens a [PyVista](https://pyvista.org) volume viewer in its own process —
19
+ intensity on the left, FLIM lifetime on the right, camera-linked — so it
20
+ doesn't compete with FLIMKit's Tkinter UI for the main thread.
21
+
22
+ ## Install
23
+
24
+ ```
25
+ pip install flimkit-zstack-explorer[gui]
26
+ ```
27
+
28
+ `[gui]` adds PyVista, which does the actual 3D rendering. Without it, the
29
+ volume-building and OME-Zarr saving still work, but the viewer step will
30
+ tell you it's missing and how to install it.
31
+
32
+ FLIMKit discovers this through the `flimkit.plugins` entry point, so there's
33
+ nothing else to wire up. On start it appears under Tools.
34
+
35
+ ## From code
36
+
37
+ ```python
38
+ from flimkit_zstack_explorer import build_zstack_volumes, save_ome_zarr, load_ome_zarr
39
+
40
+ # app is FLIMKit's running App instance, with a z-stack loaded in its
41
+ # FOV preview panel
42
+ intensity_stack, lifetime_stack = build_zstack_volumes(app)
43
+ save_ome_zarr('volume.zarr', intensity_stack, lifetime_stack,
44
+ voxel_size_um=(2.0, 0.5, 0.5))
45
+
46
+ intensity_stack, lifetime_stack = load_ome_zarr('volume.zarr')
47
+ ```
48
+
49
+ To view a store outside FLIMKit:
50
+
51
+ ```
52
+ python -m flimkit_zstack_explorer.viewer --zarr volume.zarr
53
+ ```
54
+
55
+ ## Why a separate process for the viewer
56
+
57
+ FLIMKit's UI is Tkinter; PyVista's is VTK. Running VTK's render loop on a
58
+ background thread inside a Tkinter process is unreliable, especially on
59
+ macOS, where GUI toolkits generally expect to own the main thread. Building
60
+ the volume and saving to disk happens inside FLIMKit; the popout viewer is a
61
+ plain `python -m flimkit_zstack_explorer.viewer` subprocess reading the
62
+ saved store, so it gets its own main thread and doesn't touch Tkinter at
63
+ all.
64
+
65
+ ## Development
66
+
67
+ ```
68
+ pip install -e .[gui,test]
69
+ pytest
70
+ ```
71
+
72
+ `tests/test_volumes.py` exercises the z-stack-to-array logic against a fake
73
+ FOV preview panel, so it runs without Tkinter or a real FLIMKit install.
74
+ `tests/test_zarr_io.py` round-trips real arrays through zarr/ome-zarr.
@@ -0,0 +1,19 @@
1
+ FLIMKIT_PLUGIN_API = 1
2
+
3
+ from flimkit_zstack_explorer.volumes import build_zstack_volumes
4
+ from flimkit_zstack_explorer.zarr_io import save_ome_zarr, load_ome_zarr
5
+
6
+ registered = False
7
+ register_error = None
8
+ try:
9
+ from flimkit_zstack_explorer import plugin as _plugin
10
+ registered = True
11
+ except ImportError as exc:
12
+ register_error = exc
13
+
14
+ __all__ = [
15
+ 'FLIMKIT_PLUGIN_API',
16
+ 'build_zstack_volumes',
17
+ 'save_ome_zarr',
18
+ 'load_ome_zarr',
19
+ ]
@@ -0,0 +1,101 @@
1
+ from flimkit.plugins import tool
2
+
3
+
4
+ def _parent(app):
5
+ return getattr(app, 'root', None) or app
6
+
7
+
8
+ def _deps_ok():
9
+ try:
10
+ import zarr # noqa: F401
11
+ import ome_zarr # noqa: F401
12
+ except ImportError:
13
+ return False
14
+ return True
15
+
16
+
17
+ @tool(id='zstack_3d_explorer', label='3D Z-stack Explorer...', menu='Tools', order=850)
18
+ def open_3d_explorer(app):
19
+ from tkinter import filedialog, messagebox
20
+
21
+ parent = _parent(app)
22
+ panel = getattr(app, '_fov_preview', None)
23
+ if not getattr(panel, '_zstack', None):
24
+ messagebox.showinfo(
25
+ 'No z-stack loaded',
26
+ 'This tool builds a 3D volume from a loaded z-stack.\n\n'
27
+ 'Load or fit a z-stack first (FOV mode > Z-stack), then run '
28
+ 'this tool again.',
29
+ parent=parent)
30
+ return
31
+
32
+ if not _deps_ok():
33
+ messagebox.showerror(
34
+ 'Missing dependency',
35
+ 'The 3D Z-stack Explorer needs zarr and ome-zarr.\n\n'
36
+ 'Install with:\n\n pip install flimkit-zstack-explorer',
37
+ parent=parent)
38
+ return
39
+
40
+ out_path = filedialog.asksaveasfilename(
41
+ parent=parent, title='Save 3D volume as OME-Zarr',
42
+ defaultextension='.zarr', initialfile='zstack_volume.zarr',
43
+ filetypes=[('OME-Zarr store', '*.zarr'), ('All files', '*')])
44
+ if not out_path:
45
+ return
46
+
47
+ _run_build_and_view(app, parent, out_path)
48
+
49
+
50
+ def _run_build_and_view(app, parent, out_path):
51
+ import threading
52
+ from flimkit.UI.progress_window import ProgressWindow
53
+
54
+ n_slices = len(app._fov_preview._zstack)
55
+ win = ProgressWindow(parent, task_name='Building 3D volume')
56
+ win.set_progress(0, maximum=n_slices)
57
+
58
+ def on_progress(i, n):
59
+ app.root.after(0, lambda: win.set_progress(i, maximum=n))
60
+
61
+ def worker():
62
+ from flimkit_zstack_explorer.volumes import build_zstack_volumes
63
+ from flimkit_zstack_explorer.zarr_io import save_ome_zarr
64
+
65
+ try:
66
+ intensity, lifetime = build_zstack_volumes(app, progress=on_progress)
67
+ app.root.after(0, lambda: win.set_status('Saving OME-Zarr...'))
68
+ save_ome_zarr(out_path, intensity, lifetime)
69
+ except Exception as exc:
70
+ app.root.after(0, win.close)
71
+ app.root.after(0, lambda exc=exc: _show_error(parent, exc))
72
+ return
73
+ app.root.after(0, win.close)
74
+ app.root.after(0, lambda: _launch_viewer(out_path, parent))
75
+
76
+ threading.Thread(target=worker, daemon=True).start()
77
+
78
+
79
+ def _show_error(parent, exc):
80
+ from tkinter import messagebox
81
+ messagebox.showerror('3D Z-stack Explorer failed',
82
+ f'{type(exc).__name__}: {exc}', parent=parent)
83
+
84
+
85
+ def _launch_viewer(zarr_path, parent):
86
+ import subprocess
87
+ import sys
88
+ from tkinter import messagebox
89
+
90
+ try:
91
+ subprocess.Popen([
92
+ sys.executable, '-m', 'flimkit_zstack_explorer.viewer',
93
+ '--zarr', str(zarr_path),
94
+ ])
95
+ except Exception as exc:
96
+ messagebox.showerror(
97
+ '3D Z-stack Explorer',
98
+ f'Volume saved to {zarr_path}, but the viewer failed to start:\n'
99
+ f'{type(exc).__name__}: {exc}\n\n'
100
+ 'Install the viewer with: pip install "flimkit-zstack-explorer[gui]"',
101
+ parent=parent)
@@ -0,0 +1,79 @@
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()
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+ import threading
3
+ from typing import Callable, Optional, Tuple
4
+
5
+ import numpy as np
6
+
7
+
8
+ def _run_on_ui_thread(app, callback: Callable):
9
+ """Run `callback` on the Tkinter main thread and block for its result.
10
+
11
+ FLIMKit's own FOV preview panel is Tkinter/matplotlib state and is not
12
+ thread-safe, so anything that touches it (loading a slice, reading back
13
+ the rendered maps) has to happen on the main thread even when this
14
+ function is called from a worker thread.
15
+ """
16
+ if threading.current_thread() is threading.main_thread():
17
+ return callback()
18
+ root = getattr(app, 'root', None)
19
+ if root is None or not callable(getattr(root, 'after', None)):
20
+ raise RuntimeError('FLIMKit UI is not available')
21
+ done = threading.Event()
22
+ outcome = {}
23
+
24
+ def run():
25
+ try:
26
+ outcome['value'] = callback()
27
+ except BaseException as error:
28
+ outcome['error'] = error
29
+ finally:
30
+ done.set()
31
+
32
+ root.after(0, run)
33
+ done.wait()
34
+ if 'error' in outcome:
35
+ raise outcome['error']
36
+ return outcome.get('value')
37
+
38
+
39
+ def build_zstack_volumes(
40
+ app,
41
+ progress: Optional[Callable[[int, int], None]] = None,
42
+ ) -> Tuple[np.ndarray, np.ndarray]:
43
+ """Build (intensity_stack, lifetime_stack) arrays, each shape (Z, Y, X),
44
+ from the z-stack currently loaded in FLIMKit's FOV preview panel.
45
+
46
+ Each z-slice is displayed through the panel's own `display_fit_results` /
47
+ `load_fov` (the same code path the app uses when you drag the z-slider)
48
+ so the maps match what you'd see on screen, rather than re-deriving the
49
+ intensity/lifetime math here. The panel is left showing whatever slice it
50
+ was on before this ran. Raises RuntimeError if no z-stack is loaded, or
51
+ if slices don't all share one shape.
52
+
53
+ Safe to call from any thread.
54
+ """
55
+ def get_zstack():
56
+ panel = getattr(app, '_fov_preview', None)
57
+ zstack = getattr(panel, '_zstack', None) if panel is not None else None
58
+ return panel, (list(zstack) if zstack else None)
59
+
60
+ panel, zstack = _run_on_ui_thread(app, get_zstack)
61
+ if not zstack:
62
+ raise RuntimeError('No z-stack is loaded in FLIMKit')
63
+
64
+ def show_slice(desc):
65
+ fit_result = desc.get('fit_result')
66
+ if fit_result is not None:
67
+ panel.display_fit_results(desc.get('ptu_path'), fit_result, _keep_zstack=True)
68
+ else:
69
+ panel.load_fov(desc.get('ptu_path'), _keep_zstack=True)
70
+ intensity = getattr(panel, '_intensity_map', None)
71
+ lifetime = getattr(panel, '_lifetime_map', None)
72
+ intensity = (np.array(intensity, dtype=np.float32, copy=True)
73
+ if intensity is not None else None)
74
+ lifetime = (np.array(lifetime, dtype=np.float32, copy=True)
75
+ if lifetime is not None else None)
76
+ return intensity, lifetime
77
+
78
+ original_i = _run_on_ui_thread(app, lambda: getattr(panel, '_z_i', 0))
79
+ intensity_slices = []
80
+ lifetime_slices = []
81
+ try:
82
+ for i, desc in enumerate(zstack):
83
+ intensity, lifetime = _run_on_ui_thread(app, lambda desc=desc: show_slice(desc))
84
+ if intensity is None:
85
+ raise RuntimeError(f'z-slice {i} ({desc.get("z", i)}) has no intensity image')
86
+ intensity_slices.append(intensity)
87
+ lifetime_slices.append(
88
+ lifetime if lifetime is not None
89
+ else np.full(intensity.shape[:2], np.nan, dtype=np.float32))
90
+ if progress is not None:
91
+ progress(i + 1, len(zstack))
92
+ finally:
93
+ def restore():
94
+ panel._z_i = original_i
95
+ panel._show_zstack_slice(original_i)
96
+ sync = getattr(panel, '_sync_z_slider', None)
97
+ if callable(sync):
98
+ sync()
99
+ _run_on_ui_thread(app, restore)
100
+
101
+ shape = intensity_slices[0].shape
102
+ for name, slices in (('intensity', intensity_slices), ('lifetime', lifetime_slices)):
103
+ for i, arr in enumerate(slices):
104
+ if arr.shape != shape:
105
+ raise RuntimeError(
106
+ f'{name} slice {i} has shape {arr.shape}, expected {shape} '
107
+ '(all z-slices must be the same size)')
108
+
109
+ return np.stack(intensity_slices), np.stack(lifetime_slices)
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+ from typing import Optional, Sequence
3
+
4
+ import numpy as np
5
+
6
+ CHANNELS = ('intensity', 'lifetime_ns')
7
+
8
+
9
+ def save_ome_zarr(
10
+ path: str,
11
+ intensity_stack: np.ndarray,
12
+ lifetime_stack: np.ndarray,
13
+ *,
14
+ voxel_size_um: Optional[Sequence[float]] = None,
15
+ ) -> str:
16
+ """Write intensity + lifetime volumes as a 2-channel OME-Zarr store.
17
+
18
+ `intensity_stack` and `lifetime_stack` must both be (Z, Y, X) and share
19
+ a shape. Channel 0 is intensity, channel 1 is lifetime in ns (NaN where
20
+ a slice had no fit). `voxel_size_um`, if given, is (z, y, x) physical
21
+ spacing used for the store's scale metadata; otherwise voxels are unit
22
+ spacing. Returns `path`.
23
+ """
24
+ if intensity_stack.shape != lifetime_stack.shape:
25
+ raise ValueError(
26
+ f'intensity {intensity_stack.shape} and lifetime {lifetime_stack.shape} '
27
+ 'stacks must have the same shape')
28
+ if intensity_stack.ndim != 3:
29
+ raise ValueError(f'expected (Z, Y, X) stacks, got shape {intensity_stack.shape}')
30
+
31
+ import zarr
32
+ from ome_zarr.io import parse_url
33
+ from ome_zarr.writer import write_image
34
+
35
+ data = np.stack([
36
+ np.nan_to_num(intensity_stack, nan=0.0).astype(np.float32),
37
+ lifetime_stack.astype(np.float32),
38
+ ])
39
+
40
+ store = parse_url(str(path), mode='w').store
41
+ root = zarr.group(store=store)
42
+
43
+ nz, ny, nx = intensity_stack.shape
44
+ chunks = (1, 1, min(256, ny), min(256, nx))
45
+ write_image(image=data, group=root, axes='czyx', scaler=None,
46
+ storage_options=dict(chunks=chunks))
47
+
48
+ scale = [1.0] + [float(v) for v in (voxel_size_um or (1.0, 1.0, 1.0))]
49
+ root.attrs['flimkit_zstack_explorer'] = {
50
+ 'channels': list(CHANNELS),
51
+ 'voxel_size_um': scale[1:],
52
+ }
53
+ return str(path)
54
+
55
+
56
+ def load_ome_zarr(path: str):
57
+ """Read back a store written by `save_ome_zarr`.
58
+
59
+ Returns (intensity_stack, lifetime_stack), each (Z, Y, X) numpy arrays.
60
+ """
61
+ import zarr
62
+ from ome_zarr.io import parse_url
63
+ from ome_zarr.reader import Reader
64
+
65
+ reader = Reader(parse_url(str(path)))
66
+ node = next(iter(reader()))
67
+ data = np.asarray(node.data[0])
68
+ if data.shape[0] != 2:
69
+ raise ValueError(f'expected a 2-channel store (intensity, lifetime), got shape {data.shape}')
70
+ return data[0], data[1]
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: flimkit-zstack-explorer
3
+ Version: 0.1.0
4
+ Summary: 3D intensity and FLIM volume explorer for FLIMKit z-stacks
5
+ Author-email: Alex Hunt <alexander.hunt@ed.ac.uk>
6
+ License-Expression: MIT
7
+ Keywords: FLIM,fluorescence,lifetime,z-stack,3D,zarr,pyvista
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy
12
+ Requires-Dist: zarr>=2.16
13
+ Requires-Dist: ome-zarr>=0.9
14
+ Provides-Extra: gui
15
+ Requires-Dist: pyvista>=0.44; extra == "gui"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # flimkit-zstack-explorer
21
+
22
+ 3D intensity and FLIM volume explorer for FLIMKit z-stacks. Takes a z-stack
23
+ you've already loaded or fitted in FLIMKit, stacks its per-slice intensity
24
+ and lifetime maps into `(Z, Y, X)` volumes, saves them as an OME-Zarr store,
25
+ and opens a popout 3D viewer with the two volumes side by side.
26
+
27
+ ## What it does
28
+
29
+ - Adds **Tools > 3D Z-stack Explorer...** to FLIMKit.
30
+ - Only does anything once a z-stack is loaded (FOV mode > Z-stack); otherwise
31
+ it tells you to load one first.
32
+ - Walks every slice in the loaded z-stack through the FOV preview panel's own
33
+ display code (the same path used when you drag the z-slider), so the
34
+ volume matches what's on screen slice by slice.
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).
37
+ - Opens a [PyVista](https://pyvista.org) volume viewer in its own process —
38
+ intensity on the left, FLIM lifetime on the right, camera-linked — so it
39
+ doesn't compete with FLIMKit's Tkinter UI for the main thread.
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install flimkit-zstack-explorer[gui]
45
+ ```
46
+
47
+ `[gui]` adds PyVista, which does the actual 3D rendering. Without it, the
48
+ volume-building and OME-Zarr saving still work, but the viewer step will
49
+ tell you it's missing and how to install it.
50
+
51
+ FLIMKit discovers this through the `flimkit.plugins` entry point, so there's
52
+ nothing else to wire up. On start it appears under Tools.
53
+
54
+ ## From code
55
+
56
+ ```python
57
+ from flimkit_zstack_explorer import build_zstack_volumes, save_ome_zarr, load_ome_zarr
58
+
59
+ # app is FLIMKit's running App instance, with a z-stack loaded in its
60
+ # FOV preview panel
61
+ intensity_stack, lifetime_stack = build_zstack_volumes(app)
62
+ save_ome_zarr('volume.zarr', intensity_stack, lifetime_stack,
63
+ voxel_size_um=(2.0, 0.5, 0.5))
64
+
65
+ intensity_stack, lifetime_stack = load_ome_zarr('volume.zarr')
66
+ ```
67
+
68
+ To view a store outside FLIMKit:
69
+
70
+ ```
71
+ python -m flimkit_zstack_explorer.viewer --zarr volume.zarr
72
+ ```
73
+
74
+ ## Why a separate process for the viewer
75
+
76
+ FLIMKit's UI is Tkinter; PyVista's is VTK. Running VTK's render loop on a
77
+ background thread inside a Tkinter process is unreliable, especially on
78
+ macOS, where GUI toolkits generally expect to own the main thread. Building
79
+ the volume and saving to disk happens inside FLIMKit; the popout viewer is a
80
+ plain `python -m flimkit_zstack_explorer.viewer` subprocess reading the
81
+ saved store, so it gets its own main thread and doesn't touch Tkinter at
82
+ all.
83
+
84
+ ## Development
85
+
86
+ ```
87
+ pip install -e .[gui,test]
88
+ pytest
89
+ ```
90
+
91
+ `tests/test_volumes.py` exercises the z-stack-to-array logic against a fake
92
+ FOV preview panel, so it runs without Tkinter or a real FLIMKit install.
93
+ `tests/test_zarr_io.py` round-trips real arrays through zarr/ome-zarr.
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ flimkit_zstack_explorer/__init__.py
5
+ flimkit_zstack_explorer/plugin.py
6
+ flimkit_zstack_explorer/viewer.py
7
+ flimkit_zstack_explorer/volumes.py
8
+ flimkit_zstack_explorer/zarr_io.py
9
+ flimkit_zstack_explorer.egg-info/PKG-INFO
10
+ flimkit_zstack_explorer.egg-info/SOURCES.txt
11
+ flimkit_zstack_explorer.egg-info/dependency_links.txt
12
+ flimkit_zstack_explorer.egg-info/entry_points.txt
13
+ flimkit_zstack_explorer.egg-info/requires.txt
14
+ flimkit_zstack_explorer.egg-info/top_level.txt
15
+ tests/test_plugin_guard.py
16
+ tests/test_viewer.py
17
+ tests/test_volumes.py
18
+ tests/test_zarr_io.py
@@ -0,0 +1,2 @@
1
+ [flimkit.plugins]
2
+ flimkit_zstack_explorer = flimkit_zstack_explorer
@@ -0,0 +1,9 @@
1
+ numpy
2
+ zarr>=2.16
3
+ ome-zarr>=0.9
4
+
5
+ [gui]
6
+ pyvista>=0.44
7
+
8
+ [test]
9
+ pytest
@@ -0,0 +1 @@
1
+ flimkit_zstack_explorer
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ['setuptools>=68']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [project]
6
+ name = 'flimkit-zstack-explorer'
7
+ version = '0.1.0'
8
+ description = '3D intensity and FLIM volume explorer for FLIMKit z-stacks'
9
+ readme = 'README.md'
10
+ requires-python = '>=3.12'
11
+ license = 'MIT'
12
+ authors = [
13
+ {name = 'Alex Hunt', email = 'alexander.hunt@ed.ac.uk'},
14
+ ]
15
+ keywords = ['FLIM', 'fluorescence', 'lifetime', 'z-stack', '3D', 'zarr', 'pyvista']
16
+ dependencies = [
17
+ 'numpy',
18
+ 'zarr>=2.16',
19
+ 'ome-zarr>=0.9',
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ gui = ['pyvista>=0.44']
24
+ test = ['pytest']
25
+
26
+ [project.entry-points.'flimkit.plugins']
27
+ flimkit_zstack_explorer = 'flimkit_zstack_explorer'
28
+
29
+ [tool.setuptools.packages.find]
30
+ include = ['flimkit_zstack_explorer*']
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,52 @@
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())
@@ -0,0 +1,20 @@
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))
@@ -0,0 +1,106 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from flimkit_zstack_explorer.volumes import build_zstack_volumes
5
+
6
+
7
+ class FakePanel:
8
+ """Stands in for flimkit.UI.fov_preview.FOVPreviewPanel: enough of its
9
+ z-stack surface for build_zstack_volumes to drive without Tkinter."""
10
+
11
+ def __init__(self, zstack):
12
+ self._zstack = zstack
13
+ self._z_i = 0
14
+ self._intensity_map = None
15
+ self._lifetime_map = None
16
+ self.shown = []
17
+
18
+ def display_fit_results(self, ptu_path, fit_result, _keep_zstack=False):
19
+ self._intensity_map = fit_result['intensity']
20
+ self._lifetime_map = fit_result.get('lifetime')
21
+ self.shown.append(ptu_path)
22
+
23
+ def load_fov(self, ptu_path, _keep_zstack=False):
24
+ self._intensity_map = np.ones((4, 4), dtype=np.float32)
25
+ self._lifetime_map = None
26
+ self.shown.append(ptu_path)
27
+
28
+ def _show_zstack_slice(self, i):
29
+ self._z_i = i
30
+ desc = self._zstack[i]
31
+ if desc.get('fit_result') is not None:
32
+ self.display_fit_results(desc.get('ptu_path'), desc['fit_result'], _keep_zstack=True)
33
+ else:
34
+ self.load_fov(desc.get('ptu_path'), _keep_zstack=True)
35
+
36
+
37
+ class FakeApp:
38
+ def __init__(self, panel):
39
+ self._fov_preview = panel
40
+ self.root = None
41
+
42
+
43
+ def _slice(z, intensity, lifetime=None):
44
+ fit_result = {'intensity': np.array(intensity, dtype=np.float32)}
45
+ if lifetime is not None:
46
+ fit_result['lifetime'] = np.array(lifetime, dtype=np.float32)
47
+ return {'z': z, 'ptu_path': f'slice_{z}.ptu', 'fit_result': fit_result}
48
+
49
+
50
+ def test_builds_stack_from_zstack_slices():
51
+ intensity = np.full((3, 3), 10.0)
52
+ lifetime = np.full((3, 3), 2.5)
53
+ zstack = [_slice(0, intensity, lifetime), _slice(1, intensity * 2, lifetime + 1)]
54
+ panel = FakePanel(zstack)
55
+ app = FakeApp(panel)
56
+
57
+ intensity_stack, lifetime_stack = build_zstack_volumes(app)
58
+
59
+ assert intensity_stack.shape == (2, 3, 3)
60
+ assert lifetime_stack.shape == (2, 3, 3)
61
+ assert np.allclose(intensity_stack[0], 10.0)
62
+ assert np.allclose(intensity_stack[1], 20.0)
63
+ assert np.allclose(lifetime_stack[1], 3.5)
64
+
65
+
66
+ def test_missing_lifetime_becomes_nan_plane():
67
+ intensity = np.full((2, 2), 5.0)
68
+ zstack = [_slice(0, intensity, lifetime=None)]
69
+ panel = FakePanel(zstack)
70
+ app = FakeApp(panel)
71
+
72
+ _, lifetime_stack = build_zstack_volumes(app)
73
+
74
+ assert lifetime_stack.shape == (1, 2, 2)
75
+ assert np.all(np.isnan(lifetime_stack[0]))
76
+
77
+
78
+ def test_no_zstack_raises():
79
+ panel = FakePanel(zstack=None)
80
+ app = FakeApp(panel)
81
+
82
+ with pytest.raises(RuntimeError, match='No z-stack'):
83
+ build_zstack_volumes(app)
84
+
85
+
86
+ def test_mismatched_slice_shapes_raise():
87
+ zstack = [
88
+ _slice(0, np.zeros((3, 3))),
89
+ _slice(1, np.zeros((4, 4))),
90
+ ]
91
+ panel = FakePanel(zstack)
92
+ app = FakeApp(panel)
93
+
94
+ with pytest.raises(RuntimeError, match='same size'):
95
+ build_zstack_volumes(app)
96
+
97
+
98
+ def test_restores_original_slice_after_building():
99
+ zstack = [_slice(0, np.zeros((2, 2))), _slice(1, np.ones((2, 2)))]
100
+ panel = FakePanel(zstack)
101
+ panel._z_i = 1
102
+ app = FakeApp(panel)
103
+
104
+ build_zstack_volumes(app)
105
+
106
+ assert panel._z_i == 1
@@ -0,0 +1,38 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from flimkit_zstack_explorer.zarr_io import save_ome_zarr, load_ome_zarr
5
+
6
+
7
+ def test_roundtrip(tmp_path):
8
+ rng = np.random.default_rng(0)
9
+ intensity = rng.poisson(50, size=(4, 16, 20)).astype(np.float32)
10
+ lifetime = rng.uniform(1.5, 3.5, size=(4, 16, 20)).astype(np.float32)
11
+ lifetime[0] = np.nan
12
+
13
+ path = str(tmp_path / 'volume.zarr')
14
+ save_ome_zarr(path, intensity, lifetime, voxel_size_um=(2.0, 0.5, 0.5))
15
+
16
+ i2, l2 = load_ome_zarr(path)
17
+ assert i2.shape == intensity.shape
18
+ assert l2.shape == lifetime.shape
19
+ assert np.allclose(i2, intensity)
20
+ # NaNs in the source lifetime slice are written as 0 by save_ome_zarr's
21
+ # nan_to_num pass on intensity only; lifetime itself is written as-is,
22
+ # so slice 0 round-trips as NaN.
23
+ assert np.all(np.isnan(l2[0]))
24
+ assert np.allclose(l2[1:], lifetime[1:])
25
+
26
+
27
+ def test_shape_mismatch_raises(tmp_path):
28
+ intensity = np.zeros((3, 4, 4), dtype=np.float32)
29
+ lifetime = np.zeros((3, 5, 5), dtype=np.float32)
30
+ with pytest.raises(ValueError, match='same shape'):
31
+ save_ome_zarr(str(tmp_path / 'bad.zarr'), intensity, lifetime)
32
+
33
+
34
+ def test_requires_3d_stacks(tmp_path):
35
+ intensity = np.zeros((4, 4), dtype=np.float32)
36
+ lifetime = np.zeros((4, 4), dtype=np.float32)
37
+ with pytest.raises(ValueError, match='Z, Y, X'):
38
+ save_ome_zarr(str(tmp_path / 'bad2.zarr'), intensity, lifetime)