flimkit-zstack-explorer 0.1.0__py3-none-any.whl
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/__init__.py +19 -0
- flimkit_zstack_explorer/plugin.py +101 -0
- flimkit_zstack_explorer/viewer.py +79 -0
- flimkit_zstack_explorer/volumes.py +109 -0
- flimkit_zstack_explorer/zarr_io.py +70 -0
- flimkit_zstack_explorer-0.1.0.dist-info/METADATA +93 -0
- flimkit_zstack_explorer-0.1.0.dist-info/RECORD +11 -0
- flimkit_zstack_explorer-0.1.0.dist-info/WHEEL +5 -0
- flimkit_zstack_explorer-0.1.0.dist-info/entry_points.txt +2 -0
- flimkit_zstack_explorer-0.1.0.dist-info/licenses/LICENSE +21 -0
- flimkit_zstack_explorer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,11 @@
|
|
|
1
|
+
flimkit_zstack_explorer/__init__.py,sha256=pW_qMjT80Mnb1aMZCHhP6GGPgXBF7ffPka24clno5jY,452
|
|
2
|
+
flimkit_zstack_explorer/plugin.py,sha256=9F2UDuJMUayyj3PskH8bqYZCvffiag4r0QLWaiXIhmk,3298
|
|
3
|
+
flimkit_zstack_explorer/viewer.py,sha256=vKQEvC23HKylaZE6CcC75l5vwFAfukxvhiBm6KHx2jc,2921
|
|
4
|
+
flimkit_zstack_explorer/volumes.py,sha256=n3rP2hcFw0a3fcK-YrnEtp0p2k1rxKAq32toXEubo0Y,4319
|
|
5
|
+
flimkit_zstack_explorer/zarr_io.py,sha256=HAfqCCDeelY8xEnWADdYsvgXSP6mmfoAkXdFT3ekvxw,2338
|
|
6
|
+
flimkit_zstack_explorer-0.1.0.dist-info/licenses/LICENSE,sha256=xRkLMCd6sG_d7rMTncTVtj94vE9hvqc3Dpwl24IpfiU,1064
|
|
7
|
+
flimkit_zstack_explorer-0.1.0.dist-info/METADATA,sha256=hdakiIR5NSLYk1_t4oI5R_K1En4jgIIgyrUwgEN1dUE,3336
|
|
8
|
+
flimkit_zstack_explorer-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
flimkit_zstack_explorer-0.1.0.dist-info/entry_points.txt,sha256=U_gF0roUnvUsu3hlCHabbNbHGvWYv1AaD8Ys58a2134,68
|
|
10
|
+
flimkit_zstack_explorer-0.1.0.dist-info/top_level.txt,sha256=vMm_lu3R3uwr_SEW05dLX2wwSdPeAjbSNL0MeLHvMXI,24
|
|
11
|
+
flimkit_zstack_explorer-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
flimkit_zstack_explorer
|