# ROI Editor with serial 2D viewer and annotator
# Author: David Young, 2018, 2023
"""ROI editing GUI in the MagellanMapper package.
Attributes:
verify: If true, verification mode is turned on, which for now
simply turns on interior borders as the picker remains on
by default.
"""
from collections import OrderedDict
import math
import os
from enum import Enum
import re
from time import time
from typing import Callable, Dict, List, Optional, Sequence, TYPE_CHECKING, \
Tuple
import numpy as np
from matplotlib import figure
from matplotlib import gridspec
from matplotlib import patches
from matplotlib import pyplot as plt
from matplotlib.collections import PatchCollection
from magmap.cv import detector
from magmap.gui import image_viewer, pixel_display, plot_editor
from magmap.io import libmag, naming
from magmap.plot import colormaps, plot_support
from magmap.settings import config
if TYPE_CHECKING:
from matplotlib import axes
from magmap.io import np_io
verify = False
[docs]
class DraggableCircle:
"""Circle representation of a blob to allow the user to manipulate
blob position, size, and status.
Attributes:
BLOB_COLORS (:obj:`dict`): Mapping of integers to ``Matplotlib``
color strings.
CUT (str): Flag to cut a circle.
circle (:obj:`patches.Circle`): A circle patch.
segment (:obj:`np.ndarray`): Array in the format,
`[z, y, x, r, confirmation, truth]` of the blob.
fn_update_seg (`meth`): Function that takes
`(segment_new, segment_old)` to call when updating the blob.
picked (:obj:`list`): List of picked, active blobs in the
tuple format, `(segment, pick_flag)`.
"""
# segment colors based on confirmation status
BLOB_COLORS = {
-1: "none",
0: "r",
1: "g",
2: "y"
}
CUT = "cut"
#: str: Flag to copy a circle.
_COPY = "copy"
picked = None
def __init__(self, circle, segment, fn_update_seg, picked, color="none"):
"""Initialize a circle from a blob.
Args:
circle (:obj:`patches.Circle`): A circle patch.
segment (:obj:`np.ndarray`): Array in the format,
`[z, y, x, r, confirmation, truth]` of the blob.
fn_update_seg (`meth`): Function that takes
`(segment_new, segment_old)` to call when updating the blob.
picked (:obj:`list`): List of picked, active blobs in the
tuple format, `(segment, pick_flag)`.
color (str, optional): ``Matplotlib`` color string for the circle;
defaults to "none".
"""
self.circle = circle
self.circle.set_picker(self.circle.radius)
self._facecolori = -1
for key, val in self.BLOB_COLORS.items():
if val == color:
self._facecolori = key
self.segment = segment
self.fn_update_seg = fn_update_seg
self.picked = picked
self._press = None # event position
self._background = None # bbox of bkgd for blitting
# event connection objects
self._cidpress = None
self._cidrelease = None
self._cidmotion = None
self._cidpick = None
[docs]
def connect(self):
"""Connect events to functions.
"""
self._cidpress = self.circle.figure.canvas.mpl_connect(
"button_press_event", self.on_press)
self._cidrelease = self.circle.figure.canvas.mpl_connect(
"button_release_event", self.on_release)
self._cidmotion = self.circle.figure.canvas.mpl_connect(
"motion_notify_event", self.on_motion)
self._cidpick = self.circle.figure.canvas.mpl_connect(
"pick_event", self.on_pick)
#print("connected circle at {}".format(self.circle.center))
[docs]
def remove_self(self):
self.disconnect()
self.circle.remove()
[docs]
def on_press(self, event):
"""Initiate drag events with Shift- or Alt-click inside a circle.
Shift-click to move a circle, and Alt-click to resize a circle's radius.
"""
if (event.key not in ("shift", "alt")
or event.inaxes != self.circle.axes):
# ignore if without the given modifiers or outside of circle's axes
return
# ensure that event is within the circle
contains, attrd = self.circle.contains(event, radius=self.circle.radius)
if not contains: return
print("pressed on {}".format(self.circle.center))
x0, y0 = self.circle.center
self._press = x0, y0, event.xdata, event.ydata
DraggableCircle.lock = self
# draw everywhere except the circle itself, store the pixel buffer
# in background, and draw the circle
canvas = self.circle.figure.canvas
ax = self.circle.axes
self.circle.set_animated(True)
canvas.draw_idle()
self._background = canvas.copy_from_bbox(self.circle.axes.bbox)
ax.draw_artist(self.circle)
canvas.blit(ax.bbox)
[docs]
def on_motion(self, event):
"""Move the circle if the drag event has been initiated.
"""
if self._press is None: return
if event.inaxes != self.circle.axes: return
x0, y0, xpress, ypress = self._press
dx = None
dy = None
if event.key == "shift":
dx = event.xdata - xpress
dy = event.ydata - ypress
self.circle.center = x0 + dx, y0 + dy
elif event.key == "alt":
dx = abs(event.xdata - x0)
dy = abs(event.ydata - y0)
self.circle.radius = max([dx, dy])
print("initial position: {}, {}; change thus far: {}, {}"
.format(x0, y0, dx, dy))
# restore the saved background and redraw the circle at its new position
canvas = self.circle.figure.canvas
ax = self.circle.axes
canvas.restore_region(self._background)
ax.draw_artist(self.circle)
canvas.blit(ax.bbox)
[docs]
def on_release(self, event):
"""Finalize the circle and segment's position after a drag event
is completed with a button release.
"""
if self._press is None: return
print("released on {}".format(self.circle.center))
print("segment moving from {}...".format(self.segment))
seg_old = np.copy(self.segment)
self.segment[1:3] += np.subtract(
self.circle.center, self._press[0:2]).astype(int)[::-1]
rad_sign = -1 if self.segment[3] < config.POS_THRESH else 1
self.segment[3] = rad_sign * self.circle.radius
print("...to {}".format(self.segment))
self.fn_update_seg(self.segment, seg_old)
self._press = None
# turn off animation property, reset background
DraggableCircle.lock = None
self.circle.set_animated(False)
self._background = None
self.circle.figure.canvas.draw()
[docs]
def on_pick(self, event):
"""Select the verification flag with button press on a circle when
not dragging the circle.
"""
if (event.mouseevent.key in ("control", "shift", "alt")
or event.artist != self.circle):
return
#print("color: {}".format(self._facecolori))
if event.mouseevent.key == "x":
# "cut" segment
self.picked.append((self, self.CUT))
self.remove_self()
print("cut seg: {}".format(self.segment))
elif event.mouseevent.key == "c":
# "copy" segment
self.picked.append((self, self._COPY))
print("copied seg: {}".format(self.segment))
elif event.mouseevent.key == "d":
# delete segment, which will be stored as cut segment to allow
# undoing the deletion by pasting
self.picked.append((self, self.CUT))
self.remove_self()
self.fn_update_seg(self.segment, remove=True)
print("deleted seg: {}".format(self.segment))
else:
# change verification flag
seg_old = np.copy(self.segment)
# "r"-click to change flag in reverse order
change = -1 if event.mouseevent.key == "r" else 1
i = self._facecolori + change
# wrap around keys if exceeding min/max
if i > max(self.BLOB_COLORS.keys()):
if self.segment[3] < config.POS_THRESH:
# user-added segments simply disappear when exceeding
self.picked.append((self, self.CUT))
self.remove_self()
i = -1
elif i < min(self.BLOB_COLORS.keys()):
i = max(self.BLOB_COLORS.keys())
self.circle.set_facecolor(self.BLOB_COLORS[i])
self._facecolori = i
self.segment[4] = i
self.fn_update_seg(self.segment, seg_old)
print("picked segment: {}".format(self.segment))
if self.circle.figure:
# redraw if figure is still attached
self.circle.figure.canvas.draw()
[docs]
def disconnect(self):
"""Disconnect event listeners.
"""
self.circle.figure.canvas.mpl_disconnect(self._cidpress)
self.circle.figure.canvas.mpl_disconnect(self._cidrelease)
self.circle.figure.canvas.mpl_disconnect(self._cidmotion)
self.circle.figure.canvas.mpl_disconnect(self._cidpick)
[docs]
class ROIEditor(plot_support.ImageSyncMixin):
"""Graphical interface for viewing and annotating 3D ROIs through
serial 2D planes.
Provides overview plots showing context for the ROI at various
zoom levels, which can be synchronized with the selected 2D plane or
scrolled to other planes.
Overlays detected blobs as :class:``DraggableCircle`` objects to
flag, reposition, or add/subtract annotations.
:attr:`plot_eds` are dictionaries where keys are zoom levels and values
are Plot Editors.
Attributes:
ROI_COLS (int): Default number of columns for the "zoomed-in"
2D plots, the 2D planes for the ROI.
ZLevels (:obj:`Enum`): Enum denoting the possible positions of the
z-plane shown in the overview plots.
fig (:obj:`figure.figure`): Matplotlib figure.
labels_img (:obj:`np.ndarray`): Atlas labels image in ``z,y,x`` format;
defaults to None.
img_region (:obj:`np.ndarray`): 3D boolean or binary array with the
selected region labeled as True or 1. Defaults to None, in
which case the region will be ignored.
fn_status_bar (func): Function to call during status bar updates
in :class:`pixel_display.PixelDisplay`; defaults to None.
plane (str): The plane to show in each 2D plot, eg "xy" to show the
XY plane (default) or "xz" to show the XZ plane.
zoom_shift (List[float]): Sequence of x,y shift in zoomed plot
origin when zooming into ROI; defaults to None.
fn_update_coords (func): Function to call when updating coordinates
in the overview plots; defaults to None.
fn_redraw (func): Function to call when double-clicking in an
overview plot; defaults to None.
blobs (:obj:`magmap.cv.detector.Blobs`]): Blobs object; defaults
to None. Blobs should have coordinates relative to the ROI
and may include blobs from adjacent regions.
"""
ROI_COLS = 9
ZLevels = Enum(
"ZLevels", (
"BOTTOM", "MIDDLE", "TOP",
)
)
[docs]
class CircleStyles(Enum):
CIRCLES = "Blob circles"
REPEAT_CIRCLES = "Repeat circles"
NO_CIRCLES = "No circles"
FULL_ANNOTATION = "Full annotation"
# segment line styles based on channel
_BLOB_LINESTYLES = {
0: ":",
1: "-.",
2: "--",
3: (0, (3, 5, 1, 5, 1, 5)),
4: "-",
}
# face colors for truth flags
_TRUTH_COLORS = {
-1: None,
0: "m",
1: "b"
}
_BLOB_LINEWIDTH = 1
# divisor for finding array interval to downsample images
_DOWNSAMPLE_MAX_ELTS = 1000
#: int: padding for ROI within overview plots
_ROI_PADDING = 10
def __init__(self, img5d, labels_img=None, img_region=None,
fn_show_label_3d=None, fn_status_bar=None):
"""Initialize the editor."""
#: Dictionary of z-planes to zoomed plots.
self._ax_subplots: Dict[
"axes.Axes", "plot_editor.PlotEditor"] = OrderedDict()
super().__init__(img5d)
print("Initiating ROI Editor")
#: Image instance to display.
self.image5d: Optional[
"np_io.Image5d"] = None if self.img5d is None else self.img5d.img
self.labels_img: Optional[np.ndarray] = labels_img
if img_region is not None:
# invert region selection image to opacify areas outside of the
# region; note that in MIP mode, will still only show lowest plane
img_region = np.invert(img_region).astype(float)
img_region[img_region == 0] = np.nan
self.img_region: Optional[np.ndarray] = img_region
self.fn_show_label_3d: Optional[
Callable[[float], None]] = fn_show_label_3d
self.fn_status_bar: Optional[Callable[[str], None]] = fn_status_bar
# initialize other instance attributes
self.filename = None
self.offset = None
self.roi_size = None
self.plane = config.PLANE[0]
self.zoom_shift = None
self.fn_update_coords = None
self.fn_redraw = None
self.blobs = None
#: int: Planes to show in overview images as local max intensity
# projections (MIP) through the ROI; 0 to not show as MIP.
self._max_intens_proj = 0
self._blobs_coloc_text = None
self._z_overview = None
self._channel = None # list of channel lists
#: Image blitter.
self._blitter: Optional["image_viewer.Blitter"] = None
# store DraggableCircles objects to prevent premature garbage collection
self._draggable_circles = []
self._circle_last_picked = []
# additional z's above/below
margin = config.plot_labels[config.PlotLabels.MARGIN]
if margin is None:
self._z_planes_padding = 3
else:
# assumes x,y,z order
self._z_planes_padding = libmag.get_if_within(margin, 2, 3)
print("margin: {}, savefig: {}".format(margin, config.savefig))
@plot_support.ImageSyncMixin.additive_blend.setter
def additive_blend(self, val: bool):
"""Set additive blending in overview and zoomed Plot Editors."""
# call parent function
plot_support.ImageSyncMixin.additive_blend.fset(self, val)
for zoomed_plot in self._ax_subplots.values():
if zoomed_plot is None or zoomed_plot.overlayer is None: continue
# synchronize setting in zoomed editor
zoomed_plot.overlayer.additive_blend = val
def _show_overview(self, ax_ov, lev, zoom_levels, arrs_3d, cmap_labels,
aspect, origin, scaling, max_size):
"""Show overview image with progressive zooming on the ROI for each
zoom level, displayed in a a :class:`PlotEditor`.
Shifts the zoom based on :attr:`zoom_shift`, defaulting to ``(1, 1)``
if None.
Args:
ax_ov: Subplot axes.
lev: Zoom level index, where 0 is the original image.
zoom_levels (List[float]): Sequence of zoom levels.
arrs_3d (List[:obj:`np.ndarray`]): Sequence of 3D arrays to
overlay.
cmap_labels (:obj:`colors.ListedColormap`): Atlas labels colormap.
aspect (float): Aspect ratio.
origin (str): Planar orientation, usually either "lower" or None.
scaling (List[float]): Scaling/spacing in z,y,x.
max_size (int): Maximum size of either side of the 2D plane shown;
defaults to None.
"""
def update_coords(coord, plane):
# update displayed overview plot for the given coordinates and
# show preview ROI
plot_ed.update_coord(coord)
plot_ed.show_roi(coord[1:], self.roi_size[1::-1], preview=True)
if self.fn_update_coords:
# trigger callback with coordinates in z-plane orientation
coord_zax = libmag.transpose_1d_rev(list(coord), plane)
self.fn_update_coords(coord_zax)
# main overview image, on which other images may be overlaid
roi_end = np.add(self.offset, self.roi_size)
offsets = [] # z,y,x
sizes = [] # z,y,x
zoom = zoom_levels[lev]
if lev > 0:
# move origin progressively closer with each zoom level,
# a small fraction less than the offset
# default to shifting origin so that ROI is near upper L corner
zoom_shift = (1, 1) if self.zoom_shift is None else self.zoom_shift
ori = np.multiply(
self.offset[:2],
np.subtract(zoom, zoom_shift) / zoom).astype(int)
zoom_shape = np.flipud(arrs_3d[0].shape[1:3])
# progressively decrease size, zooming in for each level
size = (zoom_shape / zoom).astype(int)
end = np.add(ori, size)
# if ROI exceeds bounds of zoomed plot, shift plot
for o in range(len(ori)):
roi_end_padded = roi_end[o] + self._ROI_PADDING
if end[o] < roi_end_padded:
diff = roi_end_padded - end[o]
ori[o] += diff
end[o] += diff
# keep the zoomed area within the full 2D image
for o in range(len(ori)):
if end[o] > zoom_shape[o]:
ori[o] -= end[o] - zoom_shape[o]
for img_i, img in enumerate(arrs_3d):
if img is not None:
# zoom images based on scaling to main image
scale = np.divide(
img.shape[1:3], arrs_3d[0].shape[1:3])[::-1]
origin_scaled = np.multiply(ori, scale).astype(int)
end_scaled = np.multiply(end, scale).astype(int)
offsets.append(origin_scaled[::-1])
sizes.append(np.subtract(end_scaled, origin_scaled)[::-1])
# create a Plot Editor for the overview image
num_arrs_3d = len(arrs_3d)
labels_img = None if num_arrs_3d <= 1 else arrs_3d[1]
img3d_extras = arrs_3d[2:] if num_arrs_3d > 2 else None
if img3d_extras is not None:
img3d_extras = [np.array(img) for img in img3d_extras]
overlayer = plot_support.ImageOverlayer(
ax_ov, aspect, origin, rgb=self.img5d.rgb,
additive_blend=self.additive_blend)
plot_ed = plot_editor.PlotEditor(
overlayer, arrs_3d[0], labels_img, cmap_labels,
self.plane, update_coords,
scaling, max_size=max_size, fn_status_bar=self.fn_status_bar,
img3d_extras=img3d_extras,
fn_show_label_3d=self.fn_show_label_3d)
plot_ed.scale_bar = True
plot_ed.enable_painting = False
plot_ed.max_intens_proj = self._max_intens_proj
plot_ed.blitter = self._blitter
update_coords((self._z_overview, *self.offset[1::-1]), self.plane)
plot_ed.show_roi(self.offset[1::-1], self.roi_size[1::-1])
if offsets and sizes:
# zoom toward ROI
plot_ed.view_subimg(offsets[0], sizes[0])
self.plot_eds[zoom] = plot_ed
self._update_overview_title(ax_ov, lev, zoom)
def _update_overview_title(self, ax_ov, lev, zoom):
"""Set title with total zoom including objective and plane number.
Args:
ax_ov: Subplot axes.
lev: Zoom level, where 0 is the original image.
zoom (float): Microscope total zoom. Overridden by
:attr:`config.zoom` and :attr:`config.magnification` if they
both exist.
"""
if config.zoom and config.magnification:
# calculate total mag from objective zoom and mag
zoom_components = np.array(
[config.zoom, config.magnification, zoom]).astype(float)
# use abs since the default mag and zoom were previously -1.0
tot_zoom = "{}x".format(
libmag.compact_float(abs(np.prod(zoom_components)), 1))
elif lev == 0:
tot_zoom = "original magnification"
else:
tot_zoom = "{}x of original".format(zoom)
plot_support.set_overview_title(
ax_ov, self.plane, self._z_overview, tot_zoom, lev,
self._max_intens_proj != 0)
def _redraw(self, event):
"""Trigger :attr:`fn_redraw` if the event was a right button
double-clck that took place in a Plot Editor.
Args:
event (:obj:`matplotlib.backend_bases.MouseEvent`): Mouse event.
"""
if not self.fn_redraw or not event.dblclick or not event.button == 3:
return
for ed in self.plot_eds.values():
if ed.axes == event.inaxes:
self.fn_redraw()
break
[docs]
def plot_2d_stack(self, fn_update_seg, filename, channel, roi_size, offset,
mask_in, blobs_cmap, fn_close_listener, border=None,
zoom_levels=1, single_roi_row=False,
z_level=ZLevels.BOTTOM, roi=None, labels=None,
blobs_truth=None, circles=None, mlab_screenshot=None,
grid=False, roi_cols=None, fig=None, region_name=None):
"""Shows a figure of 2D plots to compare with the 3D plot.
Args:
fn_update_seg (func): Callback when updating a
:obj:`DraggableCircle`.
filename (str): Path to use when saving the plot.
channel: Channel of the image to display.
roi_size: List of x,y,z dimensions of the ROI.
offset: Tuple of x,y,z coordinates of the ROI.
mask_in: Boolean mask of ``segments`` within the ROI.
blobs_cmap: Colormap for blobs inside the ROI.
fn_close_listener: Handle figure close events.
border: Border dimensions in pixels given as (x, y, z); defaults
to None.
zoom_levels (int, List[int]): Number of overview zoom levels to
include or sequence of zoom multipliers; defaults to 1.
single_roi_row: True if the ROI-sized plots should be
displayed on a single row; defaults to False.
z_level: Position of the z-plane shown in the overview plots,
based on the Z_LEVELS attribute constant; defaults to
Z_LEVELS[0].
roi (:obj:`np.ndarray`): A denoised region of interest for display
in ROI plots, such as a preprocessed ROI.
Defaults to None, in which case image5d will be used instead.
labels (:obj:`np.ndarray`): Segmentation labels of the same shape
as that of ``image5d``; defaults to None.
blobs_truth (:obj:`np.ndarray`): Array of blobs to display as
ground truth; defaults to None.
circles: :class:``CircleStyles`` enum member; defaults to None.
mlab_screenshot (:obj:`np.ndarray`): Array from Mayavi screenshot;
defaults to None.
grid (bool): True to overlay a grid on all plots.
roi_cols (int): Number of columns per row to reserve for ROI plots;
defaults to None, in which case :attr:`ROI_COLS` will be used.
fig (:obj:`figure.Figure`): Matplotlib figure; defaults to None
to generate a new figure.
region_name (str): Name of atlas region for title; defaults to None.
"""
time_start = time()
self.filename = filename
self.offset = offset
self.roi_size = roi_size
self._channel = [channel]
if not roi_cols:
roi_cols = self.ROI_COLS
if not np.ndim(zoom_levels):
# convert scalar to sequence of zoom multipliers for zooming into
# the ROI in overview plots; scale the zoom to a default of 3x
# the ROI shape
size_max = self.image5d.shape[2:4][::-1]
size_min = np.multiply(roi_size[:2], 3)
if any(np.greater(size_min, size_max)):
# fallback to ROI size if default exceeds the full image size
size_min = roi_size[:2]
# zoom increasingly faster toward the max zoom for the ROI
# shape by using a power function scaling output from 1 to max
# zoom, excluding final value to allow greater zooming with
# increased zoom levels
max_zoom = np.amin(np.divide(size_max, size_min))
zoom_levels = np.power(np.linspace(
np.power(1 / max_zoom, 1 / 3), 1, zoom_levels, endpoint=False),
3) * max_zoom
print("zoom_levels:", zoom_levels, "max_zoom:", max_zoom)
num_zoom_levels = len(zoom_levels)
# set up figure
if fig is None:
fig = figure.Figure()
fig.clear()
self.fig = fig
self._blitter = image_viewer.Blitter(fig)
# black text with transluscent background the color of the figure
# background in case the title is a 2D plot
fig.suptitle(
ROIEditor._fig_title(
region_name, os.path.basename(filename), offset, roi_size),
bbox=dict(
facecolor="xkcd:silver", edgecolor="none", alpha=0.5))
# adjust array order based on which plane to show
border_full = np.copy(border)
border[2] = 0
blobs = self.blobs.blobs if self.blobs else None
if self.plane == config.PLANE[1]:
# "xz" planes; flip y-z to give y-planes instead of z
roi_size = libmag.swap_elements(roi_size, 1, 2)
offset = libmag.swap_elements(offset, 1, 2)
border = libmag.swap_elements(border, 1, 2)
border_full = libmag.swap_elements(border_full, 1, 2)
if blobs is not None and len(blobs) > 0:
blobs[:, [0, 1]] = blobs[:, [1, 0]]
elif self.plane == config.PLANE[2]:
# "yz" planes; roll backward to flip x-z and x-y
roi_size = libmag.roll_elements(roi_size, -1)
offset = libmag.roll_elements(offset, -1)
border = libmag.roll_elements(border, -1)
border_full = libmag.roll_elements(border_full, -1)
print("orig blobs:\n{}".format(blobs))
if blobs is not None and len(blobs) > 0:
# roll forward since segments in zyx order
blobs[:, [0, 2]] = blobs[:, [2, 0]]
blobs[:, [1, 2]] = blobs[:, [2, 1]]
print("rolled blobs:\n{}".format(blobs))
print("2D border: {}".format(border))
# mark z-planes to show
z_start = offset[2]
z_planes = roi_size[2]
z_planes = z_planes + self._z_planes_padding * 2
# position overview at bottom (default), middle, or top of stack
self._z_overview = z_start # abs positioning
if z_level == self.ZLevels.MIDDLE:
self._z_overview = (2 * z_start + z_planes) // 2
elif z_level == self.ZLevels.TOP:
self._z_overview = z_start + z_planes
print("z_overview: {}".format(self._z_overview))
# set up images to overlay in overview plots
arrs3d = [self.image5d[0], self.labels_img]
if self.img_region is not None:
arrs3d.append(self.img_region)
arrs_3d, aspect, origin, scaling = plot_support.setup_images_for_plane(
self.plane, arrs3d)
scaling = config.labels_scaling
if scaling is not None: scaling = [scaling]
max_sizes = plot_support.get_downsample_max_sizes()
max_size = max_sizes[self.plane] if max_sizes else None
# plot layout depending on number of z-planes
if single_roi_row:
# show all plots in single row
zoom_plot_rows = 1
col_remainder = 0
zoom_plot_cols = z_planes
else:
# wrap plots after reaching max, but tolerates additional column
# if it will fit all the remainder plots from the last row
zoom_plot_rows = math.ceil(z_planes / roi_cols)
col_remainder = z_planes % roi_cols
zoom_plot_cols = roi_cols
if 0 < col_remainder < zoom_plot_rows:
zoom_plot_cols += 1
zoom_plot_rows = math.ceil(z_planes / zoom_plot_cols)
col_remainder = z_planes % zoom_plot_cols
# number of columns for top row with overview plots
top_cols = len(zoom_levels)
height_ratios = (3, zoom_plot_rows)
if mlab_screenshot is None:
main_img_shape = arrs_3d[0].shape[1:]
if main_img_shape[1] > 2 * main_img_shape[0]:
# for wide layouts, prioritize the ROI plots, especially
# if only one overview column
height_ratios = (1, 1) if top_cols >= 2 else (1, 2)
else:
# add column for screenshot
top_cols += 1
gs = gridspec.GridSpec(
2, top_cols, wspace=0.01, hspace=0.01, height_ratios=height_ratios,
figure=fig, left=0.01, right=0.99, bottom=0.01, top=0.93)
# overview subplotting
ax_overviews = [] # overview axes
self._ax_subplots = OrderedDict() # zoom plot axes
def jump(event):
z_ov = None
subplots = list(self._ax_subplots.keys())
if event.inaxes in subplots:
# right-arrow to jump to z-plane of given zoom plot
z_ov = (subplots.index(event.inaxes) + z_start
- self._z_planes_padding)
return z_ov
def scroll_overview(event):
"""Scroll through overview images along their orthogonal axis.
Args:
event: Mouse or key event. For mouse events, scroll step sizes
will be used for movements. For key events, up/down arrows
will be used.
"""
for edi, plot_ed in enumerate(self.plot_eds.values()):
plot_ed.scroll_overview(event, only_in_axes=False, fn_jump=jump)
if edi == 0:
# z-plane index should be same for all editors
self._z_overview = plot_ed.coord[0]
self._update_overview_title(
plot_ed.axes, edi, zoom_levels[edi])
update_subplot_border()
fig.canvas.draw_idle()
def update_subplot_border():
# show a colored border around zoomed plot corresponding to
# overview plots
for axi, axz in enumerate(self._ax_subplots.keys()):
if axi + z_start - self._z_planes_padding == self._z_overview:
# highlight border
axz.patch.set_edgecolor("orange")
axz.patch.set_linewidth(3)
else:
# make border invisible
axz.patch.set_linewidth(0)
def key_press(event):
# respond to key presses
if 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())
else:
# default to scrolling commands for up/down/right arrows
scroll_overview(event)
def on_btn_release_scroll(event):
# scroll the overview plot to the selected zoomed plot
scroll_overview(event)
def on_btn_release(event):
# respond to mouse button presses for DraggableCircle management
inax = event.inaxes
print("event key: {}".format(event.key))
subplots = list(self._ax_subplots.keys())
if event.key is None:
# for some reason becomes none if previous event was
# ctrl combo and this event is control
pass
elif event.key == "control" or event.key.startswith("ctrl"):
# add a circle
blob_channel = None
if channel:
# default to using the first selected channel
blob_channel = channel[0]
chl_matches = re.search(regex_key_chl, event.key)
if chl_matches:
# ctrl+n to specify channel n
chl = int(chl_matches[0])
if chl in channel:
blob_channel = chl
else:
self.fn_status_bar(
f"Selected channel, {chl}, must be in "
f"{channel}")
return
try:
# add the circle patch
axi = subplots.index(inax)
if (axi != -1 and self._z_planes_padding <= axi
< z_planes - self._z_planes_padding):
blob = np.array([[axi - self._z_planes_padding,
event.ydata.astype(int),
event.xdata.astype(int), -5]])
blob = detector.Blobs(blob).format_blobs(blob_channel)
detector.Blobs.shift_blob_abs_coords(blob, offset[::-1])
detector.Blobs.set_blob_confirmed(blob, 1)
blob = fn_update_seg(blob[0])
# adds a circle to denote the new segment
patch = self._plot_circle(
inax, blob, self._BLOB_LINEWIDTH, "-",
fn_update_seg)
except ValueError as e:
print(e)
print("not on a plot to select a point")
fig.canvas.draw_idle()
elif event.key == "v":
# paste a circle
_circle_last_picked_len = len(self._circle_last_picked)
if _circle_last_picked_len < 1:
print("No previously picked circle to paste")
return
moved_item = self._circle_last_picked[
_circle_last_picked_len - 1]
circle, move_type = moved_item
axi = subplots.index(inax)
dz = axi - self._z_planes_padding - circle.segment[0]
seg_old = np.copy(circle.segment)
seg_new = np.copy(circle.segment)
seg_new[0] += dz
if move_type == DraggableCircle.CUT:
print("Pasting a cut segment")
self._draggable_circles.remove(circle)
self._circle_last_picked.remove(moved_item)
seg_new = fn_update_seg(seg_new, seg_old)
else:
print("Pasting a copied in segment")
detector.Blobs.shift_blob_abs_coords(seg_new, (dz, 0, 0))
seg_new = fn_update_seg(seg_new)
self._plot_circle(
inax, seg_new, self._BLOB_LINEWIDTH, None, fn_update_seg)
fig.canvas.draw_idle()
# overview images taken from the bottom plane of the offset, with
# progressively zoomed overview images if set for additional zoom levels
for level in range(num_zoom_levels):
ax = fig.add_subplot(gs[0, level])
ax_overviews.append(ax)
plot_support.hide_axes(ax)
self._show_overview(
ax, level, zoom_levels, arrs_3d, config.cmap_labels, aspect,
origin, scaling, max_size)
# attach overview plot navigation handlers
# mouse scroll to scroll these overview planes
self._listeners.append(
fig.canvas.mpl_connect("scroll_event", scroll_overview))
# arrow keys to scroll plane-by-plane
self._listeners.append(
fig.canvas.mpl_connect("key_press_event", key_press))
# right-click on zoomed plot to jump to corresponding plane in
# overview plots; fig/axes lose focus sporadically in lower
# right canvas on Mac, at which time axes are not associated with
# key events but are with mouse events
fig.canvas.mpl_connect("button_release_event", on_btn_release_scroll)
self._listeners.append(fig.canvas.mpl_connect(
"button_release_event", on_btn_release_scroll))
# disconnect listeners
self._listeners.append(
fig.canvas.mpl_connect("close_event", self.on_close))
# fig.canvas.mpl_connect("draw_event", lambda x: print("redraw"))
if self.fn_redraw:
# handle potential redraws
self._listeners.append(
fig.canvas.mpl_connect("button_press_event", self._redraw))
# zoomed-in views of z-planes spanning from just below to just above ROI
blobs_in = None
blobs_out = None
if (circles != self.CircleStyles.NO_CIRCLES and blobs is not None
and len(blobs) > 0):
# separate segments inside from outside the ROI
if mask_in is not None:
blobs_in = blobs[mask_in]
blobs_out = blobs[np.invert(mask_in)]
# separate out truth blobs
if blobs.shape[1] >= 6:
if blobs_truth is None:
blobs_truth = blobs[blobs[:, 5] >= 0]
print("blobs_truth:\n{}".format(blobs_truth))
# non-truth blobs have truth flag unset (-1)
if blobs_in is not None:
blobs_in = blobs_in[blobs_in[:, 5] == -1]
if blobs_out is not None:
blobs_out = blobs_out[blobs_out[:, 5] == -1]
#print("blobs_in:\n{}".format(blobs_in))
# selected or newly added patches since difficult to get patch from
# collection,and they don't appear to be individually editable
seg_patch_dict = {}
# sub-gridspec for fully zoomed plots to allow flexible number of cols
gs_zoomed = gridspec.GridSpecFromSubplotSpec(
zoom_plot_rows, zoom_plot_cols, gs[1, :], wspace=0.1, hspace=0.1)
cmap_labels = None
if labels is not None:
# partially transparent segmentation labels to show any mismatch
cmap_labels = colormaps.get_labels_discrete_colormap(labels, 100)
# plot the fully zoomed plots
#zoom_plot_rows = 0 # TESTING: show no fully zoomed plots
for i in range(zoom_plot_rows):
# adjust columns for last row to number of plots remaining
cols = zoom_plot_cols
if i == zoom_plot_rows - 1 and col_remainder > 0:
cols = col_remainder
# show zoomed in plots and highlight one at offset z
for j in range(cols):
# z relative to the start of ROI, since blobs are relative to ROI
z_relative = i * zoom_plot_cols + j - self._z_planes_padding
# absolute z value, relative to start of image5d
z = z_start + z_relative
zoom_offset = (offset[0], offset[1], z)
# fade z-planes outside of ROI and show only image5d
if z < z_start or z >= z_start + roi_size[2]:
alpha = 0.5
roi_show = None
else:
alpha = 1
roi_show = roi
# collects truth blobs within the given z-plane
blobs_truth_z = None
if blobs_truth is not None:
blobs_truth_z = blobs_truth[np.all([
blobs_truth[:, 0] == z_relative,
blobs_truth[:, 4] > 0], axis=0)]
#print("blobs_truth_z:\n{}".format(blobs_truth_z))
# show border outlining area that will be saved in verify mode
show_border = (verify and border[2] <= z_relative
< roi_size[2] - border[2])
# show the zoomed subplot with scale bar for the current z-plane
ax_z, ax_z_plot_ed = self.show_subplot(
fig, gs_zoomed, i, j, channel, roi_size, zoom_offset,
fn_update_seg, blobs_in, blobs_out, blobs_cmap, alpha,
z_relative, z == self._z_overview,
border_full if show_border else None,
self.plane, roi_show, labels, blobs_truth_z,
circles=circles, aspect=aspect, grid=grid,
cmap_labels=cmap_labels)
if (i == 0 and j == 0
and config.plot_labels[config.PlotLabels.SCALE_BAR]):
plot_support.add_scale_bar(ax_z, plane=self.plane)
self._ax_subplots[ax_z] = ax_z_plot_ed
update_subplot_border()
if not circles == self.CircleStyles.NO_CIRCLES:
# add points that were not segmented by ctrl-clicking on zoom plots
# as long as not in "no circles" mode
regex_key_chl = re.compile(r"\+[0-9]+$")
self._listeners.append(
fig.canvas.mpl_connect("button_release_event", on_btn_release))
# reset circles window flag
self._listeners.append(
fig.canvas.mpl_connect("close_event", fn_close_listener))
# show 3D screenshot if available
if mlab_screenshot is not None:
img3d = mlab_screenshot
ax = fig.add_subplot(gs[0, num_zoom_levels])
# auto to adjust size with less overlap
ax.imshow(img3d)
ax.set_aspect(img3d.shape[1] / img3d.shape[0])
plot_support.hide_axes(ax)
plt.ion()
fig.canvas.draw_idle()
print("2D plot time: {}".format(time() - time_start))
[docs]
def update_imgs_display(
self, imgi: int, refresh: bool = False, **kwargs
) -> "plot_editor.PlotAxImg":
"""Update images with the given display settings.
Args:
imgi: Index of image group.
refresh: True to refresh all zoomed Plot Editors; defaults to False.
**kwargs: Arguments to pass to the updater.
Returns:
Updated plotted image.
"""
# update overview images
plot_ax_img = super().update_imgs_display(
imgi, refresh=refresh, **kwargs)
alpha = kwargs["alpha"] if "alpha" in kwargs else None
num_subplots = len(self._ax_subplots)
for i, zoomed_plot_ed in enumerate(self._ax_subplots.values()):
if zoomed_plot_ed is None: continue
# update zoomed image
alpha_subplot = alpha
if alpha and (i < self._z_planes_padding
or i >= num_subplots - self._z_planes_padding):
alpha_subplot /= 2
kwargs["alpha"] = alpha_subplot
zoomed_plot_ed.update_img_display(imgi, **kwargs)
if refresh:
zoomed_plot_ed.show_overview()
return plot_ax_img
[docs]
def update_max_intens_proj(self, shape, show=False):
"""Update max intensity projection planes.
Args:
shape (Sequence[int]): Number of planes in ``z,y,x``, of which
the value corresponding to :attr:`plane` will be used.
show (bool): True to trigger an update in the Plot Editors;
defaults to False.
"""
# get dimension corresponding to planar orientation
super().update_max_intens_proj(shape[plot_support.get_plane_axis(
self.plane, get_index=True)], show)
if show: self.fig.canvas.draw_idle()
[docs]
def plot_roi(self, roi, segments, channel, show=True, title=""):
"""Plot ROI as sequence of z-planes containing only the ROI itself.
Args:
roi: The ROI image as a 3D array in (z, y, x) order.
segments: Numpy array of segments to display in the subplot, which
can be None. Segments are generally given as an (n, 4)
dimension array, where each segment is in (z, y, x, radius).
All segments are assumed to be within the ROI for display.
channel: Channel of the image to display.
show: True if the plot should be displayed to screen; defaults
to True.
title: String used as basename of output file. Defaults to ""
and only used if :attr:``config.savefig`` is set to a file
extension.
"""
# TODO: replace with modularized version from plot_2d_stack?
fig = plt.figure()
# fig.suptitle(title)
# total number of z-planes
z_planes = roi.shape[0]
# wrap plots after reaching max, but tolerates additional column
# if it will fit all the remainder plots from the last row
zoom_plot_rows = math.ceil(z_planes / self.ROI_COLS)
col_remainder = z_planes % self.ROI_COLS
zoom_plot_cols = self.ROI_COLS
if col_remainder > 0 and col_remainder < zoom_plot_rows:
zoom_plot_cols += 1
zoom_plot_rows = math.ceil(z_planes / zoom_plot_cols)
col_remainder = z_planes % zoom_plot_cols
roi_size = roi.shape[:3][::-1]
zoom_offset = [0, 0, 0]
gs = gridspec.GridSpec(
zoom_plot_rows, zoom_plot_cols, wspace=0.1, hspace=0.1)
# plot the fully zoomed plots
for i in range(zoom_plot_rows):
# adjust columns for last row to number of plots remaining
cols = zoom_plot_cols
if i == zoom_plot_rows - 1 and col_remainder > 0:
cols = col_remainder
# show zoomed in plots and highlight one at offset z
for j in range(cols):
# z relative to start of ROI, since blobs are relative to ROI
z = i * zoom_plot_cols + j
zoom_offset[2] = z
# shows the zoomed subplot with scale bar for the current
# z-plane with all segments
ax_z, _ = self.show_subplot(
fig, gs, i, j, channel, roi_size, zoom_offset,
None, segments, None, None, 1.0, z,
circles=self.CircleStyles.CIRCLES, roi=roi)
if (i == 0 and j == 0
and config.plot_labels[config.PlotLabels.SCALE_BAR]):
plot_support.add_scale_bar(ax_z)
gs.tight_layout(fig, pad=0.5)
if show:
plt.show()
plot_support.save_fig(title, config.savefig)
[docs]
def get_save_path(self):
"""Get default figure save path.
Returns:
str: Figure save path based on ROI offset, shape, plane axis,
z-plane of overview image, and extension based on
:attr:`config.savefig` if available or
:const:`config.DEFAULT_SAVEFIG` if not.
"""
ext = config.savefig if config.savefig else config.DEFAULT_SAVEFIG
return "{}_{}{}.{}".format(naming.get_roi_path(
os.path.basename(self.filename), self.offset, self.roi_size),
plot_support.get_plane_axis(self.plane),
self._z_overview, ext)
@staticmethod
def _fig_title(atlas_region, name, offset, roi_size):
"""Figure title parser.
Arguments:
atlas_region: Name of the region in the atlas; if None, the region
will be ignored.
offset: (x, y, z) image offset
roi_size: (x, y, z) region of interest size
Returns:
Figure title string.
"""
region = ""
if atlas_region is not None:
region = "{} from ".format(atlas_region)
# cannot round to decimal places or else tuple will further round
roi_size_um = np.around(
np.multiply(roi_size, config.resolutions[0][::-1]))
series = ""
if config.series is not None:
series = " (series {})".format(config.series)
return "{}{}{} ROI at x={}, y={}, z={}; size {}px ({}{})".format(
region, name, series, *offset[:3], str(tuple(roi_size)).strip("()"),
str(tuple(roi_size_um)).strip("()"), u'\u00b5m')
[docs]
def show_subplot(
self, fig, gs, row, col, channel, roi_size,
offset, fn_update_seg, segs_in, segs_out, segs_cmap, alpha,
z_relative, highlight=False, border=None, plane="xy",
roi=None, labels=None, blobs_truth=None, circles=None,
aspect=None, grid=False, cmap_labels=None
) -> Tuple["axes.Axes", Optional["plot_editor.PlotEditor"]]:
"""Shows subplots of the region of interest.
Args:
fig (:obj:`figure.Figure`): Matplotlib figure.
gs: Gridspec layout.
row: Row number of the subplot in the layout.
col: Column number of the subplot in the layout.
channel: Channel of the image to display.
roi_size: List of x,y,z dimensions of the ROI.
offset: Tuple of x,y,z coordinates of the ROI.
fn_update_seg: Function to update blob.
segs_in: Numpy array of segments within the ROI to display in the
subplot, which can be None. Segments are generally given as an
``(n, 4)`` dimension array, where each segment is in
``(z, y, x, radius)``.
segs_out: Subset of segments that are adjacent to rather than
inside the ROI, which will be drawn in a different style.
Can be None.
segs_cmap: Colormap for segments.
alpha: Opacity level.
z_relative: Index of the z-plane relative to the start of the ROI.
highlight: If true, the plot will be highlighted; defaults
to False.
border: Border dimensions in pixels given as (x, y, z); defaults
to None.
plane: The plane to show in each 2D plot, with "xy" to show the
XY plane (default) and "xz" to show XZ plane.
roi: A denoised region of interest, to show in place of image5d
for the zoomed images. Defaults to None, in which case
image5d will be used instead.
labels: Segmentation labels; defaults to None.
blobs_truth: Truth blobs formatted similarly to ``segs_in``;
defaults to None.
circles: :class:``CircleStyles`` enum member; defaults to None.
aspect: Image aspect; defauls to None.
grid: True if a grid should be overlaid; defaults to False.
cmap_labels: :class:``colormaps.DiscreteColormap`` for labels;
defaults to None.
"""
def on_motion(event):
if event.inaxes == ax:
# update status bar based on position in axes
self.fn_status_bar(ax.format_coord.get_msg(event))
ax = fig.add_subplot(gs[row, col])
plot_support.hide_axes(ax)
size = self.image5d.shape
# swap columns if showing a different plane
plane_axis = plot_support.get_plane_axis(plane)
image5d_shape_offset = 1 if self.image5d.ndim >= 4 else 0
if plane == config.PLANE[1]:
# "xz" planes
size = libmag.swap_elements(size, 0, 1, image5d_shape_offset)
elif plane == config.PLANE[2]:
# "yz" planes
size = libmag.swap_elements(size, 0, 2, image5d_shape_offset)
size = libmag.swap_elements(size, 0, 1, image5d_shape_offset)
z = offset[2]
if border is not None:
# boundaries of border region, with xy point of corner in first
# elements and [width, height] in 2nd, allowing flip for yz plane
border_bounds = np.array([
border[0:2],
[roi_size[0] - 2 * border[0], roi_size[1] - 2 * border[1]]])
plot_ed = None
if z < 0 or z >= size[image5d_shape_offset]:
# draw empty, grey subplot out of image planes just for spacing
ax_imgs = [[ax.imshow(np.zeros(roi_size[0:2]), alpha=0)]]
else:
# show the zoomed in 2D region
# calculate the region depending on whether given ROI directly and
# remove time dimension since roi argument does not have it
if roi is None:
region = [offset[2],
slice(offset[1], offset[1] + roi_size[1]),
slice(offset[0], offset[0] + roi_size[0])]
roi = self.image5d[0]
#print("region: {}".format(region))
else:
region = [z_relative, slice(0, roi_size[1]),
slice(0, roi_size[0])]
# swap columns if showing a different plane
if plane == config.PLANE[1]:
region = libmag.swap_elements(region, 0, 1)
elif plane == config.PLANE[2]:
region = libmag.swap_elements(region, 0, 2)
region = libmag.swap_elements(region, 0, 1)
# get the zoomed region
if roi.ndim >= 4:
roi = roi[tuple(region + [slice(None)])]
else:
roi = roi[tuple(region)]
#print("roi shape:", roi.shape)
if highlight:
# highlight borders of z plane at bottom of ROI
for spine in ax.spines.values():
spine.set_edgecolor("yellow")
if grid:
# draw grid lines by directly editing copy of image
grid_intervals = (roi_size[0] // 4, roi_size[1] // 4)
roi = np.copy(roi)
roi[::grid_intervals[0], :] = roi[::grid_intervals[0], :] / 2
roi[:, ::grid_intervals[1]] = roi[:, ::grid_intervals[1]] / 2
# show the ROI, which is now a 2D zoomed image
overlaid = plot_support.ImageOverlayer(
ax, aspect, rgb=self.img5d.rgb,
additive_blend=self.additive_blend)
plot_ed = plot_editor.PlotEditor(overlaid, roi[None])
plot_ed.alpha_img3d = [alpha]
plot_ed.coord = (0, 0, 0)
plot_ed.show_overview()
# add title after showing overview, which cleared the axes
ax.set_title("{}={}".format(plane_axis, z))
# show labels if provided and within ROI
if labels is not None:
for i in range(len(labels)):
label = labels[i]
if 0 <= z_relative < label.shape[0]:
img = label[z_relative]
if img.ndim > 1:
ax.imshow(
img, cmap=cmap_labels, norm=cmap_labels.norm)
if ((segs_in is not None or segs_out is not None)
and not circles == self.CircleStyles.NO_CIRCLES):
# shows truth blobs as blue circles
if blobs_truth is not None:
for blob in blobs_truth:
ax.add_patch(patches.Circle(
(blob[2], blob[1]), radius=blob[3]/2,
facecolor=self._TRUTH_COLORS[blob[5]], alpha=0.8))
segs_in = np.copy(segs_in)
if circles is None or circles == self.CircleStyles.CIRCLES:
# show circles at detection point only mode:
# zero radius of all segments outside of current z to
# preserve the order of segments for the corresponding
# colormap order while hiding outside segments
segs_in[segs_in[:, 0] != z_relative, 3] = 0
if segs_in is not None and segs_cmap is not None:
if circles in (self.CircleStyles.REPEAT_CIRCLES,
self.CircleStyles.FULL_ANNOTATION):
# repeat circles and full annotation:
# show segments from all z's as circles with colored
# outlines, gradually decreasing in size when moving
# away from the blob's central z-plane
z_diff = np.abs(np.subtract(segs_in[:, 0], z_relative))
r_orig = np.abs(np.copy(segs_in[:, 3]))
segs_in[:, 3] = np.subtract(
r_orig, np.divide(z_diff, 3))
# make circles below 90% of their original radius
# invisible but not removed to preserve their
# corresponding colormap index
segs_in[np.less(
segs_in[:, 3], np.multiply(r_orig, 0.9)), 3] = 0
# show colored, non-pickable circles
segs_color = segs_in
if circles == self.CircleStyles.FULL_ANNOTATION:
# zero out circles from other z's in full annotation
# mode to minimize crowding and highlight center circle
segs_color = np.copy(segs_in)
segs_color[segs_color[:, 0] != z_relative, 3] = 0
collection = self._circle_collection(
segs_color, segs_cmap.astype(float) / 255.0, "none",
self._BLOB_LINEWIDTH)
ax.add_collection(collection)
# segments outside the ROI shown in black dotted line only for
# their corresponding z
segs_out_z = None
if segs_out is not None:
segs_out_z = segs_out[segs_out[:, 0] == z_relative]
collection_adj = self._circle_collection(
segs_out_z, "k", "none", self._BLOB_LINEWIDTH)
collection_adj.set_linestyle("--")
ax.add_collection(collection_adj)
# for planes within ROI, overlay segments with dotted line
# patch and make pickable for verifying the segment
segments_z = segs_in[segs_in[:, 3] > 0] # full annotation
if circles == self.CircleStyles.FULL_ANNOTATION:
# when showing full annotation, show all segments in the
# ROI with adjusted radii unless radius is <= 0
for i in range(len(segments_z)):
seg = segments_z[i]
if seg[0] != z_relative:
# add segments outside of plane to Visualizer table
# since they have been added to the plane,
# adjusting rel and abs z coords to the given plane
z_diff = z_relative - seg[0]
seg[0] = z_relative
detector.Blobs.shift_blob_abs_coords(
segments_z[i], (z_diff, 0, 0))
segments_z[i] = fn_update_seg(seg)
else:
# apply only to segments in their current z
segments_z = segs_in[segs_in[:, 0] == z_relative]
if segs_out_z is not None:
segs_out_z_confirmed = segs_out_z[
detector.Blobs.get_blob_confirmed(segs_out_z) == 1]
if len(segs_out_z_confirmed) > 0:
# include confirmed blobs; TODO: show contextual
# circles in adjacent planes?
segments_z = np.concatenate(
(segments_z, segs_out_z_confirmed))
print("segs_out_z_confirmed:\n{}"
.format(segs_out_z_confirmed))
if segments_z is not None:
# show pickable circles
for seg in segments_z:
self._plot_circle(
ax, seg, self._BLOB_LINEWIDTH, None, fn_update_seg)
if (self.blobs is not None
and self.blobs.blob_matches is not None):
blobs_matched = self.blobs.blob_matches.get_blobs_all()
if blobs_matched is not None:
# show blob matches by corresponding number labels
for i, (blob1, blob2) in enumerate(zip(*blobs_matched)):
for j, blob in enumerate((blob1, blob2)):
if blob[0] != z_relative: continue
# add label with number; italicize if 1st blob
style = "italic" if j == 0 else "normal"
ax.text(blob[2], blob[1], i, color="w",
alpha=0.5, style=style,
horizontalalignment="center",
verticalalignment="center")
# adds a simple border to highlight the border of the ROI
if border is not None:
#print("border: {}, roi_size: {}".format(border, roi_size))
ax.add_patch(patches.Rectangle(border_bounds[0],
border_bounds[1, 0],
border_bounds[1, 1],
fill=False, edgecolor="yellow",
linestyle="dashed",
linewidth=self._BLOB_LINEWIDTH))
if self.fn_status_bar and plot_ed:
# set up status bar pixel display for mouseover
imgs2d = [roi if channel is None or len(roi.shape) < 3
else roi[..., tuple(channel)]]
ax_imgs = [
[p.ax_img for p in ps] for ps in plot_ed.plot_ax_imgs]
ax.format_coord = pixel_display.PixelDisplay(
imgs2d, ax_imgs, offset=offset[1::-1])
self._listeners.append(
fig.canvas.mpl_connect("motion_notify_event", on_motion))
return ax, plot_ed
def _circle_collection(self, segments, edgecolor, facecolor, linewidth):
"""Draws a patch collection of circles for segments.
Args:
segments: Numpy array of segments, generally as an (n, 4)
dimension array, where each segment is in (z, y, x, radius).
edgecolor: Color of patch borders.
facecolor: Color of patch interior.
linewidth: Width of the border.
Returns:
The patch collection.
"""
seg_patches = []
for seg in segments:
seg_patches.append(
patches.Circle((seg[2], seg[1]), radius=self._get_radius(seg)))
collection = PatchCollection(seg_patches)
collection.set_edgecolor(edgecolor)
collection.set_facecolor(facecolor)
collection.set_linewidth(linewidth)
return collection
def _plot_circle(self, ax, segment, linewidth, linestyle, fn_update_seg,
alpha=0.5, edgecolor="w"):
"""Create and draw a DraggableCircle from the given segment.
Args:
ax: Matplotlib axes.
segment: Numpy array of segments, generally as an (n, 4)
dimension array, where each segment is in (z, y, x, radius).
linewidth: Edge line width.
linestyle: Edge line style.
fn_update_seg: Function to call from DraggableCircle.
alpha: Alpha transparency level; defaults to 0.5.
edgecolor: String of circle edge color; defaults to "w" for white.
Returns:
The DraggableCircle object.
"""
channel = detector.Blobs.get_blobs_channel(segment)
facecolor = DraggableCircle.BLOB_COLORS[
detector.Blobs.get_blob_confirmed(segment)]
if linestyle is None:
linestyle = self._BLOB_LINESTYLES[channel]
circle = patches.Circle(
(segment[2], segment[1]), radius=self._get_radius(segment),
edgecolor=edgecolor, facecolor=facecolor, linewidth=linewidth,
linestyle=linestyle, alpha=alpha)
ax.add_patch(circle)
#print("added circle: {}".format(circle))
draggable_circle = DraggableCircle(
circle, segment, fn_update_seg, self._circle_last_picked, facecolor)
draggable_circle.connect()
self._draggable_circles.append(draggable_circle)
return draggable_circle
def _get_radius(self, seg):
"""Gets the radius for a segments, defaulting to 5 if the segment's
radius is close to 0.
Args:
seg: The segments, where seg[3] is the radius.
Returns:
The radius, defaulting to 0 if the given radius value is close
to 0 by numpy.allclose.
"""
radius = seg[3]
if radius < config.POS_THRESH:
radius *= -1
return radius
[docs]
def show_colocalized_blobs(self, visible):
"""Show blob co-localization by overlaying text showing all the
channels with signal at each blob's position.
Args:
visible (bool): True to make the co-localization text visible.
"""
if self._blobs_coloc_text:
for text in self._blobs_coloc_text:
# change existing label's visibility
text.set_visible(visible)
else:
if (not visible or self.blobs is None or self.blobs.blobs is None
or self.blobs.colocalizations is None):
return
# show labels for each blob
self._blobs_coloc_text = []
for i, ax in enumerate(self._ax_subplots.keys()):
# get blobs at given z-val relative to ROI, shifting for
# plots in the padding region above and below the ROI
z = i - self._z_planes_padding
if i < 0: continue
mask = self.blobs.blobs[:, 0] == z
blobs = self.blobs.blobs[mask]
colocs = self.blobs.colocalizations[mask]
for j, (blob, coloc) in enumerate(zip(blobs, colocs)):
# overlay the channels with signal at given blob position
self._blobs_coloc_text.append(ax.text(
blob[2], blob[1],
",".join([str(c) for c in np.where(coloc > 0)[0]]),
color="C{}".format(
int(detector.Blobs.get_blobs_channel(blob))),
alpha=0.8, horizontalalignment="center",
verticalalignment="center"))
self.fig.canvas.draw_idle()
[docs]
def set_circle_visibility(self, visible):
"""Set the visibility of detection circles.
Args:
visible (bool): True to make the circles visible, False for
invisibility.
"""
for circle in self._draggable_circles:
# change the visibility of selectable circles
circle.circle.set_visible(visible)
for ax in self._ax_subplots.keys():
for collection in ax.collections:
# change the visibility of colored circle collections
collection.set_visible(visible)
self.fig.canvas.draw_idle()