from brainglobe_atlasapi.bg_atlas import BrainGlobeAtlas
import brainglobe_space as bgs
import brainglobe_atlasapi
import matplotlib.pyplot as plt
import pyqtgraph as pg
import functools, json
from coperniFUS import *
from coperniFUS.modules.module_base import Module
# Worker thread
[docs]
class AsynchronousOnlineAtlasListRetrieval(pyqtc.QThread):
""" Handles network issues when retrieving online atlas lists. """
finished = pyqtc.pyqtSignal()
""" Signal emitted whenever the atlas retreival is done (or has failed). """
def __init__(self, skip_online_atlas_retreival):
super().__init__()
self.skip_online_atlas_retreival = skip_online_atlas_retreival
self.formatted_online_atlases = None
[docs]
def run(self):
if self.skip_online_atlas_retreival:
self.formatted_online_atlases = {
None: 'Offline mode -> Showing downloaded atlases only'
}
else:
try:
online_atlases = brainglobe_atlasapi.list_atlases.get_all_atlases_lastversions()
online_atlases_names = list(online_atlases.keys())
online_atlases_versions = list(online_atlases.values())
self.formatted_online_atlases = {
f'online_{atlas_name}': f'{atlas_name} | v{online_atlases_versions[ii]} (online)'
for (ii, atlas_name) in enumerate(online_atlases_names)
}
except Exception as e:
print(f'> Could not proceed with online altas retrieval: {type(e).__name__}: {str(e)}\nBrainGlobe Atlas API servers could not be reached..\nEnsure that your internet connection works properly and run "brainglobe list" in a terminal.\nIf you do not have any atlases installed locally you can install one using "brainglobe install -a <atlas_name>"')
self.formatted_online_atlases = {
None: 'Online atlas retrieval has failed -> Showing downloaded atlases only'
}
self.finished.emit()
[docs]
class BrainAtlas(Module):
"""
BrainAtlas module.
"""
_DEFAULT_N_VOXELS = 1e6 # target number of voxels for default atlas subsampling stride computation
_DEFAULT_PARAMS = {
'jsonable_layers_dict': "{}",
'atlas_transforms_str' : 'Rx0deg Tz0um',
'subsampling_stride': 10,
}
""" Default configuration parameters used when a parameter value is not yet cached """
_CYCLIC_STRUCTURES_COLORS = [tuple([int(255*cc) for cc in plt.get_cmap('Set1')(ii)]) for ii in range(5)]
def __init__(self, parent_viewer, skip_online_atlas_retreival=False, **kwargs) -> None:
super().__init__(parent_viewer, 'atlas', **kwargs)
self._layers = None
self.skip_online_atlas_retreival = skip_online_atlas_retreival
self._init_attributes()
self._formatted_online_atlases = {
None: 'Retrieving atlases available online...'
}
self.async_online_altas_list_handler = AsynchronousOnlineAtlasListRetrieval(skip_online_atlas_retreival)
self.async_online_altas_list_handler.finished.connect(self._update_atlas_selector)
self.async_online_altas_list_handler.start()
self.parent_viewer.statusBar().showMessage('Loading online atlas list')
# --- Module specific public attributes ---
@property
def atlas_resolution(self):
""" Resultion of the reference altas in meters (x, y, z)
Suplampling is accounted for in the returned value. """
subs_stride = self.get_user_param('subsampling_stride', default_value=self._default_subsampling_stride)
atlas_res = subs_stride * np.array(self.bg_atlas.resolution) * 1e-6 # um to meters
return atlas_res
@property
def brain_atlas_tmat(self):
""" Holds the atlas volume affine transformation matrix """
if self._brain_atlas_tmat is None:
resolution = self.atlas_resolution
atlas_shape = self.raw_rgba_ndimage_compound.shape
# Axes ordering correction
source_space = bgs.AnatomicalSpace(self.bg_atlas.orientation, shape=atlas_shape[:3])
target_space = bgs.AnatomicalSpace(get_flipped_atlas_space_convention(self.parent_viewer.ATLAS_SPACE_CONVENTION))
axes_order_idx = source_space.map_to(target_space)[0]
space_conversion_tmat = source_space.transformation_matrix_to(target_space)
self._brain_atlas_tmat = space_conversion_tmat.T
# Setting atlas scale based on resolution
self._brain_atlas_tmat = self._brain_atlas_tmat @ af_tr.scale_mat(resolution)
# Img space origin to atlas center
self._brain_atlas_tmat = self._brain_atlas_tmat @ af_tr.translat_mat('x', -(resolution[0] * atlas_shape[axes_order_idx[0]]) / 2)
self._brain_atlas_tmat = self._brain_atlas_tmat @ af_tr.translat_mat('y', -(resolution[1] * atlas_shape[axes_order_idx[1]]) / 2)
self._brain_atlas_tmat = self._brain_atlas_tmat @ af_tr.translat_mat('z', -(resolution[2] * atlas_shape[axes_order_idx[2]]) / 2)
atlas_transforms_matrices = af_tr_from_str.transform_matrices_from_str(
self.get_user_param('atlas_transforms_str')
)
for tr_mat in atlas_transforms_matrices:
self._brain_atlas_tmat = self._brain_atlas_tmat @ tr_mat
# Apply anatomical landmark calibration transformation
anatomically_calibrated_brain_atlas_tmat = self._brain_atlas_tmat @ self.parent_viewer.anat_calib.landmarks_calib_tmat
return anatomically_calibrated_brain_atlas_tmat
@brain_atlas_tmat.setter
def brain_atlas_tmat(self, value):
if value is not None:
if value.shape != (4, 4):
raise ValueError('Transformation matrix should be of shape (4, 4)')
self._brain_atlas_tmat = value
@property
def atlas_voxel_coordinates(self):
""" Holds the coordinates of the brain atlas volume """
# Check if tmat has changed since last update
if self._tmat_version_hash != object_list_hash([self.brain_atlas_tmat]):
self._atlas_voxel_coordinates = None # Recompute if it is
if self._atlas_voxel_coordinates is None:
atlas_shape = self.raw_rgba_ndimage_compound.shape[:3]
voxel_coords = np.mgrid[0:atlas_shape[0], 0:atlas_shape[1], 0:atlas_shape[2]]
raveled_coords = voxel_coords.reshape(3, -1).T
# Apply atlas spatial transformations
self._update_atlas_transform()
raveled_coords_4by = np.vstack([raveled_coords.T, np.ones(len(raveled_coords))]).T
transformed_coords = raveled_coords_4by @ self.brain_atlas_tmat
self._atlas_voxel_coordinates = transformed_coords[:, :3]
self._tmat_version_hash = object_list_hash([self.brain_atlas_tmat])
return self._atlas_voxel_coordinates
@atlas_voxel_coordinates.setter
def atlas_voxel_coordinates(self, value):
self._atlas_voxel_coordinates = value
@property
def bg_atlas(self):
""" Holds the Brain Globe instance currently loaded in the module """
if self._bg_atlas is None:
if 'Reference Atlas' in self.layers:
if 'ref_altas_name' not in self.layers['Reference Atlas']:
raise ValueError('"ref_altas_name" missing in "Reference Atlas" layer')
offline_atlas_name = self.layers['Reference Atlas']['ref_altas_name']
if offline_atlas_name not in brainglobe_atlasapi.list_atlases.get_downloaded_atlases():
raise ValueError(f'This atlas is not available locally.\n Run\n\tbrainglobe install -a {offline_atlas_name}\nin a Terminal to do so.\nRestart CoperniFUS once the download is complete.')
self._init_attributes()
self._bg_atlas = BrainGlobeAtlas(offline_atlas_name, check_latest=False)
return self._bg_atlas
@property
def available_atlases(self):
""" Get a list of all (online and offline) available atlases in the dictionary form """
if self._available_atlases is None:
self._offline_atlases_names = brainglobe_atlasapi.list_atlases.get_downloaded_atlases()
formatted_offline_atlases = {
f'offline_{atlas_name}': f'{atlas_name} | v{brainglobe_atlasapi.list_atlases.get_local_atlas_version(atlas_name)} (DOWNLOADED)'
for (ii, atlas_name) in enumerate(self._offline_atlases_names)
}
if self.async_online_altas_list_handler.formatted_online_atlases is not None:
self._formatted_online_atlases = self.async_online_altas_list_handler.formatted_online_atlases
self._available_atlases = {
'no_atlas': 'Select Atlas',
**formatted_offline_atlases,
**self._formatted_online_atlases
}
return self._available_atlases
@available_atlases.setter
def available_atlases(self, value):
self._available_atlases = value
[docs]
def add_reference_atlas(self, offline_atlas_name):
""" Add a reference atlas to the module """
if offline_atlas_name not in brainglobe_atlasapi.list_atlases.get_downloaded_atlases():
raise ValueError(f'This atlas is not available locally.\n Run\n\tbrainglobe install -a {offline_atlas_name}\nin a Terminal to do so.\nRestart CoperniFUS once the download is complete.')
self.remove_reference_atlas()
layer_name = 'Reference Atlas'
self.layers[layer_name] = {
'_visible': True,
'ref_altas_name': offline_atlas_name,
'lut_preset': self._get_ref_atlas_lut_preset(),
'levels_preset': (0, 255),
'skip_plane_slicing': False,
}
self._reference_atlas_setup(layer_name)
self.add_rendered_object()
[docs]
def remove_reference_atlas(self):
""" Delete the reference atlas currently loaded in the module """
self.delete_rendered_object()
self._clear_layers_tree_view()
self._clear_LUT_editor_floating_dock()
self._init_attributes()
self.layers = None
self._save_layers_state_to_cache()
self._update_atlas_selector()
self.structure_selector.setEnabled(False)
self.hemisphere_selector.setEnabled(False)
self.add_structure_layer_btn.setEnabled(False)
self.rm_structure_layer_btn.setEnabled(False)
[docs]
def add_structure_layer(self, structure, hemisphere):
""" Add a brain structure layer to the atlas """
# Catch parameters errors
if structure not in self.bg_atlas_structures:
raise ValueError(f'{structure} structure not available for {self.bg_atlas.atlas_name}. Available structures are:\n\t- {"\n\t- ".join(self.bg_atlas_structures.keys())}')
if hemisphere not in ['Both Hemispheres', 'Left Hemisphere', 'Right Hemisphere']:
raise ValueError('Invalid hemisphere name -> hemisphere_id has to be either "Both Hemispheres", "Left Hemisphere" or "Right Hemisphere"')
number_of_existing_struture_layers = len([kk for kk, ll in self.layers.items() if 'atlas_structure_name' in ll])
layer_name = f'{structure} ({hemisphere})'
layer_name_suffix = 1
while layer_name in self.layers:
layer_name = f'{layer_name} {layer_name_suffix}'
layer_name_suffix += 1
self.layers[layer_name] = {
'_visible': True,
'atlas_structure_name': structure,
'atlas_structure_hemisphere': hemisphere,
'ndimage_from_layer_name': 'Reference Atlas',
'lut_preset': self._get_highlighted_structure_lut_preset(
structure_index=number_of_existing_struture_layers
),
'levels_preset': (0, 255),
'skip_plane_slicing': True,
}
self._structure_layer_setup(layer_name)
[docs]
def remove_altas_layer(self, layer_name=None, tree_viewer_layer_index=None):
""" Delete one of the currently loaded brain structure layer from the rendered atlas """
if layer_name is None:
if tree_viewer_layer_index is None:
raise ValueError('Please provide either tree_viewer_layer_index or layer_name.')
layer_name = self._get_layer_name_by_tree_viewer_layer_index(tree_viewer_layer_index)
if layer_name not in self.layers:
raise IndexError('{layer_name} not in atlas layers -> cannot be removed')
if layer_name == 'Reference Atlas':
self.remove_reference_atlas()
else: # Rm individual structure layer
self._remove_lut_editor_from_dock(layer_name)
self._remove_layer_from_layers_tree_view(layer_name=layer_name, tree_viewer_layer_index=tree_viewer_layer_index)
_ = self.layers.pop(layer_name)
self._on_layers_update()
@property
def layers(self):
""" Atlas layer dict, keys starting with an underscore corresponds to large data that will be omitted in the version saved in cache. Make sure than other keys contain jsonable data
General dict structure:
'layer_name': {'ndimage': data, OR 'ndimage_from_layer_name': 'src_layer_name', OR 'atlas_structure_name' AND 'atlas_structure_hemisphere'}
"""
if self._layers is None:
self._layers = json.loads(self.parent_viewer.cache.get_attr(
'atlas.jsonable_layers_dict', default_value=self._DEFAULT_PARAMS['jsonable_layers_dict']
))
return self._layers
@layers.setter
def layers(self, value):
""" Holds the altas layers to be rendered """
if value is None:
value = {}
self._layers = value
@property
def raw_rgba_ndimage_compound(self):
""" Holds the RGBA n-dimension image of the atlas compounded layers before plane slicing operations (raw) """
def apply_plane_slicing_on_layer(layer):
apply_plane_slicing = not layer['skip_plane_slicing'] if 'skip_plane_slicing' in layer else False
return apply_plane_slicing
def layer_visible(layer):
visible = layer['_visible'] if '_visible' in layer else True
return visible
subs_stride = self.get_user_param('subsampling_stride', default_value=self._default_subsampling_stride)
ndimage_params_id = f'{subs_stride}'
# Compute if undefined or subsampling stride has changed
if self._raw_rgba_ndimage_compound is None or self._raw_rgba_ndimage_compound[0] != ndimage_params_id:
base_layer_name = list(self.layers.keys())[0]
layer = self.layers[base_layer_name]
# Get ndimage data
ndimage_data = self._get_layer_ndimage_data(base_layer_name, apply_mask=False)
# Evaluate plane slicing binary mask for layer
self._ndimage_plane_slicing_application_mask = apply_plane_slicing_on_layer(layer) * np.ones(ndimage_data.shape[:3], dtype=bool)
# Get LUT from widget
lut = layer['_lut_widgets'].item.getLookupTable(n=256, alpha=True)
levels = layer['_lut_widgets'].item.getLevels()
# Compute rgba ndimage
_raw_rgba_ndimage_compound = self._apply_lut_to_ndimage(ndimage_data, lut, levels)
if not layer_visible(layer):
_raw_rgba_ndimage_compound[..., 3] = 0 # Make fully transparent
for layer_ii, (layer_name, layer) in enumerate(self.layers.items()):
if layer_ii > 0 and layer_visible(layer): # Skip base layer -> already processed AND invisible layers
# Get ndimage data
ndimage_data = self._get_layer_ndimage_data(layer_name, apply_mask=True)
# Get LUT from widget
lut = layer['_lut_widgets'].item.getLookupTable(n=256, alpha=True)
levels = layer['_lut_widgets'].item.getLevels()
# Compute rgba ndimage
rgba_ndimage = self._apply_lut_to_ndimage(ndimage_data, lut, levels)
if '_ndimage_mask' in layer:
_raw_rgba_ndimage_compound[layer['_ndimage_mask']] = self._alpha_blend(
_raw_rgba_ndimage_compound[layer['_ndimage_mask']], rgba_ndimage
)
# Evaluate plane slicing binary mask for layer
self._ndimage_plane_slicing_application_mask[layer['_ndimage_mask']] = apply_plane_slicing_on_layer(layer)
else:
_raw_rgba_ndimage_compound = self._alpha_blend(_raw_rgba_ndimage_compound, rgba_ndimage)
# Evaluate plane slicing binary mask for layer
self._ndimage_plane_slicing_application_mask = np.logical_and(
self._ndimage_plane_slicing_application_mask,
apply_plane_slicing_on_layer(layer) * np.ones(ndimage_data.shape[:3], dtype=bool)
)
self._raw_rgba_ndimage_compound = (ndimage_params_id, _raw_rgba_ndimage_compound)
return self._raw_rgba_ndimage_compound[1]
@property
def rgba_ndimage_compound(self):
""" Holds the RGBA n-dimension image of the atlas compounded layers with plane slicing applied """
self._rgba_ndimage_compound = self.raw_rgba_ndimage_compound.copy()
self._compute_slicing_plane()
return self._rgba_ndimage_compound
@property
def ndimage_plane_slicing_application_mask(self):
""" Holds a boolean mask indicating atlas voxels where plane silicing is ignored """
if self._ndimage_plane_slicing_application_mask is None:
self._ndimage_plane_slicing_application_mask = np.ones(self.raw_rgba_ndimage_compound.shape[:3], dtype=bool)
return self._ndimage_plane_slicing_application_mask
@property
def jsonable_layers_dict(self):
""" Provides a copy of the layers dict omitting non-jsonable data keys (starting with an underscore) for caching purposes """
def private_keys_free_dict(d):
if isinstance(d, dict):
return {
k: private_keys_free_dict(v)
for k, v in d.items()
if not k.startswith('_')
}
else:
return d
def numpy_obj_serializer(obj):
if isinstance(obj, (np.integer, np.floating, np.bool_)):
return obj.item()
elif isinstance(obj, np.ndarray):
return obj.tolist()
raise TypeError(f"Type {type(obj)} not serializable")
try:
json_layers = json.dumps(private_keys_free_dict(self.layers), default=numpy_obj_serializer)
except Exception as e:
json_layers = "{}"
raise ValueError(f'Error when attempting to cache jsonable_layers_dict:\n{str(e)}\n\nPlease create an issue on GitHub with the content of the data that needed to be cached:\n{nested_dict_formatter(private_keys_free_dict(self.layers))}')
return json_layers
# --- Required module attributes ---
[docs]
def init_dock(self):
""" Called on GUI setup to add a module dock """
# Setting up dock layout
self.dock = pyqtw.QDockWidget('Brain Atlas', self.parent_viewer)
self.parent_viewer.addDockWidget(pyqtc.Qt.DockWidgetArea.BottomDockWidgetArea, self.dock)
self.dock_widget = pyqtw.QWidget(self.dock)
self.dock.setWidget(self.dock_widget)
self.dock_layout = pyqtw.QGridLayout()
self.dock_widget.setLayout(self.dock_layout)
# Adding Atlas selector
self.atlas_selector = pyqtw.QComboBox()
# self.atlas_selector.setStyleSheet("QComboBox { combobox-popup: 0; }") # Limit dropdown height
# self.atlas_selector.setMaxVisibleItems(20) # Limit dropdown height # BUG darkmode interference
self.dock_layout.addWidget(self.atlas_selector, 0, 0, 1, 2) # Y, X, h, w
self.atlas_selector.setToolTip('Select a brain atlas for download. Previously downloaded atlases are registered as (DOWNLOADED).<br>You can find detailed descriptions of the available atlases on BrainGlobe\'s documentation.')
# Subsampling stride editor
self.subsampling_stride_editor = descriptive_line_edit(str(self._DEFAULT_PARAMS['subsampling_stride']), 'Subsampling')
self.subsampling_stride_editor.editingFinished.connect(functools.partial(self._parse_editor, self.subsampling_stride_editor, 'subsampling_stride', '', 'int'))
self.dock_layout.addWidget(self.subsampling_stride_editor, 1, 0, 1, 1) # Y, X, h, w
self.subsampling_stride_editor.setToolTip('Atlas subsampling stride<br>Use 1 to show the altas in its full resolution, larger strides will however improve performances.')
# Transform string editor
self.atlas_transform_editor = descriptive_line_edit(str(self._DEFAULT_PARAMS['atlas_transforms_str']), 'Transform')
self.atlas_transform_editor.editingFinished.connect(functools.partial(self._parse_editor, self.atlas_transform_editor, 'atlas_transforms_str', '', 'str'))
self.dock_layout.addWidget(self.atlas_transform_editor, 1, 1, 1, 1) # Y, X, h, w
self.atlas_transform_editor.setToolTip('STL mesh transformations<br> - S0.5: Apply a 0.5 scaling factor (Use Sx to scale along x)<br> - Ty1mm: 1mm translation along y<br> - Rz90deg: Rotate by 90 degrees around z axis')
# Altas layers viewer
self.atlas_layers_tree_view = DynamicallyResizableTreeView()
self.atlas_layers_tree_view.resized.connect(self._on_tree_view_resize)
self.atlas_layers_model = pyqtg.QStandardItemModel()
self.atlas_layers_tree_view.setModel(self.atlas_layers_model)
self.atlas_layers_model.itemChanged.connect(self._on_layers_tree_view_checkbox_toggle)
self.dock_layout.addWidget(self.atlas_layers_tree_view, 0, 2, 2, 2) # Y, X, h, w
self._clear_layers_tree_view()
self.atlas_layers_tree_view.setToolTip('Altases and any added layer overlays will be shown here. Double click on any layer to edit the color representation.')
# Adding substructure selector
self.structure_selector = pyqtw.QComboBox()
self.structure_selector.addItems(self.bg_atlas_structures.keys())
self.structure_selector.setEnabled(False)
self.dock_layout.addWidget(self.structure_selector, 0, 4, 1, 1) # Y, X, h, w
self.structure_selector.setToolTip('Select the brain structure to be highlighted then click on Add Structure Layer.<br>You can find detailed descriptions of the available structure on BrainGlobe\'s documentation.')
# Brain structure hemisphere selector
self.hemisphere_selector = pyqtw.QComboBox()
self.hemisphere_selector.addItems(['Both Hemispheres', 'Left Hemisphere', 'Right Hemisphere'])
self.hemisphere_selector.setEnabled(False)
self.dock_layout.addWidget(self.hemisphere_selector, 0, 5, 1, 1)
self.hemisphere_selector.setToolTip('Choose the hemisphere(s) where the structure will be highlighted.')
# Brain structure layer button
self.add_structure_layer_btn = pyqtw.QPushButton('Add Structure Layer')
self.add_structure_layer_btn.clicked.connect(self._add_structure_layer_btn_pressed)
self.add_structure_layer_btn.setEnabled(False)
self.dock_layout.addWidget(self.add_structure_layer_btn, 1, 4, 1, 1)
# Remove layer button
self.rm_structure_layer_btn = pyqtw.QPushButton('Remove Selected Layer')
self.rm_structure_layer_btn.clicked.connect(self._remove_altas_layer_btn_pressed)
self.rm_structure_layer_btn.setEnabled(False)
self.dock_layout.addWidget(self.rm_structure_layer_btn, 1, 5, 1, 1)
# Equalize column widths
self.dock_layout.setColumnStretch(0, 1)
self.dock_layout.setColumnStretch(1, 3)
self.dock_layout.setColumnStretch(2, 2)
self.dock_layout.setColumnStretch(3, 2)
self.dock_layout.setColumnStretch(4, 2)
self.dock_layout.setColumnStretch(5, 2)
self._init_LUT_editor_floating_dock()
self._update_atlas_selector()
self._init_module_from_cached_layers_dict()
[docs]
def add_rendered_object(self):
""" Called when populating the viewer with the module rendered objects """
self.delete_rendered_object()
# self._new_reference_atlas_selected()
if self.bg_atlas is not None:
self._update_atlas_user_params_editors()
self.atlas_glvol = gl.GLVolumeItem(self.rgba_ndimage_compound, smooth=True, glOptions='translucent')
self.parent_viewer.gl_view.addItem(self.atlas_glvol, name='Brain atlas compound')
self.atlas_glvol.setDepthValue(1) # GL volumes -> render tree foreground
self._update_atlas_transform()
self._update_atlas_selector()
[docs]
def update_rendered_object(self):
""" Called on render view updates """
if self.atlas_glvol is not None:
self._update_atlas_transform()
self.atlas_glvol.setData(self.rgba_ndimage_compound)
[docs]
def delete_rendered_object(self):
""" Called on deletion of the module rendered objects """
if self.atlas_glvol in self.parent_viewer.gl_view.items:
self.parent_viewer.gl_view.removeItem(self.atlas_glvol)
self.atlas_glvol = None
# --- Atlas specific cache wrapper ---
[docs]
def get_user_param(self, param_name, default_value=None):
""" Get module configuration parameter stored in cache (or default values if non existant) """
if self.bg_atlas is not None:
param_value = super().get_user_param(
param_name,
additional_identifiers=[self.bg_atlas.atlas_name],
default_value=default_value)
else:
param_value = self._DEFAULT_PARAMS[param_name]
return param_value
[docs]
def set_user_param(self, param_name, param_value):
""" Set module configuration parameter to cache """
if self.bg_atlas is not None:
super().set_user_param(
param_name,
additional_identifiers=[self.bg_atlas.atlas_name],
param_value=param_value)
# --- Module specific attributes ---
def _on_tree_view_resize(self):
self.atlas_layers_tree_view.setColumnWidth(0, self.atlas_layers_tree_view.width() - 50)
self.atlas_layers_tree_view.setColumnWidth(1, 50-30)
def _init_attributes(self):
self._loaded_structure_mask = {}
self._ndimage_plane_slicing_application_mask = None
self._tooltip_to_layer_centroid = None
self._raw_rgba_ndimage_compound = None
self._rgba_ndimage_compound = None
self._available_atlases = None
self._tmat_version_hash = None
self._brain_atlas_tmat = None
self.atlas_glvol = None
self._bg_atlas = None
self.bg_atlas_structures = {'Select Structure': None}
self._atlas_voxel_coordinates = None
self._slicing_plane_mask = None
def _init_LUT_editor_floating_dock(self):
# Create dock widget
self.lut_editor_dock = pyqtw.QDockWidget("Compound Atlas LUT editor", self.parent_viewer)
self.parent_viewer.addDockWidget(pyqtc.Qt.DockWidgetArea.BottomDockWidgetArea, self.lut_editor_dock)
self.lut_editor_dock.setFloating(True) # Make it detached/floating
self.lut_editor_dock.move(50, 50) # Set default location
self.lut_editor_dock.setAllowedAreas(pyqtc.Qt.DockWidgetArea.NoDockWidgetArea)
self.lut_editor_dock.setMinimumWidth(500)
self.lut_editor_dock.hide() # Hidden by default
self.lut_editor_dock.setStyleSheet("""
QDockWidget {
background-color: black;
color: white;
}
""")
self.lut_editor_dock_widget = pyqtw.QWidget(self.lut_editor_dock)
self.lut_editor_dock.setWidget(self.lut_editor_dock_widget)
self.lut_editor_dock_layout = pyqtw.QGridLayout()
self.lut_editor_dock_widget.setLayout(self.lut_editor_dock_layout)
self.atlas_layers_tree_view.doubleClicked.connect(self._show_LUT_editor_floating_dock)
def _init_module_from_cached_layers_dict(self):
for layer_name, layer in self.layers.items():
if layer_name == 'Reference Atlas':
self._reference_atlas_setup(layer_name)
else:
self._structure_layer_setup(layer_name)
def _show_LUT_editor_floating_dock(self):
self.lut_editor_dock.show()
def _clear_LUT_editor_floating_dock(self):
def clear_grid_layout(layout):
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
elif item.layout() is not None:
clear_grid_layout(item.layout()) # Recursively clear nested layouts
clear_grid_layout(self.lut_editor_dock_layout)
def _add_lut_editor_to_dock(self, layer_name):
if not layer_name in self.layers:
raise ValueError(f'{layer_name} not in layers')
layer = self.layers[layer_name]
# Create HistogramLUTWidget objects
layer['_lut_widgets'] = pg.HistogramLUTWidget(orientation='horizontal', fillHistogram=False)
# Set HistogramLUTWidget with a dummy image with proper dynamic range for LUT control
ndimage_data = self._get_layer_ndimage_data(layer_name, apply_mask=False)
data_min, data_max = np.min(ndimage_data), np.max(ndimage_data)
dummy_img = pg.ImageItem(np.array([[data_min], [data_max]]), dtype=np.ubyte)
layer['_lut_widgets'].setImageItem(dummy_img)
# Set layer name as axis label
layer['_lut_widgets'].setFixedHeight(100)
layer['_lut_widgets'].item.layout.itemAt(0).setLabel(layer_name)
layer['_lut_widgets'].setFixedHeight(120)
try:
layer['_lut_widgets'].setLevels(*layer['levels_preset'])
layer['_lut_widgets'].gradient.restoreState(layer['lut_preset'])
except Exception as e:
warnings.warn(f'lut_widgets could not be populated with cached values -> skipping\n{str(e)}')
# Add widgets to GUI and connect signals
self.lut_editor_dock_layout.addWidget(layer['_lut_widgets'])
layer['_lut_widgets'].item.sigLookupTableChanged.connect(self._on_layers_update)
layer['_lut_widgets'].item.sigLevelsChanged.connect(self._on_layers_update)
def _remove_lut_editor_from_dock(self, layer_name):
if not layer_name in self.layers:
raise ValueError(f'{layer_name} not in layers')
layer = self.layers[layer_name]
layer['_lut_widgets'].deleteLater()
def _get_tree_viewer_layer_index_by_name(self, layer_name: str):
for row in range(self.atlas_layers_model.rowCount()):
item = self.atlas_layers_model.item(row, 0) # column 0 is the Layer name
if item.text() == layer_name:
return self.atlas_layers_model.indexFromItem(item)
return None # invalid index if not found
def _get_layer_name_by_tree_viewer_layer_index(self, tree_viewer_layer_index):
if not tree_viewer_layer_index.isValid():
return None
row = tree_viewer_layer_index.row()
item = self.atlas_layers_model.item(row, 0) # column 0 contains the layer name
return item.text()
def _add_layer_to_layers_tree_view(self, layer_name):
# Layer name item (with visibility checkbox)
layer_item = pyqtg.QStandardItem(layer_name)
layer_item.setCheckable(True)
layer_item.setCheckState(pyqtc.Qt.CheckState.Checked)
layer_item.setFlags(layer_item.flags() & ~pyqtc.Qt.ItemFlag.ItemIsEditable)
# Tooltip item (mutually exclusive checkbox)
tooltip_item = pyqtg.QStandardItem()
tooltip_item.setCheckable(True)
tooltip_item.setCheckState(pyqtc.Qt.CheckState.Unchecked)
tooltip_item.setFlags(tooltip_item.flags() & ~pyqtc.Qt.ItemFlag.ItemIsEditable)
self.atlas_layers_model.appendRow([layer_item, tooltip_item])
def _remove_layer_from_layers_tree_view(self, tree_viewer_layer_index=None, layer_name=None):
if tree_viewer_layer_index is None:
if layer_name is None:
raise ValueError('Please provide either tree_viewer_layer_index or layer_name.')
tree_viewer_layer_index = self._get_tree_viewer_layer_index_by_name(layer_name)
self.atlas_layers_model.removeRow(tree_viewer_layer_index.row())
def _clear_layers_tree_view(self):
self.atlas_layers_model.clear()
self.atlas_layers_model.setHorizontalHeaderLabels(["Atlas Layer", "Tooltip"])
self.atlas_layers_tree_view.setRootIsDecorated(False)
def _on_layers_tree_view_checkbox_toggle(self, changed_item):
layer_name = self.atlas_layers_model.item(changed_item.row(), 0).text()
col = changed_item.column()
# Layers visibility checkboxes
if col == 0:
if layer_name in self.layers:
self.layers[layer_name]['_visible'] = (changed_item.checkState() == pyqtc.Qt.CheckState.Checked)
self._on_layers_update()
# Tooltip placement checkboxes
if col == 1:
self._tooltip_to_layer_centroid = None
if changed_item.checkState() == pyqtc.Qt.CheckState.Checked:
# Uncheck all other tooltip checkboxes
for row in range(self.atlas_layers_model.rowCount()):
item = self.atlas_layers_model.item(row, 1)
if item is not changed_item:
item.setCheckState(pyqtc.Qt.CheckState.Unchecked)
self._tooltip_to_layer_centroid = layer_name
self.update_tooltip_to_layer_centroid()
def _structure_layer_setup(self, layer_name):
self._add_layer_to_layers_tree_view(layer_name)
self._add_lut_editor_to_dock(layer_name)
self._on_layers_update()
def _add_structure_layer_btn_pressed(self):
# Grab structure settings from GUI
selected_structure = self.structure_selector.currentText()
selected_hemisphere = self.hemisphere_selector.currentText()
if selected_structure == 'Select Structure':
self.parent_viewer.show_error_popup('Add Layer', error_description='Please select a brain structure.')
return
self.add_structure_layer(selected_structure, selected_hemisphere)
def _remove_altas_layer_btn_pressed(self):
# Grab layer selected in treee view
tree_viewer_layer_index = self.atlas_layers_tree_view.currentIndex()
if not tree_viewer_layer_index.isValid():
self.parent_viewer.show_error_popup('Remove Layer', error_description='No altas layer selected.')
return
self.remove_altas_layer(tree_viewer_layer_index=tree_viewer_layer_index)
def _reference_atlas_setup(self, layer_name):
self._update_structure_selector()
self._add_layer_to_layers_tree_view(layer_name)
self._add_lut_editor_to_dock(layer_name)
self.structure_selector.setEnabled(True)
self.hemisphere_selector.setEnabled(True)
self.add_structure_layer_btn.setEnabled(True)
self.rm_structure_layer_btn.setEnabled(True)
self._save_layers_state_to_cache()
def _new_reference_atlas_selected(self):
""" Triggered whenever atlas_selector selection changes """
selected_atlas_description = self.atlas_selector.currentText()
if selected_atlas_description in ['Select Atlas', 'Offline mode -> Only downloaded atlas are visible']:
self.remove_reference_atlas()
elif selected_atlas_description.endswith('(DOWNLOADED)'):
offline_atlas_name = selected_atlas_description.split(' | ')[0]
self.parent_viewer.statusBar().showMessage(f'Loading offline {offline_atlas_name}', self.parent_viewer._STATUS_BAR_MSG_TIMEOUT)
self.add_reference_atlas(offline_atlas_name)
elif selected_atlas_description.endswith('(online)'):
online_atlas_name = selected_atlas_description.split(' | ')[0]
dialog = AcceptRejectDialog(parent=self.parent_viewer, title='This atlas has to be downloaded externally', msg=f'Run\n\tbrainglobe install -a {online_atlas_name}\nin a Terminal to do so\n\nClick OK to copy the command in the clipboard and close CoperniFUS.\nRestart CoperniFUS once the download is complete.')
dialog_result = dialog.exec()
if dialog_result == 1:
# Download cmd to clipboard
clipboard = self.parent_viewer.app.clipboard()
clipboard.setText(f'brainglobe install -a {online_atlas_name}')
# Close CoperniFUS
self.parent_viewer.close()
else:
self.parent_viewer.statusBar().showMessage('Proceeding with offline atlases', self.parent_viewer._STATUS_BAR_MSG_TIMEOUT)
def _parse_editor(self, src_editor, param_name, unit='', param_type='float'):
if param_type == 'int':
edited_value = int(src_editor.text())
elif param_type == 'float':
edited_text = src_editor.text().replace(' ', '') # remove spaces
edited_text_nounit = edited_text[:-len(unit)]
edited_value = si_parse(edited_text_nounit.replace('u', 'ยต'))
else: # raw str
edited_value = src_editor.text()
self.set_user_param(param_name, edited_value)
self.parent_viewer.update_rendered_view()
@property
def _default_subsampling_stride(self):
return int((np.prod(self.bg_atlas.shape) / self._DEFAULT_N_VOXELS)**(1/3))
def _update_atlas_selector(self):
try: self.atlas_selector.currentIndexChanged.disconnect()
except Exception: pass
self.available_atlases = None
self.atlas_selector.clear()
self.atlas_selector.addItems(self.available_atlases.values())
# Init atlas selector
if self.bg_atlas is not None:
selected_offline_atlas = self.bg_atlas.atlas_name
else:
selected_offline_atlas = 'no_atlas'
if selected_offline_atlas == 'no_atlas':
self.atlas_selector.setCurrentIndex(0) # set to "Select Atlas" option
elif f'offline_{selected_offline_atlas}' in self.available_atlases.keys():
self.atlas_selector.setCurrentIndex(list(self.available_atlases.keys()).index(f'offline_{selected_offline_atlas}'))
self.atlas_selector.currentIndexChanged.connect(self._new_reference_atlas_selected)
def _update_structure_selector(self):
if self.bg_atlas is None:
self.bg_atlas_structures = {'Select Structure': None}
else:
sorted_structure_dict = dict(sorted({f"{struct['name']} ({struct['acronym']})": struct['acronym'] for struct in self.bg_atlas.structures_list}.items()))
self.bg_atlas_structures = {
**{'Select Structure': None},
**sorted_structure_dict
}
self.structure_selector.clear()
self.structure_selector.setEnabled(True)
self.structure_selector.addItems(self.bg_atlas_structures.keys())
# Set highlighted structure to default
self.structure_selector.setCurrentIndex(0)
# Set highlighted structure hemisphere to default
self.hemisphere_selector.setCurrentText('Both Hemispheres')
self.hemisphere_selector.setEnabled(True)
def _update_atlas_user_params_editors(self):
self.subsampling_stride_editor.setText(str(self.get_user_param('subsampling_stride', default_value=self._default_subsampling_stride)))
self.atlas_transform_editor.setText(self.get_user_param('atlas_transforms_str'))
def _update_atlas_transform(self):
self.brain_atlas_tmat = None
self.atlas_voxel_coordinates = None # Reset voxels coordinates
if self.atlas_glvol is not None:
self.atlas_glvol.resetTransform()
self.atlas_glvol.applyTransform(pyqtg.QMatrix4x4(self.brain_atlas_tmat.T.ravel()), local=False)
def _compute_slicing_plane(self):
""" Computes a binary mask based on the slicing plane location and hides (opacity=0) voxels affected by the slicing """
slicing_plane_pts = self.parent_viewer.slicing_plane_3pts
if slicing_plane_pts is not None:
if not self.parent_viewer._postpone_slicing_plane_computation or self._slicing_plane_mask is None:
raveled_mask = np.dot(
self.atlas_voxel_coordinates - slicing_plane_pts[0],
np.cross(
slicing_plane_pts[1] - slicing_plane_pts[0],
slicing_plane_pts[2] - slicing_plane_pts[0]
)) < 0
self._slicing_plane_mask = np.logical_and(
raveled_mask.reshape(self.ndimage_plane_slicing_application_mask.shape),
self.ndimage_plane_slicing_application_mask
)
self._rgba_ndimage_compound[self._slicing_plane_mask, 3] = 0
def _get_highlighted_structure_lut_preset(self, structure_index=0):
rgb_color = self._CYCLIC_STRUCTURES_COLORS[structure_index%len(self._CYCLIC_STRUCTURES_COLORS)][:3]
lut_state = {
'mode': 'rgb',
'ticks': [
(0.0, (*rgb_color, 0)),
(0.5, (*rgb_color, 255))],
'ticksVisible': True
}
return lut_state
def _get_ref_atlas_lut_preset(self):
ALTAS_OPACITY = 20
lut_state = {
'mode': 'rgb',
'ticks': [
(0.0, (0, 0, 0, 0)),
(0.05, (0, 0, 0, 0)),
(0.06, (25, 25, 25, ALTAS_OPACITY)),
(1.0, (255, 255, 255, ALTAS_OPACITY)),
],
'ticksVisible': True
}
return lut_state
def _apply_lut_to_ndimage(self, ndimage_data, lut, levels):
""" Converts ndim image data to ndim RGBA based on a lookup table (LUT) """
# Normalize data to levels
min_val, max_val = levels
scaled = np.clip((ndimage_data - min_val) / (max_val - min_val), 0, 1)
indices = (scaled * (len(lut) - 1)).astype(np.ubyte)
rgba = lut[indices] # shape: (..., 4)
return rgba
def _alpha_blend(self, background, foreground):
"""
Alpha blending of two N-dimensional uint8 RGBA images.
Parameters:
Foreground ndim image (..., 4), dtype=uint8
Background ndim image (..., 4), dtype=uint8
Returns:
Blended RGBA ndim image, dtype=uint8
"""
foreground_rgb = foreground[..., :3].astype(np.uint16)
foreground_a = foreground[..., 3:4].astype(np.uint16)
background_rgb = background[..., :3].astype(np.uint16)
background_a = background[..., 3:4].astype(np.uint16)
out_rgb = (foreground_rgb * foreground_a + background_rgb * (255 - foreground_a)) // 255
out_a = foreground_a + (background_a * (255 - foreground_a)) // 255
out = np.concatenate((out_rgb, out_a), axis=-1).astype(np.uint8)
return out
def _reference_atlas_ndimage_data(self, ref_altas_name, subsampling_stride):
if self.bg_atlas.atlas_name != ref_altas_name:
self._bg_atlas = None
normalisation_func = plt.Normalize()
ref_atlas_uint8 = (np.asarray(normalisation_func(self.bg_atlas.reference)) * 255).astype(np.ubyte)
subsampled_ref_atlas_uint8 = ref_atlas_uint8[::subsampling_stride, ::subsampling_stride, ::subsampling_stride]
return subsampled_ref_atlas_uint8
# ===
def _get_struct_mask(self, structure_name, hemisphere_id, subsampling_stride):
# Structure acronym retreival from name displayed in selector
if structure_name not in self.bg_atlas_structures:
raise ValueError(f'{structure_name} structure not available for {self.bg_atlas.atlas_name}. Available structures are:\n\t- {"\n\t- ".join(self.bg_atlas_structures.keys())}')
structure_acronym = self.bg_atlas_structures[structure_name]
if structure_acronym not in self._loaded_structure_mask:
# Load only once for fast atlas updates
self._loaded_structure_mask[structure_acronym] = self.bg_atlas.get_structure_mask(structure_acronym).astype(bool)
if hemisphere_id == 'Both Hemispheres':
struct_mask = self._loaded_structure_mask[structure_acronym]
else:
if hemisphere_id == 'Left Hemisphere':
hemisphere_index = 1
elif hemisphere_id == 'Right Hemisphere':
hemisphere_index = 2
else:
raise ValueError('Invalid hemisphere name -> hemisphere_id has to be either "Both Hemispheres", "Left Hemisphere" or "Right Hemisphere"')
struct_mask = (self._loaded_structure_mask[structure_acronym] * (self.bg_atlas.hemispheres == hemisphere_index)).astype(bool)
subsampled_struct_mask = struct_mask[::subsampling_stride, ::subsampling_stride, ::subsampling_stride]
return subsampled_struct_mask
def _get_layer_ndimage_data(self, layer_name, apply_mask=True):
"""
layers: dictionary of all the layers
layer_name: dict key of the layer from which ndimage data has to be retreived
All layers should contain either 'ref_altas_name' or 'ndimage_from_layer_name' fields referencing ndimage data
"""
subs_stride = self.get_user_param('subsampling_stride', default_value=self._default_subsampling_stride)
if '_ndimage_data' in self.layers[layer_name] and '_subs_stride' in self.layers[layer_name] and self.layers[layer_name]['_subs_stride'] == subs_stride:
ndimage_data = self.layers[layer_name]['_ndimage_data']
else: # Evaluate data content if '_ndimage_data' not in dict
if 'ref_altas_name' in self.layers[layer_name]:
ndimage_data = self._reference_atlas_ndimage_data(self.layers[layer_name]['ref_altas_name'], subsampling_stride=subs_stride)
elif 'ndimage_from_layer_name'in self.layers[layer_name]:
ndimage_data = self._get_layer_ndimage_data(self.layers[layer_name]['ndimage_from_layer_name'], apply_mask=False)
else:
raise ValueError(f'ndimage data reference not provided in layer {layer_name}')
# Store in dict for future use
self.layers[layer_name]['_ndimage_data'] = ndimage_data
self.layers[layer_name]['_subs_stride'] = subs_stride
if apply_mask is True and ('atlas_structure_name' in self.layers[layer_name] or 'atlas_structure_hemisphere' in self.layers[layer_name] or '_ndimage_mask' in self.layers[layer_name]):
if '_ndimage_mask' in self.layers[layer_name] and '_mask_subs_stride' in self.layers[layer_name] and self.layers[layer_name]['_mask_subs_stride'] == subs_stride:
ndimage_mask = self.layers[layer_name]['_ndimage_mask']
else: # Evaluate mask data content if '_ndimage_mask' not in dict
if 'atlas_structure_name' in self.layers[layer_name] and 'atlas_structure_hemisphere' in self.layers[layer_name]:
ndimage_mask = self._get_struct_mask(
self.layers[layer_name]['atlas_structure_name'],
self.layers[layer_name]['atlas_structure_hemisphere'],
subsampling_stride=subs_stride
)
else:
raise ValueError(f'_ndimage_mask or atlas_structure_name/atlas_structure_hemisphere not provided in layer {layer_name}')
# Store in dict for future use
self.layers[layer_name]['_ndimage_mask'] = ndimage_mask
self.layers[layer_name]['_mask_subs_stride'] = subs_stride
ndimage_data = ndimage_data[ndimage_mask] # Apply mask
return ndimage_data
def _on_layers_update(self):
""" Callback to update RGBA volume on LUT edition """
self._raw_rgba_ndimage_compound = None # reset
if self.atlas_glvol is not None:
self.atlas_glvol.setData(self.rgba_ndimage_compound)
self._save_layers_state_to_cache()
def _save_layers_state_to_cache(self):
for layer_name, layer in self.layers.items():
if '_lut_widgets' in layer:
self.layers[layer_name]['levels_preset'] = layer['_lut_widgets'].getLevels()
self.layers[layer_name]['lut_preset'] = layer['_lut_widgets'].gradient.saveState()
self.parent_viewer.cache.set_attr('atlas.jsonable_layers_dict', self.jsonable_layers_dict)