Source code for fastplotlib.tools._histogram_lut
from math import ceil
import weakref
import numpy as np
import pygfx
from ..utils import subsample_array
from ..graphics import LineGraphic, ImageGraphic, TextGraphic
from ..graphics.utils import pause_events
from ..graphics._base import Graphic
from ..graphics.selectors import LinearRegionSelector
def _get_image_graphic_events(image_graphic: ImageGraphic) -> list[str]:
"""Small helper function to return the relevant events for an ImageGraphic"""
events = ["vmin", "vmax"]
if not image_graphic.data.value.ndim > 2:
events.append("cmap")
# if RGB(A), do not add cmap
return events
# TODO: This is a widget, we can think about a BaseWidget class later if necessary
[docs]
class HistogramLUTTool(Graphic):
def __init__(
self,
data: np.ndarray,
image_graphic: ImageGraphic,
nbins: int = 100,
flank_divisor: float = 5.0,
**kwargs,
):
"""
Parameters
----------
data
image_graphic
nbins: int, defaut 100.
Total number of bins used in the histogram
flank_divisor: float, default 5.0.
Fraction of empty histogram bins on the tails of the distribution set `np.inf` for no flanks
kwargs
"""
super().__init__(**kwargs)
self._nbins = nbins
self._flank_divisor = flank_divisor
self._image_graphic = image_graphic
self._data = weakref.proxy(data)
self._scale_factor: float = 1.0
hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data)
line_data = np.column_stack([hist_scaled, edges_flanked])
self._histogram_line = LineGraphic(line_data)
bounds = (edges[0] * self._scale_factor, edges[-1] * self._scale_factor)
limits = (edges_flanked[0], edges_flanked[-1])
size = 120 # since it's scaled to 100
origin = (hist_scaled.max() / 2, 0)
self._linear_region_selector = LinearRegionSelector(
selection=bounds,
limits=limits,
size=size,
center=origin[0],
axis="y",
edge_thickness=8,
parent=self._histogram_line,
)
# there will be a small difference with the histogram edges so this makes them both line up exactly
self._linear_region_selector.selection = (
self._image_graphic.vmin * self._scale_factor,
self._image_graphic.vmax * self._scale_factor,
)
self._vmin = self.image_graphic.vmin
self._vmax = self.image_graphic.vmax
vmin_str, vmax_str = self._get_vmin_vmax_str()
self._text_vmin = TextGraphic(
text=vmin_str,
font_size=16,
offset=(0, 0, 0),
anchor="top-left",
outline_color="black",
outline_thickness=1,
)
self._text_vmin.world_object.material.pick_write = False
self._text_vmax = TextGraphic(
text=vmax_str,
font_size=16,
offset=(0, 0, 0),
anchor="bottom-left",
outline_color="black",
outline_thickness=1,
)
self._text_vmax.world_object.material.pick_write = False
widget_wo = pygfx.Group()
widget_wo.add(
self._histogram_line.world_object,
self._linear_region_selector.world_object,
self._text_vmin.world_object,
self._text_vmax.world_object,
)
self._set_world_object(widget_wo)
self.world_object.local.scale_x *= -1
self._text_vmin.offset = (-120, self._linear_region_selector.selection[0], 0)
self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0)
self._linear_region_selector.add_event_handler(
self._linear_region_handler, "selection"
)
ig_events = _get_image_graphic_events(self.image_graphic)
self.image_graphic.add_event_handler(self._image_cmap_handler, *ig_events)
# colorbar for grayscale images
if self.image_graphic.data.value.ndim != 3:
self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked)
self._colorbar.add_event_handler(self._open_cmap_picker, "click")
self.world_object.add(self._colorbar.world_object)
else:
self._colorbar = None
self._cmap = None
def _make_colorbar(self, edges_flanked) -> ImageGraphic:
# use the histogram edge values as data for an
# image with 2 columns, this will be our colorbar!
colorbar_data = np.column_stack(
[
np.linspace(
edges_flanked[0], edges_flanked[-1], ceil(np.ptp(edges_flanked))
)
]
* 2
).astype(np.float32)
colorbar_data /= self._scale_factor
cbar = ImageGraphic(
data=colorbar_data,
vmin=self.vmin,
vmax=self.vmax,
cmap=self.image_graphic.cmap,
interpolation="linear",
offset=(-55, edges_flanked[0], -1),
)
cbar.world_object.world.scale_x = 20
self._cmap = self.image_graphic.cmap
return cbar
def _get_vmin_vmax_str(self) -> tuple[str, str]:
if self.vmin < 0.001 or self.vmin > 99_999:
vmin_str = f"{self.vmin:.2e}"
else:
vmin_str = f"{self.vmin:.2f}"
if self.vmax < 0.001 or self.vmax > 99_999:
vmax_str = f"{self.vmax:.2e}"
else:
vmax_str = f"{self.vmax:.2f}"
return vmin_str, vmax_str
def _fpl_add_plot_area_hook(self, plot_area):
self._plot_area = plot_area
self._linear_region_selector._fpl_add_plot_area_hook(plot_area)
self._histogram_line._fpl_add_plot_area_hook(plot_area)
self._plot_area.auto_scale()
self._plot_area.controller.enabled = True
def _calculate_histogram(self, data):
# get a subsampled view of this array
data_ss = subsample_array(data, max_size=int(1e6)) # 1e6 is default
hist, edges = np.histogram(data_ss, bins=self._nbins)
# used if data ptp <= 10 because event things get weird
# with tiny world objects due to floating point error
# so if ptp <= 10, scale up by a factor
data_interval = edges[-1] - edges[0]
self._scale_factor: int = max(1, 100 * int(10 / data_interval))
edges = edges * self._scale_factor
bin_width = edges[1] - edges[0]
flank_nbins = int(self._nbins / self._flank_divisor)
flank_size = flank_nbins * bin_width
flank_left = np.arange(edges[0] - flank_size, edges[0], bin_width)
flank_right = np.arange(
edges[-1] + bin_width, edges[-1] + flank_size, bin_width
)
edges_flanked = np.concatenate((flank_left, edges, flank_right))
hist_flanked = np.concatenate(
(np.zeros(flank_nbins), hist, np.zeros(flank_nbins))
)
# scale 0-100 to make it easier to see
# float32 data can produce unnecessarily high values
hist_scale_value = hist_flanked.max()
if np.allclose(hist_scale_value, 0):
hist_scale_value = 1
hist_scaled = hist_flanked / (hist_scale_value / 100)
if edges_flanked.size > hist_scaled.size:
# we don't care about accuracy here so if it's off by 1-2 bins that's fine
edges_flanked = edges_flanked[: hist_scaled.size]
return hist, edges, hist_scaled, edges_flanked
def _linear_region_handler(self, ev):
# must use world coordinate values directly from selection()
# otherwise the linear region bounds jump to the closest bin edges
selected_ixs = self._linear_region_selector.selection
vmin, vmax = selected_ixs[0], selected_ixs[1]
vmin, vmax = vmin / self._scale_factor, vmax / self._scale_factor
self.vmin, self.vmax = vmin, vmax
def _image_cmap_handler(self, ev):
setattr(self, ev.type, ev.info["value"])
@property
def cmap(self) -> str:
return self._cmap
@cmap.setter
def cmap(self, name: str):
if self._colorbar is None:
return
with pause_events(self.image_graphic):
self.image_graphic.cmap = name
self._cmap = name
self._colorbar.cmap = name
@property
def vmin(self) -> float:
return self._vmin
@vmin.setter
def vmin(self, value: float):
with pause_events(self.image_graphic, self._linear_region_selector):
# must use world coordinate values directly from selection()
# otherwise the linear region bounds jump to the closest bin edges
self._linear_region_selector.selection = (
value * self._scale_factor,
self._linear_region_selector.selection[1],
)
self.image_graphic.vmin = value
self._vmin = value
if self._colorbar is not None:
self._colorbar.vmin = value
vmin_str, vmax_str = self._get_vmin_vmax_str()
self._text_vmin.offset = (-120, self._linear_region_selector.selection[0], 0)
self._text_vmin.text = vmin_str
@property
def vmax(self) -> float:
return self._vmax
@vmax.setter
def vmax(self, value: float):
with pause_events(self.image_graphic, self._linear_region_selector):
# must use world coordinate values directly from selection()
# otherwise the linear region bounds jump to the closest bin edges
self._linear_region_selector.selection = (
self._linear_region_selector.selection[0],
value * self._scale_factor,
)
self.image_graphic.vmax = value
self._vmax = value
if self._colorbar is not None:
self._colorbar.vmax = value
vmin_str, vmax_str = self._get_vmin_vmax_str()
self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0)
self._text_vmax.text = vmax_str
[docs]
def set_data(self, data, reset_vmin_vmax: bool = True):
hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data)
line_data = np.column_stack([hist_scaled, edges_flanked])
# set x and y vals
self._histogram_line.data[:, :2] = line_data
bounds = (edges[0], edges[-1])
limits = (edges_flanked[0], edges_flanked[-11])
origin = (hist_scaled.max() / 2, 0)
if reset_vmin_vmax:
# reset according to the new data
self._linear_region_selector.limits = limits
self._linear_region_selector.selection = bounds
else:
with pause_events(self.image_graphic, self._linear_region_selector):
# don't change the current selection
self._linear_region_selector.limits = limits
self._data = weakref.proxy(data)
if self._colorbar is not None:
self._colorbar.clear_event_handlers()
self.world_object.remove(self._colorbar.world_object)
if self.image_graphic.data.value.ndim != 3:
self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked)
self._colorbar.add_event_handler(self._open_cmap_picker, "click")
self.world_object.add(self._colorbar.world_object)
else:
self._colorbar = None
self._cmap = None
# reset plotarea dims
self._plot_area.auto_scale()
@property
def image_graphic(self) -> ImageGraphic:
return self._image_graphic
@image_graphic.setter
def image_graphic(self, graphic):
if not isinstance(graphic, ImageGraphic):
raise TypeError(
f"HistogramLUTTool can only use ImageGraphic types, you have passed: {type(graphic)}"
)
if self._image_graphic is not None:
# cleanup events from current image graphic
ig_events = _get_image_graphic_events(self._image_graphic)
self._image_graphic.remove_event_handler(
self._image_cmap_handler, *ig_events
)
self._image_graphic = graphic
ig_events = _get_image_graphic_events(self._image_graphic)
self.image_graphic.add_event_handler(self._image_cmap_handler, *ig_events)
[docs]
def disconnect_image_graphic(self):
ig_events = _get_image_graphic_events(self._image_graphic)
self._image_graphic.remove_event_handler(self._image_cmap_handler, *ig_events)
del self._image_graphic
# self._image_graphic = None
def _open_cmap_picker(self, ev):
# check if right click
if ev.button != 2:
return
pos = ev.x, ev.y
self._plot_area.get_figure().open_popup("colormap-picker", pos, lut_tool=self)
def _fpl_prepare_del(self):
self._linear_region_selector._fpl_prepare_del()
self._histogram_line._fpl_prepare_del()
del self._histogram_line
del self._linear_region_selector