Source code for ClearMap.pipeline_orchestrators.stitching_orchestrator

import platform
import re
import warnings
from concurrent.futures.process import BrokenProcessPool
from pathlib import Path
from typing import Optional, TYPE_CHECKING

import numpy as np

from ClearMap.Alignment.Stitching import stitching_wobbly as stitching_wobbly
from ClearMap.Alignment.Stitching import stitching_rigid as stitching_rigid

from ClearMap.IO import IO as clearmap_io
from ClearMap.IO.metadata import define_auto_stitching_params, parse_ome_info

from ClearMap.Utils.exceptions import MissingRequirementException
from ClearMap.Utils.tag_expression import Expression
from ClearMap.Utils.utilities import check_stopped, sanitize_n_processes

from ClearMap.config.config_coordinator import ConfigCoordinator

from ClearMap.pipeline_orchestrators.generic_orchestrators import PipelineOrchestrator
from ClearMap.pipeline_orchestrators.sample_info_management import SampleManager

if TYPE_CHECKING:
    from ClearMap.gui.widgets import ProgressWatcher


[docs] class StitchingProcessor(PipelineOrchestrator): """ This class is used to manage the stitching process Handle image stitching operations. Manage stitching configurations and processes. """ config_name = 'stitching' def __init__(self, sample_manager: SampleManager, cfg_coordinator: ConfigCoordinator): super().__init__(cfg_coordinator) self.sample_manager: SampleManager = sample_manager self.progress_watcher: Optional["ProgressWatcher"] = None self.__wobbly_stitching_place_re = 'done constructing constraints for component' self.__wobbly_stitching_align_lyt_re = ('Alignment: Wobbly alignment', re.compile(r"Alignment:\sWobbly alignment \(\d+, \d+\)->\(\d+, \d+\) " r"along axis [0-3] done: elapsed time: \d+:\d{2}:\d{2}.\d+")) self.__wobbly_stitching_stitch_re = ('Stitching: stitching', re.compile(r'Stitching: stitching wobbly slice \d+/\d+')) self.__rigid_stitching_align_re = ('done', re.compile(r"Alignment: aligning \(\d+, \d+\) with \(\d+, \d+\), alignment" r" pair \d+/\d+ done, shift = \(-?\d+, -?\d+, -?\d+\)," r" quality = -\d+\.\d+e\+\d+!")) self.setup(sample_manager)
[docs] def setup(self, sample_manager: Optional[SampleManager] = None, convert_tiles: bool = False): self.sample_manager = sample_manager if sample_manager else self.sample_manager if not self.cfg_coordinator.get_config_view('stitching'): # Config not yet loaded (e.g. processor created before boot_open). # workspace stays None; setup_complete stays False. warnings.warn('Stitching config not set in config coordinator; StitchingProcessor setup incomplete.', stacklevel=2) return if self.sample_manager.setup_complete: self.workspace = self.sample_manager.workspace if convert_tiles: self.convert_tiles() # TODO: check if needed self.setup_complete = True else: self.setup_complete = False warnings.warn('Sample manager not set up yet, stitching processor setup incomplete', stacklevel=2)
@check_stopped def convert_tiles(self, _force=False): """Convert list of input files to numpy files for efficiency reasons""" for channel in self.sample_manager.stitchable_channels: self.convert_tiles_channel(channel, _force=_force) self.update_watcher_main_progress()
[docs] def convert_tiles_channel(self, channel, _force=False): if self.config['channels'][channel].get('use_npy', False) or _force: asset = self.get('raw', channel=channel, prefix=self.sample_manager.prefix) n_procs = self.config['performance']['file_conversion']['n_processes'] n_procs = sanitize_n_processes(n_procs) file_list = asset.file_list if not file_list or Path(file_list[0]).suffix == '.tif': try: asset.convert('.npy', processes=n_procs, workspace=self.workspace, verbose=self.verbose) except BrokenProcessPool: print(f'File conversion of {channel} to numpy canceled') return
[docs] def get_stitching_order(self, strict=False): """ Returns a list of trees (each tree is a list of channels in hierarchical order). Raises a ValueError if any channel is unreachable or if there is a cycle. """ config = self.config channels = set(config['channels'].keys()) # All channels root_channels = [ch for ch, cfg in config['channels'].items() if cfg.get('layout_channel', ch) == ch] visited = set() forest = {} def traverse_tree(root): stack = [root] tree = [] while stack: node = stack.pop() tree.append(node) visited.add(node) # Find immediate children whose layout_channel == node for ch, c_cfg in config['channels'].items(): if ch not in visited and c_cfg.get('layout_channel', ch) == node: stack.append(ch) return tree def tree_is_stitchable(root_channel, strict): cfg = self.config['channels'].get(root_channel) has_layout = self.get('layout', channel=root_channel, asset_sub_type='placed').exists if not (cfg['run'] or has_layout): msg = (f"Cannot stitch tree with root `{root_channel}`:" f" {root_channel} does not have a layout and is not set for stitching.") if strict: raise ValueError(msg) else: warnings.warn(msg, stacklevel=2) return False return True for root in root_channels: if not tree_is_stitchable(root, strict): continue if root not in visited: forest[root] = traverse_tree(root) unassigned = channels - visited if unassigned: raise ValueError(f"Unreachable or extra channels found: {unassigned}") return forest
def _pick_overlap_px(self, stitching: dict) -> tuple[int, int] | None: """Prefer declared overlap (px); else derived; return ints if both axes present.""" ov = (stitching or {}).get('overlap_px') or {} dec = ov.get('declared') or (None, None) der = ov.get('derived') or (None, None) ox = dec[0] if dec[0] is not None else der[0] oy = dec[1] if dec[1] is not None else der[1] if ox is None or oy is None: return None return int(round(ox)), int(round(oy))
[docs] def create_layout_from_ome(self, channel: str) -> None: """ Build and persist a real WobblyLayout for 'channel' from OME TileConfiguration. Marks the channel as 'use_existing_layout' so rigid placement can be skipped. """ raw_asset = self.get('raw', channel=channel, prefix=self.sample_manager.prefix, extension='.tif') if not raw_asset.file_list: raise FileNotFoundError(f"No tiles found for channel '{channel}'") first_tif = Path(raw_asset.file_list[0]) ome_info = parse_ome_info(first_tif) # Map this channel to OME TileConfiguration channel index ch_idx = self.sample_manager.infer_channel_index_from_name(first_tif) tile_cfg = ome_info.get('tile_configuration') or {} if ch_idx not in tile_cfg: raise ValueError(f"Cannot map channel '{channel}' to OME TileConfiguration channel index; " f"available: {sorted(tile_cfg.keys())}") mapping = tile_cfg[ch_idx] # {(ix, iy): {'filename', 'relative_x', 'relative_y', ...}} if not mapping: raise ValueError(f"Empty TileConfiguration for channel index {ch_idx}") # Grid size from indices (unzip) keys_sorted = sorted(mapping.keys()) xs, ys = zip(*keys_sorted) # safe because mapping is not empty nx, ny = max(xs) + 1, max(ys) + 1 # Basename -> full path (all tiles for this channel) basename_to_full = {Path(p).name: Path(p) for p in raw_asset.file_list} # Build sources/tile_positions/positions in row-major (ix,iy) order sources = [] tile_positions = [] positions = [] # (x, y, z=0) for (ix, iy) in keys_sorted: rec = mapping[(ix, iy)] base = rec['filename'] sources.append(str(basename_to_full[base])) tile_positions.append((ix, iy)) positions.append((float(rec['relative_x']), float(rec['relative_y']), 0.0)) # z=0 for 2D tiling # Overlap in pixels (int,int) or None overlaps_px = self._pick_overlap_px(ome_info.get('stitching')) # Real WobblyLayout lyt = stitching_wobbly.WobblyLayout(sources=sources, tile_shape=(nx, ny), tile_positions=tile_positions, positions=positions, overlaps=overlaps_px, axis=2) # Axis = which axis "wobbles" lyt.lower_to_origin() # Just in case placed_asset_path = self.get_path('layout', channel=channel, asset_sub_type='placed') lyt.save(str(placed_asset_path)) self.cfg_coordinator.submit_patch({'stitching': {'channels': {channel: {'use_existing_layout': True}}}}, sample_manager=self.sample_manager)
[docs] def stack_columns(self, channel): asset = self.get('raw', channel=channel, prefix=self.sample_manager.prefix) exp = asset.expression x_min, x_max = exp.tag_range('X') y_min, y_max = exp.tag_range('Y') z_expression = '_xyz-Table Z<Z,4>' # REFACTOR: brittle, find better way if exp.n_tags() >= 3: for y in range(y_min, y_max + 1): for x in range(x_min, x_max + 1): column_expression = exp.string(values={'X': x, 'Y': y}) dest = column_expression.replace(z_expression, '') clearmap_io.convert(column_expression, dest) # squash Z axis asset.expression = exp.string().replace(z_expression, '') # overwrite expression self.sample_manager.set_channel_expression(channel, asset.expression)
[docs] def prepare_all_channels_raw_data(self, force: bool = False) -> list[str]: """ Prepare all pipeline-ready channels that don't yet have their working asset. Tiled channels → convert tiles to npy. Non-tiled → stack/copy to stitched volume. Idempotent unless force=True. Parameters ---------- force : bool If True, re-prepare even if the working asset already exists. Returns ------- list[str] Channel names that were prepared. """ prepared = [] for ch in self.sample_manager.pipeline_ready_channels: if self.sample_manager.is_tiled(ch): if force or not self.sample_manager.has_npy(ch): self.convert_tiles_channel(ch) prepared.append(ch) else: if force or not self.sample_manager.get('stitched', channel=ch).exists: # WARNING: self.workspace might not yet exist self.copy_or_stack(ch) prepared.append(ch) return prepared
[docs] def copy_or_stack(self, channel): """ Copy or stack or convert to npy the channel data in case there is no X/Y tiling Parameters ---------- channel : str The channel to copy or stack """ try: clearmap_io.convert(self.get_path('raw', channel=channel), self.get_path('stitched', channel=channel)) except FileNotFoundError as err: warnings.warn(f'Could not copy / stack {channel=}, files not found; {err}')
[docs] def stitch(self): if self.stopped: return # Get the channels in dependency order (based on layout_channel) for stitching_tree in self.get_stitching_order().values(): for channel in stitching_tree: self.stack_columns(channel) # If x/y/z, stack first channel_cfg = self.config['channels'][channel] has_layout = self.get('layout', channel=channel, asset_sub_type='placed').exists if channel_cfg.get('use_existing_layout'): if not has_layout: raise MissingRequirementException(f'Channel {channel} set to use existing layout,' f' but no layout found') self._stitch_layout_wobbly(channel) if self.stopped: return continue if channel == channel_cfg['layout_channel']: if not channel_cfg['rigid']['skip']: self.align_channel_rigid(channel) if self.stopped: return if not channel_cfg['wobbly']['skip']: self.stitch_channel_wobbly(channel) else: self.place_layout_rigid(channel) if self.stopped: return self._stitch_layout_wobbly(channel) else: self._stitch_layout_wobbly(channel) if self.stopped: return
[docs] def channel_was_stitched_rigid(self, channel): return self.get('layout', channel=channel, asset_sub_type='aligned_axis').exists
@property def n_rigid_steps_to_run(self): cfg = self.config['channels'] return [not cfg[channel]['rigid']['skip'] for channel in cfg.keys() if 'rigid' in cfg[channel]].count(True) # @check_stopped # @requires_assets([FilePath('raw')])
[docs] def align_channel_rigid(self, channel, _force=False): if not self.sample_manager.check_has_all_tiles(channel): if self.sample_manager.use_npy(channel): self.convert_tiles_channel(channel) if not self.sample_manager.check_has_all_tiles(channel): raise MissingRequirementException(f'Channel {channel} missing tiles') else: raise MissingRequirementException(f'Channel {channel} missing tiles') self.set_watcher_step(f'Stitching {channel} rigid') rigid_cfg = self.config['channels'][channel]['rigid'] raw_asset = self.get('raw', channel=channel) if raw_asset.is_expression: params_file = raw_asset.file_list[0] else: params_file = raw_asset.existing_path overlaps, projection_thickness = define_auto_stitching_params(params_file, rigid_cfg) layout = self.get_wobbly_layout(channel, overlaps) if rigid_cfg['background_pixels'] is None: background_params = rigid_cfg['background_level'] else: background_params = (rigid_cfg['background_level'], rigid_cfg['background_pixels']) max_shifts = [rigid_cfg[f'max_shifts_{ax}'] for ax in 'xyz'] n_procs = self.config['performance']['stitching']['n_processes'] n_procs = sanitize_n_processes(n_procs) self.prepare_watcher_for_substep(len(layout.alignments), self.__rigid_stitching_align_re, 'Align layout rigid') try: stitching_rigid.align_layout_rigid_mip(layout, depth=projection_thickness, max_shifts=max_shifts, ranges=[None, None, None], background=background_params, clip=25000, processes=n_procs, workspace=self.workspace, verbose=True) except BrokenProcessPool: print('Stitching canceled') self.stopped = True # FIXME: see if appropriate solution return # WARNING: do not run stitching_wobbly if rigid failed layout.place(method='optimization', min_quality=-np.inf, lower_to_origin=True, verbose=True) self.update_watcher_main_progress() stitching_rigid.save_layout(self.get_path('layout', channel=channel, asset_sub_type='aligned_axis'), layout)
[docs] def place_layout_rigid(self, channel): layout = stitching_rigid.load_layout(self.get_path('layout', channel=channel, asset_sub_type='aligned_axis')) layout.place(method='optimization', min_quality=-np.inf, lower_to_origin=True, verbose=True) stitching_rigid.save_layout(self.get_path('layout', channel=channel, asset_sub_type='aligned'), layout)
# @requires_assets([FilePath('raw')]) # TODO: optional requires npy + requires that channel is kwarg
[docs] def get_wobbly_layout(self, channel, overlaps=None): if overlaps is None: rigid_cfg = self.config['channels'][channel]['rigid'] first_tile_path = self.get('raw', channel=channel).file_list[0] overlaps, _ = define_auto_stitching_params(first_tile_path, rigid_cfg) extension = '.npy' if self.sample_manager.use_npy(channel) else None # TODO: optional requires raw_expr = str(self.get_path('raw', channel=channel, extension=extension)) tag_names = tuple(sorted(Expression(raw_expr).tag_names())) # sort alphabetically to ensure consistent order # Drop irrelevant axes if e.g. scanning only rows or columns overlaps = [overlap for name, overlap in zip(('X', 'Y'), overlaps) if name in tag_names] layout = stitching_wobbly.WobblyLayout(expression=raw_expr, tile_axes=tag_names, overlaps=overlaps) return layout
@property def n_wobbly_steps_to_run(self): out = len(self.sample_manager.stitchable_channels) - 1 for channel in self.sample_manager.stitchable_channels: cfg = self.config['channels'][channel] if 'wobbly' in cfg and not cfg['wobbly']['skip']: out += 3 return out def __align_layout_wobbly(self, channel, layout): wobbly_cfg = self.config['channels'][channel]['wobbly'] max_shifts = [ wobbly_cfg['max_shifts_x'], wobbly_cfg['max_shifts_y'], wobbly_cfg['max_shifts_z'], ] stack_validation_params = dict( method='foreground', valid_range=wobbly_cfg["stack_valid_range"], size=wobbly_cfg["stack_pixel_size"] ) slice_validation_params = dict( method='foreground', valid_range=wobbly_cfg["slice_valid_range"], size=wobbly_cfg["slice_pixel_size"] ) n_pairs = len(layout.alignments) self.prepare_watcher_for_substep(n_pairs, self.__wobbly_stitching_align_lyt_re, 'Align layout wobbly') try: n_processes = self.config['performance']['stitching']['n_processes'] n_processes = sanitize_n_processes(n_processes) stitching_wobbly.align_layout(layout, axis_range=(None, None, 3), max_shifts=max_shifts, axis_mip=None, stack_validation_params=stack_validation_params, prepare=dict(method='normalization', clip=None, normalize=True), slice_validation_params=slice_validation_params, prepare_slice=None, find_shifts=dict(method='tracing', cutoff=3 * np.sqrt(2)), processes=n_processes, workspace=self.workspace, verbose=True) except BrokenProcessPool: print('Wobbly stitching canceled') return self.update_watcher_main_progress() def __place_layout_wobbly(self, layout): self.prepare_watcher_for_substep(len(layout.alignments) // 2, # WARNING: bad estimation self.__wobbly_stitching_place_re, 'Place layout wobbly') try: n_processes = self.config['performance']['stitching']['n_processes'] if platform.system().lower().startswith('darwin'): # No parallel on MacOS n_processes = 1 n_processes = sanitize_n_processes(n_processes) stitching_wobbly.place_layout(layout, min_quality=-np.inf, method='optimization', smooth=dict(method='window', window='bartlett', window_length=100, binary=None), smooth_optimized=dict(method='window', window='bartlett', window_length=20, binary=10), fix_isolated=False, lower_to_origin=True, processes=n_processes, workspace=self.workspace, verbose=True) except BrokenProcessPool: print('Wobbly stitching canceled') return self.update_watcher_main_progress() def _stitch_layout_wobbly(self, channel): layout_channel = self.config['channels'][channel]['layout_channel'] layout = stitching_rigid.load_layout(self.get_path('layout', channel=layout_channel, asset_sub_type='placed')) try: ref_asset = self.get('raw', channel=self.sample_manager.alignment_reference_channel) n_slices = len(ref_asset.file_list) # TODO: find better proxy except KeyError: n_slices = clearmap_io.shape(self.get('raw', channel=channel).file_list[0])[0] self.prepare_watcher_for_substep(n_slices, self.__wobbly_stitching_stitch_re, 'Stitch layout wobbly', True) try: self._replace_layout_sources(layout, channel, layout_channel) n_processes = self.config['performance']['stitching']['n_processes'] n_processes = sanitize_n_processes(n_processes) stitching_wobbly.stitch_layout(layout, sink=str(self.get_path('stitched', channel=channel)), method='interpolation', processes=n_processes, workspace=self.workspace, verbose=True) except BrokenProcessPool: print('Wobbly stitching canceled') return self.update_watcher_main_progress() def _replace_layout_sources(self, layout, channel, layout_channel): if channel == layout_channel: return # layout_channel_asset = self.get('raw', channel=layout_channel) channel_asset = self.get('raw', channel=channel) if layout_channel != channel: # layout_extension = Path(layout.sources[0].location).suffix # Use the actual extension that was used if self.sample_manager.use_npy(channel): # FIXME: check if we need to copy layout first channel_pattern = channel_asset.with_extension(extension='.npy') else: channel_pattern = channel_asset.path layout.replace_source_location(None, str(channel_pattern), method='infer') @check_stopped def stitch_channel_wobbly(self, channel, _force=False): # Warning, will stitch channel on its own def get_layout_path(layout_sub_type): return self.get_path('layout', channel=channel, asset_sub_type=layout_sub_type) # FIXME: check if ui_to_cfg is needed if not self.channel_was_stitched_rigid(channel): raise MissingRequirementException(f'Channel {channel} not stitched rigid yet') self.set_watcher_step('Stitching wobbly') layout = stitching_rigid.load_layout(get_layout_path('aligned_axis')) self.__align_layout_wobbly(channel, layout) if self.stopped: return stitching_rigid.save_layout(get_layout_path('aligned'), layout) self.__place_layout_wobbly(layout) if self.stopped: return stitching_rigid.save_layout(get_layout_path('placed'), layout) self._stitch_layout_wobbly(channel) if self.stopped: return
[docs] def plot_stitching_results(self, channels=None, mode='side-by-side', parent=None): from ClearMap.Visualization.Qt import Plot3d as plot_3d if channels is None: channels = self.sample_manager.stitchable_channels paths = [] titles = [] for c in channels: asset = self.get('stitched', channel=c) if asset.exists: paths.append(asset.path) titles.append(f'{c.title()} stitched') else: print(f'No stitched file for channel {c}') if not paths: raise MissingRequirementException('No stitched files found') if len(paths) == 1: paths = paths[0] if mode == 'overlay': titles = ' and '.join(titles) paths = [paths] elif mode == 'side-by-side': pass else: raise ValueError(f'Unknown mode {mode}') dvs = plot_3d.plot(paths, title=titles, arrange=False, lut='white', parent=parent) return dvs
[docs] def stitch_overlay(self, channel, color=True): """ This creates a *dumb* overlay of the tiles i.e. only using the fixed guess overlap Parameters ---------- channel color Returns ------- np.array(dtype=uint8) The overlay image """ from ClearMap.Visualization.Color.Color import gray_image_to_rgb asset = self.get('raw', channel=channel, sample_id=self.sample_manager.prefix) positions = asset.positions tile_shape = {k: v for k, v in zip('XYZ', asset.tile_shape)} # TODO: use asset.tile_grid_shape middle_z = int(tile_shape['Z'] / 2) overlaps = self._get_overlaps(channel) output_shape = self._compute_stitched_shape_from_overlaps(overlaps, positions, tile_shape) layers = [np.zeros(output_shape, dtype=int), np.zeros(output_shape, dtype=int)] if self.sample_manager.has_npy: files = asset.variant(extension='.npy').file_list else: files = asset.file_list for tile_path, pos in zip(files, positions): tile = self.__read_tile_middle_plane(tile_path, middle_z) starts = {ax: pos[ax] * tile_shape[ax] - pos[ax] * overlaps[ax] for ax in 'XY'} ends = {ax: starts[ax] + tile_shape[ax] for ax in starts.keys()} layer = layers[(pos['Y'] + pos['X']) % 2] # Alternate colors layer[starts['X']: ends['X'], starts['Y']: ends['Y']] = tile if color: layers[0] = gray_image_to_rgb(layers[0], 'cyan', pseudo_z_score=True, range_max=255) layers[1] = gray_image_to_rgb(layers[1], 'magenta', pseudo_z_score=True, range_max=255) output_image = layers[0] + layers[1] if color: output_image = output_image.clip(0, 255).astype(np.uint8) return output_image
def __read_tile_middle_plane(self, tile_path, middle_z): if self.sample_manager.has_npy: # use memmap tile = clearmap_io.buffer(tile_path)[:, :, middle_z] else: tile = clearmap_io.read(tile_path)[:, :, middle_z] return tile def _compute_stitched_shape_from_overlaps(self, overlaps, positions, tile_shape): mosaic_shape = {ax: max([p[ax] for p in positions]) + 1 for ax in 'XY'} # +1 because 0 indexing output_shape = [tile_shape[ax] * mosaic_shape[ax] - overlaps[ax] * (mosaic_shape[ax] - 1) for ax in 'XY'] return output_shape def _get_overlaps(self, channel): layout_channel = self.config['channels'][channel]['layout_channel'] overlaps = {k: self.config['channels'][layout_channel]['rigid'][f'overlap_{k.lower()}'] for k in 'XY'} return overlaps
[docs] def overlay_layout_plane(self, layout): # REFACTOR: move to e.g. layout class """Overlays the sources to check their placement. Arguments --------- layout : Layout class The layout with the sources to overlay. Returns ------- image : array A color image. """ from ClearMap.Visualization.Color.Color import gray_image_to_rgb dest_shape = tuple(layout.extent[:-1]) full_lower = layout.lower middle_z = round(layout.sources[0].shape[-1] / 2) color_layers = [np.zeros(dest_shape, dtype=int), np.zeros(dest_shape, dtype=int)] # construct full image for src in layout.sources: tile = self.__read_tile_middle_plane(src.location, middle_z) # So as not to load the data into the list for memory efficiency is_odd = sum(src.tile_position) % 2 layer = color_layers[is_odd] # Alternate colors current_slicing = self.__compute_overlay_slicing(full_lower, src) layer[current_slicing] = tile cyan_image = gray_image_to_rgb(color_layers[0], 'cyan', pseudo_z_score=True, range_max=255) magenta_image = gray_image_to_rgb(color_layers[1], 'magenta', pseudo_z_score=True, range_max=255) # TODO: normalise output_image = np.clip(cyan_image + magenta_image, 0, 255).astype(np.uint8) return output_image
def __compute_overlay_slicing(self, full_lower, src): l = src.lower u = src.upper current_slicing = tuple(slice(ll - fl, uu - fl) for ll, uu, fl in zip(l, u, full_lower))[:2] return current_slicing
[docs] def plot_layout(self, channel, asset_sub_type='aligned_axis'): valid_sub_types = ("aligned_axis", "aligned", "placed") if asset_sub_type not in valid_sub_types: raise ValueError(f'Expected on of {valid_sub_types} for asset_sub_type, got "{asset_sub_type}"') layout = stitching_rigid.load_layout(self.get_path('layout', channel=channel, asset_sub_type=asset_sub_type)) overlay = self.overlay_layout_plane(layout) return overlay