From 424c5687d302a8f0ac053a59e00f267d841be05d Mon Sep 17 00:00:00 2001 From: Maneesh John Date: Fri, 14 Nov 2025 14:22:02 -0500 Subject: [PATCH 1/3] Corrected overflow error Casting to uint8 will cause overflow if the pixel value is beyond the display range. Common behavior (e.g. in MATLAB, matplotlib) is that any values above the display range will be displayed as 255 so to match this in stackview the casting should be done after the clipping to [0,255]. --- stackview/_image_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stackview/_image_widget.py b/stackview/_image_widget.py index e41a9f6..a44129d 100644 --- a/stackview/_image_widget.py +++ b/stackview/_image_widget.py @@ -91,10 +91,10 @@ def _img_to_rgb(image, if diplay_range_width == 0: diplay_range_width = 1 - image = ((image.astype(float) - display_min) / diplay_range_width * 255).astype(np.uint8) + image = ((image.astype(float) - display_min) / diplay_range_width * 255) image = np.minimum(image, 255) - image = np.maximum(image, 0) + image = np.maximum(image, 0).astype(np.uint8) if colormap is None: return np.asarray([image, image, image]).swapaxes(0, 2).swapaxes(1, 0) From 6900755d61e3e4b2141f8709565c7db03440fbd9 Mon Sep 17 00:00:00 2001 From: Maneesh John Date: Sat, 15 Nov 2025 18:39:29 -0500 Subject: [PATCH 2/3] python vis initial commit Trying to implement a tool for python notebooks that matches the functionality of Yi Wang Lab's in-house MATLAB "vis" tool --- .gitignore | 3 + test.ipynb | 143 +++++++++++++++++++++ vis/__init__.py | 10 ++ vis/_colormaps.py | 127 +++++++++++++++++++ vis/_context.py | 56 +++++++++ vis/_display_range.py | 111 +++++++++++++++++ vis/_image_widget.py | 105 ++++++++++++++++ vis/_slice_viewer.py | 119 ++++++++++++++++++ vis/_static_view.py | 283 ++++++++++++++++++++++++++++++++++++++++++ vis/_uint_field.py | 180 +++++++++++++++++++++++++++ vis/_utilities.py | 98 +++++++++++++++ vis/_vis.py | 132 ++++++++++++++++++++ 12 files changed, 1367 insertions(+) create mode 100644 test.ipynb create mode 100644 vis/__init__.py create mode 100644 vis/_colormaps.py create mode 100644 vis/_context.py create mode 100644 vis/_display_range.py create mode 100644 vis/_image_widget.py create mode 100644 vis/_slice_viewer.py create mode 100644 vis/_static_view.py create mode 100644 vis/_uint_field.py create mode 100644 vis/_utilities.py create mode 100644 vis/_vis.py diff --git a/.gitignore b/.gitignore index acb7a36..1898f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,6 @@ deploy.bat *Untitled* docs/png_umap* + +# Temporary test data +*.mat diff --git a/test.ipynb b/test.ipynb new file mode 100644 index 0000000..dc9baef --- /dev/null +++ b/test.ipynb @@ -0,0 +1,143 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "2113986d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "dict_keys(['__header__', '__version__', '__globals__', 'medi_chi_pos', 'medi_chi_neg', 'lin_chi_pos', 'lin_chi_neg', 'nonlin_chi_pos', 'nonlin_chi_neg'])" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import scipy.io\n", + "import numpy as np\n", + "data = scipy.io.loadmat('HEMO1_results_Oct24_1031am.mat')\n", + "data.keys()" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "26dd6cf7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(1536, 512, 64)" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "img = np.concatenate([data['medi_chi_pos'], data['lin_chi_pos'], data['nonlin_chi_pos']], axis=0)\n", + "img.shape\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "fd26eb4e", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "d8eb4608f3cc49aa9455a918f1147361", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "HBox(children=(VBox(children=(VBox(children=(HBox(children=(VBox(children=(ImageWidget(height=512, width=1536)…" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from vis import vis\n", + "\n", + "vis(-img, display_min=0, display_max=0.2)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0865799d", + "metadata": {}, + "outputs": [], + "source": [ + "comp = data['medi_chi_pos'] + 1j * data['lin_chi_pos']" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "c42b5a33", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "8f2622fe54ee4c099d8ac6d956ce736d", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "HBox(children=(VBox(children=(VBox(children=(HBox(children=(VBox(children=(ImageWidget(height=512, width=512),…" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vis(comp, display_min=0, display_max=0.2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5064702c", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "deep", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/vis/__init__.py b/vis/__init__.py new file mode 100644 index 0000000..f1f37e3 --- /dev/null +++ b/vis/__init__.py @@ -0,0 +1,10 @@ +__version__ = "0.0.1" + +from ._static_view import jupyter_displayable_output, insight +from ._utilities import merge_rgb +from ._context import nop +from ._slice_viewer import _SliceViewer +from ._utilities import _no_resize +from ._colormaps import create_colormap +from ._display_range import display_range +from ._vis import vis diff --git a/vis/_colormaps.py b/vis/_colormaps.py new file mode 100644 index 0000000..161a83b --- /dev/null +++ b/vis/_colormaps.py @@ -0,0 +1,127 @@ +# the code in this file is modified from +# https://github.com/guiwitz/microfilm/blob/c0666e19b77db66b0af3a4d3759baaf19243b9db/microfilm/colorify.py#L11 + +# BSD 3-Clause License +# +# Copyright (c) 2021, Bern University, Mathematical Institute and Microscopy +# Imaging Center, Guillaume Witz +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from functools import lru_cache + +@lru_cache(maxsize=16) +def create_colormap(cmap_name, num_colors=256): + """ + Return a colormap defined by its name + + Parameters + ---------- + cmap_name: str + {'pure_red', 'pure_green', 'pure_blue', 'pure_magenta', + 'pure_cyan', 'pure_yellow'} or Matplotlib colormap + Colors will be reversed if the name ends with '_r' + num_colors: int + number of steps in color scale + Returns + ------- + cmap: Matplotlib colormap + """ + import matplotlib.pyplot as plt + import numpy as np + from matplotlib.colors import ListedColormap + + if cmap_name in plt.colormaps(): + flip_map = False + cmap = _convert_to_listed_colormap(plt.get_cmap(cmap_name, num_colors), num_colors) + else: + flip_map = cmap_name.endswith("_r") + if flip_map: + cmap_name = cmap_name[:-2] + + if cmap_name == 'pure_red': + cmap = ListedColormap(np.c_[np.linspace(0, 1, num_colors), np.zeros(num_colors), np.zeros(num_colors)]) + elif cmap_name == 'pure_green': + cmap = ListedColormap(np.c_[np.zeros(num_colors), np.linspace(0, 1, num_colors), np.zeros(num_colors)]) + elif cmap_name == 'pure_blue': + cmap = ListedColormap(np.c_[np.zeros(num_colors), np.zeros(num_colors), np.linspace(0, 1, num_colors)]) + elif cmap_name == 'pure_cyan': + cmap = ListedColormap(np.c_[np.zeros(num_colors), np.linspace(0, 1, num_colors), np.linspace(0, 1, num_colors)]) + elif cmap_name == 'pure_magenta': + cmap = ListedColormap(np.c_[np.linspace(0, 1, num_colors), np.zeros(num_colors), np.linspace(0, 1, num_colors)]) + elif cmap_name == 'pure_yellow': + cmap = ListedColormap(np.c_[np.linspace(0, 1, num_colors), np.linspace(0, 1, num_colors), np.zeros(num_colors)]) + elif cmap_name == 'segmentation': + cmap = ListedColormap(_labels_lut()) + elif cmap_name == 'ran_gradient': + cmap = random_grandient_cmap(num_colors=num_colors) + else: + raise Exception(f"Your colormap {cmap_name} doesn't exist either in Matplotlib or microfilm.") + + if flip_map: + cmap = cmap.reversed() + + return cmap + + +def random_grandient_cmap(num_colors=25, seed=42): + """Create a colormap as the gradient of a given random color""" + from skimage.color import hsv2rgb + import numpy as np + from matplotlib.colors import ListedColormap + + rgb = hsv2rgb([np.random.random(1)[0], 0.95, 0.95]) + + cmap = ListedColormap(np.c_[ + np.linspace(0,rgb[0],num_colors), + np.linspace(0,rgb[1],num_colors), + np.linspace(0,rgb[2],num_colors) + ]) + return cmap + + +@lru_cache(maxsize=1) +def _labels_lut(): + from numpy.random import MT19937 + from numpy.random import RandomState, SeedSequence + rs = RandomState(MT19937(SeedSequence(3))) + lut = rs.rand(65537, 3) + lut[0, :] = 0 + # these are the first four colours from matplotlib's default + lut[1] = [0.12156862745098039, 0.4666666666666667, 0.7058823529411765] + lut[2] = [1.0, 0.4980392156862745, 0.054901960784313725] + lut[3] = [0.17254901960784313, 0.6274509803921569, 0.17254901960784313] + lut[4] = [0.8392156862745098, 0.15294117647058825, 0.1568627450980392] + return lut + + +def _convert_to_listed_colormap(colormap, num_samples): + import numpy as np + from matplotlib.colors import ListedColormap + samples = np.linspace(0, 1, num_samples) + colors = colormap(samples) + return ListedColormap(colors) diff --git a/vis/_context.py b/vis/_context.py new file mode 100644 index 0000000..f82fc73 --- /dev/null +++ b/vis/_context.py @@ -0,0 +1,56 @@ +import numpy as np + +blacklist = ["try_all_threshold", + "test", + "minimum_of_touching_neighbors_map", + "maximum_of_touching_neighbors_map", + "standard_deviation_of_touching_neighbors_map", + "mean_of_touching_neighbors_map", + "z_position_of_minimum_z_projection", + "z_position_of_maximum_z_projection", + ] + +def nop(image): + """No operation, returns the image unchanged""" + return image + + + +class Context(): + def __init__(self, variables): + from ._utilities import logo + + self._functions = {"stackview.nop": nop} + self._modules = { } + self._images = {"no_image": logo} + #self._label_images = {} + + self.parse(variables.items()) + + def parse(self, items, prefix: str = None): + + from types import ModuleType + from typing import Callable + import stackview + from ._utilities import is_image, is_label_image, count_image_parameters + + for name, value in items: + if not name.startswith("_"): + if isinstance(value, ModuleType) and value is not stackview: + if prefix is None: + self._modules[name] = value + self.parse({key: getattr(value, key) for key in dir(value)}.items(), prefix=name + ".") + elif isinstance(value, Callable): + if count_image_parameters(value) > 0: + if name not in blacklist: + if prefix is None: + self._functions[name] = value + else: + self._functions[prefix + name] = value + elif is_image(value): + if prefix is None: + #if is_label_image(value): + # self._label_images[name] = value + #else: + self._images[name] = value + diff --git a/vis/_display_range.py b/vis/_display_range.py new file mode 100644 index 0000000..740e9cb --- /dev/null +++ b/vis/_display_range.py @@ -0,0 +1,111 @@ + +def display_range( + image, + slice_number: int = None, + axis: int = 0, + continuous_update: bool = True, + zoom_factor :float = 1.0, + zoom_spline_order :int = 0, + colormap:str = None, + display_min:float = None, + display_max:float = None, +): + """Shows an image and allows to change the display range using two sliders (min, max intensity). + + Parameters + ---------- + image : image + Image shown on the left (behind the curtain) + slice_number : int, optional + Slice-position in case we are looking at an image stack + axis : int, optional + This parameter is obsolete. If you want to show any other axis than the first, you need to transpose the image before, e.g. using np.swapaxes(). + display_width : int, optional + This parameter is obsolete. Use zoom_factor instead + display_height : int, optional + This parameter is obsolete. Use zoom_factor instead + continuous_update : bool, optional + Update the image while dragging the mouse, default: False + zoom_factor: float, optional + Allows showing the image larger (> 1) or smaller (<1) + zoom_spline_order: int, optional + Spline order used for interpolation (default=0, nearest-neighbor) + colormap: str, optional + Matplotlib colormap name or "pure_green", "pure_magenta", ... + display_min: float, optional + Lower bound of properly shown intensities + display_max: float, optional + Upper bound of properly shown intensities + + Returns + ------- + An ipywidget with an image display and a slider. + """ + import ipywidgets + from ._image_widget import ImageWidget + import numpy as np + from ._utilities import _no_resize + + if 'cupy.ndarray' in str(type(image)): + image = image.get() + + slice_slider = None + + if slice_number is None: + slice_number = int(image.shape[axis] / 2) + + # setup user interface for changing the slice + slice_slider = ipywidgets.IntSlider( + value=slice_number, + min=0, + max=image.shape[axis ] -1, + continuous_update=continuous_update, + description="Slice" + ) + if len(image.shape) < 3 or (len(image.shape) == 3 and image.shape[-1] == 3): + slice_slider.layout.display = 'none' + + if display_min is None: + display_min = np.min(image) + if display_max is None: + display_max = np.max(image) + + min_slider = ipywidgets.IntSlider( + value=display_min, + min=display_min, + max=display_max, + continuous_update=continuous_update, + description="Minimum" + ) + + max_slider = ipywidgets.IntSlider( + value=display_max, + min=display_min, + max=display_max, + continuous_update=continuous_update, + description="Maximum" + ) + + from ._image_widget import _img_to_rgb + def transform_image(): + if len(image.shape) < 3 or (len(image.shape) == 3 and image.shape[-1] == 3): + image_slice = _img_to_rgb(image.copy(), colormap=colormap, display_min=min_slider.value, display_max=max_slider.value) + else: + image_slice = _img_to_rgb(np.take(image, slice_slider.value, axis=axis), colormap=colormap, display_min=min_slider.value, display_max=max_slider.value) + return image_slice + view = ImageWidget(transform_image(), zoom_factor=zoom_factor, zoom_spline_order=zoom_spline_order) + + # event handler when the user changed something: + def configuration_updated(event=None): + view.data = transform_image() + + configuration_updated(None) + + # connect user interface with event + slice_slider.observe(configuration_updated) + min_slider.observe(configuration_updated) + max_slider.observe(configuration_updated) + + result = _no_resize(ipywidgets.VBox([_no_resize(view), slice_slider, min_slider, max_slider])) + result.update = configuration_updated + return result diff --git a/vis/_image_widget.py b/vis/_image_widget.py new file mode 100644 index 0000000..798754d --- /dev/null +++ b/vis/_image_widget.py @@ -0,0 +1,105 @@ +from ipycanvas import Canvas +import numpy as np +from ._colormaps import _labels_lut # for internal backwards compatibility + +class ImageWidget(Canvas): + def __init__(self, image, zoom_factor:float=1.0, zoom_spline_order:int=0, colormap:str=None, display_min:float=None, display_max:float=None): + if not ((len(image.shape) == 2) or (len(image.shape) in [3, 4] and image.shape[-1] == 3)): + raise NotImplementedError("Only 2D images are supported" + str(image.shape)) + height = image.shape[0] * zoom_factor + width = image.shape[1] * zoom_factor + self.zoom_factor = zoom_factor + self.zoom_spline_order = zoom_spline_order + super().__init__(width=width * zoom_factor, height=height * zoom_factor) + self.colormap = colormap + self.display_min = display_min + self.display_max = display_max + self.data = np.asarray(image) + self.layout.stretch = False + # self.layout.width = f"{width}px" + # self.layout.height = f"{height}px" + + @property + def data(self): + """Image data as numpy array + """ + return self._data + + @data.setter + def data(self, new_data): + """Take in new image data, compress to PNG, send to image widget. + """ + if new_data is None: + return + + self._data = np.asarray(new_data) + self._update_image() + self.height = self._data.shape[0] * self.zoom_factor + self.width = self._data.shape[1] * self.zoom_factor + + def _update_image(self): + if self.zoom_factor == 1.0: + self.put_image_data(_img_to_rgb(self._data, colormap=self.colormap, display_min=self.display_min, display_max=self.display_max), 0, 0) + else: + zoomed = self._zoom(self._data) + self.put_image_data(_img_to_rgb(zoomed, colormap=self.colormap, display_min=self.display_min, display_max=self.display_max), 0, 0) + + def _zoom(self, data): + if len(data.shape) > 2 and data.shape[-1] == 3: + # handle RGB images + return np.asarray([self._zoom(data[:,:,i]) for i in range(data.shape[2])]).swapaxes(0, 2).swapaxes(1, 0) + + from scipy.ndimage import affine_transform + matrix = np.asarray([[1.0 / self.zoom_factor, 0, -0.5], + [0, 1.0 / self.zoom_factor, -0.5], + [0, 0, 1], + ]) + zoomed_shape = (np.asarray(data.shape) * self.zoom_factor).astype(int) + zoomed = affine_transform(data, + matrix, + output_shape=zoomed_shape, + order=self.zoom_spline_order, + mode='nearest') + return zoomed + + +def _is_label_image(image): + return image.dtype == np.uint32 or image.dtype == np.uint64 or \ + image.dtype == np.int32 or image.dtype == np.int64 + + +def _img_to_rgb(image, + colormap=None, + display_min=None, + display_max=None): + from ._colormaps import _labels_lut, create_colormap + + if len(image.shape) > 2 and (image.shape[-1] == 3 or image.shape[-1] == 4): + return image + + if image.dtype == bool: + image = image * 1 + + if _is_label_image(image): + lut = _labels_lut() + return np.asarray([lut[:, c].take(image.astype(np.int64)) for c in range(0, 3)]).swapaxes(0, 2).swapaxes(1, 0) * 255 + + if display_min is None: + display_min = image.min() + if display_max is None: + display_max = image.max() + + diplay_range_width = (display_max - display_min) + if diplay_range_width == 0: + diplay_range_width = 1 + + image = ((image.astype(float) - display_min) / diplay_range_width * 255) + + image = np.minimum(image, 255) + image = np.maximum(image, 0).astype(np.uint8) + + if colormap is None: + return np.asarray([image, image, image]).swapaxes(0, 2).swapaxes(1, 0) + else: + lut = np.asarray(create_colormap(colormap).colors) + return np.asarray([lut[:, c].take(image.astype(int)) for c in range(0, 3)]).swapaxes(0, 2).swapaxes(1, 0) * 255 diff --git a/vis/_slice_viewer.py b/vis/_slice_viewer.py new file mode 100644 index 0000000..1a9f8b7 --- /dev/null +++ b/vis/_slice_viewer.py @@ -0,0 +1,119 @@ +import warnings + +import numpy as np + +class _SliceViewer(): + def __init__(self, + image, + slice_number: int = None, + continuous_update: bool = True, + slider_text: str = "[{}]", + zoom_factor:float = 1.0, + zoom_spline_order:int = 0, + colormap:str = None, + display_min:float = None, + display_max:float = None, + ): + import ipywidgets + from ._image_widget import ImageWidget + from ._uint_field import intSlider, floatRangeSlider + self.update_active = True + + self.true_image = image + self.image = image + + if slice_number is None: + slice_number = [int(s / 2) for s in image.shape[:-2]] + if isinstance(slice_number, int): + slice_number = [slice_number] * (len(image.shape) - 2) + if not isinstance(slider_text, list): + slider_text = [slider_text] * (len(image.shape) - 2) + + self.sliders = [] + offset = 2 + if image.shape[-1] == 3 or image.shape[-1] == 4: # RGB or RGBA images + offset = 3 + + for d in range(len(image.shape) - offset): + slider = intSlider( + value=slice_number[d], + min=0, + max=image.shape[d] - 1, + continuous_update=continuous_update, + description=slider_text[d].format(d) + ) + slider.layout.width = '100%' # Make the slider full-width + + slider.observe(self.update) + self.sliders.append(slider) + + self.view = ImageWidget(self.get_view_slice(), + zoom_factor=zoom_factor, + zoom_spline_order=zoom_spline_order, + colormap=colormap, + display_min=display_min, + display_max=display_max) + + + # # --- NEW: display_max and display_min slider ------------------------------------------ + # auto-range: from image min to max + im_min = float(np.nanmin(image)) + im_max = float(np.nanmax(image)) + + self.display_range_slider = floatRangeSlider( + value=(display_min if display_min is not None else im_min, + display_max if display_max is not None else im_max), + min=im_min, + max=im_max, + step=(im_max - im_min) / 1000.0, + description="Range", + continuous_update=True + ) + self.display_range_slider.layout.width = '100%' + self.display_range_slider.observe(self.update_display_range) + # # ----------------------------------------------------------------------- + + # setup user interface for changing the slice + + self.slice_slider = ipywidgets.VBox(self.sliders[::-1]) + + self.controls = [self.slice_slider, self.display_range_slider] + + self.update() + + + def update_display_range(self, event=None): + if event is None or event["name"] != "value": + return + self.view.display_min = event["new"][0] + self.view.display_max = event["new"][1] + self.update() + + def set_image(self, image): + for i, s in enumerate(self.sliders): + s._set_value_min_max(int(image.shape[i] / 2), 0, image.shape[i] - 1) + self.image = image + + def observe(self, x): + self.update_active = False + for s in self.sliders: + s.observe(x) + + # event handler when the user changed something: + def update(self, event=None): + if self.update_active: + self.view.data = self.get_view_slice() + + def configuration_updated(self, event=None): + warnings.warn('SliceViewer.configuration_updated is deprecated, use SliceViewer.update instead.') + return self.update(event) + + def get_view_slice(self, data=None): + if data is None: + data = self.image + for d, slider in enumerate(self.sliders): + data = np.take(data, slider.value, axis=0) + return data + + def get_slice_index(self): + return [s.value for s in self.sliders] diff --git a/vis/_static_view.py b/vis/_static_view.py new file mode 100644 index 0000000..f28664c --- /dev/null +++ b/vis/_static_view.py @@ -0,0 +1,283 @@ +import numpy as np +from typing import Callable +from functools import lru_cache, wraps +from toolz import curry + +def insight(image, library_name=None, help_url=None): + """ + Converts a numpy-array-like image to numpy-compatible array with a convenient display in Jupyter notebooks + including shape, min/max intensity, histogram and viewing 32-bit and 64-bit integer images as coloured labels. + """ + return StackViewNDArray(image, library_name, help_url) + +@curry +def jupyter_displayable_output( + function: Callable, + library_name:str = None, + help_url:str = None +) -> Callable: + """Wraps a given function so that it outputs a nice image view in jupyter notebooks + The view will contain a link to the specified library to guide users to read more. + """ + @wraps(function) + def worker_function(*args, **kwargs): + # call the decorated function + result = function(*args, **kwargs) + + # Attach _repr_html_ function + result = StackViewNDArray(result, library_name, help_url) + + return result + + return worker_function + + +class StackViewNDArray(np.ndarray): + + def __new__(cls, input_array, library_name=None, help_url=None): + if 'cupy.ndarray' in str(type(input_array)): + input_array = input_array.get() + input_array = np.asarray(input_array) + obj = super(StackViewNDArray, cls).__new__(cls, shape=input_array.shape, dtype=input_array.dtype) + obj.library_name = library_name + obj.help_url = help_url + obj[:] = input_array + return obj + + def __array_finalize__(self, obj): + if obj is None: return + self.library_name = getattr(obj, 'library_name', None) + self.help_url = getattr(obj, 'help_url', None) + #self.obj = obj + + def __repr__(self): + if _is_running_in_colab(): + from IPython.display import display, HTML + display(HTML(self._repr_html_())) + return "" + else: + return str(self) + + + def _repr_html_(self): + """HTML representation of the image object for IPython. + Returns + ------- + HTML text with the image and some properties. + """ + if len(self.shape) < 2: + return str(self) + + import numpy as np + size_in_pixels = np.prod(self.shape) + size_in_bytes = size_in_pixels * self.dtype.itemsize + + from ._image_widget import _is_label_image + labels = _is_label_image(self) + + import matplotlib.pyplot as plt + from ._imshow import imshow + imshow(self, + labels=labels, + continue_drawing=True, + colorbar=not labels) + image = _png_to_html(_plt_to_png()) + + if size_in_bytes > 1024: + size_in_bytes = size_in_bytes / 1024 + if size_in_bytes > 1024: + size_in_bytes = size_in_bytes / 1024 + if size_in_bytes > 1024: + size_in_bytes = size_in_bytes / 1024 + size = "{:.1f}".format(size_in_bytes) + " GB" + else: + size = "{:.1f}".format(size_in_bytes) + " MB" + else: + size = "{:.1f}".format(size_in_bytes) + " kB" + else: + size = "{:.1f}".format(size_in_bytes) + " B" + + histogram = "" + + if size_in_bytes < 100 * 1024 * 1024: + if not labels: + import numpy as np + + num_bins = 32 + h, _ = np.histogram(self, bins=num_bins) + + plt.figure(figsize=(1.8, 1.2)) + plt.bar(range(0, len(h)), h) + + # hide axis text + # https://stackoverflow.com/questions/2176424/hiding-axis-text-in-matplotlib-plots + # https://pythonguides.com/matplotlib-remove-tick-labels + frame1 = plt.gca() + frame1.axes.xaxis.set_ticklabels([]) + frame1.axes.yaxis.set_ticklabels([]) + plt.tick_params(left=False, bottom=False) + + histogram = _png_to_html(_plt_to_png()) + + min_intensity = self.min() + max_intensity = self.max() + min_max = "min" + str(min_intensity) + "" + \ + "max" + str(max_intensity) + "" + + if labels: + unique_labels = list(np.unique(self)) + if 0 in unique_labels: + unique_labels.remove(0) + + num_labels = len(unique_labels) + min_max += "n labels" + str(num_labels) + "" + if min_intensity < 0: + min_max += "Negative label values detected!" + else: + if max_intensity != num_labels: + min_max += "Not sequentially labeled!" + + else: + min_max = "" + + help_text = "" + if self.library_name is not None and len(self.library_name) > 0: + self.library_name = self.library_name + " made " + if self.help_url is not None: + help_text = "" + self.library_name + "image
" + + all = [ + "", + "", + "", + "", + "", + "
", + image, + "", + help_text, + "", + "", + "", + "", + min_max, + "
shape" + str(self.shape).replace(" ", " ") + "
dtype" + str(self.dtype) + "
size" + size + "
", + histogram, + "
", + ] + + return "\n".join(all) + +@lru_cache(maxsize=None) +def _is_running_in_colab(): + try: + import google.colab + return True + except ImportError: + return False + + +def _png_to_html(png): + import base64 + url = 'data:image/png;base64,' + base64.b64encode(png).decode('utf-8') + return f'' + + +# adapted from https://github.com/napari/napari/blob/d6bc683b019c4a3a3c6e936526e29bbd59cca2f4/napari/utils/notebook_display.py#L54-L73 +def _plt_to_png(): + """PNG representation of the image object for IPython. + Returns + ------- + In memory binary stream containing a PNG matplotlib image. + """ + import matplotlib.pyplot as plt + from io import BytesIO + + with BytesIO() as file_obj: + plt.savefig(file_obj, format='png') + plt.close() # supress plot output + file_obj.seek(0) + png = file_obj.read() + return png + + +def _imshow(image, title: str = None, labels: bool = False, min_display_intensity: float = None, + max_display_intensity: float = None, plot=None, colorbar: bool = False, colormap=None, + alpha: float = None, continue_drawing: bool = False): + """Visualize an image, e.g. in Jupyter notebooks. + + Parameters + ---------- + image: np.ndarray + numpy or OpenCL-backed image to visualize + title: str + Obsolete (kept for ImageJ-compatibility) + labels: bool + True: integer labels will be visualized with colors + False: Specified or default colormap will be used to display intensities. + min_display_intensity: float + lower limit for display range + max_display_intensity: float + upper limit for display range + color_map: str + deprecated, use colormap instead + plot: matplotlib axis + Plot object where the image should be shown. Useful for putting multiple images in subfigures. + colorbar: bool + True puts a colorbar next to the image. Will not work with label images and when visualizing multiple + images (continue_drawing=True). + colormap: str or matplotlib colormap + alpha: float + alpha blending value + continue_drawing: float + True: the next shown image can be visualized on top of the current one, e.g. with alpha = 0.5 + """ + return + import numpy as np + if len(image.shape) == 3 and image.shape[2] == 3: # RGB image + import matplotlib.pyplot as plt + plt.imshow(image, vmin=min_display_intensity, vmax=max_display_intensity, + interpolation='nearest', alpha=alpha) + if not continue_drawing: + plt.show() + return + + if len(image.shape) == 3: + image = np.asarray(image).max(axis=0) + + image = np.asarray(image) + if len(image.shape) == 1: + image = image[np.newaxis] + + if colormap is None: + colormap = "Greys_r" + + cmap = colormap + if labels: + import matplotlib + import numpy as np + + if not hasattr(imshow, "labels_cmap"): + from ._image_widget import _labels_lut + imshow.labels_cmap = matplotlib.colors.ListedColormap(_labels_lut()) + cmap = imshow.labels_cmap + + if min_display_intensity is None: + min_display_intensity = 0 + if max_display_intensity is None: + max_display_intensity = 65536 + + if plot is None: + import matplotlib.pyplot as plt + plt.clf() + plt.imshow(image, cmap=cmap, vmin=min_display_intensity, vmax=max_display_intensity, + interpolation='nearest', alpha=alpha) + if colorbar: + plt.colorbar() + if not continue_drawing: + plt.show() + else: + plot.imshow(image, cmap=cmap, vmin=min_display_intensity, vmax=max_display_intensity, + interpolation='nearest', alpha=alpha) + if colorbar: + plot.colorbar() diff --git a/vis/_uint_field.py b/vis/_uint_field.py new file mode 100644 index 0000000..1ed587f --- /dev/null +++ b/vis/_uint_field.py @@ -0,0 +1,180 @@ +import ipywidgets + +class UIntField(ipywidgets.HBox): + """ + A text field where users can select a positive integer number from. + Similar to ipywidgets.IntText + """ + + def __init__(self, value): + self._value = value + self._label = ipywidgets.Label(str(value)) + layout = ipywidgets.Layout(min_width='10px', max_width='30px') + self._button_up = ipywidgets.Button(description="+", layout=layout) + self._button_down = ipywidgets.Button(description="-", layout=layout) + super().__init__([self._button_down, self._label, self._button_up]) + + def increase(event=None): + self.value = self.value + 1 + + def decrease(event=None): + self.value = self.value - 1 + + self._button_up.on_click(increase) + self._button_down.on_click(decrease) + + @property + def value(self): + return self._value + + @value.setter + def value(self, value): + if value >= 0: + self._value = value + self._label.value = str(value) + +def intSlider(min:int=0, max:int=100, step:int=1, value:int=1, continuous_update:bool=True, description:str=""): + slider = ipywidgets.IntSlider( + value=value, + min=min, + max=max, + step=step, + continuous_update=continuous_update + ) + slider.layout.width = '100%' + + label = ipywidgets.Label(description) + + custom_css = "" + html = ipywidgets.HTML(custom_css) + + box = ipywidgets.VBox([ipywidgets.HBox([label, slider]),html]) + + def observe(*args, **kwargs): + slider.observe(*args, **kwargs) + box.observe = observe + + def update(event=None): + box.value = slider.value + + slider.observe(update) + + def _set_value_min_max(value, min, max): + slider.value = value + slider.min = min + slider.max = max + + box._set_value_min_max = _set_value_min_max + + update() + + return box + +def floatSlider(min:float=0.0, + max:float=1.0, + step:float=0.01, + value:float=0.5, + continuous_update:bool=True, + description:str=""): + """ + A slider widget for selecting a floating-point value. + Mirrors the structure and behavior of intSlider, + but uses ipywidgets.FloatSlider internally. + """ + + slider = ipywidgets.FloatSlider( + value=value, + min=min, + max=max, + step=step, + continuous_update=continuous_update + ) + slider.layout.width = '100%' + + label = ipywidgets.Label(description) + + # same CSS trick you used for intSlider + custom_css = "" + html = ipywidgets.HTML(custom_css) + + # container exactly like your intSlider + box = ipywidgets.VBox([ipywidgets.HBox([label, slider]), html]) + + # wrap observe so external code can attach callbacks + def observe(*args, **kwargs): + slider.observe(*args, **kwargs) + box.observe = observe + + # simple update callback + def update(event=None): + box.value = slider.value + + slider.observe(update) + + # allow external adjustment of (value, min, max) + def _set_value_min_max(value, new_min, new_max): + slider.min = new_min + slider.max = new_max + slider.value = value + + def _set_min(new_min): + slider.min = new_min + + def _set_max(new_max): + slider.max = new_max + + box._set_value_min_max = _set_value_min_max + box._set_min = _set_min + box._set_max = _set_max + + # initialize box.value + update() + + return box + + +def floatRangeSlider(min:float=0.0, + max:float=1.0, + step:float=0.01, + value:tuple=(0.25, 0.75), + continuous_update:bool=True, + description:str=""): + """ + A range slider widget for selecting a range of floating-point values. + """ + + slider = ipywidgets.FloatRangeSlider( + value=value, + min=min, + max=max, + step=step, + continuous_update=continuous_update + ) + slider.layout.width = '100%' + + label = ipywidgets.Label(description) + + custom_css = "" + html = ipywidgets.HTML(custom_css) + + box = ipywidgets.VBox([ipywidgets.HBox([label, slider]), html]) + + def observe(*args, **kwargs): + slider.observe(*args, **kwargs) + box.observe = observe + + def update(event=None): + box.value = slider.value + + slider.observe(update) + + def _set_value_min_max(value, new_min, new_max): + slider.min = new_min + slider.max = new_max + slider.value = value + + box._set_value_min_max = _set_value_min_max + + update() + + return box \ No newline at end of file diff --git a/vis/_utilities.py b/vis/_utilities.py new file mode 100644 index 0000000..bb2d2a7 --- /dev/null +++ b/vis/_utilities.py @@ -0,0 +1,98 @@ +import numpy as np +def merge_rgb(image_red, image_green, image_blue): + """ + Turns three images (channels) into a scikit-image-style RGB image + with the last dimension being the channel axis. + """ + return np.asarray([image_red, image_green, image_blue]).swapaxes(0, 2) + +def is_image(image): + return isinstance(image, np.ndarray) or \ + str(type(image)) in ["", + "", + "", + "", + "", + "", + ""] + # isinstance(image, tuple) or \ + # isinstance(image, list) or \ + + +def is_label_image(image): + if isinstance(image, tuple) or \ + isinstance(image, list): + return False + + return image.dtype == np.uint32 or image.dtype == np.uint64 or \ + image.dtype == np.int32 or image.dtype == np.int64 + + +def count_image_parameters(func): + import inspect + try: + sig = inspect.signature(func) + except: + return 0 + + n = 0 + + for i, key in enumerate(list(sig.parameters.keys())): + if parameter_is_image_parameter(sig.parameters[key]): + n = n + 1 + return n + + +def parameter_is_image_parameter(parameter): + type_annotation = str(parameter.annotation) + name = parameter.name + + return ("NewType..new_type" in type_annotation or \ + "Image" in type_annotation or \ + "LabelsData" in type_annotation or \ + "LayerData" in type_annotation or \ + "image" in name.lower() or \ + "label" in name.lower() or \ + "mask" in name.lower() + ) + +def _no_resize(widget): + import ipywidgets + return ipywidgets.HBox([ipywidgets.VBox([widget])]) + + + + +def numpy_to_gif_bytestream(timelapse, frame_delay_ms=100, num_loops=1000): + """Turn a NumPy array into a bytestream""" + import numpy as np + import imageio + import io + + # Convert the NumPy array to a PIL Image + # image = Image.fromarray(data.astype(np.uint8)).convert("RGBA") + + # Create a BytesIO object + bytes_io = io.BytesIO() + + # Save the PIL image to the BytesIO object as a PNG + # image.save(bytes_io, format='GIF') + + with imageio.get_writer(bytes_io, mode='I', duration=frame_delay_ms, loop=num_loops, format="GIF") as writer: + for frame in timelapse: + writer.append_data(frame.astype(np.uint8)) + + # return the beginning of the file as a bytestream + bytes_io.seek(0) + return bytes_io.read() + + +def _gif_to_html(gif, width=None): + import base64 + + style = "" + if width is not None: + style = f"style=\"width: {width}px;\"" + + url = 'data:image/gif;base64,' + base64.b64encode(gif).decode('utf-8') + return f'' diff --git a/vis/_vis.py b/vis/_vis.py new file mode 100644 index 0000000..00b5491 --- /dev/null +++ b/vis/_vis.py @@ -0,0 +1,132 @@ +import numpy +import ipywidgets +from ._slice_viewer import _SliceViewer +from ._utilities import _no_resize +from ipyevents import Event + +def vis( + image, + slice_number: int = None, + slider_text: str = "Slice", + display_min: float = None, + display_max: float = None, + mode: str = 'mag' +): + """Shows an image stack with controls for slice and display range, plus a label with the current mouse position and intensity at that position. + + Parameters + ---------- + image : image + Image shown + slice_number : int, optional + Slice-position in the stack + continuous_update : bool, optional + Update the image while dragging the mouse, default: False + slider_text: str, optional + Text shown on the slider + zoom_factor: float, optional + Allows showing the image larger (> 1) or smaller (<1) + zoom_spline_order: int, optional + Spline order used for interpolation (default=0, nearest-neighbor) + colormap: str, optional + Matplotlib colormap name or "pure_green", "pure_magenta", ... + display_min: float, optional + Lower bound of properly shown intensities + display_max: float, optional + Upper bound of properly shown intensities + + Returns + ------- + An ipywidget with an image display, a slider and a label showing mouse position and intensity. + """ + + image = numpy.transpose(image, (2, 1, 0)) + if numpy.iscomplexobj(image): + modes = ['mag', 'real', 'imag', 'phase'] + else: + modes = ['real', 'mag'] + true_image = image.copy() + + def get_image_mode(im, mo): + if mo == 'mag': + im = numpy.abs(im) + elif numpy.iscomplexobj(im): + if mo == 'real': + im = numpy.real(im) + elif mo == 'imag': + im = numpy.imag(im) + elif mo == 'phase': + im = numpy.angle(im) + else: + mo = 'real' + return im + + image = get_image_mode(true_image, mode) + + viewer = _SliceViewer(image, + slice_number=slice_number, + continuous_update=True, + slider_text=slider_text, + colormap=None, + display_min=display_min, + display_max=display_max + ) + view = viewer.view + label = ipywidgets.Label("():") + + mode_dropdown = ipywidgets.Dropdown( + options=modes, + value=mode, + # description='Mode:', + disabled=False, + layout=ipywidgets.Layout(margin='0px 20px 0px 0px') + ) + + def on_mode_change(event=None): + if event is None or event["name"] != "value": + return + mode = event['new'] + image = get_image_mode(true_image, mode) + viewer.set_image(image) + + new_min_value = min(viewer.display_range_slider.value[0], image.min()) + new_max_value = max(viewer.display_range_slider.value[1], image.max()) + viewer.display_range_slider._set_value_min_max((new_min_value, new_max_value), image.min(), image.max()) + viewer.update() + + mode_dropdown.observe(on_mode_change) + + modelabel = ipywidgets.Label('Mode:') + mode_box = ipywidgets.HBox([ipywidgets.HBox([modelabel, mode_dropdown])]) + + event_handler = Event(source=view, watched_events=['mousemove']) + + image_shape = image.shape + + def update_display(event=None): + slice_number = viewer.get_slice_index() + bbox_width, bbox_height = int(event['boundingRectWidth']), int(event['boundingRectHeight']) + relative_position_x = event['relativeX'] + relative_position_y = event['relativeY'] + + relative_position_x_scaled = relative_position_x / bbox_width + relative_position_y_scaled = relative_position_y / bbox_height + + absolute_position_x = int(relative_position_x_scaled * image_shape[2]) + absolute_position_y = int(relative_position_y_scaled * image_shape[1]) + + intensity = image[slice_number, absolute_position_y, absolute_position_x].item() + label.value = f"({absolute_position_x}, {absolute_position_y}, {slice_number[0]}) = {intensity:03f}" + + event_handler.on_dom_event(update_display) + + display_elements = [_no_resize(view)] + display_elements.extend(viewer.controls) + # display_elements.append(mode_dropdown) + # display_elements.append(label) + + display_elements.append(ipywidgets.HBox([mode_box, label])) + + result = _no_resize(ipywidgets.VBox(display_elements, stretch=False)) + result.update = update_display + return result From 1e58a72a619f331edeee2a135ebf8dfc78e3630d Mon Sep 17 00:00:00 2001 From: Maneesh John Date: Mon, 17 Nov 2025 13:00:27 -0500 Subject: [PATCH 3/3] Revert "python vis initial commit" This reverts commit 6900755d61e3e4b2141f8709565c7db03440fbd9. --- .gitignore | 3 - test.ipynb | 143 --------------------- vis/__init__.py | 10 -- vis/_colormaps.py | 127 ------------------- vis/_context.py | 56 --------- vis/_display_range.py | 111 ----------------- vis/_image_widget.py | 105 ---------------- vis/_slice_viewer.py | 119 ------------------ vis/_static_view.py | 283 ------------------------------------------ vis/_uint_field.py | 180 --------------------------- vis/_utilities.py | 98 --------------- vis/_vis.py | 132 -------------------- 12 files changed, 1367 deletions(-) delete mode 100644 test.ipynb delete mode 100644 vis/__init__.py delete mode 100644 vis/_colormaps.py delete mode 100644 vis/_context.py delete mode 100644 vis/_display_range.py delete mode 100644 vis/_image_widget.py delete mode 100644 vis/_slice_viewer.py delete mode 100644 vis/_static_view.py delete mode 100644 vis/_uint_field.py delete mode 100644 vis/_utilities.py delete mode 100644 vis/_vis.py diff --git a/.gitignore b/.gitignore index 1898f4b..acb7a36 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,3 @@ deploy.bat *Untitled* docs/png_umap* - -# Temporary test data -*.mat diff --git a/test.ipynb b/test.ipynb deleted file mode 100644 index dc9baef..0000000 --- a/test.ipynb +++ /dev/null @@ -1,143 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "2113986d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "dict_keys(['__header__', '__version__', '__globals__', 'medi_chi_pos', 'medi_chi_neg', 'lin_chi_pos', 'lin_chi_neg', 'nonlin_chi_pos', 'nonlin_chi_neg'])" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import scipy.io\n", - "import numpy as np\n", - "data = scipy.io.loadmat('HEMO1_results_Oct24_1031am.mat')\n", - "data.keys()" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "26dd6cf7", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "(1536, 512, 64)" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "img = np.concatenate([data['medi_chi_pos'], data['lin_chi_pos'], data['nonlin_chi_pos']], axis=0)\n", - "img.shape\n" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "fd26eb4e", - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "d8eb4608f3cc49aa9455a918f1147361", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "HBox(children=(VBox(children=(VBox(children=(HBox(children=(VBox(children=(ImageWidget(height=512, width=1536)…" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from vis import vis\n", - "\n", - "vis(-img, display_min=0, display_max=0.2)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "0865799d", - "metadata": {}, - "outputs": [], - "source": [ - "comp = data['medi_chi_pos'] + 1j * data['lin_chi_pos']" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "c42b5a33", - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "8f2622fe54ee4c099d8ac6d956ce736d", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "HBox(children=(VBox(children=(VBox(children=(HBox(children=(VBox(children=(ImageWidget(height=512, width=512),…" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "vis(comp, display_min=0, display_max=0.2)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5064702c", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "deep", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/vis/__init__.py b/vis/__init__.py deleted file mode 100644 index f1f37e3..0000000 --- a/vis/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -__version__ = "0.0.1" - -from ._static_view import jupyter_displayable_output, insight -from ._utilities import merge_rgb -from ._context import nop -from ._slice_viewer import _SliceViewer -from ._utilities import _no_resize -from ._colormaps import create_colormap -from ._display_range import display_range -from ._vis import vis diff --git a/vis/_colormaps.py b/vis/_colormaps.py deleted file mode 100644 index 161a83b..0000000 --- a/vis/_colormaps.py +++ /dev/null @@ -1,127 +0,0 @@ -# the code in this file is modified from -# https://github.com/guiwitz/microfilm/blob/c0666e19b77db66b0af3a4d3759baaf19243b9db/microfilm/colorify.py#L11 - -# BSD 3-Clause License -# -# Copyright (c) 2021, Bern University, Mathematical Institute and Microscopy -# Imaging Center, Guillaume Witz -# -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from functools import lru_cache - -@lru_cache(maxsize=16) -def create_colormap(cmap_name, num_colors=256): - """ - Return a colormap defined by its name - - Parameters - ---------- - cmap_name: str - {'pure_red', 'pure_green', 'pure_blue', 'pure_magenta', - 'pure_cyan', 'pure_yellow'} or Matplotlib colormap - Colors will be reversed if the name ends with '_r' - num_colors: int - number of steps in color scale - Returns - ------- - cmap: Matplotlib colormap - """ - import matplotlib.pyplot as plt - import numpy as np - from matplotlib.colors import ListedColormap - - if cmap_name in plt.colormaps(): - flip_map = False - cmap = _convert_to_listed_colormap(plt.get_cmap(cmap_name, num_colors), num_colors) - else: - flip_map = cmap_name.endswith("_r") - if flip_map: - cmap_name = cmap_name[:-2] - - if cmap_name == 'pure_red': - cmap = ListedColormap(np.c_[np.linspace(0, 1, num_colors), np.zeros(num_colors), np.zeros(num_colors)]) - elif cmap_name == 'pure_green': - cmap = ListedColormap(np.c_[np.zeros(num_colors), np.linspace(0, 1, num_colors), np.zeros(num_colors)]) - elif cmap_name == 'pure_blue': - cmap = ListedColormap(np.c_[np.zeros(num_colors), np.zeros(num_colors), np.linspace(0, 1, num_colors)]) - elif cmap_name == 'pure_cyan': - cmap = ListedColormap(np.c_[np.zeros(num_colors), np.linspace(0, 1, num_colors), np.linspace(0, 1, num_colors)]) - elif cmap_name == 'pure_magenta': - cmap = ListedColormap(np.c_[np.linspace(0, 1, num_colors), np.zeros(num_colors), np.linspace(0, 1, num_colors)]) - elif cmap_name == 'pure_yellow': - cmap = ListedColormap(np.c_[np.linspace(0, 1, num_colors), np.linspace(0, 1, num_colors), np.zeros(num_colors)]) - elif cmap_name == 'segmentation': - cmap = ListedColormap(_labels_lut()) - elif cmap_name == 'ran_gradient': - cmap = random_grandient_cmap(num_colors=num_colors) - else: - raise Exception(f"Your colormap {cmap_name} doesn't exist either in Matplotlib or microfilm.") - - if flip_map: - cmap = cmap.reversed() - - return cmap - - -def random_grandient_cmap(num_colors=25, seed=42): - """Create a colormap as the gradient of a given random color""" - from skimage.color import hsv2rgb - import numpy as np - from matplotlib.colors import ListedColormap - - rgb = hsv2rgb([np.random.random(1)[0], 0.95, 0.95]) - - cmap = ListedColormap(np.c_[ - np.linspace(0,rgb[0],num_colors), - np.linspace(0,rgb[1],num_colors), - np.linspace(0,rgb[2],num_colors) - ]) - return cmap - - -@lru_cache(maxsize=1) -def _labels_lut(): - from numpy.random import MT19937 - from numpy.random import RandomState, SeedSequence - rs = RandomState(MT19937(SeedSequence(3))) - lut = rs.rand(65537, 3) - lut[0, :] = 0 - # these are the first four colours from matplotlib's default - lut[1] = [0.12156862745098039, 0.4666666666666667, 0.7058823529411765] - lut[2] = [1.0, 0.4980392156862745, 0.054901960784313725] - lut[3] = [0.17254901960784313, 0.6274509803921569, 0.17254901960784313] - lut[4] = [0.8392156862745098, 0.15294117647058825, 0.1568627450980392] - return lut - - -def _convert_to_listed_colormap(colormap, num_samples): - import numpy as np - from matplotlib.colors import ListedColormap - samples = np.linspace(0, 1, num_samples) - colors = colormap(samples) - return ListedColormap(colors) diff --git a/vis/_context.py b/vis/_context.py deleted file mode 100644 index f82fc73..0000000 --- a/vis/_context.py +++ /dev/null @@ -1,56 +0,0 @@ -import numpy as np - -blacklist = ["try_all_threshold", - "test", - "minimum_of_touching_neighbors_map", - "maximum_of_touching_neighbors_map", - "standard_deviation_of_touching_neighbors_map", - "mean_of_touching_neighbors_map", - "z_position_of_minimum_z_projection", - "z_position_of_maximum_z_projection", - ] - -def nop(image): - """No operation, returns the image unchanged""" - return image - - - -class Context(): - def __init__(self, variables): - from ._utilities import logo - - self._functions = {"stackview.nop": nop} - self._modules = { } - self._images = {"no_image": logo} - #self._label_images = {} - - self.parse(variables.items()) - - def parse(self, items, prefix: str = None): - - from types import ModuleType - from typing import Callable - import stackview - from ._utilities import is_image, is_label_image, count_image_parameters - - for name, value in items: - if not name.startswith("_"): - if isinstance(value, ModuleType) and value is not stackview: - if prefix is None: - self._modules[name] = value - self.parse({key: getattr(value, key) for key in dir(value)}.items(), prefix=name + ".") - elif isinstance(value, Callable): - if count_image_parameters(value) > 0: - if name not in blacklist: - if prefix is None: - self._functions[name] = value - else: - self._functions[prefix + name] = value - elif is_image(value): - if prefix is None: - #if is_label_image(value): - # self._label_images[name] = value - #else: - self._images[name] = value - diff --git a/vis/_display_range.py b/vis/_display_range.py deleted file mode 100644 index 740e9cb..0000000 --- a/vis/_display_range.py +++ /dev/null @@ -1,111 +0,0 @@ - -def display_range( - image, - slice_number: int = None, - axis: int = 0, - continuous_update: bool = True, - zoom_factor :float = 1.0, - zoom_spline_order :int = 0, - colormap:str = None, - display_min:float = None, - display_max:float = None, -): - """Shows an image and allows to change the display range using two sliders (min, max intensity). - - Parameters - ---------- - image : image - Image shown on the left (behind the curtain) - slice_number : int, optional - Slice-position in case we are looking at an image stack - axis : int, optional - This parameter is obsolete. If you want to show any other axis than the first, you need to transpose the image before, e.g. using np.swapaxes(). - display_width : int, optional - This parameter is obsolete. Use zoom_factor instead - display_height : int, optional - This parameter is obsolete. Use zoom_factor instead - continuous_update : bool, optional - Update the image while dragging the mouse, default: False - zoom_factor: float, optional - Allows showing the image larger (> 1) or smaller (<1) - zoom_spline_order: int, optional - Spline order used for interpolation (default=0, nearest-neighbor) - colormap: str, optional - Matplotlib colormap name or "pure_green", "pure_magenta", ... - display_min: float, optional - Lower bound of properly shown intensities - display_max: float, optional - Upper bound of properly shown intensities - - Returns - ------- - An ipywidget with an image display and a slider. - """ - import ipywidgets - from ._image_widget import ImageWidget - import numpy as np - from ._utilities import _no_resize - - if 'cupy.ndarray' in str(type(image)): - image = image.get() - - slice_slider = None - - if slice_number is None: - slice_number = int(image.shape[axis] / 2) - - # setup user interface for changing the slice - slice_slider = ipywidgets.IntSlider( - value=slice_number, - min=0, - max=image.shape[axis ] -1, - continuous_update=continuous_update, - description="Slice" - ) - if len(image.shape) < 3 or (len(image.shape) == 3 and image.shape[-1] == 3): - slice_slider.layout.display = 'none' - - if display_min is None: - display_min = np.min(image) - if display_max is None: - display_max = np.max(image) - - min_slider = ipywidgets.IntSlider( - value=display_min, - min=display_min, - max=display_max, - continuous_update=continuous_update, - description="Minimum" - ) - - max_slider = ipywidgets.IntSlider( - value=display_max, - min=display_min, - max=display_max, - continuous_update=continuous_update, - description="Maximum" - ) - - from ._image_widget import _img_to_rgb - def transform_image(): - if len(image.shape) < 3 or (len(image.shape) == 3 and image.shape[-1] == 3): - image_slice = _img_to_rgb(image.copy(), colormap=colormap, display_min=min_slider.value, display_max=max_slider.value) - else: - image_slice = _img_to_rgb(np.take(image, slice_slider.value, axis=axis), colormap=colormap, display_min=min_slider.value, display_max=max_slider.value) - return image_slice - view = ImageWidget(transform_image(), zoom_factor=zoom_factor, zoom_spline_order=zoom_spline_order) - - # event handler when the user changed something: - def configuration_updated(event=None): - view.data = transform_image() - - configuration_updated(None) - - # connect user interface with event - slice_slider.observe(configuration_updated) - min_slider.observe(configuration_updated) - max_slider.observe(configuration_updated) - - result = _no_resize(ipywidgets.VBox([_no_resize(view), slice_slider, min_slider, max_slider])) - result.update = configuration_updated - return result diff --git a/vis/_image_widget.py b/vis/_image_widget.py deleted file mode 100644 index 798754d..0000000 --- a/vis/_image_widget.py +++ /dev/null @@ -1,105 +0,0 @@ -from ipycanvas import Canvas -import numpy as np -from ._colormaps import _labels_lut # for internal backwards compatibility - -class ImageWidget(Canvas): - def __init__(self, image, zoom_factor:float=1.0, zoom_spline_order:int=0, colormap:str=None, display_min:float=None, display_max:float=None): - if not ((len(image.shape) == 2) or (len(image.shape) in [3, 4] and image.shape[-1] == 3)): - raise NotImplementedError("Only 2D images are supported" + str(image.shape)) - height = image.shape[0] * zoom_factor - width = image.shape[1] * zoom_factor - self.zoom_factor = zoom_factor - self.zoom_spline_order = zoom_spline_order - super().__init__(width=width * zoom_factor, height=height * zoom_factor) - self.colormap = colormap - self.display_min = display_min - self.display_max = display_max - self.data = np.asarray(image) - self.layout.stretch = False - # self.layout.width = f"{width}px" - # self.layout.height = f"{height}px" - - @property - def data(self): - """Image data as numpy array - """ - return self._data - - @data.setter - def data(self, new_data): - """Take in new image data, compress to PNG, send to image widget. - """ - if new_data is None: - return - - self._data = np.asarray(new_data) - self._update_image() - self.height = self._data.shape[0] * self.zoom_factor - self.width = self._data.shape[1] * self.zoom_factor - - def _update_image(self): - if self.zoom_factor == 1.0: - self.put_image_data(_img_to_rgb(self._data, colormap=self.colormap, display_min=self.display_min, display_max=self.display_max), 0, 0) - else: - zoomed = self._zoom(self._data) - self.put_image_data(_img_to_rgb(zoomed, colormap=self.colormap, display_min=self.display_min, display_max=self.display_max), 0, 0) - - def _zoom(self, data): - if len(data.shape) > 2 and data.shape[-1] == 3: - # handle RGB images - return np.asarray([self._zoom(data[:,:,i]) for i in range(data.shape[2])]).swapaxes(0, 2).swapaxes(1, 0) - - from scipy.ndimage import affine_transform - matrix = np.asarray([[1.0 / self.zoom_factor, 0, -0.5], - [0, 1.0 / self.zoom_factor, -0.5], - [0, 0, 1], - ]) - zoomed_shape = (np.asarray(data.shape) * self.zoom_factor).astype(int) - zoomed = affine_transform(data, - matrix, - output_shape=zoomed_shape, - order=self.zoom_spline_order, - mode='nearest') - return zoomed - - -def _is_label_image(image): - return image.dtype == np.uint32 or image.dtype == np.uint64 or \ - image.dtype == np.int32 or image.dtype == np.int64 - - -def _img_to_rgb(image, - colormap=None, - display_min=None, - display_max=None): - from ._colormaps import _labels_lut, create_colormap - - if len(image.shape) > 2 and (image.shape[-1] == 3 or image.shape[-1] == 4): - return image - - if image.dtype == bool: - image = image * 1 - - if _is_label_image(image): - lut = _labels_lut() - return np.asarray([lut[:, c].take(image.astype(np.int64)) for c in range(0, 3)]).swapaxes(0, 2).swapaxes(1, 0) * 255 - - if display_min is None: - display_min = image.min() - if display_max is None: - display_max = image.max() - - diplay_range_width = (display_max - display_min) - if diplay_range_width == 0: - diplay_range_width = 1 - - image = ((image.astype(float) - display_min) / diplay_range_width * 255) - - image = np.minimum(image, 255) - image = np.maximum(image, 0).astype(np.uint8) - - if colormap is None: - return np.asarray([image, image, image]).swapaxes(0, 2).swapaxes(1, 0) - else: - lut = np.asarray(create_colormap(colormap).colors) - return np.asarray([lut[:, c].take(image.astype(int)) for c in range(0, 3)]).swapaxes(0, 2).swapaxes(1, 0) * 255 diff --git a/vis/_slice_viewer.py b/vis/_slice_viewer.py deleted file mode 100644 index 1a9f8b7..0000000 --- a/vis/_slice_viewer.py +++ /dev/null @@ -1,119 +0,0 @@ -import warnings - -import numpy as np - -class _SliceViewer(): - def __init__(self, - image, - slice_number: int = None, - continuous_update: bool = True, - slider_text: str = "[{}]", - zoom_factor:float = 1.0, - zoom_spline_order:int = 0, - colormap:str = None, - display_min:float = None, - display_max:float = None, - ): - import ipywidgets - from ._image_widget import ImageWidget - from ._uint_field import intSlider, floatRangeSlider - self.update_active = True - - self.true_image = image - self.image = image - - if slice_number is None: - slice_number = [int(s / 2) for s in image.shape[:-2]] - if isinstance(slice_number, int): - slice_number = [slice_number] * (len(image.shape) - 2) - if not isinstance(slider_text, list): - slider_text = [slider_text] * (len(image.shape) - 2) - - self.sliders = [] - offset = 2 - if image.shape[-1] == 3 or image.shape[-1] == 4: # RGB or RGBA images - offset = 3 - - for d in range(len(image.shape) - offset): - slider = intSlider( - value=slice_number[d], - min=0, - max=image.shape[d] - 1, - continuous_update=continuous_update, - description=slider_text[d].format(d) - ) - slider.layout.width = '100%' # Make the slider full-width - - slider.observe(self.update) - self.sliders.append(slider) - - self.view = ImageWidget(self.get_view_slice(), - zoom_factor=zoom_factor, - zoom_spline_order=zoom_spline_order, - colormap=colormap, - display_min=display_min, - display_max=display_max) - - - # # --- NEW: display_max and display_min slider ------------------------------------------ - # auto-range: from image min to max - im_min = float(np.nanmin(image)) - im_max = float(np.nanmax(image)) - - self.display_range_slider = floatRangeSlider( - value=(display_min if display_min is not None else im_min, - display_max if display_max is not None else im_max), - min=im_min, - max=im_max, - step=(im_max - im_min) / 1000.0, - description="Range", - continuous_update=True - ) - self.display_range_slider.layout.width = '100%' - self.display_range_slider.observe(self.update_display_range) - # # ----------------------------------------------------------------------- - - # setup user interface for changing the slice - - self.slice_slider = ipywidgets.VBox(self.sliders[::-1]) - - self.controls = [self.slice_slider, self.display_range_slider] - - self.update() - - - def update_display_range(self, event=None): - if event is None or event["name"] != "value": - return - self.view.display_min = event["new"][0] - self.view.display_max = event["new"][1] - self.update() - - def set_image(self, image): - for i, s in enumerate(self.sliders): - s._set_value_min_max(int(image.shape[i] / 2), 0, image.shape[i] - 1) - self.image = image - - def observe(self, x): - self.update_active = False - for s in self.sliders: - s.observe(x) - - # event handler when the user changed something: - def update(self, event=None): - if self.update_active: - self.view.data = self.get_view_slice() - - def configuration_updated(self, event=None): - warnings.warn('SliceViewer.configuration_updated is deprecated, use SliceViewer.update instead.') - return self.update(event) - - def get_view_slice(self, data=None): - if data is None: - data = self.image - for d, slider in enumerate(self.sliders): - data = np.take(data, slider.value, axis=0) - return data - - def get_slice_index(self): - return [s.value for s in self.sliders] diff --git a/vis/_static_view.py b/vis/_static_view.py deleted file mode 100644 index f28664c..0000000 --- a/vis/_static_view.py +++ /dev/null @@ -1,283 +0,0 @@ -import numpy as np -from typing import Callable -from functools import lru_cache, wraps -from toolz import curry - -def insight(image, library_name=None, help_url=None): - """ - Converts a numpy-array-like image to numpy-compatible array with a convenient display in Jupyter notebooks - including shape, min/max intensity, histogram and viewing 32-bit and 64-bit integer images as coloured labels. - """ - return StackViewNDArray(image, library_name, help_url) - -@curry -def jupyter_displayable_output( - function: Callable, - library_name:str = None, - help_url:str = None -) -> Callable: - """Wraps a given function so that it outputs a nice image view in jupyter notebooks - The view will contain a link to the specified library to guide users to read more. - """ - @wraps(function) - def worker_function(*args, **kwargs): - # call the decorated function - result = function(*args, **kwargs) - - # Attach _repr_html_ function - result = StackViewNDArray(result, library_name, help_url) - - return result - - return worker_function - - -class StackViewNDArray(np.ndarray): - - def __new__(cls, input_array, library_name=None, help_url=None): - if 'cupy.ndarray' in str(type(input_array)): - input_array = input_array.get() - input_array = np.asarray(input_array) - obj = super(StackViewNDArray, cls).__new__(cls, shape=input_array.shape, dtype=input_array.dtype) - obj.library_name = library_name - obj.help_url = help_url - obj[:] = input_array - return obj - - def __array_finalize__(self, obj): - if obj is None: return - self.library_name = getattr(obj, 'library_name', None) - self.help_url = getattr(obj, 'help_url', None) - #self.obj = obj - - def __repr__(self): - if _is_running_in_colab(): - from IPython.display import display, HTML - display(HTML(self._repr_html_())) - return "" - else: - return str(self) - - - def _repr_html_(self): - """HTML representation of the image object for IPython. - Returns - ------- - HTML text with the image and some properties. - """ - if len(self.shape) < 2: - return str(self) - - import numpy as np - size_in_pixels = np.prod(self.shape) - size_in_bytes = size_in_pixels * self.dtype.itemsize - - from ._image_widget import _is_label_image - labels = _is_label_image(self) - - import matplotlib.pyplot as plt - from ._imshow import imshow - imshow(self, - labels=labels, - continue_drawing=True, - colorbar=not labels) - image = _png_to_html(_plt_to_png()) - - if size_in_bytes > 1024: - size_in_bytes = size_in_bytes / 1024 - if size_in_bytes > 1024: - size_in_bytes = size_in_bytes / 1024 - if size_in_bytes > 1024: - size_in_bytes = size_in_bytes / 1024 - size = "{:.1f}".format(size_in_bytes) + " GB" - else: - size = "{:.1f}".format(size_in_bytes) + " MB" - else: - size = "{:.1f}".format(size_in_bytes) + " kB" - else: - size = "{:.1f}".format(size_in_bytes) + " B" - - histogram = "" - - if size_in_bytes < 100 * 1024 * 1024: - if not labels: - import numpy as np - - num_bins = 32 - h, _ = np.histogram(self, bins=num_bins) - - plt.figure(figsize=(1.8, 1.2)) - plt.bar(range(0, len(h)), h) - - # hide axis text - # https://stackoverflow.com/questions/2176424/hiding-axis-text-in-matplotlib-plots - # https://pythonguides.com/matplotlib-remove-tick-labels - frame1 = plt.gca() - frame1.axes.xaxis.set_ticklabels([]) - frame1.axes.yaxis.set_ticklabels([]) - plt.tick_params(left=False, bottom=False) - - histogram = _png_to_html(_plt_to_png()) - - min_intensity = self.min() - max_intensity = self.max() - min_max = "min" + str(min_intensity) + "" + \ - "max" + str(max_intensity) + "" - - if labels: - unique_labels = list(np.unique(self)) - if 0 in unique_labels: - unique_labels.remove(0) - - num_labels = len(unique_labels) - min_max += "n labels" + str(num_labels) + "" - if min_intensity < 0: - min_max += "Negative label values detected!" - else: - if max_intensity != num_labels: - min_max += "Not sequentially labeled!" - - else: - min_max = "" - - help_text = "" - if self.library_name is not None and len(self.library_name) > 0: - self.library_name = self.library_name + " made " - if self.help_url is not None: - help_text = "" + self.library_name + "image
" - - all = [ - "", - "", - "", - "", - "", - "
", - image, - "", - help_text, - "", - "", - "", - "", - min_max, - "
shape" + str(self.shape).replace(" ", " ") + "
dtype" + str(self.dtype) + "
size" + size + "
", - histogram, - "
", - ] - - return "\n".join(all) - -@lru_cache(maxsize=None) -def _is_running_in_colab(): - try: - import google.colab - return True - except ImportError: - return False - - -def _png_to_html(png): - import base64 - url = 'data:image/png;base64,' + base64.b64encode(png).decode('utf-8') - return f'' - - -# adapted from https://github.com/napari/napari/blob/d6bc683b019c4a3a3c6e936526e29bbd59cca2f4/napari/utils/notebook_display.py#L54-L73 -def _plt_to_png(): - """PNG representation of the image object for IPython. - Returns - ------- - In memory binary stream containing a PNG matplotlib image. - """ - import matplotlib.pyplot as plt - from io import BytesIO - - with BytesIO() as file_obj: - plt.savefig(file_obj, format='png') - plt.close() # supress plot output - file_obj.seek(0) - png = file_obj.read() - return png - - -def _imshow(image, title: str = None, labels: bool = False, min_display_intensity: float = None, - max_display_intensity: float = None, plot=None, colorbar: bool = False, colormap=None, - alpha: float = None, continue_drawing: bool = False): - """Visualize an image, e.g. in Jupyter notebooks. - - Parameters - ---------- - image: np.ndarray - numpy or OpenCL-backed image to visualize - title: str - Obsolete (kept for ImageJ-compatibility) - labels: bool - True: integer labels will be visualized with colors - False: Specified or default colormap will be used to display intensities. - min_display_intensity: float - lower limit for display range - max_display_intensity: float - upper limit for display range - color_map: str - deprecated, use colormap instead - plot: matplotlib axis - Plot object where the image should be shown. Useful for putting multiple images in subfigures. - colorbar: bool - True puts a colorbar next to the image. Will not work with label images and when visualizing multiple - images (continue_drawing=True). - colormap: str or matplotlib colormap - alpha: float - alpha blending value - continue_drawing: float - True: the next shown image can be visualized on top of the current one, e.g. with alpha = 0.5 - """ - return - import numpy as np - if len(image.shape) == 3 and image.shape[2] == 3: # RGB image - import matplotlib.pyplot as plt - plt.imshow(image, vmin=min_display_intensity, vmax=max_display_intensity, - interpolation='nearest', alpha=alpha) - if not continue_drawing: - plt.show() - return - - if len(image.shape) == 3: - image = np.asarray(image).max(axis=0) - - image = np.asarray(image) - if len(image.shape) == 1: - image = image[np.newaxis] - - if colormap is None: - colormap = "Greys_r" - - cmap = colormap - if labels: - import matplotlib - import numpy as np - - if not hasattr(imshow, "labels_cmap"): - from ._image_widget import _labels_lut - imshow.labels_cmap = matplotlib.colors.ListedColormap(_labels_lut()) - cmap = imshow.labels_cmap - - if min_display_intensity is None: - min_display_intensity = 0 - if max_display_intensity is None: - max_display_intensity = 65536 - - if plot is None: - import matplotlib.pyplot as plt - plt.clf() - plt.imshow(image, cmap=cmap, vmin=min_display_intensity, vmax=max_display_intensity, - interpolation='nearest', alpha=alpha) - if colorbar: - plt.colorbar() - if not continue_drawing: - plt.show() - else: - plot.imshow(image, cmap=cmap, vmin=min_display_intensity, vmax=max_display_intensity, - interpolation='nearest', alpha=alpha) - if colorbar: - plot.colorbar() diff --git a/vis/_uint_field.py b/vis/_uint_field.py deleted file mode 100644 index 1ed587f..0000000 --- a/vis/_uint_field.py +++ /dev/null @@ -1,180 +0,0 @@ -import ipywidgets - -class UIntField(ipywidgets.HBox): - """ - A text field where users can select a positive integer number from. - Similar to ipywidgets.IntText - """ - - def __init__(self, value): - self._value = value - self._label = ipywidgets.Label(str(value)) - layout = ipywidgets.Layout(min_width='10px', max_width='30px') - self._button_up = ipywidgets.Button(description="+", layout=layout) - self._button_down = ipywidgets.Button(description="-", layout=layout) - super().__init__([self._button_down, self._label, self._button_up]) - - def increase(event=None): - self.value = self.value + 1 - - def decrease(event=None): - self.value = self.value - 1 - - self._button_up.on_click(increase) - self._button_down.on_click(decrease) - - @property - def value(self): - return self._value - - @value.setter - def value(self, value): - if value >= 0: - self._value = value - self._label.value = str(value) - -def intSlider(min:int=0, max:int=100, step:int=1, value:int=1, continuous_update:bool=True, description:str=""): - slider = ipywidgets.IntSlider( - value=value, - min=min, - max=max, - step=step, - continuous_update=continuous_update - ) - slider.layout.width = '100%' - - label = ipywidgets.Label(description) - - custom_css = "" - html = ipywidgets.HTML(custom_css) - - box = ipywidgets.VBox([ipywidgets.HBox([label, slider]),html]) - - def observe(*args, **kwargs): - slider.observe(*args, **kwargs) - box.observe = observe - - def update(event=None): - box.value = slider.value - - slider.observe(update) - - def _set_value_min_max(value, min, max): - slider.value = value - slider.min = min - slider.max = max - - box._set_value_min_max = _set_value_min_max - - update() - - return box - -def floatSlider(min:float=0.0, - max:float=1.0, - step:float=0.01, - value:float=0.5, - continuous_update:bool=True, - description:str=""): - """ - A slider widget for selecting a floating-point value. - Mirrors the structure and behavior of intSlider, - but uses ipywidgets.FloatSlider internally. - """ - - slider = ipywidgets.FloatSlider( - value=value, - min=min, - max=max, - step=step, - continuous_update=continuous_update - ) - slider.layout.width = '100%' - - label = ipywidgets.Label(description) - - # same CSS trick you used for intSlider - custom_css = "" - html = ipywidgets.HTML(custom_css) - - # container exactly like your intSlider - box = ipywidgets.VBox([ipywidgets.HBox([label, slider]), html]) - - # wrap observe so external code can attach callbacks - def observe(*args, **kwargs): - slider.observe(*args, **kwargs) - box.observe = observe - - # simple update callback - def update(event=None): - box.value = slider.value - - slider.observe(update) - - # allow external adjustment of (value, min, max) - def _set_value_min_max(value, new_min, new_max): - slider.min = new_min - slider.max = new_max - slider.value = value - - def _set_min(new_min): - slider.min = new_min - - def _set_max(new_max): - slider.max = new_max - - box._set_value_min_max = _set_value_min_max - box._set_min = _set_min - box._set_max = _set_max - - # initialize box.value - update() - - return box - - -def floatRangeSlider(min:float=0.0, - max:float=1.0, - step:float=0.01, - value:tuple=(0.25, 0.75), - continuous_update:bool=True, - description:str=""): - """ - A range slider widget for selecting a range of floating-point values. - """ - - slider = ipywidgets.FloatRangeSlider( - value=value, - min=min, - max=max, - step=step, - continuous_update=continuous_update - ) - slider.layout.width = '100%' - - label = ipywidgets.Label(description) - - custom_css = "" - html = ipywidgets.HTML(custom_css) - - box = ipywidgets.VBox([ipywidgets.HBox([label, slider]), html]) - - def observe(*args, **kwargs): - slider.observe(*args, **kwargs) - box.observe = observe - - def update(event=None): - box.value = slider.value - - slider.observe(update) - - def _set_value_min_max(value, new_min, new_max): - slider.min = new_min - slider.max = new_max - slider.value = value - - box._set_value_min_max = _set_value_min_max - - update() - - return box \ No newline at end of file diff --git a/vis/_utilities.py b/vis/_utilities.py deleted file mode 100644 index bb2d2a7..0000000 --- a/vis/_utilities.py +++ /dev/null @@ -1,98 +0,0 @@ -import numpy as np -def merge_rgb(image_red, image_green, image_blue): - """ - Turns three images (channels) into a scikit-image-style RGB image - with the last dimension being the channel axis. - """ - return np.asarray([image_red, image_green, image_blue]).swapaxes(0, 2) - -def is_image(image): - return isinstance(image, np.ndarray) or \ - str(type(image)) in ["", - "", - "", - "", - "", - "", - ""] - # isinstance(image, tuple) or \ - # isinstance(image, list) or \ - - -def is_label_image(image): - if isinstance(image, tuple) or \ - isinstance(image, list): - return False - - return image.dtype == np.uint32 or image.dtype == np.uint64 or \ - image.dtype == np.int32 or image.dtype == np.int64 - - -def count_image_parameters(func): - import inspect - try: - sig = inspect.signature(func) - except: - return 0 - - n = 0 - - for i, key in enumerate(list(sig.parameters.keys())): - if parameter_is_image_parameter(sig.parameters[key]): - n = n + 1 - return n - - -def parameter_is_image_parameter(parameter): - type_annotation = str(parameter.annotation) - name = parameter.name - - return ("NewType..new_type" in type_annotation or \ - "Image" in type_annotation or \ - "LabelsData" in type_annotation or \ - "LayerData" in type_annotation or \ - "image" in name.lower() or \ - "label" in name.lower() or \ - "mask" in name.lower() - ) - -def _no_resize(widget): - import ipywidgets - return ipywidgets.HBox([ipywidgets.VBox([widget])]) - - - - -def numpy_to_gif_bytestream(timelapse, frame_delay_ms=100, num_loops=1000): - """Turn a NumPy array into a bytestream""" - import numpy as np - import imageio - import io - - # Convert the NumPy array to a PIL Image - # image = Image.fromarray(data.astype(np.uint8)).convert("RGBA") - - # Create a BytesIO object - bytes_io = io.BytesIO() - - # Save the PIL image to the BytesIO object as a PNG - # image.save(bytes_io, format='GIF') - - with imageio.get_writer(bytes_io, mode='I', duration=frame_delay_ms, loop=num_loops, format="GIF") as writer: - for frame in timelapse: - writer.append_data(frame.astype(np.uint8)) - - # return the beginning of the file as a bytestream - bytes_io.seek(0) - return bytes_io.read() - - -def _gif_to_html(gif, width=None): - import base64 - - style = "" - if width is not None: - style = f"style=\"width: {width}px;\"" - - url = 'data:image/gif;base64,' + base64.b64encode(gif).decode('utf-8') - return f'' diff --git a/vis/_vis.py b/vis/_vis.py deleted file mode 100644 index 00b5491..0000000 --- a/vis/_vis.py +++ /dev/null @@ -1,132 +0,0 @@ -import numpy -import ipywidgets -from ._slice_viewer import _SliceViewer -from ._utilities import _no_resize -from ipyevents import Event - -def vis( - image, - slice_number: int = None, - slider_text: str = "Slice", - display_min: float = None, - display_max: float = None, - mode: str = 'mag' -): - """Shows an image stack with controls for slice and display range, plus a label with the current mouse position and intensity at that position. - - Parameters - ---------- - image : image - Image shown - slice_number : int, optional - Slice-position in the stack - continuous_update : bool, optional - Update the image while dragging the mouse, default: False - slider_text: str, optional - Text shown on the slider - zoom_factor: float, optional - Allows showing the image larger (> 1) or smaller (<1) - zoom_spline_order: int, optional - Spline order used for interpolation (default=0, nearest-neighbor) - colormap: str, optional - Matplotlib colormap name or "pure_green", "pure_magenta", ... - display_min: float, optional - Lower bound of properly shown intensities - display_max: float, optional - Upper bound of properly shown intensities - - Returns - ------- - An ipywidget with an image display, a slider and a label showing mouse position and intensity. - """ - - image = numpy.transpose(image, (2, 1, 0)) - if numpy.iscomplexobj(image): - modes = ['mag', 'real', 'imag', 'phase'] - else: - modes = ['real', 'mag'] - true_image = image.copy() - - def get_image_mode(im, mo): - if mo == 'mag': - im = numpy.abs(im) - elif numpy.iscomplexobj(im): - if mo == 'real': - im = numpy.real(im) - elif mo == 'imag': - im = numpy.imag(im) - elif mo == 'phase': - im = numpy.angle(im) - else: - mo = 'real' - return im - - image = get_image_mode(true_image, mode) - - viewer = _SliceViewer(image, - slice_number=slice_number, - continuous_update=True, - slider_text=slider_text, - colormap=None, - display_min=display_min, - display_max=display_max - ) - view = viewer.view - label = ipywidgets.Label("():") - - mode_dropdown = ipywidgets.Dropdown( - options=modes, - value=mode, - # description='Mode:', - disabled=False, - layout=ipywidgets.Layout(margin='0px 20px 0px 0px') - ) - - def on_mode_change(event=None): - if event is None or event["name"] != "value": - return - mode = event['new'] - image = get_image_mode(true_image, mode) - viewer.set_image(image) - - new_min_value = min(viewer.display_range_slider.value[0], image.min()) - new_max_value = max(viewer.display_range_slider.value[1], image.max()) - viewer.display_range_slider._set_value_min_max((new_min_value, new_max_value), image.min(), image.max()) - viewer.update() - - mode_dropdown.observe(on_mode_change) - - modelabel = ipywidgets.Label('Mode:') - mode_box = ipywidgets.HBox([ipywidgets.HBox([modelabel, mode_dropdown])]) - - event_handler = Event(source=view, watched_events=['mousemove']) - - image_shape = image.shape - - def update_display(event=None): - slice_number = viewer.get_slice_index() - bbox_width, bbox_height = int(event['boundingRectWidth']), int(event['boundingRectHeight']) - relative_position_x = event['relativeX'] - relative_position_y = event['relativeY'] - - relative_position_x_scaled = relative_position_x / bbox_width - relative_position_y_scaled = relative_position_y / bbox_height - - absolute_position_x = int(relative_position_x_scaled * image_shape[2]) - absolute_position_y = int(relative_position_y_scaled * image_shape[1]) - - intensity = image[slice_number, absolute_position_y, absolute_position_x].item() - label.value = f"({absolute_position_x}, {absolute_position_y}, {slice_number[0]}) = {intensity:03f}" - - event_handler.on_dom_event(update_display) - - display_elements = [_no_resize(view)] - display_elements.extend(viewer.controls) - # display_elements.append(mode_dropdown) - # display_elements.append(label) - - display_elements.append(ipywidgets.HBox([mode_box, label])) - - result = _no_resize(ipywidgets.VBox(display_elements, stretch=False)) - result.update = update_display - return result