from coperniFUS import *
from coperniFUS.modules.module_base import Module
[docs]
class AnatLandmarksCalib(Module):
_DEFAULT_PARAMS = {
'uncal_anatomical_landmarks_coords': {
# 'Lambda': [-1e-2, 0, 2e-3],
# 'Bregma': [0, 0, 0]
},
'cal_anatomical_landmarks_coords': {
# 'Lambda': [-1e-2, 0, 2e-3],
# 'Bregma': None # not specified
},
'cal_tmat': np.eye(4),
}
""" Default configuration parameters used when a parameter value is not yet cached """
_LANDMARKS_STYLES_BY_SUBTYPE = {
'Uncalibrated': {
'pt_color': (1, 0, 0, .7), 'pt_size': 18,
}, 'Calibrated': {
'pt_color': (0, 1, 0, .7), 'pt_size': 16,
}
}
def __init__(self, parent_viewer, **kwargs) -> None:
super().__init__(parent_viewer, 'anat_calib', **kwargs)
self._anat_landmarks_dict_name2ref = {
'Uncalibrated': 'uncal_anatomical_landmarks_coords',
'Calibrated': 'cal_anatomical_landmarks_coords',
}
self._anat_landmarks_dict_ref2name = {
ref: name for name, ref in self._anat_landmarks_dict_name2ref.items()
}
self._tooltip_to_landmark_full_name = None
self._landmarks_calib_tmat = None
self._landmarks_gl_items = {}
# --- Module specific public attributes ---
@property
def anat_landmarks_dict(self):
anat_calib_dict = {}
for landmark_subtype in self._anat_landmarks_dict_ref2name.keys():
anat_calib_dict[landmark_subtype] = self.get_user_param(landmark_subtype)
return anat_calib_dict
@property
def landmark_coords_from_full_name(self):
""" Dictionary of all landmark coordinates and their associated full names """
landmark_coords_dict = {
x[0]: x[1]
for xs in [[(f'{self._anat_landmarks_dict_ref2name[landmark_subtype]} {landmark_name}', coordinates) for landmark_name, coordinates in anat_sublandmarks_dict.items()] for landmark_subtype, anat_sublandmarks_dict in self.anat_landmarks_dict.items()]
for x in xs
}
return landmark_coords_dict
[docs]
def show_anat_calib_debug_trihedras(self):
""" Show trihedras to debug issues with anatomical calibration transform.
Uncalibrated and calibrated coordinate frame vizualisation"""
uncal_tmat = self.get_tmat_from_anat_landmarks(
self.get_user_param('uncal_anatomical_landmarks_coords')
)
cal_tmat = self.get_tmat_from_anat_landmarks(
self.get_user_param('cal_anatomical_landmarks_coords')
)
self.parent_viewer.delete_debug_trihedras()
self.parent_viewer.add_scale_accurate_debug_trihedra(cal_tmat.T)
self.parent_viewer.add_scale_accurate_debug_trihedra(uncal_tmat.T)
[docs]
def get_tmat_from_anat_landmarks(self, anat_landmarks_dict):
""" Computes the calibration transformation matrix based on calibrated and uncalibrated landmarks coordinates defined in anat_landmarks_dict
anat_landmarks_dict should repect the format {'landmark #0': np.array(landmark_coords)}
Note: The current implementation only supports calibrations based on a pair of anatomical landmarks. Please create an issue on GtHub if you have specific needs.
"""
if len(anat_landmarks_dict) == 2:
# # Unpack landmarks
A, B = [np.array(ll) for ll in anat_landmarks_dict.values()]
y_global = np.array([0, 1, 0])
# Step 1: Compute the local x-axis
x_local = B - A
x_local /= np.linalg.norm(x_local)
# Step 2: Compute the local z-axis
z_local = np.cross(x_local, y_global)
z_local /= np.linalg.norm(z_local)
# Step 3: Compute the local y-axis
y_local = np.cross(z_local, x_local)
# Step 4: Construct the rotation matrix
R = np.column_stack((x_local, y_local, z_local))
# Step 5: Combine into a 4x4 transformation matrix
anat_landmarks_tmat = np.eye(4)
anat_landmarks_tmat[:3, :3] = R
# Step 6: Set A as the local origin
anat_landmarks_tmat[:3, 3] = A
# Setp 7: Apply scale
anat_landmarks_scale = np.linalg.norm(A - B)
anat_landmarks_tmat = anat_landmarks_tmat @ af_tr.scale_mat(anat_landmarks_scale)
else:
raise NotImplementedError('Evaluation of calibration affine transformations matrix is only implemented for a pair of landmarks at the moment.')
return anat_landmarks_tmat
[docs]
def apply_calibration_tmat(self):
""" Applies the calibration transformation to all affected objects. """
if len(self.anat_landmarks_dict['cal_anatomical_landmarks_coords']) < 2:
self.parent_viewer.show_error_popup('Anatomical Calibration', error_description='Please add at least two anatomical landmarks before proceeding.')
return
if None in self.landmark_coords_from_full_name.values():
self.parent_viewer.show_error_popup('Anatomical Calibration', error_description='Please make sure that the coordinates of all anatomical landmarks are set before proceeding.')
return
try:
uncal_tmat = self.get_tmat_from_anat_landmarks(
self.get_user_param('uncal_anatomical_landmarks_coords')
)
cal_tmat = self.get_tmat_from_anat_landmarks(
self.get_user_param('cal_anatomical_landmarks_coords')
)
except Exception as e:
self.parent_viewer.show_error_popup('Anatomical Calibration', error_description=str(e))
return
# Calibration matrix evaluation
calibration_tmat = (cal_tmat @ np.linalg.inv(uncal_tmat)).T
landmarks_hash = self._get_anat_landmarks_dicts_hash()
self._landmarks_calib_tmat = (calibration_tmat, landmarks_hash) # tmat + hash for version tracking
self.parent_viewer.update_rendered_view()
self._update_calib_tmat_btn_status()
# Set btn to calibration reset
self.apply_calibration_tmat_btn.setText('Disable Anatomical Landmark Transformation')
try: self.apply_calibration_tmat_btn.clicked.disconnect()
except Exception: pass
self.apply_calibration_tmat_btn.clicked.connect(self.disable_calibration_tmat)
[docs]
def disable_calibration_tmat(self):
""" Disables the calibration transformation on all affected objects. """
landmarks_hash = None
self._landmarks_calib_tmat = (np.eye(4), landmarks_hash) # tmat + hash for version tracking
self.parent_viewer.update_rendered_view()
self._update_calib_tmat_btn_status()
# Set btn to calibration reset
self.apply_calibration_tmat_btn.setText('Apply Anatomical Landmark Transformation')
try: self.apply_calibration_tmat_btn.clicked.disconnect()
except Exception: pass
self.apply_calibration_tmat_btn.clicked.connect(self.apply_calibration_tmat)
@property
def landmarks_calib_tmat(self):
""" Anatomical calibration affine transformation matrix evaluated in get_tmat_from_anat_landmarks """
if self._landmarks_calib_tmat is None:
landmarks_hash = None
self._landmarks_calib_tmat = (np.eye(4), landmarks_hash) # tmat + hash for version tracking
return self._landmarks_calib_tmat[0]
# --- 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('Anatomical Landmarks Calibration', self.parent_viewer)
self.parent_viewer.addDockWidget(pyqtc.Qt.DockWidgetArea.RightDockWidgetArea, 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)
# Tree view and model
self.tree_view = DynamicallyResizableTreeView()
self.tree_view.resized.connect(self._on_tree_view_resize)
self.model = pyqtg.QStandardItemModel()
self.model.setHorizontalHeaderLabels(["Anatomical Landmarks", "Tooltip"])
self.tree_view.setModel(self.model)
self.dock_layout.addWidget(self.tree_view, 0, 0, 1, 3) # Y, X, h, w
self._populate_treeview_cached_landmarks()
self.tree_view.setMaximumHeight(100)
# Add / Remove Landmarks Buttons
self.add_landmark_button = pyqtw.QPushButton('+')
self.add_landmark_button.setFixedWidth(25)
self.dock_layout.addWidget(self.add_landmark_button, 1, 0, 1, 1) # Y, X, h, w
self.add_landmark_button.clicked.connect(self._add_landmark_btn_pressed)
self.add_landmark_button.setToolTip('Add an Anatomical Landmark. Landmarks names can be edited later in the list.')
self.remove_landmark_button = pyqtw.QPushButton('-')
self.remove_landmark_button.setFixedWidth(25)
self.dock_layout.addWidget(self.remove_landmark_button, 1, 1, 1, 1) # Y, X, h, w
self.remove_landmark_button.clicked.connect(self._remove_landmark_btn_pressed)
self.remove_landmark_button.setToolTip('Remove the selected Anatomical Landmark.')
# Connect model item changed signal
self.model.itemChanged.connect(self._handle_landmark_item_change)
# Set coords btn
self.set_landmark_from_tooltip_button = pyqtw.QPushButton("Set selected landmark to Tooltip")
self.set_landmark_from_tooltip_button.setEnabled(False)
self.set_landmark_from_tooltip_button.clicked.connect(self.set_landmark_coordinates_from_tooltip)
self.dock_layout.addWidget(self.set_landmark_from_tooltip_button, 1, 2, 1, 1) # Y, X, h, w
self.set_landmark_from_tooltip_button.setToolTip('To set the coordinates of the landmark place the Tooltip on the desired location, ensure that the landmark to be edited is the only one made visible in the above list.')
# Landmark to tooltip button
self.apply_calibration_tmat_btn = pyqtw.QPushButton('Apply Anatomical Landmark Transformation')
self.apply_calibration_tmat_btn.clicked.connect(self.apply_calibration_tmat)
self._update_calib_tmat_btn_status()
self.dock_layout.addWidget(self.apply_calibration_tmat_btn, 2, 0, 1, 3) # Y, X, h, w
self.apply_calibration_tmat_btn.setToolTip('Once all landmark coordinates are set, coordinate transformation from Uncalibrated to Calibrated spaces can be performed.')
# Resize to most comptact height
self.dock.adjustSize()
self.dock.setMinimumHeight(self.dock.sizeHint().height())
self.dock.setMaximumHeight(self.dock.sizeHint().height())
[docs]
def add_rendered_object(self):
""" Called when populating the viewer with the module rendered objects """
self.delete_rendered_object()
self._add_or_update_landmarks_to_rendered_view(
self.get_user_param('uncal_anatomical_landmarks_coords'),
landmark_subtype='Uncalibrated')
self._add_or_update_landmarks_to_rendered_view(
self.get_user_param('cal_anatomical_landmarks_coords'),
landmark_subtype='Calibrated')
[docs]
def update_rendered_object(self):
""" Called on render view updates """
self._add_or_update_landmarks_to_rendered_view(
self.get_user_param('uncal_anatomical_landmarks_coords'),
landmark_subtype='Uncalibrated')
self._add_or_update_landmarks_to_rendered_view(
self.get_user_param('cal_anatomical_landmarks_coords'),
landmark_subtype='Calibrated')
[docs]
def delete_rendered_object(self):
""" Called on deletion of the module rendered objects """
for full_landmark_name in self._landmarks_gl_items:
if self._landmarks_gl_items[full_landmark_name] in self.parent_viewer.gl_view.items:
self.parent_viewer.gl_view.removeItem(self._landmarks_gl_items[full_landmark_name])
self._landmarks_gl_items = {}
# --- Module specific attributes ---
def _on_tree_view_resize(self):
self.tree_view.setColumnWidth(0, self.tree_view.width() - 50)
self.tree_view.setColumnWidth(1, 50-30)
def _add_or_update_landmarks_to_rendered_view(self, anat_landmarks_dict, landmark_subtype):
for landmark_name, landmark_coords in anat_landmarks_dict.items():
# landmark_subtype_ref = self._anat_landmarks_dict_name2ref[landmark_subtype]
full_landmark_name = f'{landmark_subtype} {landmark_name}'
if isinstance(landmark_coords, list) and full_landmark_name in self.landmark_coords_from_full_name.keys() and full_landmark_name in self._visible_anat_landmarks_full_names_list: # valid and visible
landmark_loc = np.array(landmark_coords).reshape((1, 3))
if full_landmark_name in self._landmarks_gl_items: # Update if True
self._landmarks_gl_items[full_landmark_name].setData(pos=landmark_loc)
if self._landmarks_gl_items[full_landmark_name] not in self.parent_viewer.gl_view.items:
self.parent_viewer.gl_view.addItem(self._landmarks_gl_items[full_landmark_name], name=full_landmark_name)
else: # Add if False
self._landmarks_gl_items[full_landmark_name] = gl.GLScatterPlotItem(
pos=landmark_loc,
color=self._LANDMARKS_STYLES_BY_SUBTYPE[landmark_subtype]['pt_color'],
size=self._LANDMARKS_STYLES_BY_SUBTYPE[landmark_subtype]['pt_size'],
pxMode=True, # pt size expressed in pixels
glOptions='additive',
)
self._landmarks_gl_items[full_landmark_name].setDepthValue(5)
self.parent_viewer.gl_view.addItem(self._landmarks_gl_items[full_landmark_name], name=full_landmark_name)
else: # landmark_coords not a list -> delete
if full_landmark_name in self._landmarks_gl_items:
if self._landmarks_gl_items[full_landmark_name] in self.parent_viewer.gl_view.items:
self.parent_viewer.gl_view.removeItem(self._landmarks_gl_items[full_landmark_name])
def _populate_treeview_cached_landmarks(self):
self._populate_treeview_from_dict(self.anat_landmarks_dict)
def _get_available_landmark_name(self, landmark_name, sender_item=None):
""" Alters the provided landmark name if a duplicate exists """
available_landmark_name = landmark_name
existing_landmarks_names = [
item.text() for item in [
self.model.item(ii, 0) for ii in range(self.model.rowCount())
] if item is not sender_item
]
landmark_name_suffix = 1
while available_landmark_name in existing_landmarks_names:
available_landmark_name = f'{landmark_name} {landmark_name_suffix}'
landmark_name_suffix += 1
return available_landmark_name
def _add_landmark_btn_pressed(self):
landmark_name = self._get_available_landmark_name('Anatomical Landmark')
landmark_header_item = pyqtg.QStandardItem(landmark_name)
landmark_header_item.setEditable(True)
landmark_header_item.setCheckable(False)
landmark_header_tooltip = pyqtg.QStandardItem("") # No tooltip checkbox on header
landmark_header_tooltip.setEditable(False)
# Append the landmark_header with its two columns items
self.model.appendRow([landmark_header_item, landmark_header_tooltip])
# Create sublandmarks items with tooltip checkboxes in the second column
uncal_sublandmark_label_item = pyqtg.QStandardItem(self._anat_landmarks_dict_ref2name['uncal_anatomical_landmarks_coords'])
uncal_sublandmark_label_item.setEditable(False)
uncal_sublandmark_label_item.setCheckable(True)
self._set_coordinates_to_sublandmark_qtree_item(uncal_sublandmark_label_item, None)
uncal_sublandmark_tooltip_item = pyqtg.QStandardItem()
uncal_sublandmark_tooltip_item.setCheckable(True)
uncal_sublandmark_tooltip_item.setEditable(False)
cal_sublandmark_label_item = pyqtg.QStandardItem(self._anat_landmarks_dict_ref2name['cal_anatomical_landmarks_coords'])
cal_sublandmark_label_item.setEditable(False)
cal_sublandmark_label_item.setCheckable(True)
self._set_coordinates_to_sublandmark_qtree_item(cal_sublandmark_label_item, None)
cal_sublandmark_tooltip_item = pyqtg.QStandardItem()
cal_sublandmark_tooltip_item.setCheckable(True)
cal_sublandmark_tooltip_item.setEditable(False)
landmark_header_item.appendRow([uncal_sublandmark_label_item, uncal_sublandmark_tooltip_item])
landmark_header_item.appendRow([cal_sublandmark_label_item, cal_sublandmark_tooltip_item])
self._on_treeview_edit()
def _remove_landmark_btn_pressed(self):
index = self.tree_view.currentIndex()
if index.isValid():
parent = index.parent()
if not parent.isValid(): # Only allow removing header items
self.model.removeRow(index.row())
self._on_treeview_edit()
def _get_sublandmark_selection(self):
checked_sublandmarks = []
for i in range(self.model.rowCount()):
landmark_header_item = self.model.item(i, 0)
for j in range(landmark_header_item.rowCount()):
sublandmark_label_item = landmark_header_item.child(j, 0)
if sublandmark_label_item.checkState() == pyqtc.Qt.CheckState.Checked:
checked_sublandmarks.append((sublandmark_label_item, landmark_header_item.text(), sublandmark_label_item.text())) # (sublandmark_label_item, landmark_header_text, sublandmark_type_label)
return checked_sublandmarks
def _handle_landmark_item_change(self, changed_item):
# Handle landmark header duplicate names -> add suffix if duplicate
if changed_item.column() == 0 and changed_item.parent() is None:
edited_landmark_name = changed_item.text()
changed_item.setText(self._get_available_landmark_name(edited_landmark_name, sender_item=changed_item))
# Ignorethe rest of the function if not a checkbox
if not changed_item.isCheckable():
return
state = changed_item.checkState()
# Allow a single tooltip checkbox to be selected at once
if changed_item.column() == 1 and changed_item.parent() is not None: # Not a header checkbox -> sublandmark:
self._tooltip_to_landmark_full_name = None
if state == pyqtc.Qt.CheckState.Checked:
# Uncheck all other checkboxes across the entire model
for i in range(self.model.rowCount()):
landmark_header_item = self.model.item(i, 0)
for j in range(landmark_header_item.rowCount()):
other_tooltip_checkbox = landmark_header_item.child(j, 1)
if other_tooltip_checkbox is not changed_item:
other_tooltip_checkbox.setCheckState(pyqtc.Qt.CheckState.Unchecked)
landmark_header_item = changed_item.parent()
full_landmark_name = f'{landmark_header_item.child(changed_item.row(), 0).text()} {landmark_header_item.text()}'
self._tooltip_to_landmark_full_name = full_landmark_name
self.update_tooltip_to_landmark()
self._update_landmark_from_tooltip_button_state()
self._on_treeview_edit()
def _update_landmark_from_tooltip_button_state(self):
""" Enables set_landmark_from_tooltip_button only when a unique sublandmark is selected """
if hasattr(self, 'set_landmark_from_tooltip_button'):
self.set_landmark_from_tooltip_button.setEnabled(len(self._get_sublandmark_selection()) == 1)
def _set_coordinates_to_sublandmark_qtree_item(self, sublandmark_label_item, coordinates=None):
""" Set coordinates and update color state """
if isinstance(coordinates, list) and len(coordinates) == 3:
sublandmark_label_item.setData(coordinates, pyqtc.Qt.ItemDataRole.ForegroundRole)
else:
sublandmark_label_item.setForeground(pyqtg.QColor("red"))
if coordinates is not None:
raise ValueError('Attempting to set invalid coordinates to a landmark')
def _clear_tree_view(self):
self.model.removeRows(0, self.model.rowCount())
def _get_treeview_content(self):
landmarks_dict = {sublandmark_type_id: {} for sublandmark_type_id in self._anat_landmarks_dict_ref2name}
for i in range(self.model.rowCount()):
landmark_header_item = self.model.item(i, 0)
landmark_header_text = landmark_header_item.text()
for j in range(landmark_header_item.rowCount()):
sublandmark_label_item = landmark_header_item.child(j, 0)
sublandmark_label_text = sublandmark_label_item.text()
coords = sublandmark_label_item.data(pyqtc.Qt.ItemDataRole.ForegroundRole)
if sublandmark_label_text in self._anat_landmarks_dict_name2ref:
landmarks_dict[self._anat_landmarks_dict_name2ref[sublandmark_label_text]][landmark_header_text] = coords if isinstance(coords, list) else None
return landmarks_dict
def _update_anat_landmarks_dict_from_treeview_content(self):
treeview_landmark_dict = self._get_treeview_content()
for landmark_dict_name, anat_landmarks_dict in treeview_landmark_dict.items():
self.set_user_param(landmark_dict_name, anat_landmarks_dict)
def _on_treeview_edit(self):
self._update_anat_landmarks_dict_from_treeview_content()
self.update_rendered_object()
def _populate_treeview_from_dict(self, landmarks_dict):
self._clear_tree_view()
self.model.removeRows(0, self.model.rowCount())
anat_landmark_names = set()
for sublandmark_dict in landmarks_dict.values():
anat_landmark_names.update(sublandmark_dict.keys())
anat_landmark_names = sorted(anat_landmark_names)
for anat_landmark_name in anat_landmark_names:
landmark_header_item = pyqtg.QStandardItem(anat_landmark_name)
landmark_header_item.setEditable(True)
landmark_header_item.setCheckable(False)
tooltip_checkboxes_col = pyqtg.QStandardItem("") # No tooltip check box on header
tooltip_checkboxes_col.setEditable(False)
self.model.appendRow([landmark_header_item, tooltip_checkboxes_col])
# Add calibrated and uncalibrated landmarks subitems
for sublandmark_id, sublandmark_label in self._anat_landmarks_dict_ref2name.items():
sublandmark_label_item = pyqtg.QStandardItem(sublandmark_label)
sublandmark_label_item.setEditable(False)
sublandmark_label_item.setCheckable(True)
sublandmark_tooltip_item = pyqtg.QStandardItem()
sublandmark_tooltip_item.setCheckable(True)
sublandmark_tooltip_item.setEditable(False)
coords = landmarks_dict.get(sublandmark_id, {}).get(anat_landmark_name)
self._set_coordinates_to_sublandmark_qtree_item(sublandmark_label_item, coords)
landmark_header_item.appendRow([sublandmark_label_item, sublandmark_tooltip_item])
self._update_landmark_from_tooltip_button_state()
@property
def _visible_anat_landmarks_full_names_list(self):
visible_full_names = [
f'{sublandmark_type_label} {landmark_header_text}'
for sublandmark_label_item, landmark_header_text, sublandmark_type_label
in self._get_sublandmark_selection()
]
return visible_full_names
def _get_anat_landmarks_dicts_hash(self):
dicts_hash = object_list_hash([
self.get_user_param('uncal_anatomical_landmarks_coords'),
self.get_user_param('cal_anatomical_landmarks_coords')
])
return dicts_hash
def _update_calib_tmat_btn_status(self):
self.landmarks_calib_tmat # Ensures that self._landmarks_calib_tmat[1] exists
landmarks_hash = self._get_anat_landmarks_dicts_hash()
if landmarks_hash == self._landmarks_calib_tmat[1]:
self.apply_calibration_tmat_btn.setStyleSheet("background-color: green; color: white;")
else:
self.apply_calibration_tmat_btn.setStyleSheet("background-color: red; color: white;")