"""
stitching_plots
===============
Plot functions and Layout/Alignment plot mixins for rigid and wobbly stitching.
Mixins are injected at runtime via ``@lazy_mixin``, so matplotlib is never
imported on a headless cluster unless a plot method is actually called.
Standalone functions duck-type on layout / source / alignment objects and only
import from ``stitching_rigid`` / ``stitching_wobbly`` locally when they need to
*construct* helper objects.
"""
from __future__ import annotations
import warnings
import numpy as np
from ClearMap.Alignment.Stitching.layout_graph_utils import get_color_ids
# ═══════════════════════════════════════════════════════════════════════════════
# Rigid Layout mixin
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
class LayoutPlotMixin:
"""Plot methods for :class:`Layout`, injected via ``@lazy_mixin``."""
[docs]
def plot(self, colors=None, percentile=98, normalize=True, color_ids=None, coordinate=None, axis=2):
"""Plots overlayed sources to check their placement.
Arguments
---------
colors : list of tuple of floats or color names
The optional RGB colors to use.
percentile : int
Use this percentile as upper cutoff in the resulting image to enhance contrast.
normalize : bool
If True normalize image to floats between 0 and 1.
color_ids : list of ints
Use specific color ids for the sources contributing to the layout.
coordinate : int or None
Optional coordinate at which to take a slice.
axis : int
Optional axis to take the slice in.
Returns
-------
image : array
A color image.
"""
if coordinate is None:
layout = self
else:
layout = self.slice_along_axis(coordinate=coordinate, axis=axis)
return plot_layout(layout, colors=colors, percentile=percentile, normalize=normalize, color_ids=color_ids)
[docs]
def overlay(self, colors=None, percentile=98, normalize=True, coordinate=None, axis=2):
if coordinate is None:
layout = self
else:
layout = self.slice_along_axis(coordinate=coordinate, axis=axis)
return overlay_layout(layout, colors=colors, percentile=percentile, normalize=normalize)
[docs]
def plot_regions(self, cmap=None, annotate=True, axes=None):
"""Overlays and plots regions to check the alignment of this layout.
Arguments
---------
cmap : colormap
The color map to use to color the regions.
annotate : bool
Use annotation or not.
axes : tuple of ints
Axes to use if sources are larger than 2d.
"""
if axes is None:
axes = [0, 1]
_, _, regions = self.embedding()
return plot_regions(regions, sources=self.sources, cmap=cmap, annotate=annotate, axes=axes)
[docs]
def plot_alignments(self, cmap=None, annotate=True, axes=None):
"""Overlays and plots regions to check the alignment of this layout.
Arguments
---------
cmap : colormap
The color map to use to color the regions.
annotate : bool
Use annotation or not.
axes : tuple of ints
Axes to use if sources are larger than 2d.
"""
if axes is None:
axes = [0, 1]
if cmap is None:
from matplotlib import pyplot as plt
plt.cm.rainbow
return plot_alignments(self.alignments, sources=self.sources, cmap=cmap, annotate=annotate, axes=axes)
# ═══════════════════════════════════════════════════════════════════════════════
# Rigid Alignment mixin
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
class AlignmentBasePlotMixin:
"""Plot methods for :class:`AlignmentBase`, injected via ``@lazy_mixin``."""
[docs]
def plot(self, *args, **kwargs):
"""Plot the two sources overlayed."""
plot_sources(self.sources, *args, **kwargs)
[docs]
def overlay(self, **kwargs):
"""Return a colour-overlay array of the two sources."""
return overlay_sources(self.sources, **kwargs)
[docs]
def plot_overlay(self, **kwargs):
"""Overlay and display with 3D viewer."""
import ClearMap.Visualization.Plot3d as p3d
ovl = self.overlay(colors='ids', **kwargs)
return p3d.plot([ovl])
[docs]
def overlay_overlap(self, max_shifts=0):
"""Return the overlapping sub-arrays of pre and post."""
from ClearMap.Alignment.Stitching.stitching_rigid import _overlap_with_shifts
o1, o2 = _overlap_with_shifts(self.pre, self.post, max_shifts=max_shifts)
i1 = self.pre[o1.local_slicing(self.pre)]
i2 = self.post[o2.local_slicing(self.post)]
return [i1, i2]
[docs]
def plot_overlap(self, **kwargs):
"""Display the overlap region with the 3D viewer."""
import ClearMap.Visualization.Plot3d as p3d
return p3d.plot([self.overlay_overlap(**kwargs)])
[docs]
def overlay_mip(self, *args, **kwargs):
"""Overlay using max intensity projection."""
return overlay_along_axis_mip(self.pre, self.post, *args, **kwargs)
[docs]
def plot_mip(self, *args, **kwargs):
"""Plot using max intensity projection."""
return plot_along_axis_mip(self.pre, self.post, *args, **kwargs)
[docs]
class AlignmentPlotMixin(AlignmentBasePlotMixin):
"""Plot methods for :class:`Alignment`.
Overrides ``plot`` to shift post by the computed displacement before
overlaying.
"""
[docs]
def plot(self, *args, **kwargs):
post = self.post.copy()
post.position = tuple(p + d for p, d in zip(self.pre.position, self.displacement))
return plot_sources([self.pre, post], *args, **kwargs)
# ═══════════════════════════════════════════════════════════════════════════════
# Wobbly Alignment mixin
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
class WobblyAlignmentPlotMixin:
"""Plot methods for :class:`WobblyAlignment`, injected via ``@lazy_mixin``."""
[docs]
def plot_overlay_wobbly(self):
"""Display wobbly overlay with 3D viewer."""
import ClearMap.Visualization.Plot3d as p3d
return p3d.plot([self.overlay_wobbly()])
[docs]
def overlay_mip_wobbly(self, overlap=True, mip_axis=None,
percentile=98, normalize=True):
"""Colour MIP overlay of the wobbly alignment."""
from ClearMap.Alignment.Stitching.stitching_rigid import _mip_axis
import ClearMap.Visualization.Color as col
ovl_mip = self.overlay_wobbly(overlap=overlap)
# max project
if mip_axis is None:
mip_axis = _mip_axis(self.pre, self.post)
ovl_mip = [np.max(o, axis=mip_axis) for o in ovl_mip]
for o in ovl_mip:
p = np.percentile(o, percentile)
o[o > p] = p
colors = [[1, 0, 1], [0, 1, 0]]
colors = [col.color(c, alpha=False, as_int=True) for c in colors]
image = np.zeros(ovl_mip[0].shape + (3,))
for o, c in zip(ovl_mip, colors):
image += np.multiply.outer(o / o.max(), c)
if normalize:
for c in range(3):
image[:, :, c] /= image[:, :, c].max()
return image
[docs]
def plot_mip_wobbly(self, overlap=True, mip_axis=None, percentile=98):
"""Display wobbly MIP overlay with matplotlib."""
from matplotlib import pyplot as plt
image = self.overlay_mip_wobbly(overlap=overlap, mip_axis=mip_axis, percentile=percentile)
plt.imshow(np.transpose(image, [1, 0, 2])[:, :, :], origin='lower')
plt.tight_layout()
# ═══════════════════════════════════════════════════════════════════════════════
# Wobbly Layout mixin
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
class WobblyLayoutPlotMixin:
"""Plot methods for :class:`WobblyLayout`, injected via ``@lazy_mixin``."""
[docs]
def plot_wobble(self):
"""Plot per-alignment displacement curves."""
from matplotlib import pyplot as plt
fig = plt.gcf()
axs = [plt.subplot(1, 2, i + 1) for i in range(2)]
for i, a in enumerate(self.alignments):
invalid = a.status < a.VALID
d = np.array(a.displacements, dtype=float)
d[invalid] = np.nan
x = np.arange(a.lower_coordinate, a.upper_coordinate)
label = f'{i:d}: {a.pre.identifier!r}-{a.post.identifier!r}'
plt.subplot(1, 2, 1)
plt.plot(x, d[:, 0], label=label)
plt.subplot(1, 2, 2, sharex=axs[0])
plt.plot(x, d[:, 1], label=label)
def on_pick(event):
for ax in axs:
for curve in ax.get_lines():
if curve.contains(event)[0]:
print(curve.get_label())
fig.canvas.mpl_connect('motion_notify_event', on_pick)
plt.show()
[docs]
def alignment_info(self, tile_position, coordinate,
plot=True, use_displacements=True, **kwargs):
"""Diagnostic: gather alignment info and optionally overlay a slice."""
import ClearMap.IO.Slice as slc
from ClearMap.Alignment.Stitching.stitching_wobbly import WobblySource, WobblyAlignment
from ClearMap.Alignment.Stitching.stitching_rigid import Source, Layout
s = self.source_from_tile_position(tile_position)
status = s.status_at_coordinate(coordinate)
a_status = []
alignments = self.alignments_from_tile_position(tile_position)
for a in alignments:
a_status.append((a.pre.identifier, a.post.identifier,
a.status_at_coordinate(coordinate)))
print(f'Source status: {WobblySource.status_to_description[status]!r}')
for a in a_status:
status = WobblyAlignment.status_to_description[a[2]]
print(f'Alignment status: {a[0]!r}->{a[1]!r}: {status !r}')
# plot overlay to all neighbours
sliced_layout = None
if plot:
axis = self.axis
ndim = self.ndim
sources = [s]
for a in alignments:
if a.pre not in sources:
sources.append(a.pre)
if a.post not in sources:
sources.append(a.post)
sliced_sources = []
for source in sources:
if use_displacements:
if source == s:
position = tuple(0 for _ in range(ndim - 1))
else:
for a in alignments:
if source == a.pre:
position = tuple(-p for p in a.displacement)
break
if source == a.post:
position = a.displacement
break
position = position[:axis] + position[axis + 1:]
else:
position = source.wobble_at_coordinate(coordinate)
slicing = ((slice(None),) * axis
+ (coordinate - source.coordinate,)
+ (slice(None),) * (ndim - 1 - axis))
sliced_sources.append(
Source(source=slc.Slice(source=source.source.as_virtual(), slicing=slicing),
position=position, tile_position=source.tile_position))
sliced_layout = Layout(sources=sliced_sources, shape=None, position=None,
dtype=self.dtype, order=self.order)
plot_layout(sliced_layout, **kwargs)
return sliced_layout
# ═══════════════════════════════════════════════════════════════════════════════
# Standalone plot helpers — duck-typed, no stitching_rigid import at module level
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
def plot_layout(layout, colors=None, percentile=98, normalize=True, color_ids=None):
"""Overlays and plots sources in a layout to check alignment.
..Note::
For 2-D layouts uses matplotlib; for 3-D uses Plot3d.
Arguments
---------
layout : Layout class
The layout to use for plotting.
colors : list of colors or None
The optional RGB colors to use.
percentile : int
Use this percentile as upper cutoff in the resulting image to enhance contrast.
normalize : bool
If True normalize image to floats between 0 and 1.
Returns
-------
image : array
A color image of the overlayed sources.
"""
img = overlay_layout(layout, colors=colors, percentile=percentile,
normalize=normalize, color_ids=color_ids)
if img.ndim == 3:
from matplotlib import pyplot as plt
plt.imshow(np.transpose(img, [1, 0, 2])[:, :, :], origin='lower')
plt.tight_layout()
else:
import ClearMap.Visualization.Plot3d as p3d
p3d.plot(img)
[docs]
def overlay_layout(layout, colors=None, percentile=98, normalize=True, color_ids=None):
"""Build a colour-overlay array of all sources in a layout.
Arguments
---------
layout : Layout
The layout with the sources to overlay.
colors : list of colours, ``'ids'``, or None
RGB colours, or ``'ids'`` to return per-channel arrays.
percentile : int
Upper cutoff percentile for contrast enhancement.
normalize : bool
Normalize to [0, 1] floats.
color_ids : list of int or None
Explicit colour index per source.
Returns
-------
image : array
A colour image (or list of single-channel images when ``colors='ids'``).
"""
# full shape
full_shape = tuple(layout.extent)
full_lower = layout.lower
source_colors = layout_coloring(layout, colors=colors, color_ids=color_ids)
if colors == 'ids':
image = [np.zeros(full_shape) for _ in range(max(source_colors) + 1)]
else:
image = np.zeros(full_shape + (3,))
# construct full image
sources = layout.sources
for s, c in zip(sources, source_colors):
l = s.lower
u = s.upper
r = tuple(slice(ll - fl, uu - fl) for ll, uu, fl in zip(l, u, full_lower))
if colors == 'ids':
image[c][r] += s[:]
else:
r += (slice(None),)
if normalize:
image[r] += np.multiply.outer(np.array(s[:], dtype=float) / s[:].max(), c)
else:
image[r] += np.multiply.outer(s[:], c)
if percentile is not None:
if colors == 'ids':
for i in image:
p = np.percentile(i, percentile)
i[i > p] = p
else:
p = np.percentile(image, percentile)
image[image > p] = p
if normalize:
if colors == 'ids':
for i in image:
mx = i.max()
if mx > 0:
i /= mx
else:
for c in range(3):
mx = image[..., c].max()
if mx > 0:
image[..., c] /= mx
return image
[docs]
def layout_coloring(layout, colors=None, color_ids=None):
"""Assign colours to sources based on adjacency colouring."""
from ClearMap.Visualization import Color as col
sources = layout.sources
nsources = len(sources)
color_ids = get_color_ids(sources, nsources, color_ids)
if colors == 'ids':
return color_ids
ncols = np.max(color_ids) + 1
if colors is None:
if ncols <= 2:
colors = [[1, 0, 1], [0, 1, 0]]
elif ncols <= 4:
colors = [[0.5, 0.5, 0], [0, 0.5, 0.5], [0.5, 0, 0], [0, 0, 0.5]]
else:
colors = [[0.25, 0, 0 ],
[0, 0.25, 0 ],
[0, 0, 0.25 ],
[0.25, 0.25, 0 ],
[0, 0.25, 0.25 ],
[0.25, 0, 0.25 ],
[0.125, 0.25, 0 ],
[0.125, 0, 0.25 ]]
colors = [col.color(c, alpha=False, as_int=True) for c in colors]
colors = np.pad(colors[:ncols], ((0, max(0, ncols - len(colors))), (0, 0)), 'wrap')
return colors[color_ids]
[docs]
def plot_regions(regions, sources=None, cmap=None, annotate=True, axes=None):
"""Overlays and plots regions to check the alignment.
Arguments
---------
regions : list of Region classes
The regions to plot.
sources : list of Source or None
Sources used for annotation ids.
cmap : colormap
The color map to use to color the regions.
annotate : bool
Use annotation or not.
"""
from matplotlib import pyplot as plt
if axes is None:
axes = [0, 1]
if cmap is None:
cmap = plt.cm.rainbow
if len(regions) == 0:
return
if sources is None:
sources = list(np.unique(np.hstack([r.sources for r in regions])))
sources_to_ids = {s: i for i, s in enumerate(sources)}
ndim = regions[0].ndim
if ndim != 2:
warnings.warn(f"Regions are plotted in 2d using axes {axes!r} but are {ndim:d}d!")
ax = plt.gca()
rmin = np.zeros(ndim)
rmax = np.zeros(ndim)
for i, r in enumerate(regions):
rec = plt.Rectangle(np.array(r.lower)[axes],
r.upper[axes[0]] - r.lower[axes[0]],
r.upper[axes[1]] - r.lower[axes[1]],
fill=True, alpha=0.3,
color=cmap(float(i) / len(regions)))
ax.add_patch(rec)
if annotate:
ids = [sources_to_ids[s] for s in r.sources]
ax.annotate(str(tuple(ids)), xy=rec.get_xy(), xytext=(0, 0),
textcoords='offset points', color='w', ha='center', fontsize=8,
bbox=dict(boxstyle='round, pad=.5', fc=(.1, .1, .1, .92),
ec=(1., 1., 1.), lw=1, zorder=1))
rmin = np.min([rmin, r.lower], axis=0)
rmax = np.max([rmax, r.upper], axis=0)
plt.xlim((rmin[axes[0]], rmax[axes[0]]))
plt.ylim((rmin[axes[1]], rmax[axes[1]]))
[docs]
def plot_alignments(alignments, sources=None, axes=None, annotate=True,
min_quality=-np.inf, cmap=None):
"""Plot alignment edges coloured by quality."""
from matplotlib import pyplot as plt
if axes is None:
axes = [0, 1]
if cmap is None:
cmap = plt.cm.hot
ndim = alignments[0].ndim
if ndim != 2:
warnings.warn(f"Regions are plotted in 2d using axes {axes!r} but are {ndim:d}d!")
ndim = 2
q = np.array([a.quality for a in alignments])
q_max = np.max(q)
if q_max == -np.inf:
q_max = 0
q_min = -1
else:
q_min = np.min(q)
if q_min == -np.inf:
q_min = np.min(q[q > -np.inf])
q_min = max(min_quality, q_min)
if q_max <= q_min:
q_max = q_min + 1
if sources is None:
from ClearMap.Alignment.Stitching.stitching_rigid import sources_from_alignments
sources = sources_from_alignments(alignments)
# plot
ax = plt.gca()
rmin = np.zeros(ndim)
rmax = np.zeros(ndim)
for s in sources:
# plot the source boundary
lower = np.array(s.lower)[axes]
upper = np.array(s.upper)[axes]
rec = plt.Rectangle(lower, upper[0] - lower[0], upper[1] - lower[1],
fill=True, alpha=0.3, color='gray')
ax.add_patch(rec)
rmin = np.min([rmin, lower], axis=0)
rmax = np.max([rmax, upper], axis=0)
plt.xlim((rmin[axes[0]], rmax[axes[0]]))
plt.ylim((rmin[axes[1]], rmax[axes[1]]))
for a in alignments:
p1 = 0.5 * (np.array(a.pre.lower)[axes] + np.array(a.pre.upper)[axes])
p2 = 0.5 * (np.array(a.post.lower)[axes] + np.array(a.post.upper)[axes])
if a.quality > -np.inf:
c = cmap((float(a.quality) - q_min) / (q_max - q_min))
else:
c = 'black'
ax.plot([p1[0], p2[0]], [p1[1], p2[1]], color=c, linewidth=1)
if annotate:
ax.annotate(f'{a.quality:.2e}', xy=0.5 * (p1 + p2), xytext=(0, 0),
textcoords='offset points', color='w', ha='center', fontsize=8,
bbox=dict(boxstyle='round, pad=.5', fc=(.1, .1, .1, .92),
ec=(1., 1., 1.), lw=1, zorder=1))
# ═══════════════════════════════════════════════════════════════════════════════
# Convenience wrappers — construct SR objects, so local imports needed
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
def plot_sources(sources, colors=None, percentile=98, normalize=True):
"""Overlays and plots sources in a layout to check alignment.
Arguments
---------
sources : list of Source
The sources to overlay.
colors : list of colors or None
The optional RGB colors to use.
percentile : int
Use this percentile as upper cutoff in the resulting image to enhance contrast.
normalize : bool
If True normalize image to floats between 0 and 1.
Returns
-------
image : array
A color image of the overlayed sources.
"""
from ClearMap.Alignment.Stitching.stitching_rigid import Layout
layout = Layout(sources=sources)
return plot_layout(layout, colors=colors, percentile=percentile, normalize=normalize)
[docs]
def overlay_sources(sources, colors=None, percentile=98, normalize=True):
"""Overlays the sources to check their placement.
Arguments
---------
sources : list of Source
The sources to overlay.
colors : list of tuple of floats or color names
The optional RGB colors to use.
percentile : int
Use this percentile as upper cutoff in the resulting image to enhance contrast.
normalize : bool
If True normalize image to floats between 0 and 1.
Returns
-------
image : array
A color image.
"""
from ClearMap.Alignment.Stitching.stitching_rigid import Layout
layout = Layout(sources=sources)
return overlay_layout(layout, colors=colors, percentile=percentile, normalize=normalize)
[docs]
def layout_along_axis_mip(src1, src2, axis=2, depth=10, max_shifts=10, ranges=None, verbose=False):
"""Build a Layout from MIP-projected sources."""
from ClearMap.Alignment.Stitching.stitching_rigid import (Layout, Source, Slice, _format_max_shifts, _mip_axis,
max_intensity_projection)
# format the shifts
ndim = src1.ndim
if not isinstance(depth, (list, tuple)):
depth = (depth,) * ndim
if not isinstance(ranges, list):
ranges = [ranges] * ndim
max_shifts = _format_max_shifts(max_shifts, ndim)
mip_axis = _mip_axis(src1, src2, axis=None, max_shifts=max_shifts)
mip_depth = depth[mip_axis]
# reduce sources to ranges along non-mip axes
if ranges != [None] * len(ranges):
sl1 = ()
sl2 = ()
p1 = src1.position
p2 = src2.position
for d, r in enumerate(ranges):
if d != mip_axis and r is not None:
sl1 += (slice(r[0] - p1[d], r[1] - p1[d]),)
sl2 += (slice(r[0] - p2[d], r[1] - p2[d]),)
else:
sl1 += (slice(None),)
sl2 += (slice(None),)
src1 = Slice(source=src1, slicing=sl1)
src2 = Slice(source=src2, slicing=sl2)
# mip
mip_depth = depth[mip_axis]
max_shifts = max_shifts[:mip_axis] + max_shifts[mip_axis + 1:]
s1 = src1.shape
s2 = src2.shape
# max intensity projections
sub1 = [slice(None)] * ndim
sub1[mip_axis] = slice(max(0, s1[mip_axis] - mip_depth), None)
sub1 = tuple(sub1)
sub2 = [slice(None)] * ndim
sub2[mip_axis] = slice(None, min(mip_depth, s2[mip_axis]))
sub2 = tuple(sub2)
# calculate max projection along axis
mip1 = max_intensity_projection(src1[sub1], axis=mip_axis)
mip2 = max_intensity_projection(src2[sub2], axis=mip_axis)
# add position information
p1 = src1.position[:mip_axis] + src1.position[mip_axis + 1:]
p2 = src2.position[:mip_axis] + src2.position[mip_axis + 1:]
mip1 = Source(mip1, position=p1, tile_position=src1.tile_position)
mip2 = Source(mip2, position=p2, tile_position=src2.tile_position)
return Layout(sources=[mip1, mip2])
[docs]
def plot_along_axis_mip(src1, src2, axis=2, depth=10, max_shifts=10, ranges=None, verbose=False, **kwargs):
"""Plot a MIP-projected alignment."""
layout = layout_along_axis_mip(src1, src2, axis=axis, depth=depth,
max_shifts=max_shifts, ranges=ranges, verbose=verbose)
return plot_layout(layout, **kwargs)
[docs]
def overlay_along_axis_mip(src1, src2, axis=2, depth=10, max_shifts=10, ranges=None, verbose=False, **kwargs):
"""Build a colour overlay of a MIP-projected alignment."""
layout = layout_along_axis_mip(src1, src2, axis=axis, depth=depth, max_shifts=max_shifts, ranges=ranges, verbose=verbose)
return overlay_layout(layout, **kwargs)