# Atlas Editor with orthogonal viewing
# Author: David Young, 2018, 2020
"""Atlas editing GUI in the MagellanMapper package.
"""
import datetime
import os
from typing import Sequence
from matplotlib import pyplot as plt
from matplotlib import figure
from matplotlib import gridspec
from matplotlib.widgets import Slider, Button, TextBox
from magmap.cv import cv_nd
from magmap.gui import image_viewer, plot_editor
from magmap.io import libmag, naming, sitk_io
from magmap.plot import colormaps, plot_support
from magmap.settings import config
[docs]
class AtlasEditor(plot_support.ImageSyncMixin):
"""Graphical interface to view an atlas in multiple orthogonal
dimensions and edit atlas labels.
:attr:`plot_eds` are dictionaries of keys specified by one of
:const:`magmap.config.PLANE` plane orientations to Plot Editors.
Attributes:
labels_img: Numpy image array in z,y,x format.
channel: Channel of the image to display.
offset: Index of plane at which to start viewing in x,y,z (user)
order.
fn_close_listener: Handle figure close events.
borders_img: Numpy image array in z,y,x,[c] format to show label
borders, such as that generated during label smoothing.
Defaults to None. If this image has a different number of
labels than that of ``labels_img``, a new colormap will
be generated.
fn_show_label_3d: Function to call to show a label in a
3D viewer. Defaults to None.
title (str): Window title; defaults to None.
fn_refresh_atlas_eds (func): Callback for refreshing other
Atlas Editors to synchronize them; defaults to None.
Typically takes one argument, this ``AtlasEditor`` object
to refreshing it. Defaults to None.
alpha_slider: Matplotlib alpha slider control.
alpha_reset_btn: Maplotlib button for resetting alpha transparency.
alpha_last: Float specifying the previous alpha value.
interp_planes: Current :class:`InterpolatePlanes` object.
interp_btn: Matplotlib button to initiate plane interpolation.
save_btn: Matplotlib button to save the atlas.
fn_status_bar (func): Function to call during status bar updates
in :class:`pixel_display.PixelDisplay`; defaults to None.
fn_update_coords (func): Handler for coordinate updates, which
takes coordinates in z-plane orientation; defaults to None.
"""
_EDIT_BTN_LBLS = ("Edit", "Editing")
def __init__(self, img5d, labels_img, channel, offset, fn_close_listener,
borders_img=None, fn_show_label_3d=None, title=None,
fn_refresh_atlas_eds=None, fig=None, fn_status_bar=None):
"""Plot ROI as sequence of z-planes containing only the ROI itself."""
super().__init__(img5d)
self.labels_img = labels_img
self.channel = channel
self.offset = offset
self.fn_close_listener = fn_close_listener
self.borders_img = borders_img
self.fn_show_label_3d = fn_show_label_3d
self.title = title
self.fn_refresh_atlas_eds = fn_refresh_atlas_eds
self.fig = fig
self.fn_status_bar = fn_status_bar
self.alpha_slider = None
self.alpha_reset_btn = None
self.alpha_last = None
self.interp_planes = None
self.interp_btn = None
self.save_btn = None
self.edit_btn = None
self.color_picker_box = None
self.fn_update_coords = None
#: Sequence of planes to show in the Plot Editors.
#: Planes are given as axis-border strings (eg "xy"). Defaults
#: to :attr:`magmap.settings.config.PLANE`. Plots are ordered
#: clockwise from the left of the Atlas Editor and can be None to
#: remove the plot.
self.planes: Sequence[str] = config.PLANE
self._labels_img_sitk = None # for saving labels image
[docs]
def show_atlas(self):
"""Set up the atlas display with multiple orthogonal views."""
# set up the figure
if self.fig is None:
fig = figure.Figure(self.title)
self.fig = fig
else:
fig = self.fig
fig.clear()
gs = gridspec.GridSpec(
2, 1, wspace=0.1, hspace=0.1, height_ratios=(20, 1), figure=fig,
left=0.06, right=0.94, bottom=0.02, top=0.98)
gs_viewers = gridspec.GridSpecFromSubplotSpec(
2, 2, subplot_spec=gs[0, 0])
blitter = image_viewer.Blitter(fig)
# lay out plot editors based on number shown
planes = [p for p in self.planes if p]
nplanes = len(planes)
if nplanes == 0:
# no planes
viewers = tuple()
elif nplanes == 1:
# show single plot editor filling the frame
viewers = (gs_viewers[:2, :2],)
elif nplanes == 2:
if self.planes[0]:
# show plot editors side-by-side
viewers = (gs_viewers[:2, 0], gs_viewers[:2, 1])
else:
# show plot editors on top of one another
viewers = (gs_viewers[0, :2], gs_viewers[1, :2])
else:
# vertical plot on left, two plots above one another on right
viewers = (gs_viewers[:2, 0], gs_viewers[0, 1], gs_viewers[1, 1])
# set up a colormap for the borders image if present
cmap_borders = colormaps.get_borders_colormap(
self.borders_img, self.labels_img, config.cmap_labels)
coord = list(self.offset[::-1])
# editor controls, split into a slider sub-spec to allow greater
# spacing for labels on either side and a separate sub-spec for
# buttons and other fields
gs_controls = gridspec.GridSpecFromSubplotSpec(
1, 2, subplot_spec=gs[1, 0], width_ratios=(1, 1),
wspace=0.15)
self.alpha_slider = Slider(
fig.add_subplot(gs_controls[0, 0]), "Opacity", 0.0, 1.0,
valinit=plot_editor.PlotEditor.ALPHA_DEFAULT)
gs_controls_btns = gridspec.GridSpecFromSubplotSpec(
1, 5, subplot_spec=gs_controls[0, 1], wspace=0.1)
self.alpha_reset_btn = Button(
fig.add_subplot(gs_controls_btns[0, 0]), "Reset")
self.interp_btn = Button(
fig.add_subplot(gs_controls_btns[0, 1]), "Fill Label")
self.interp_planes = InterpolatePlanes(self.interp_btn)
self.interp_planes.update_btn()
self.save_btn = Button(
fig.add_subplot(gs_controls_btns[0, 2]), "Save")
self.edit_btn = Button(
fig.add_subplot(gs_controls_btns[0, 3]), "Edit")
self.color_picker_box = TextBox(
fig.add_subplot(gs_controls_btns[0, 4]), None)
# adjust button colors based on theme and enabled status; note
# that colors do not appear to refresh until fig mouseover
for btn in (self.alpha_reset_btn, self.edit_btn):
self.enable_btn(btn)
self.enable_btn(self.save_btn, False)
self.enable_btn(self.color_picker_box, color=config.widget_color+0.1)
def setup_plot_ed(gs_spec, plane):
# set up a PlotEditor for the given axis
# get subplot grid with extra height ratio weighting for
# each increased row to make sliders of approx equal size and
# align top borders of top images
extra_rows = gs_spec.rowspan.stop - gs_spec.rowspan.start - 1
gs_plot = gridspec.GridSpecFromSubplotSpec(
2, 1, subplot_spec=gs_spec,
height_ratios=(1, 10 + 14 * extra_rows),
hspace=0.1/(extra_rows*1.4+1))
# transform arrays to the given orthogonal direction
ax = fig.add_subplot(gs_plot[1, 0])
plot_support.hide_axes(ax)
arrs_3d, aspect, origin, scaling = \
plot_support.setup_images_for_plane(
plane,
(self.img5d.img[0], self.labels_img, self.borders_img))
img3d_tr, labels_img_tr, borders_img_tr = arrs_3d
# slider through image planes
ax_scroll = fig.add_subplot(gs_plot[0, 0])
plane_slider = Slider(
ax_scroll, plot_support.get_plane_axis(plane), 0,
len(img3d_tr) - 1, valfmt="%d", valinit=0, valstep=1)
# plot editor
max_size = max_sizes[plane] if max_sizes else None
overlayer = plot_support.ImageOverlayer(
ax, aspect, origin, rgb=self.img5d.rgb,
additive_blend=self.additive_blend)
plot_ed = plot_editor.PlotEditor(
overlayer, img3d_tr, labels_img_tr, config.cmap_labels,
plane, self.update_coords, self.refresh_images,
scaling, plane_slider, img3d_borders=borders_img_tr,
cmap_borders=cmap_borders,
fn_show_label_3d=self.fn_show_label_3d,
interp_planes=self.interp_planes,
fn_update_intensity=self.update_color_picker,
max_size=max_size, fn_status_bar=self.fn_status_bar)
plot_ed.blitter = blitter
return plot_ed
# set up plot editors for the given orthogonal planes
max_sizes = plot_support.get_downsample_max_sizes()
for pln, gs_viewer in zip(planes, viewers):
if gs_viewer:
self.plot_eds[pln] = setup_plot_ed(gs_viewer, pln)
self.set_show_crosslines(True)
# attach listeners
self._listeners.append(
fig.canvas.mpl_connect("scroll_event", self.scroll_overview))
self._listeners.append(
fig.canvas.mpl_connect("key_press_event", self.on_key_press))
self._listeners.append(
fig.canvas.mpl_connect("close_event", self._close))
self._listeners.append(
fig.canvas.mpl_connect("axes_leave_event", self.axes_exit))
self.alpha_slider.on_changed(self.alpha_update)
self.alpha_reset_btn.on_clicked(self.alpha_reset)
self.interp_btn.on_clicked(self.interpolate)
self.save_btn.on_clicked(self.save_atlas)
self.edit_btn.on_clicked(self.toggle_edit_mode)
self.color_picker_box.on_text_change(self.color_picker_changed)
# initialize and show planes in all plot editors
if self._max_intens_proj is not None:
self.update_max_intens_proj(self._max_intens_proj)
self.update_coords(coord, config.PLANE[0])
plt.ion() # avoid the need for draw calls
def _close(self, evt):
"""Handle figure close events by calling :attr:`fn_close_listener`
with this object.
Args:
evt (:obj:`matplotlib.backend_bases.CloseEvent`): Close event.
"""
self.on_close()
self.fn_close_listener(evt, self)
[docs]
def on_key_press(self, event):
"""Respond to key press events.
"""
if event.key == "a":
# toggle between current and 0 opacity
if self.alpha_slider.val == 0:
# return to saved alpha if available and reset
if self.alpha_last is not None:
self.alpha_slider.set_val(self.alpha_last)
self.alpha_last = None
else:
# make translucent, saving alpha if not already saved
# during a halve-opacity event
if self.alpha_last is None:
self.alpha_last = self.alpha_slider.val
self.alpha_slider.set_val(0)
elif event.key == "A":
# halve opacity, only saving alpha on first halving to allow
# further halving or manual movements while still returning to
# originally saved alpha
if self.alpha_last is None:
self.alpha_last = self.alpha_slider.val
self.alpha_slider.set_val(self.alpha_slider.val / 2)
elif event.key == "up" or event.key == "down":
# up/down arrow for scrolling planes
self.scroll_overview(event)
elif event.key == "w":
# shortcut to toggle editing mode
self.toggle_edit_mode(event)
elif event.key == "ctrl+s" or event.key == "cmd+s":
# support default save shortcuts on multiple platforms;
# ctrl-s will bring up save dialog from fig, but cmd/win-S
# will bypass
self.save_fig(self.get_save_path())
[docs]
def update_coords(self, coord, plane_src=config.PLANE[0]):
"""Update all plot editors with given coordinates.
Args:
coord: Coordinate at which to center images, in z,y,x order.
plane_src: One of :const:`magmap.config.PLANE` (defaults to first
element) to specify the orientation from which the coordinates
were given.
"""
coord_rev = libmag.transpose_1d_rev(list(coord), plane_src)
for i, plane in enumerate(self.planes):
if not plane: continue
coord_transposed = libmag.transpose_1d(list(coord_rev), plane)
if i == 0:
self.offset = coord_transposed[::-1]
if self.fn_update_coords:
# update offset based on xy plane, without centering
# planes are centered on the offset as-is
self.fn_update_coords(coord_transposed, False)
self.plot_eds[plane].update_coord(coord_transposed)
[docs]
def view_subimg(self, offset, shape):
"""Zoom all Plot Editors to the given sub-image.
Args:
offset: Sub-image coordinates in ``z,y,x`` order.
shape: Sub-image shape in ``z,y,x`` order.
"""
for i, plane in enumerate(self.planes):
if not plane: continue
offset_tr = libmag.transpose_1d(list(offset), plane)
shape_tr = libmag.transpose_1d(list(shape), plane)
self.plot_eds[plane].view_subimg(offset_tr[1:], shape_tr[1:])
self.fig.canvas.draw_idle()
[docs]
def refresh_images(self, plot_ed=None, update_atlas_eds=False):
"""Refresh images in a plot editor, such as after editing one
editor and updating the displayed image in the other editors.
Args:
plot_ed (:obj:`magmap.plot_editor.PlotEditor`): Editor that
does not need updating, typically the editor that originally
changed. Defaults to None.
update_atlas_eds (bool): True to update other ``AtlasEditor``s;
defaults to False.
"""
for key in self.plot_eds:
ed = self.plot_eds[key]
if ed != plot_ed: ed.refresh_img3d_labels()
if ed.edited:
# display save button as enabled if any editor has been edited
self.enable_btn(self.save_btn)
self.edited = True
if update_atlas_eds and self.fn_refresh_atlas_eds is not None:
# callback to synchronize other Atlas Editors
self.fn_refresh_atlas_eds(self)
[docs]
def alpha_update(self, event):
"""Update the alpha transparency in all plot editors.
Args:
event: Slider event.
"""
for key in self.plot_eds:
self.plot_eds[key].alpha_updater(event)
[docs]
def alpha_reset(self, event):
"""Reset the alpha transparency in all plot editors.
Args:
event: Button event, currently ignored.
"""
self.alpha_slider.reset()
[docs]
def interpolate(self, event):
"""Interpolate planes using :attr:`interp_planes`.
Args:
event: Button event, currently ignored.
"""
try:
self.interp_planes.interpolate(self.labels_img)
# flag Plot Editors as edited so labels can be saved
for ed in self.plot_eds.values(): ed.edited = True
self.refresh_images(None, True)
except ValueError as e:
print(e)
[docs]
def save_atlas(self, event=None):
"""Save atlas labels using the registered image suffix given by
:attr:`config.reg_suffixes[config.RegSuffixes.ANNOTATION]`.
Args:
event: Button event, currently not used; defaults to None.
"""
# only save if at least one editor has been edited
if not any([ed.edited for ed in self.plot_eds.values()]): return
# save to the labels reg suffix, using the prefix as the base path if
# set; use sitk Image as template image and store the saved image
reg_name = config.reg_suffixes[config.RegSuffixes.ANNOTATION]
if self._labels_img_sitk is None:
self._labels_img_sitk = config.labels_img_sitk
self._labels_img_sitk = sitk_io.write_registered_image(
self.labels_img,
config.prefix if config.prefix else config.filename, reg_name,
self._labels_img_sitk, overwrite=True)
# reset edited flag in all editors and show save button as disabled
for ed in self.plot_eds.values(): ed.edited = False
self.edited = False
self.enable_btn(self.save_btn, False)
print("Saved labels image at {}".format(datetime.datetime.now()))
[docs]
def get_save_path(self):
"""Get figure save path based on filename, ROI, and overview plane
shown.
Returns:
str: Figure save path.
"""
ext = config.savefig if config.savefig else config.DEFAULT_SAVEFIG
return "{}.{}".format(naming.get_roi_path(
os.path.basename(self.title), self.offset), ext)
[docs]
def toggle_edit_mode(self, event):
"""Toggle editing mode, determining the current state from the
first :class:`magmap.plot_editor.PlotEditor` and switching to the
opposite value for all plot editors.
Args:
event: Button event, currently not used.
"""
edit_mode = False
for i, ed in enumerate(self.plot_eds.values()):
if i == 0:
# change edit mode based on current mode in first plot editor
edit_mode = not ed.edit_mode
self.toggle_btn(self.edit_btn, edit_mode, text=self._EDIT_BTN_LBLS)
ed.edit_mode = edit_mode
if not edit_mode:
# reset the color picker text box when turning off editing
self.color_picker_box.set_val("")
[docs]
def update_color_picker(self, val):
"""Update the color picker :class:`TextBox` with the given value.
Args:
val (str): Color value. If None, only :meth:`color_picker_changed`
will be triggered.
"""
if val is None:
# updated picked color directly
self.color_picker_changed(val)
else:
# update text box, which triggers color_picker_changed
self.color_picker_box.set_val(val)
[docs]
def color_picker_changed(self, text):
"""Respond to color picker :class:`TextBox` changes by updating
the specified intensity value in all plot editors.
Args:
text (str): String of text box value. Converted to an int if
non-empty.
"""
intensity = text
if text:
if not libmag.is_number(intensity): return
intensity = int(intensity)
print("updating specified color to", intensity)
for i, ed in enumerate(self.plot_eds.values()):
ed.intensity_spec = intensity
[docs]
class InterpolatePlanes:
"""Track manually edited planes between which to interpolate changes
for a given label.
This interpolation replaces unedited planes based on the trends of
the edited ones to avoid the need to manually edit every single plane.
Attributes:
btn (:obj:`matplotlib.widgets.Button`): Button to initiate plane
interpolation.
plane (str): Plane in which editing has occurred.
bounds (List[int]): Unsorted start and end planes.
label_id (int): Label ID of the edited region.
"""
def __init__(self, btn):
"""Initialize plane interpolation object."""
self.btn = btn
self.plane = None
self.bounds = None
self.label_id = None
[docs]
def update_btn(self):
"""Update text and color of button to interpolate planes.
"""
if any(self.bounds):
# show current values if any exist
self.btn.label.set_text(
"Fill {} {}\nID {}"
.format(plot_support.get_plane_axis(self.plane), self.bounds,
self.label_id))
self.btn.label.set_fontsize("xx-small")
plot_support.ImageSyncMixin.enable_btn(self.btn, all(self.bounds))
[docs]
def update_plane(self, plane, i, label_id):
"""Update the current plane.
Args:
plane: Plane direction, which will overwrite any current direction.
i: Index of the plane to add, which will overwrite the oldest
bounds element.
label_id: ID of label, which will overwrite any current ID.
"""
if self.plane is not None and (
plane != self.plane or label_id != self.label_id):
# reset bounds if new plane or label ID (plane and label_id
# should have been set together)
self.bounds = None
self.plane = plane
self.label_id = label_id
self.bounds = i
self.update_btn()
[docs]
def interpolate(self, labels_img):
"""Interpolate between :attr:`bounds` in the given :attr:`plane`
direction in the bounding box surrounding :attr:`label_id`.
Args:
labels_img: Labels image as a Numpy array of x,y,z dimensions.
"""
if not all(self.bounds):
raise ValueError("boundaries not fully set: {}".format(self.bounds))
print("interpolating edits between planes", self.bounds)
cv_nd.interpolate_label_between_planes(
labels_img, self.label_id, config.PLANE.index(self.plane),
self.bounds)
def __str__(self):
return "{}: {} (ID: {})".format(
plot_support.get_plane_axis(self.plane), self.bounds, self.label_id)
@property
def bounds(self):
"""Get the bounds property."""
return self._bounds
@bounds.setter
def bounds(self, val):
"""Set the bounds property.
Args:
val: Integer to append to the bounds list if not already
present, removing the first value in the list, or a value to
replace the current bounds value. None to reset.
"""
if val is None:
self._bounds = [None, None]
elif libmag.is_int(val):
if val not in self._bounds:
self._bounds.append(val)
del self._bounds[0]
else:
self._bounds = val
if __name__ == "__main__":
print("Starting atlas editor")