diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
index c00e554..de1d2d5 100644
--- a/.github/workflows/test_and_deploy.yml
+++ b/.github/workflows/test_and_deploy.yml
@@ -47,10 +47,28 @@ jobs:
with:
qt: true
+ # cache atlases needed by the tests
+ - name: Cache Atlases
+ id: atlas-cache
+ uses: actions/cache@v3
+ with:
+ path: | # ensure we don't cache any interrupted atlas download and extraction!
+ ~/.brainglobe/*
+ !~/.brainglobe/atlas.tar.gz
+ key: ${{ runner.os }}-cached-atlases
+ enableCrossOsArchive: false # ~ and $HOME evaluate to different places across OSs!
+
+ - if: ${{ steps.atlas-cache.outputs.cache-hit == 'true' }}
+ name: List files in brainglobe data folder # good to be able to sanity check that user data is as expected
+ run: |
+ ls -af ~/.brainglobe/
+
+
# Run tests
- uses: neuroinformatics-unit/actions/test@v2
with:
python-version: ${{ matrix.python-version }}
+ secret-codecov-token: ${{ secrets.CODECOV_TOKEN }}
build_sdist_wheels:
name: Build source distribution
@@ -66,11 +84,6 @@ jobs:
needs: [build_sdist_wheels]
runs-on: ubuntu-latest
steps:
- - uses: actions/download-artifact@v3
- with:
- name: artifact
- path: dist
- - uses: pypa/gh-action-pypi-publish@v1.5.0
+ - uses: neuroinformatics-unit/actions/upload_pypi@v2
with:
- user: __token__
- password: ${{ secrets.TWINE_API_KEY }}
+ secret-pypi-key: ${{ secrets.TWINE_API_KEY }}
diff --git a/.gitignore b/.gitignore
index 73d56d3..08e97e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,3 +82,6 @@ venv/
# written by setuptools_scm
**/_version.py
+
+# Elastix related files
+/Logs/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6ffb785..f719a01 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,15 +17,15 @@ repos:
- id: requirements-txt-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.1.9
+ rev: v0.3.5
hooks:
- id: ruff
- repo: https://github.com/psf/black
- rev: 23.12.1
+ rev: 24.3.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.8.0
+ rev: v1.9.0
hooks:
- id: mypy
additional_dependencies:
diff --git a/MANIFEST.in b/MANIFEST.in
index b0e8711..902dcc4 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -3,7 +3,6 @@ include README.md
include .napari-hub/DESCRIPTION.md
include .napari-hub/config.yml
include brainglobe_registration/napari.yaml
-include brainglobe_registration/resources/brainglobe.png
exclude .pre-commit-config.yaml
recursive-include brainglobe_registration/parameters *.txt
diff --git a/brainglobe_registration/elastix/register.py b/brainglobe_registration/elastix/register.py
index 955ed44..d04d918 100644
--- a/brainglobe_registration/elastix/register.py
+++ b/brainglobe_registration/elastix/register.py
@@ -2,7 +2,7 @@
import itk
import numpy as np
-from bg_atlasapi import BrainGlobeAtlas
+from brainglobe_atlasapi import BrainGlobeAtlas
def get_atlas_by_name(atlas_name: str) -> BrainGlobeAtlas:
diff --git a/brainglobe_registration/registration_widget.py b/brainglobe_registration/registration_widget.py
index 67ab52f..35016fd 100644
--- a/brainglobe_registration/registration_widget.py
+++ b/brainglobe_registration/registration_widget.py
@@ -9,13 +9,21 @@
"""
from pathlib import Path
+from typing import Optional
+import dask.array as da
+import napari.layers
import numpy as np
-from bg_atlasapi import BrainGlobeAtlas
-from bg_atlasapi.list_atlases import get_downloaded_atlases
+import numpy.typing as npt
+from brainglobe_atlasapi import BrainGlobeAtlas
+from brainglobe_atlasapi.list_atlases import get_downloaded_atlases
from brainglobe_utils.qtpy.collapsible_widget import CollapsibleWidgetContainer
+from brainglobe_utils.qtpy.logo import header_widget
+from dask_image.ndinterp import affine_transform as dask_affine_transform
+from napari.qt.threading import thread_worker
from napari.utils.notifications import show_error
from napari.viewer import Viewer
+from pytransform3d.rotations import active_matrix_from_angle
from qtpy.QtWidgets import (
QPushButton,
QTabWidget,
@@ -24,9 +32,9 @@
from skimage.transform import rescale
from brainglobe_registration.elastix.register import run_registration
-from brainglobe_registration.utils.brainglobe_logo import header_widget
from brainglobe_registration.utils.utils import (
adjust_napari_image_layer,
+ calculate_rotated_bounding_box,
find_layer_index,
get_image_layer_names,
open_parameter_file,
@@ -49,9 +57,11 @@ def __init__(self, napari_viewer: Viewer):
self.setContentsMargins(10, 10, 10, 10)
self._viewer = napari_viewer
- self._atlas: BrainGlobeAtlas = None
- self._moving_image = None
- self._moving_image_data_backup = None
+ self._atlas: Optional[BrainGlobeAtlas] = None
+ self._atlas_data_layer: Optional[napari.layers.Image] = None
+ self._atlas_annotations_layer: Optional[napari.layers.Labels] = None
+ self._moving_image: Optional[napari.layers.Image] = None
+ self._moving_image_data_backup: Optional[npt.NDArray] = None
self.transform_params: dict[str, dict] = {
"rigid": {},
@@ -110,6 +120,12 @@ def __init__(self, napari_viewer: Viewer):
self.adjust_moving_image_widget.scale_image_signal.connect(
self._on_scale_moving_image
)
+ self.adjust_moving_image_widget.atlas_rotation_signal.connect(
+ self._on_adjust_atlas_rotation
+ )
+ self.adjust_moving_image_widget.reset_atlas_signal.connect(
+ self._on_atlas_reset
+ )
self.transform_select_view = TransformSelectView()
self.transform_select_view.transform_type_added_signal.connect(
@@ -126,10 +142,17 @@ def __init__(self, napari_viewer: Viewer):
self.run_button.clicked.connect(self._on_run_button_click)
self.run_button.setEnabled(False)
- self.add_widget(header_widget(), collapsible=False)
+ self.add_widget(
+ header_widget(
+ "brainglobe-
registration", # line break at
+ "Registration with Elastix",
+ github_repo_name="brainglobe-registration",
+ ),
+ collapsible=False,
+ )
self.add_widget(self.get_atlas_widget, widget_title="Select Images")
self.add_widget(
- self.adjust_moving_image_widget, widget_title="Adjust Sample Image"
+ self.adjust_moving_image_widget, widget_title="Prepare Images"
)
self.add_widget(
self.transform_select_view, widget_title="Select Transformations"
@@ -162,13 +185,14 @@ def _on_atlas_dropdown_index_changed(self, index):
self._viewer.layers.pop(current_atlas_layer_index)
self._atlas = None
+ self._atlas_data_layer = None
+ self._atlas_annotations_layer = None
self.run_button.setEnabled(False)
self._viewer.grid.enabled = False
return
atlas_name = self._available_atlases[index]
- atlas = BrainGlobeAtlas(atlas_name)
if self._atlas:
current_atlas_layer_index = find_layer_index(
@@ -179,14 +203,41 @@ def _on_atlas_dropdown_index_changed(self, index):
else:
self.run_button.setEnabled(True)
- self._viewer.add_image(
- atlas.reference,
+ self._atlas = BrainGlobeAtlas(atlas_name)
+ dask_reference = da.from_array(
+ self._atlas.reference,
+ chunks=(
+ 1,
+ self._atlas.reference.shape[1],
+ self._atlas.reference.shape[2],
+ ),
+ )
+ dask_annotations = da.from_array(
+ self._atlas.annotation,
+ chunks=(
+ 1,
+ self._atlas.annotation.shape[1],
+ self._atlas.annotation.shape[2],
+ ),
+ )
+
+ contrast_max = np.max(
+ dask_reference[dask_reference.shape[0] // 2]
+ ).compute()
+ self._atlas_data_layer = self._viewer.add_image(
+ dask_reference,
name=atlas_name,
colormap="gray",
blending="translucent",
+ contrast_limits=[0, contrast_max],
+ multiscale=False,
+ )
+ self._atlas_annotations_layer = self._viewer.add_labels(
+ dask_annotations,
+ name="Annotations",
+ visible=False,
)
- self._atlas = BrainGlobeAtlas(atlas_name=atlas_name)
self._viewer.grid.enabled = True
def _on_sample_dropdown_index_changed(self, index):
@@ -197,18 +248,31 @@ def _on_sample_dropdown_index_changed(self, index):
self._moving_image_data_backup = self._moving_image.data.copy()
def _on_adjust_moving_image(self, x: int, y: int, rotate: float):
+ if not self._moving_image:
+ show_error(
+ "No moving image selected. "
+ "Please select a moving image before adjusting"
+ )
+ return
adjust_napari_image_layer(self._moving_image, x, y, rotate)
def _on_adjust_moving_image_reset_button_click(self):
+ if not self._moving_image:
+ show_error(
+ "No moving image selected. "
+ "Please select a moving image before adjusting"
+ )
+ return
adjust_napari_image_layer(self._moving_image, 0, 0, 0)
def _on_run_button_click(self):
+
current_atlas_slice = self._viewer.dims.current_step[0]
result, parameters, registered_annotation_image = run_registration(
- self._atlas.reference[current_atlas_slice, :, :],
+ self._atlas_data_layer.data[current_atlas_slice, :, :],
self._moving_image.data,
- self._atlas.annotation[current_atlas_slice, :, :],
+ self._atlas_annotations_layer.data[current_atlas_slice, :, :],
self.transform_selections,
)
@@ -311,7 +375,7 @@ def _on_scale_moving_image(self, x: float, y: float):
Scale the moving image to have resolution equal to the atlas.
Parameters
- ----------
+ ------------
x : float
Moving image x pixel size (> 0.0).
y : float
@@ -324,22 +388,114 @@ def _on_scale_moving_image(self, x: float, y: float):
show_error("Pixel sizes must be greater than 0")
return
- if self._moving_image and self._atlas:
- if self._moving_image_data_backup is None:
- self._moving_image_data_backup = self._moving_image.data.copy()
+ if not (self._moving_image and self._atlas):
+ show_error(
+ "Sample image or atlas not selected. "
+ "Please select a sample image and atlas before scaling",
+ )
+ return
+
+ if self._moving_image_data_backup is None:
+ self._moving_image_data_backup = self._moving_image.data.copy()
+
+ x_factor = x / self._atlas.resolution[0]
+ y_factor = y / self._atlas.resolution[1]
- x_factor = x / self._atlas.resolution[0]
- y_factor = y / self._atlas.resolution[1]
+ self._moving_image.data = rescale(
+ self._moving_image_data_backup,
+ (y_factor, x_factor),
+ mode="constant",
+ preserve_range=True,
+ anti_aliasing=True,
+ )
- self._moving_image.data = rescale(
- self._moving_image_data_backup,
- (y_factor, x_factor),
- mode="constant",
- preserve_range=True,
- anti_aliasing=True,
+ def _on_adjust_atlas_rotation(self, pitch: float, yaw: float, roll: float):
+ if not (
+ self._atlas
+ and self._atlas_data_layer
+ and self._atlas_annotations_layer
+ ):
+ show_error(
+ "No atlas selected. Please select an atlas before rotating"
)
- else:
+ return
+
+ # Create the rotation matrix
+ roll_matrix = active_matrix_from_angle(0, np.deg2rad(roll))
+ pitch_matrix = active_matrix_from_angle(2, np.deg2rad(pitch))
+ yaw_matrix = active_matrix_from_angle(1, np.deg2rad(yaw))
+
+ rot_matrix = roll_matrix @ pitch_matrix @ yaw_matrix
+
+ full_matrix = np.eye(4)
+ full_matrix[:-1, :-1] = rot_matrix
+
+ # Translate the origin to the center of the image
+ origin = np.asarray(self._atlas.reference.shape) // 2
+ translate_matrix = np.eye(4)
+ translate_matrix[:-1, -1] = origin
+
+ bounding_box = calculate_rotated_bounding_box(
+ self._atlas.reference.shape, full_matrix
+ )
+ new_translation = np.asarray(bounding_box) // 2
+ post_rotate_translation = np.eye(4)
+ post_rotate_translation[:3, -1] = -new_translation
+
+ # Combine the matrices. The order of operations is:
+ # 1. Translate the origin to the center of the image
+ # 2. Rotate the image
+ # 3. Translate the origin back to the top left corner
+ transform_matrix = (
+ translate_matrix @ full_matrix @ post_rotate_translation
+ )
+
+ self._atlas_data_layer.data = dask_affine_transform(
+ self._atlas.reference,
+ transform_matrix,
+ order=2,
+ output_shape=bounding_box,
+ output_chunks=(2, bounding_box[1], bounding_box[2]),
+ )
+
+ self._atlas_annotations_layer.data = dask_affine_transform(
+ self._atlas.annotation,
+ transform_matrix,
+ order=0,
+ output_shape=bounding_box,
+ output_chunks=(2, bounding_box[1], bounding_box[2]),
+ )
+
+ # Resets the viewer grid to update the grid to the new atlas
+ self._viewer.reset_view()
+
+ worker = self.compute_atlas_rotation(self._atlas_data_layer.data)
+ worker.returned.connect(self.set_atlas_layer_data)
+ worker.start()
+
+ @thread_worker
+ def compute_atlas_rotation(self, dask_array: da.Array):
+ self.adjust_moving_image_widget.reset_atlas_button.setEnabled(False)
+ self.adjust_moving_image_widget.adjust_atlas_rotation.setEnabled(False)
+
+ computed_array = dask_array.compute()
+
+ self.adjust_moving_image_widget.reset_atlas_button.setEnabled(True)
+ self.adjust_moving_image_widget.adjust_atlas_rotation.setEnabled(True)
+
+ return computed_array
+
+ def set_atlas_layer_data(self, new_data):
+ self._atlas_data_layer.data = new_data
+
+ def _on_atlas_reset(self):
+ if not self._atlas:
show_error(
- "Sample image or atlas not selected. "
- "Please select a sample image and atlas before scaling",
+ "No atlas selected. Please select an atlas before resetting"
)
+ return
+
+ self._atlas_data_layer.data = self._atlas.reference
+ self._atlas_annotations_layer.data = self._atlas.annotation
+ self._viewer.grid.enabled = False
+ self._viewer.grid.enabled = True
diff --git a/brainglobe_registration/resources/brainglobe.png b/brainglobe_registration/resources/brainglobe.png
deleted file mode 100644
index 427bdab..0000000
Binary files a/brainglobe_registration/resources/brainglobe.png and /dev/null differ
diff --git a/brainglobe_registration/utils/brainglobe_logo.py b/brainglobe_registration/utils/brainglobe_logo.py
deleted file mode 100644
index e387adb..0000000
--- a/brainglobe_registration/utils/brainglobe_logo.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from importlib.resources import files
-
-from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
-
-brainglobe_logo = files("brainglobe_registration").joinpath(
- "resources/brainglobe.png"
-)
-
-_logo_html = f"""
-