Source code for ClearMap.pipeline_orchestrators.registration_orchestrator

import os
import re
import warnings
from concurrent.futures.process import BrokenProcessPool
from enum import Enum
from pathlib import Path
from typing import Dict, Optional, TypedDict, TYPE_CHECKING

import numpy as np
from skimage import transform as sk_transform

from ClearMap import Settings as settings, Settings
from ClearMap.Alignment import Resampling as resampling, Elastix as elastix
from ClearMap.Alignment.Annotation import Annotation

from ClearMap.IO import IO as clearmap_io, TIF
from ClearMap.IO.assets_specs import ChannelSpec, TypeSpec
from ClearMap.IO.metadata import define_auto_resolution

from ClearMap.Utils.events import (ChannelRenamed, UiAtlasIdChanged,
                                   UiAtlasStructureTreeIdChanged,  RegistrationStatusChanged)
from ClearMap.Utils.exceptions import ClearMapAssetError, ParamsOrientationError, MissingRequirementException
from ClearMap.Utils.utilities import (runs_on_ui, check_stopped, DEFAULT_ORIENTATION,
                                      validate_orientation,  sanitize_n_processes)

from ClearMap.config.atlas import ATLAS_NAMES_MAP
from ClearMap.config.config_coordinator import ConfigCoordinator

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

if TYPE_CHECKING:
    from ClearMap.gui.widgets import ProgressWatcher
    from ClearMap.Visualization import Plot3d as q_plot_3d  # WARNING: Local imports, for reference only


[docs] class RegistrationStatus(Enum): NOT_SELECTED = 0 MISSING_OUTPUTS = 1 REGISTERED = 2
[docs] class RegistrationProcessor(PipelineOrchestrator): """ This class is used to manage the registration process Perform image registration operations. Manage atlas setup and transformations. Handle registration configurations. """ _PARAMETRIZED_ASSET_TYPES = frozenset({'aligned', 'fixed_landmarks', 'moving_landmarks'}) config_name = 'registration' def __init__(self, sample_manager: SampleManager, cfg_coordinator: ConfigCoordinator): super().__init__(cfg_coordinator) self.sample_manager: SampleManager = sample_manager self.annotators: Dict[str, Annotation] = {} # 1 for each channel self.mini_brains: Dict[str, MiniBrain] = {} # 1 for each channel self.progress_watcher: Optional["ProgressWatcher"] = None # FIXME: self.__bspline_registration_re = re.compile(r"\d+\s-?\d+\.\d+\s\d+\.\d+\s\d+\.\d+\s\d+\.\d+") self.__affine_registration_re = re.compile(r"\d+\s-\d+\.\d+\s\d+\.\d+\s\d+\.\d+\s\d+\.\d+\s\d+\.\d+") self.__resample_re = ('Resampling: resampling', re.compile(r".*?Resampling:\sresampling\saxes\s.+\s?,\sslice\s.+\s/\s\d+")) self.setup_complete: bool = False self.subscribe(ChannelRenamed, self._on_channel_renamed) self.subscribe(UiAtlasIdChanged, self.setup_atlases) self.subscribe(UiAtlasStructureTreeIdChanged, self.setup_atlases)
[docs] def setup(self, sample_manager: Optional[SampleManager] = None): self.sample_manager = sample_manager if sample_manager else self.sample_manager if not self.registration_config: raise ValueError('Registration config not set in config coordinator') if self.sample_manager is None: warnings.warn('SampleManager not provided, RegistrationProcessor setup incomplete') self.setup_complete = False elif self.sample_manager.setup_complete: self.workspace = self.sample_manager.workspace self.setup_atlases() # TODO: check if needed self.add_pipeline() self.setup_complete = True else: self.setup_complete = False # FIXME: finish later warnings.warn('SampleManager not setup, RegistrationProcessor setup incomplete')
# WARNING: must be called once registration pipeline has been added to the Workspace for that channel # self.parametrize_assets()
[docs] def get(self, asset_type, channel='current', asset_sub_type=None, **kwargs): """ Get an asset, automatically resolving registration template variables for asset types that require parametrisation (e.g. registration (Elastix) assets, where moving/fixed channels are conditional). Parameters ---------- asset_type : str The asset type name. channel : str The channel name. asset_sub_type : str or None Optional sub-type. **kwargs Forwarded to the parent ``get``. Returns ------- Asset The resolved asset. """ asset = super().get(asset_type, channel=channel, asset_sub_type=asset_sub_type, **kwargs) if (asset_type not in self._PARAMETRIZED_ASSET_TYPES or not asset.is_expression or asset.is_parametrized): # is_parametrized available only on ExpressionAsset return asset moving_channel = self.get_moving_channel(channel) if moving_channel in (None, 'intrinsically_aligned'): # No alignment planned -> no parametrization needed return asset fixed_channel, moving_channel = self.get_fixed_moving_channels(channel) if fixed_channel is None or moving_channel is None: return asset parametrized = asset.specify({'moving_channel': moving_channel, 'fixed_channel': fixed_channel}) self.workspace.asset_collections[channel][asset_type] = parametrized # UPDATE WORKSPACE to cache return parametrized
def _on_channel_renamed(self, event: ChannelRenamed): if event.old in self.annotators: self.annotators[event.new] = self.annotators.pop(event.old) if event.old in self.mini_brains: self.mini_brains[event.new] = self.mini_brains.pop(event.old) @property def ref_channel_cfg(self): if self.sample_manager is None: raise ValueError('CellDetector not properly initialized') ref_channel = self.sample_manager.alignment_reference_channel if ref_channel is None: raise ValueError('No alignment reference channel specified in sample manager') reg_cfg = self.registration_config if ref_channel not in reg_cfg['channels']: raise ValueError(f'Reference channel "{ref_channel}" not found in registration config') return reg_cfg['channels'][ref_channel]
[docs] def get_registration_sequence_channels(self, first_channel, stop_channel='atlas'): out = [first_channel] registration_cfg = self.registration_config['channels'] while True: next_channel = registration_cfg[out[-1]]['align_with'] if next_channel in (None, stop_channel): break out.append(next_channel) return out
[docs] def parametrize_assets(self): for channel in self.config['channels']: channel_cfg = self.config['channels'][channel] if channel_cfg['align_with'] is None: continue if channel_cfg['moving_channel'] in (None, 'intrinsically_aligned'): # No alignment planned -> no param continue for asset_type in self._PARAMETRIZED_ASSET_TYPES: try: asset = self.get(asset_type, channel=channel) # triggers parametrization and cache to WS except KeyError: continue # the idea is to delay the parametrization # until the assets for all channels have been created except ClearMapAssetError: # Check that align_with is None warnings.warn(f'Could not parametrize {asset_type} for {channel=}') continue
[docs] def add_pipeline(self): # WARNING: hacky. Maybe add_pipeline_if_missing if self.workspace is None: return for channel in self.config['channels']: try: self.get('aligned', channel=channel) except KeyError: if self.sample_manager.setup_complete and channel in self.workspace.asset_collections: self.workspace.add_pipeline('registration', channel_id=channel) self.parametrize_assets() else: warnings.warn('Workspace not setup, cannot add registration pipeline')
@property def channels(self): return list(self.config['channels'].keys())
[docs] def channels_to_resample(self): return [c for c, v in self.config['channels'].items() if v['resample']]
[docs] def channels_to_register(self): return [c for c, v in self.config['channels'].items() if v['align_with'] is not None]
[docs] def get_align_with(self, channel): return self.config['channels'][channel]['align_with']
[docs] def get_moving_channel(self, channel: str) -> str: """ Get the moving channel for a given channel .. warning:: Contrary to get_fixed_moving_channels, this method does not check for the existence of the fixed channel. It simply returns the moving channel as specified in the config. Parameters ---------- channel: str The channel to get the moving channel for Returns ------- str The moving channel """ return self.config['channels'][channel]['moving_channel']
@property def was_registered(self): return self.registration_status() == RegistrationStatus.REGISTERED
[docs] def channel_was_registered(self, channel): align_with = self.get_align_with(channel) moving_channel = self.get_moving_channel(channel) asset = self.get('aligned', channel=channel) fixed_channel = channel if align_with == moving_channel else align_with return asset.specify({'moving_channel': moving_channel, 'fixed_channel': fixed_channel}).exists
[docs] def registration_status(self): reg_cfg = self.registration_config def is_selected(ch_cfg: dict) -> bool: # user opted-in this channel for registration? align_with = ch_cfg.get('align_with') return align_with not in (None, '', 'none') def is_intrinsically_aligned(ch_cfg: dict) -> bool: return ch_cfg.get('moving_channel') == 'intrinsically_aligned' any_selected = any(is_selected(reg_cfg['channels'][ch]) and not is_intrinsically_aligned(reg_cfg['channels'][ch]) for ch in self.channels) if not any_selected: return RegistrationStatus.NOT_SELECTED else: ref_channel = self.sample_manager.alignment_reference_channel for channel in self.channels: ch_cfg = reg_cfg['channels'][channel] if not is_selected(ch_cfg) or is_intrinsically_aligned(ch_cfg): continue if not ref_channel and ch_cfg['align_with'] == 'autofluorescence': raise ValueError(f'This should not happen, {channel=} set for registration against ' f'autofluorescence but no reference channel found') elif not self.channel_was_registered(channel): return RegistrationStatus.MISSING_OUTPUTS # at least one not registered return RegistrationStatus.REGISTERED # all selected channels are registered
@property def registration_params_files(self): align_dir = Path(settings.resources_path) / self.config['atlas']['align_files_folder'] registration_params_files = {} for channel in self.config['channels']: params_file_names = self.config['channels'][channel]['params_files'] registration_params_files[channel] = [align_dir / name for name in params_file_names] # TODO: define as property return registration_params_files
[docs] def plot_atlas(self, channel): # REFACTOR: idealy part of sample_manager from ClearMap.Visualization import Plot3d as q_plot_3d atlas_path = self.get_path('atlas', channel=channel, asset_sub_type='reference') return q_plot_3d.plot(atlas_path, lut=self.machine_config['default_lut'])
[docs] def clear_landmarks(self, channel=None): """ Clear (remove) the landmarks files """ channels = [channel] if channel else self.config['channels'].keys() for channel in channels: for landmark_type in ('fixed', 'moving'): asset = self.get_elx_asset(f'{landmark_type}_landmarks', channel=channel) if asset.exists: asset.delete()
[docs] def get_fixed_moving_channels(self, channel): moving_channel = self.get_moving_channel(channel) align_with = self.config['channels'][channel]['align_with'] if align_with is None: return None, moving_channel if not align_with: raise MissingRequirementException(f'Channel {channel} missing align_with in registration config') # fixed is whichever channel from ('channel', 'align_with') is not 'moving_channel' fixed_channel = channel if align_with == moving_channel else align_with return fixed_channel, moving_channel
[docs] def get_elx_asset(self, asset_type, channel): fixed_channel, moving_channel = self.get_fixed_moving_channels(channel) if fixed_channel is None or moving_channel is None: return None else: return self.get(asset_type, channel=channel)
[docs] def get_img_to_register(self, channel, other_channel): if other_channel == 'atlas': return self.get('atlas', channel=channel, asset_sub_type='reference') else: return self.get('resampled', channel=other_channel)
[docs] def get_moving_image(self, channel): _, moving_channel = self.get_fixed_moving_channels(channel) return self.get_img_to_register(channel, moving_channel)
[docs] def get_fixed_image(self, channel): fixed_channel, _ = self.get_fixed_moving_channels(channel) return self.get_img_to_register(channel, fixed_channel)
[docs] def get_aligned_image(self, channel): aligned = self.get_elx_asset('aligned', channel=channel) return aligned.all_existing_paths(sort=True)[-1] # The last step is the final result
[docs] def resample_channel(self, channel, increment_main=False): # set increment_main to True for channels > 0 resampled_asset = self.get('resampled', channel=channel) if not runs_on_ui() and resampled_asset.exists: resampled_asset.delete() if resampled_asset.exists: raise FileExistsError(f'Resampled asset ({resampled_asset}) already exists') default_resample_parameter = { 'processes': sanitize_n_processes(self.config['performance']['resampling']['n_processes']), 'verbose': self.config['verbose'] } # WARNING: duplicate (use method ??) source_asset = self.get('stitched', channel=channel, default=None) source_asset = source_asset if source_asset.exists else self.get('raw', channel) if not source_asset.exists: raise FileNotFoundError(f'Cannot resample {channel}, source {source_asset} missing') if source_asset.is_tiled: src_res = define_auto_resolution(source_asset.file_list[0], self.sample_manager.get_channel_resolution(channel)) else: src_res = self.sample_manager.get_channel_resolution(channel) if source_asset.is_tiled: if 'Z' in source_asset.tag_names: # real tiles -> count planes n_planes = source_asset.expression.tag_range('Z')[1] + 1 else: # columns -> take z column shape n_planes = clearmap_io.shape(source_asset.file_list[0])[0] else: # Stacked or single file, take the first dimension of the asset n_planes = source_asset.shape()[0] self.prepare_watcher_for_substep(n_planes, self.__resample_re, f'Resampling {channel}', increment_main=increment_main) result = resampling.resample(str(source_asset.path), resampled=str(resampled_asset.path), original_resolution=src_res, resampled_resolution=self.config['channels'][channel]['resampled_resolution'], workspace=self.workspace, **default_resample_parameter) try: pass except BrokenProcessPool: print('Resampling canceled') return assert result.array.max() != 0, f'Resampled {channel} has no data' assert resampled_asset.exists, f'Resampled {channel} not saved at {resampled_asset.path}'
@property def n_registration_steps(self): n_steps_atlas_setup = 1 n_steps_align = 2 # WARNING: probably 1 more when arteries included n_resampling_steps = len(self.sample_manager.channels_to_resample()) return n_steps_atlas_setup + n_resampling_steps + n_steps_align @check_stopped def resample_for_registration(self, _force=False): for i, channel in enumerate(self.sample_manager.channels_to_resample()): self.resample_channel(channel, increment_main=i != 0) if self.stopped: return self.update_watcher_main_progress() @check_stopped def align(self, _force=False): try: for channel in self.channels_to_register(): self.align_channel(channel) self.update_watcher_main_progress() except CanceledProcessing: print('Alignment canceled') self.stopped = False self.publish(RegistrationStatusChanged)
[docs] def align_channel(self, channel): fixed_channel, moving_channel = self.get_fixed_moving_channels(channel) if moving_channel is None or moving_channel == 'intrinsically_aligned': return channel_cfg = self.config['channels'][channel] run_bspline = any(['bspline' in channel_cfg['params_files']]) n_steps = 17000 if run_bspline else 2000 regexp = self.__bspline_registration_re if run_bspline else self.__affine_registration_re self.prepare_watcher_for_substep(n_steps, regexp, f'Align {moving_channel} to {fixed_channel}') align_parameters = { "moving_image": self.get_moving_image(channel).existing_path, "fixed_image": self.get_fixed_image(channel).existing_path, 'parameter_files': self.registration_params_files[channel], "result_directory": self.get_elx_asset('aligned', channel=channel).path.parent, 'workspace': self.workspace, # FIXME: use semaphore instead 'check_alignment_success': True } landmarks_steps = [step for step, weight in zip(channel_cfg['params_files'], channel_cfg['landmarks_weights']) if weight > 0] if landmarks_steps: if len(landmarks_steps) != len(self.registration_params_files[channel]): raise NotImplemented('Selecting landmarks for a subset of steps is currently not implemented') landmarks_files = { 'moving_landmarks_path': self.get_elx_asset('moving_landmarks', channel=channel).path, 'fixed_landmarks_path': self.get_elx_asset('fixed_landmarks', channel=channel).path, } else: landmarks_files = {'moving_landmarks_path': '', 'fixed_landmarks_path': ''} # Disable landmarks w/ empty str elastix.align_from_dict(align_parameters, landmarks_files, landmarks_weights=channel_cfg['landmarks_weights'])
[docs] def get_atlas_files(self): if not self.get('atlas', asset_sub_type='annotation', channel=self.sample_manager.alignment_reference_channel).exists: self.setup_atlases() atlas_files = {} for channel in self.config['channels']: atlas_files[channel] = self.annotators[channel].get_atlas_paths() return atlas_files
def __setup_source_atlas(self, atlas_base_name): default_annotator = Annotation(atlas_base_name, None, None, label_source='ABA json 2022') # TODO: use workspace instead channel_spec = ChannelSpec(channel='atlas', content_type='atlas') self.create_atlas_asset(default_annotator, channel_spec)
[docs] def create_atlas_asset(self, annotator, channel_spec): # FIXME: ensure that uses atlas subfolder from asset_constants try: atlas_asset = self.get('atlas', channel=channel_spec.name, default=None) except KeyError: atlas_asset = None if atlas_asset is not None: return atlas_asset else: type_spec = TypeSpec(resource_type='atlas', type_name='atlas', file_format_category='image', relevant_pipelines=['registration']) atlas_asset = self.workspace.create_asset(type_spec, channel_spec=channel_spec, sample_id=self.sample_manager.prefix) return self.update_atlas_asset(channel_spec.name, annotator=annotator)
[docs] def update_atlas_asset(self, channel, annotator=None): if annotator is None: annotator = self.annotators[channel] sample_cfg = self.cfg_coordinator.get_config_view('sample')['channels'][channel] if annotator.orientation != sample_cfg['orientation'] or \ annotator.slicing != sample_cfg['slicing']: slicing = sample_cfg['slicing'] if slicing is not None and slicing.values() != (None, None, None): xyz_slicing = tuple(slice(None) if slc is None else slice(*slc) for slc in slicing.values()) else: xyz_slicing = None orientation = sample_cfg['orientation'] if orientation == DEFAULT_ORIENTATION: warnings.warn(f'Orientation not set for {channel}, skipping atlas setup') return atlas_cfg = self.config['atlas'] self.annotators[channel] = Annotation(atlas_base_name=ATLAS_NAMES_MAP[atlas_cfg['id']]['base_name'], slicing=xyz_slicing, orientation=orientation, label_source=atlas_cfg['structure_tree_id'], target_directory=annotator.target_directory) annotator = self.annotators[channel] else: if channel not in self.annotators: # TODO: check if we only update self.annotators[channel] = annotator atlas_asset = self.get('atlas', channel=channel) for sub_type_name, file_path in annotator.get_atlas_paths().items(): sub_type = atlas_asset.type_spec.add_sub_type(sub_type_name, expression=os.path.abspath(file_path)) asset = self.workspace.asset_collections[channel].get(f'atlas_{sub_type_name}') if not asset: asset = self.workspace.create_asset(type_spec=sub_type, channel_spec=atlas_asset.channel_spec, sample_id=self.sample_manager.prefix) else: asset.type_spec = sub_type self.workspace.asset_collections[channel][f'atlas_{sub_type_name}'] = asset # FIXME: method in workspace2 sub_type = atlas_asset.type_spec.add_sub_type('label', expression=annotator.label_file, extensions=['.json']) asset = self.workspace.asset_collections[channel].get('atlas_label') if not asset: asset = self.workspace.create_asset(type_spec=sub_type, channel_spec=atlas_asset.channel_spec, sample_id=self.sample_manager.prefix) else: asset.type_spec = sub_type self.workspace.asset_collections[channel]['atlas_label'] = asset return atlas_asset
[docs] def project_mini_brain(self, channel): # FIXME: idealy part of sample_manager """ Project the mini brain of the channel as a mask and a surface projection Parameters ---------- channel: str The channel to project Returns ------- np.ndarray, np.ndarray The mask and the projection """ from ClearMap.gui.gui_utils_images import surface_project img = self.__transform_mini_brain(channel) mask, proj = surface_project(img) return mask, proj
def __transform_mini_brain(self, channel): # REFACTOR: move to preprocessor """ Apply the set of transforms to the mini brain as defined by the crop and orientation parameters input by the user. Returns ------- np.ndarray The transformed mini brain """ def scale_range(rng, scale): for i in range(len(rng)): if rng[i] is not None: rng[i] = round(rng[i] / scale) return rng def range_or_default(rng, scale): if rng is not None: return scale_range(rng, scale) else: return 0, None params = self.cfg_coordinator.get_config_view('sample')['channels'][channel] orientation = params['orientation'] img = self.mini_brains[channel]['array'].copy() x_scale, y_scale, z_scale = self.mini_brains[channel]['scaling'] if axes_to_flip := [abs(axis) - 1 for axis in orientation if axis < 0]: img = np.flip(img, axes_to_flip) img = img.transpose([abs(axis) - 1 for axis in orientation]) x_min, x_max = range_or_default(params['slicing']['x'], x_scale) y_min, y_max = range_or_default(params['slicing']['y'], y_scale) z_min, z_max = range_or_default(params['slicing']['z'], z_scale) img = img[x_min:x_max, y_min:y_max:, z_min:z_max] return img
[docs] def setup_atlases(self, event=None): # TODO: add possibility to load custom reference file (i.e. defaults to None in cfg) if not self.config: return # Not setup yet. TODO: find better way around self.prepare_watcher_for_substep(0, None, 'Initialising atlases') sample_cfg = self.cfg_coordinator.get_config_view('sample')['channels'] atlas_cfg = self.config['atlas'] atlas_base_name = ATLAS_NAMES_MAP[atlas_cfg['id']]['base_name'] self.__setup_source_atlas(atlas_base_name) orientation = None # TODO: atlas variants as multichannel assets for channel in sample_cfg.keys(): if sample_cfg[channel]['orientation'] != orientation: orientation = sample_cfg[channel]['orientation'] slicing = sample_cfg[channel]['slicing'] if slicing is not None and slicing.values() != (None, None, None): xyz_slicing = tuple(slice(None) if slc is None else slice(*slc) for slc in slicing.values()) else: xyz_slicing = None if xyz_slicing is None and (orientation is None or orientation == DEFAULT_ORIENTATION): target_directory = settings.atlas_folder # For the unchanged atlas else: target_directory = self.cfg_coordinator.base_dir / 'atlas' # FIXME: use asset_constants try: orientation = validate_orientation(orientation, channel=channel, raise_error=True) if orientation == DEFAULT_ORIENTATION: warnings.warn(f'Orientation not set for {channel}, skipping atlas setup') continue self.annotators[channel] = Annotation(atlas_base_name, xyz_slicing, orientation, label_source=atlas_cfg['structure_tree_id'], target_directory=target_directory) scaling, mini_brain = setup_mini_brain(atlas_base_name) self.mini_brains[channel] = MiniBrain(scaling=scaling, array=mini_brain) # Add to workspace asset = self.get('atlas', channel=channel, default=None) if asset is None or not asset.exists: channel_spec = self.get('raw', channel=channel).channel_spec atlas_asset = self.create_atlas_asset(self.annotators[channel], channel_spec) self.workspace.add_asset(atlas_asset) else: # FIXME: update_asset method in workspace2 self.workspace.asset_collections[channel]['atlas'] = self.update_atlas_asset(channel) except ParamsOrientationError: warnings.warn(f'Orientation not set for {channel}, skipping atlas setup and erasing annotators.') self.annotators[channel] = None self.mini_brains[channel] = None self.update_watcher_main_progress()
# Plot functions def __prepare_registration_results_graph(self, channel): img_paths = [self.get_fixed_image(channel).path, self.get_aligned_image(channel)] if not all([p.exists() for p in img_paths]): raise ValueError(f'Missing requirements {img_paths}') titles = [img.parent.stem if 'aligned_to' in str(img) else img.stem for img in img_paths] # TODO: replace result<N,1> by channel name return img_paths, titles
[docs] def plot_registration_results(self, channel, composite=False, parent=None): from ClearMap.Visualization import Plot3d as q_plot_3d image_sources, titles = self.__prepare_registration_results_graph(channel) if composite: image_sources = [image_sources, ] dvs = q_plot_3d.plot(image_sources, title=titles, arrange=False, sync=True, lut=self.machine_config['default_lut'], parent=parent) return dvs, titles
[docs] class MiniBrain(TypedDict): """ A downscaled brain for quick visualization It includes the downscaled image and the scaling factors """ scaling: tuple[float, float, float] array: np.ndarray
[docs] def setup_mini_brain(atlas_base_name, mini_brain_scaling=(5, 5, 5)): # TODO: scaling in prefs """ Create a downsampled version of the Allen Brain Atlas for the mini brain widget Parameters ---------- mini_brain_scaling : tuple(int, int, int) The scaling factors for the mini brain. Default is (5, 5, 5) Returns ------- tuple(scale, downsampled_array) """ atlas_path = os.path.join(Settings.atlas_folder, f'{atlas_base_name}_annotation.tif') arr = TIF.Source(atlas_path).array return mini_brain_scaling, sk_transform.downscale_local_mean(arr, mini_brain_scaling)