diff --git a/.gitignore b/.gitignore index 73d56d3..8e5bfd8 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,6 @@ venv/ # written by setuptools_scm **/_version.py + +# Other test files +Logs/ diff --git a/README.md b/README.md index 1219a7e..f22db06 100644 --- a/README.md +++ b/README.md @@ -11,29 +11,51 @@ Registration to a BrainGlobe atlas using Elastix ---------------------------------- -This [napari] plugin was generated with [Cookiecutter] using [@napari]'s [cookiecutter-napari-plugin] template. - - - +A [napari] plugin for registering images to a BrainGlobe atlas. + +![brainglobe-registration](./imgs/brainglobe_registration_main.png) + +## Usage + +1. Open `napari`. +2. Install the plugin with `pip install git+https://github.com/brainglobe/brainglobe-registration.git`. +3. Open the widget by selecting `Plugins > BrainGlobe Registration` in the napari menu bar near the +top left of the window. +![brainglobe-registration-plugin](./imgs/brainglobe_registration_plugin_window.png) +The `BrainGlobe Registration` plugin will appear on the right hand side of the napari window. +4. Open the image you want to register in napari (a sample 2D image can be found by selecting `File > Open Sample > Sample Brain Slice`). +5. Select the atlas you want to register to from the dropdown menu. +![brainglobe-registration-atlas-selection](./imgs/brainglobe_registration_atlas_selection.png) +The atlas will appear in the napari viewer. Select the approximate `Z` slice of the atlas that you want to register to, +using the slider at the bottom of the napari viewer. +![brainglobe-registration-atlas-selection](./imgs/brainglobe_registration_atlas_selection_2.png) +6. Adjust the sample image to roughly match the atlas image. +You can do this by adjusting X and Y translation as well as rotating around the centre of the image. +You can overlay the two images by toggling `Grid` mode in the napari viewer (Ctrl+G). +You can then adjust the color map and opacity of the atlas image to make manual alignment easier. +![brainglobe-registration-overlay](./imgs/brainglobe_registration_overlay.png) +The sample image can be reset to its original position and orientation by clicking `Reset Image` in the `BrainGlobe Registration` plugin window. +7. Select the transformations you want to use from the dropdown menu. Set the transformation type to empty to remove a step. +Select from one of the three provided default parameter sets (elastix, ARA, or IBL). Customise the parameters further in the +`Parameters` tab. +8. Click `Run` to register the image. The registered image will appear in the napari viewer. +![brainglobe-registration-registered](./imgs/brainglobe_registration_registered.png) +![brainglobe-registration-registered](./imgs/brainglobe_registration_registered_stacked.png) ## Installation -You can install `brainglobe-registration` via [pip]: +We strongly recommend to use a virtual environment manager (like `conda` or `venv`). The installation instructions below +will not specify the Qt backend for napari, and you will therefore need to install that separately. Please see the +[`napari` installation instructions](https://napari.org/stable/tutorials/fundamentals/installation.html) for further advice on this. - pip install brainglobe-registration +[WIP] You can install `brainglobe-registration` via [pip]: + pip install brainglobe-registration To install latest development version : pip install git+https://github.com/brainglobe/brainglobe-registration.git - ## Contributing Contributions are very welcome. Tests can be run with [tox], please ensure @@ -48,6 +70,19 @@ Distributed under the terms of the [BSD-3] license, If you encounter any problems, please [file an issue] along with a detailed description. + +## Acknowledgements + +This [napari] plugin was generated with [Cookiecutter] using [@napari]'s [cookiecutter-napari-plugin] template. + + + [napari]: https://github.com/napari/napari [Cookiecutter]: https://github.com/audreyr/cookiecutter [@napari]: https://github.com/napari diff --git a/imgs/brainglobe_registration_atlas_selection.png b/imgs/brainglobe_registration_atlas_selection.png new file mode 100644 index 0000000..f23ec30 Binary files /dev/null and b/imgs/brainglobe_registration_atlas_selection.png differ diff --git a/imgs/brainglobe_registration_atlas_selection_2.png b/imgs/brainglobe_registration_atlas_selection_2.png new file mode 100644 index 0000000..07cf11f Binary files /dev/null and b/imgs/brainglobe_registration_atlas_selection_2.png differ diff --git a/imgs/brainglobe_registration_main.png b/imgs/brainglobe_registration_main.png new file mode 100644 index 0000000..beae32c Binary files /dev/null and b/imgs/brainglobe_registration_main.png differ diff --git a/imgs/brainglobe_registration_overlay.png b/imgs/brainglobe_registration_overlay.png new file mode 100644 index 0000000..25f44fe Binary files /dev/null and b/imgs/brainglobe_registration_overlay.png differ diff --git a/imgs/brainglobe_registration_plugin_window.png b/imgs/brainglobe_registration_plugin_window.png new file mode 100644 index 0000000..4ff1f3b Binary files /dev/null and b/imgs/brainglobe_registration_plugin_window.png differ diff --git a/imgs/brainglobe_registration_registered.png b/imgs/brainglobe_registration_registered.png new file mode 100644 index 0000000..d4acc29 Binary files /dev/null and b/imgs/brainglobe_registration_registered.png differ diff --git a/imgs/brainglobe_registration_registered_stacked.png b/imgs/brainglobe_registration_registered_stacked.png new file mode 100644 index 0000000..b5bf813 Binary files /dev/null and b/imgs/brainglobe_registration_registered_stacked.png differ diff --git a/pyproject.toml b/pyproject.toml index 3b88d90..d335581 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] -write_to = "src/bg_elastix/_version.py" +write_to = "src/brainglobe_registration/_version.py" [tool.black] line-length = 79 @@ -12,3 +12,9 @@ line-length = 79 [tool.isort] profile = "black" line_length = 79 + +[tool.pytest.ini_options] +testpaths = "src/tests" +markers = [ + "slow: mark test as slow" +] diff --git a/setup.cfg b/setup.cfg index e7a52cb..824757a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ install_requires = qtpy itk-elastix bg-atlasapi + pytransform3d python_requires = >=3.8 include_package_data = True diff --git a/src/brainglobe_registration/_tests/test_widget.py b/src/brainglobe_registration/_tests/test_widget.py deleted file mode 100644 index 46cc872..0000000 --- a/src/brainglobe_registration/_tests/test_widget.py +++ /dev/null @@ -1,21 +0,0 @@ -import numpy as np - -from bg_elastix.elastix import register - -# make_napari_viewer is a pytest fixture that returns a napari viewer object -# capsys is a pytest fixture that captures stdout and stderr output streams - - -def test_example_magic_widget(make_napari_viewer, capsys): - viewer = make_napari_viewer() - layer = viewer.add_image(np.random.random((100, 100))) - - # this time, our widget will be a MagicFactory or FunctionGui instance - my_widget = register() - - # if we "call" this object, it'll execute our function - my_widget(viewer.layers[0]) - - # read captured output and check that it's as we expected - captured = capsys.readouterr() - assert captured.out == f"you have selected {layer}\n" diff --git a/src/brainglobe_registration/_widget.py b/src/brainglobe_registration/_widget.py deleted file mode 100644 index b410bcc..0000000 --- a/src/brainglobe_registration/_widget.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import TYPE_CHECKING - -from magicgui import magic_factory - -from bg_elastix.elastix.register import run_registration - -if TYPE_CHECKING: - import napari - - -@magic_factory -def register( - viewer: "napari.Viewer", - image: "napari.layers.Image", - atlas_image: "napari.layers.Image", - rigid=True, - affine=True, - bspline=True, - affine_iterations="2048", - log=False, -): - result, parameters = run_registration( - atlas_image.data, - image.data, - rigid=rigid, - affine=affine, - bspline=bspline, - affine_iterations=affine_iterations, - log=log, - ) - viewer.add_image(result, name="Registered Image") diff --git a/src/brainglobe_registration/elastix/register.py b/src/brainglobe_registration/elastix/register.py index a271a8e..c7b1a8c 100644 --- a/src/brainglobe_registration/elastix/register.py +++ b/src/brainglobe_registration/elastix/register.py @@ -1,33 +1,67 @@ import itk import numpy as np +from bg_atlasapi import BrainGlobeAtlas +from typing import List + + +def get_atlas_by_name(atlas_name: str) -> BrainGlobeAtlas: + """ + Get a BrainGlobeAtlas object by its name. + + Parameters + ---------- + atlas_name : str + The name of the atlas. + + Returns + ------- + BrainGlobeAtlas + The BrainGlobeAtlas object. + """ + atlas = BrainGlobeAtlas(atlas_name) + + return atlas def run_registration( - fixed_image, + atlas_image, moving_image, - rigid=True, - affine=True, - bspline=True, - affine_iterations="2048", - log=False, -): + annotation_image, + parameter_lists: List[tuple[str, dict]] = None, +) -> tuple[np.ndarray, itk.ParameterObject, np.ndarray]: + """ + Run the registration process on the given images. + + Parameters + ---------- + atlas_image : np.ndarray + The atlas image. + moving_image : np.ndarray + The moving image. + annotation_image : np.ndarray + The annotation image. + parameter_lists : List[tuple[str, dict]], optional + The list of parameter lists, by default None + + Returns + ------- + np.ndarray + The result image. + itk.ParameterObject + The result transform parameters. + """ # convert to ITK, view only - fixed_image = itk.GetImageViewFromArray(fixed_image).astype(itk.F) + atlas_image = itk.GetImageViewFromArray(atlas_image).astype(itk.F) moving_image = itk.GetImageViewFromArray(moving_image).astype(itk.F) # This syntax needed for 3D images elastix_object = itk.ElastixRegistrationMethod.New( - fixed_image, moving_image + moving_image, atlas_image ) - parameter_object = setup_parameter_object( - rigid=rigid, - affine=affine, - bspline=bspline, - affine_iterations=affine_iterations, - ) + parameter_object = setup_parameter_object(parameter_lists=parameter_lists) + elastix_object.SetParameterObject(parameter_object) - elastix_object.SetLogToConsole(log) # update filter object elastix_object.UpdateLargestPossibleRegion() @@ -35,32 +69,52 @@ def run_registration( # get results result_image = elastix_object.GetOutput() result_transform_parameters = elastix_object.GetTransformParameterObject() - return np.asarray(result_image), result_transform_parameters + temp_interp_order = result_transform_parameters.GetParameter( + 0, "FinalBSplineInterpolationOrder" + ) + result_transform_parameters.SetParameter( + "FinalBSplineInterpolationOrder", "0" + ) + annotation_image_transformix = itk.transformix_filter( + annotation_image.astype(np.float32, copy=False), + result_transform_parameters, + ) + + result_transform_parameters.SetParameter( + "FinalBSplineInterpolationOrder", temp_interp_order + ) + + return ( + np.asarray(result_image), + result_transform_parameters, + np.asarray(annotation_image_transformix), + ) -def setup_parameter_object( - rigid=True, - affine=True, - bspline=True, - affine_iterations="2048", -): + +def setup_parameter_object(parameter_lists: List[tuple[str, dict]] = None): + """ + Set up the parameter object for the registration process. + + Parameters + ---------- + parameter_lists : List[tuple[str, dict]], optional + The list of parameter lists, by default None + + Returns + ------- + itk.ParameterObject + The parameter object.# + """ parameter_object = itk.ParameterObject.New() - if rigid: - parameter_map_rigid = parameter_object.GetDefaultParameterMap("rigid") - parameter_object.AddParameterMap(parameter_map_rigid) - - if affine: - parameter_map_affine = parameter_object.GetDefaultParameterMap( - "affine" - ) - parameter_map_affine["MaximumNumberOfIterations"] = [affine_iterations] - parameter_object.AddParameterMap(parameter_map_affine) - - if bspline: - parameter_map_bspline = parameter_object.GetDefaultParameterMap( - "bspline" - ) - parameter_object.AddParameterMap(parameter_map_bspline) + for transform_type, parameter_dict in parameter_lists: + parameter_map = parameter_object.GetDefaultParameterMap(transform_type) + parameter_map.clear() + + for k, v in parameter_dict.items(): + parameter_map[k] = v + + parameter_object.AddParameterMap(parameter_map) return parameter_object diff --git a/src/brainglobe_registration/napari.yaml b/src/brainglobe_registration/napari.yaml index e2cd2d1..a5c3aaa 100644 --- a/src/brainglobe_registration/napari.yaml +++ b/src/brainglobe_registration/napari.yaml @@ -1,10 +1,14 @@ name: brainglobe-registration -display_name: BrainGlobe Elastix Registration +display_name: BrainGlobe Registration contributions: commands: - - id: brainglobe-registration.register - python_name: brainglobe_registration._widget:register - title: BrainGlobe Elastix Registration + - id: brainglobe-registration.make_registration_widget + python_name: brainglobe_registration.registration_widget:RegistrationWidget + title: BrainGlobe Registration + sample_data: + - key: example + display_name: Sample Brain Slice + uri: src/brainglobe_registration/resources/sample_hipp.tif widgets: - - command: brainglobe-registration.register - display_name: BrainGlobe Elastix Registration + - command: brainglobe-registration.make_registration_widget + display_name: BrainGlobe Registration diff --git a/src/brainglobe_registration/parameters/ara_tools/affine.txt b/src/brainglobe_registration/parameters/ara_tools/affine.txt new file mode 100644 index 0000000..cabe450 --- /dev/null +++ b/src/brainglobe_registration/parameters/ara_tools/affine.txt @@ -0,0 +1,66 @@ +//Affine Transformation - updated May 2012 + +// Description: affine, MI, ASGD + +//ImageTypes +(FixedInternalImagePixelType "float") +(FixedImageDimension 3) +(MovingInternalImagePixelType "float") +(MovingImageDimension 3) + +//Components +(Registration "MultiResolutionRegistration") +(FixedImagePyramid "FixedSmoothingImagePyramid") +(MovingImagePyramid "MovingSmoothingImagePyramid") +(Interpolator "BSplineInterpolator") +(Metric "AdvancedMattesMutualInformation") +(Optimizer "AdaptiveStochasticGradientDescent") +(ResampleInterpolator "FinalBSplineInterpolator") +(Resampler "DefaultResampler") +(Transform "AffineTransform") + +(ErodeMask "false" ) + +(NumberOfResolutions 4) + +(HowToCombineTransforms "Compose") +(AutomaticTransformInitialization "true") +(AutomaticScalesEstimation "true") + +(WriteTransformParametersEachIteration "false") +(WriteResultImage "true") +(ResultImageFormat "tiff") +(CompressResultImage "false") +(WriteResultImageAfterEachResolution "false") +(ShowExactMetricValue "false") + +//Maximum number of iterations in each resolution level: +(MaximumNumberOfIterations 500 ) + +//Number of grey level bins in each resolution level: +(NumberOfHistogramBins 32 ) +(FixedLimitRangeRatio 0.0) +(MovingLimitRangeRatio 0.0) +(FixedKernelBSplineOrder 3) +(MovingKernelBSplineOrder 3) + +//Number of spatial samples used to compute the mutual information in each resolution level: +(ImageSampler "RandomCoordinate") +(FixedImageBSplineInterpolationOrder 3) +(UseRandomSampleRegion "false") +(NumberOfSpatialSamples 4000 ) +(NewSamplesEveryIteration "true") +(CheckNumberOfSamples "true") +(MaximumNumberOfSamplingAttempts 10) + +//Order of B-Spline interpolation used in each resolution level: +(BSplineInterpolationOrder 3) + +//Order of B-Spline interpolation used for applying the final deformation: +(FinalBSplineInterpolationOrder 3) + +//Default pixel value for pixels that come from outside the picture: +(DefaultPixelValue 0) + +//SP: Param_A in each resolution level. a_k = a/(A+k+1)^alpha +(SP_A 20.0 ) diff --git a/src/brainglobe_registration/parameters/ara_tools/bspline.txt b/src/brainglobe_registration/parameters/ara_tools/bspline.txt new file mode 100644 index 0000000..e87a225 --- /dev/null +++ b/src/brainglobe_registration/parameters/ara_tools/bspline.txt @@ -0,0 +1,74 @@ +//Bspline Transformation - updated May 2012 + +//ImageTypes +(FixedInternalImagePixelType "float") +(FixedImageDimension 3) +(MovingInternalImagePixelType "float") +(MovingImageDimension 3) + +//Components +(Registration "MultiResolutionRegistration") +(FixedImagePyramid "FixedSmoothingImagePyramid") +(MovingImagePyramid "MovingSmoothingImagePyramid") +(Interpolator "BSplineInterpolator") +(Metric "AdvancedMattesMutualInformation") +(Optimizer "StandardGradientDescent") +(ResampleInterpolator "FinalBSplineInterpolator") +(Resampler "DefaultResampler") +(Transform "BSplineTransform") + +(ErodeMask "false" ) + +(NumberOfResolutions 3) +(FinalGridSpacingInVoxels 25.000000 25.000000 25.000000) + +(HowToCombineTransforms "Compose") + +(WriteTransformParametersEachIteration "false") +(ResultImageFormat "tiff") +(WriteResultImage "true") +(CompressResultImage "false") +(WriteResultImageAfterEachResolution "false") +(ShowExactMetricValue "false") +(WriteDiffusionFiles "true") + +// Option supported in elastix 4.1: +(UseFastAndLowMemoryVersion "true") + +//Maximum number of iterations in each resolution level: +(MaximumNumberOfIterations 5000 ) + +//Number of grey level bins in each resolution level: +(NumberOfHistogramBins 32 ) +(FixedLimitRangeRatio 0.0) +(MovingLimitRangeRatio 0.0) +(FixedKernelBSplineOrder 3) +(MovingKernelBSplineOrder 3) + +//Number of spatial samples used to compute the mutual information in each resolution level: +(ImageSampler "RandomCoordinate") +(FixedImageBSplineInterpolationOrder 1 ) +(UseRandomSampleRegion "true") +(SampleRegionSize 50.0 50.0 50.0) +(NumberOfSpatialSamples 10000 ) +(NewSamplesEveryIteration "true") +(CheckNumberOfSamples "true") +(MaximumNumberOfSamplingAttempts 10) + +//Order of B-Spline interpolation used in each resolution level: +(BSplineInterpolationOrder 3) + +//Order of B-Spline interpolation used for applying the final deformation: +(FinalBSplineInterpolationOrder 3) + +//Default pixel value for pixels that come from outside the picture: +(DefaultPixelValue 0) + +//SP: Param_a in each resolution level. a_k = a/(A+k+1)^alpha +(SP_a 10000.0 ) + +//SP: Param_A in each resolution level. a_k = a/(A+k+1)^alpha +(SP_A 100.0 ) + +//SP: Param_alpha in each resolution level. a_k = a/(A+k+1)^alpha +(SP_alpha 0.6 ) diff --git a/src/brainglobe_registration/parameters/brainregister_IBL/affine.txt b/src/brainglobe_registration/parameters/brainregister_IBL/affine.txt new file mode 100644 index 0000000..1e81449 --- /dev/null +++ b/src/brainglobe_registration/parameters/brainregister_IBL/affine.txt @@ -0,0 +1,237 @@ +// ********** Affine Transformation ********** +// -------------------------------------------------------------------------------- +// Optimised Affine transform for Mouse Brain serial section 2-photon datasets +// +// Steven J. West, SWC, UCL, UK 2020 + + +// ********** ImageTypes ********** +// -------------------------------------------------------------------------------- + +(FixedInternalImagePixelType "float") // automatically converted to this type +(FixedImageDimension 3) + +(MovingInternalImagePixelType "float") // automatically converted to this type +(MovingImageDimension 3) + +(UseDirectionCosines "true") +// Setting it to false means that you choose to ignore important information +// from the image, which relates voxel coordinates to world coordinates +// Ignoring it may easily lead to left/right swaps for example, which could +// screw up a (medical) analysis + + +// ********** Registration ********** +// -------------------------------------------------------------------------------- + +(Registration "MultiResolutionRegistration") +// the default + + +// ********** Pyramid ********** +// -------------------------------------------------------------------------------- + +(FixedImagePyramid "FixedSmoothingImagePyramid") +// Applies gaussian smoothing and no down-sampling + +(MovingImagePyramid "MovingSmoothingImagePyramid") +// Applies gaussian smoothing and no down-sampling + +(NumberOfResolutions 4) +// 4 levels + +(ImagePyramidSchedule 8 8 8 4 4 4 2 2 2 1 1 1) +// sigma: 8/2 XYZ, 4/2 XYZ, 2/2 XYZ, 1/2 XYZ + +(ErodeMask "false" ) +// information from inside any mask will flow into the ROI due to the +// smoothing step + + +// ********** Metric ********** +// -------------------------------------------------------------------------------- + +(Metric "AdvancedMattesMutualInformation") +// Cost Function Metric +// quantifies the "amount of information" (in units of shannons, commonly called +// bits) obtained about one random variable through observing the other random +// variable +// only a relation between the probability distributions of the intensities of +// the fixed and moving image is assumed +// often a good choice for image registration + +(NumberOfHistogramBins 32 ) +(NumberOfFixedHistogramBins 32 ) +(NumberOfMovingHistogramBins 32 ) +// The size of the histogram. Must be given for each resolution, or for all +// resolutions at once + +(FixedKernelBSplineOrder 3) +(MovingKernelBSplineOrder 3) +// The B-spline order of the Parzen window, used to estimate the joint histogram + +(FixedLimitRangeRatio 0.0) +(MovingLimitRangeRatio 0.0) +// The relative extension of the intensity range of the fixed image. +// 0.0 - turned off + + +(ShowExactMetricValue "false" "false" "false" "false") +// get exact metric on final resolution +// computes the exact metric value (computed on all voxels rather than on the +// set of spatial samples) and shows it each iteration +// Must be given for each resolution +// This is very SLOW for large images + + +(UseMultiThreadingForMetrics "true") +// Whether to compute metric with multi-threading + +(UseFastAndLowMemoryVersion "true") +// select between two methods for computing mutual information metric +// false : computes the derivatives of the joint histogram to each transformation +// parameter +// true : computes the mutual information via another route + +(UseJacobianPreconditioning "false") +// whether to implement the preconditioning technique by Nicholas Tustison: +// "Directly Manipulated Freeform Deformations" + +(FiniteDifferenceDerivative "false") +// Experimental feature, do not use. + +(ASGDParameterEstimationMethod "Original") +// ASGD parameter estimation method used in this optimizer + + +// ********** ImageSampler ********** +// -------------------------------------------------------------------------------- + +(ImageSampler "RandomCoordinate") + +(NumberOfSpatialSamples 4000 ) +// Number of spatial samples used to compute +// the mutual information in each resolution level + +(NewSamplesEveryIteration "true" "true" "true" "true") +// whether to select a new set of spatial samples in every iteration + +(UseRandomSampleRegion "false") +// whether to randomly select a subregion of the image in each iteration + +(CheckNumberOfSamples "true") +// whether to check if at least a certain fraction (default 1/4) of the samples map +// inside the moving image. + +(MaximumNumberOfSamplingAttempts 10 10 10 10) +// maximum number of sampling attempts + + +// ********** Interpolator and Resampler ********** +// -------------------------------------------------------------------------------- + +(Interpolator "BSplineInterpolator") +// The interpolator to use during registration process +// BSpline : Evaluates the Values of non-voxel Positions in the Moving Image +// Basis Function for Splines - set of Piecewise Polynomial Lines + +(BSplineInterpolationOrder 3) +// Order of B-Spline interpolation used in each resolution level +// 0 Nearest Neighbour, 1 Linear interpolation, +// 2+ non-linear curves with increasing degrees of freedom/power + + +// Order of B-Spline interpolation used when interpolating the fixed + // image - if using MultiInputRandomCoordinate sampler +(FixedImageBSplineInterpolationOrder 3) + +//Default pixel value for pixels that come from outside the picture: +(DefaultPixelValue 0) + +(Resampler "DefaultResampler") +// Either DefaultResampler or OpenCLResampler + +(ResampleInterpolator "FinalBSplineInterpolator") +// The interpolator to use to generate the resulting deformed moving image +// BSpline : Evaluates the Values of non-voxel Positions in the Moving Image +// Basis Function for Splines - set of Piecewise Polynomial Lines + +(FinalBSplineInterpolationOrder 3) +// Order of B-Spline interpolation used for applying the final deformation +// 0 Nearest Neighbour, 1 Linear interpolation, +// 2+ non-linear curves with increasing degrees of freedom/power + + +// ********** Transform ********** +// -------------------------------------------------------------------------------- + +(Transform "AffineTransform") +// translate, rotate, scale, shear + +(AutomaticScalesEstimation "true") +// if "true" the Scales parameter is ignored and the scales are determined +// automatically. + +(AutomaticTransformInitialization "true") +// whether the initial translation between images should be estimated as the +// distance between their centers. + +(AutomaticTransformInitializationMethod "GeometricalCenter") +// how to initialize this transform + +(HowToCombineTransforms "Compose") +// Always use Compose for combining transforms + + +// ********** Optimizer ********** +// -------------------------------------------------------------------------------- + +(Optimizer "AdaptiveStochasticGradientDescent") +// take the search direction as the negative gradient of the cost function +// Adaptive version: requires less parameters to be set and tends to be +// more robust. + +(MaximumNumberOfIterations 500 500 500 500) +// Maximum number of iterations in each resolution level + +(SP_A 20.0) +// SP: Param_A in each resolution level. a_k = a/(A+k+1)^alpha + +(SigmoidInitialTime 0.0) +// initial time input for the sigmoid +// When increased, the optimization starts with smaller steps +// If set to 0.0, the method starts with with the largest step allowed + +(MaxBandCovSize 192) +(NumberOfBandStructureSamples 10) +(UseAdaptiveStepSizes "true") +(AutomaticParameterEstimation "true") +(UseConstantStep "false") +(MaximumStepLengthRatio 1) +(NumberOfGradientMeasurements 0) +(NumberOfJacobianMeasurements 1000) +(NumberOfSamplesForExactGradient 100000) +(SigmoidScaleFactor 0.1) + + +// ********** Output ********** +// -------------------------------------------------------------------------------- + +(WriteResultImage "true") +// Whether to write the final deformed image when elastix has optimised the +// transformation. + +(ResultImageFormat "tiff") // commented out as not writing any images +// What image format to write the image as +// can use: "tiff" "dcm" "nrrd" "png" + +// (CompressResultImage "false") +// whether lossless compression of the written image is performed + + +(WriteTransformParametersEachIteration "false") +// whether to save a transform parameter file to disk in every iteration + +(WriteResultImageAfterEachResolution "false" "false" "false" "false") +// whether the intermediate result image is resampled and written after +// each resolution diff --git a/src/brainglobe_registration/parameters/brainregister_IBL/bspline.txt b/src/brainglobe_registration/parameters/brainregister_IBL/bspline.txt new file mode 100644 index 0000000..578e24d --- /dev/null +++ b/src/brainglobe_registration/parameters/brainregister_IBL/bspline.txt @@ -0,0 +1,223 @@ +// ********** B-Spline Transformation ********** +// -------------------------------------------------------------------------------- +// Optimised B-Spline transform for Mouse Brain serial section 2-photon datasets +// +// Steven J. West, SWC, UCL, UK 2020 + + +// ********** ImageTypes ********** +// -------------------------------------------------------------------------------- + +(FixedInternalImagePixelType "float") // automatically converted to this type +(FixedImageDimension 3) + +(MovingInternalImagePixelType "float") // automatically converted to this type +(MovingImageDimension 3) + +(UseDirectionCosines "true") +// Setting it to false means that you choose to ignore important information +// from the image, which relates voxel coordinates to world coordinates +// Ignoring it may easily lead to left/right swaps for example, which could +// screw up a (medical) analysis + + +// ********** Registration ********** +// -------------------------------------------------------------------------------- + +(Registration "MultiResolutionRegistration") +// the default + + +// ********** Pyramid ********** +// -------------------------------------------------------------------------------- + +(FixedImagePyramid "FixedSmoothingImagePyramid") +// Applies gaussian smoothing and no down-sampling + +(MovingImagePyramid "MovingSmoothingImagePyramid") +// Applies gaussian smoothing and no down-sampling + +(NumberOfResolutions 3) +// 3 levels + +(ImagePyramidSchedule 4 4 4 2 2 2 1 1 1) +// sigma: 4/2 XYZ, 2/2 XYZ, 1/2 XYZ + +(ErodeMask "false" ) +// information from inside any mask will flow into the ROI due to the +// smoothing step + + +// ********** Metric ********** +// -------------------------------------------------------------------------------- + +(Metric "AdvancedMattesMutualInformation") +// Cost Function Metric +// quantifies the "amount of information" (in units of shannons, commonly called +// bits) obtained about one random variable through observing the other random +// variable +// only a relation between the probability distributions of the intensities of +// the fixed and moving image is assumed +// often a good choice for image registration + +(NumberOfFixedHistogramBins 32 ) +(NumberOfMovingHistogramBins 32 ) +// The size of the histogram. Must be given for each resolution, or for all +// resolutions at once + + +(FixedKernelBSplineOrder 3) +(MovingKernelBSplineOrder 3) +// The B-spline order of the Parzen window, used to estimate the joint histogram + +(FixedLimitRangeRatio 0.0) +(MovingLimitRangeRatio 0.0) +// The relative extension of the intensity range of the fixed image. +// 0.0 - turned off + +(ShowExactMetricValue "false" "false" "false") +// get exact metric on final resolution +// computes the exact metric value (computed on all voxels rather than on the +// set of spatial samples) and shows it each iteration +// Must be given for each resolution +// This is very SLOW for large images + +(UseMultiThreadingForMetrics "true") +// Whether to compute metric with multi-threading + +(UseFastAndLowMemoryVersion "true") +// select between two methods for computing mutual information metric +// false : computes the derivatives of the joint histogram to each transformation +// parameter +// true : computes the mutual information via another route + +(UseJacobianPreconditioning "false") +// whether to implement the preconditioning technique by Nicholas Tustison: +// "Directly Manipulated Freeform Deformations" + +(FiniteDifferenceDerivative "false") +// Experimental feature, do not use. + + +// ********** ImageSampler ********** +// -------------------------------------------------------------------------------- + +(ImageSampler "RandomCoordinate") + +(NumberOfSpatialSamples 10000 ) +// Number of spatial samples used to compute +// the mutual information in each resolution level + +(NewSamplesEveryIteration "true" "true" "true" "true") +// whether to select a new set of spatial samples in every iteration + +(UseRandomSampleRegion "false") +// whether to randomly select a subregion of the image in each iteration + +(CheckNumberOfSamples "true") +// whether to check if at least a certain fraction (default 1/4) of the samples map +// inside the moving image. + +(MaximumNumberOfSamplingAttempts 10 10 10) +// maximum number of sampling attempts + + +// ********** Interpolator and Resampler ********** +// -------------------------------------------------------------------------------- + +(Interpolator "BSplineInterpolator") +// The interpolator to use during registration process +// BSpline : Evaluates the Values of non-voxel Positions in the Moving Image +// Basis Function for Splines - set of Piecewise Polynomial Lines + +(BSplineInterpolationOrder 3) +// Order of B-Spline interpolation used in each resolution level +// 0 Nearest Neighbour, 1 Linear interpolation, +// 2+ non-linear curves with increasing degrees of freedom/power + + +// Order of B-Spline interpolation used when interpolating the fixed + // image - if using MultiInputRandomCoordinate sampler +// (FixedImageBSplineInterpolationOrder 3) + +//Default pixel value for pixels that come from outside the picture: +(DefaultPixelValue 0) + +(Resampler "DefaultResampler") +// Either DefaultResampler or OpenCLResampler + +(ResampleInterpolator "FinalBSplineInterpolator") +// The interpolator to use to generate the resulting deformed moving image +// BSpline : Evaluates the Values of non-voxel Positions in the Moving Image +// Basis Function for Splines - set of Piecewise Polynomial Lines + +(FinalBSplineInterpolationOrder 3) +// Order of B-Spline interpolation used for applying the final deformation +// 0 Nearest Neighbour, 1 Linear interpolation, +// 2+ non-linear curves with increasing degrees of freedom/power + + +// ********** Transform ********** +// -------------------------------------------------------------------------------- + +(Transform "BSplineTransform") +// Set of control points are defined on a regular grid, overlayed on the +// fixed image +// Control Point Grid is spaced according to n-dimensional vector +// Number of Control Points in each dimension is approx. the image length/spacing, +// plus extra points at each end +// Pixels are LOCALLY Transformed by the B-splines at surrounding control points +// This models local transformations, and is fast to compute +// B-spline coefficients pk are the B-Spline PARAMETERS +// Number of coefficients is the number of control points x number of dimensions +// Coefficients are ordered by coefficient index first +// (p1x, p2x..., p1y, p2y..., p1z, p2z) + +(FinalGridSpacingInVoxels 25.000000 25.000000 25.000000) +// grid spacing of the B-spline transform for each dimension +// spacing is in "voxel size units" + +(HowToCombineTransforms "Compose") +// Always use Compose for combining transforms + + +// ********** Optimizer ********** +// -------------------------------------------------------------------------------- + +(Optimizer "StandardGradientDescent") +// take the search direction as the negative gradient of the cost function + +(MaximumNumberOfIterations 5000 5000 5000) +// Maximum number of iterations in each resolution level + +(SP_a 10000.0 ) +// Param_a in each resolution level. a_k = a/(A+k+1)^alpha + +(SP_A 100.0 ) +// Param_A in each resolution level. a_k = a/(A+k+1)^alpha + +(SP_alpha 0.6 ) +// Param_alpha in each resolution level. a_k = a/(A+k+1)^alpha + + +// ********** Output ********** +// -------------------------------------------------------------------------------- + +(WriteResultImage "true") +// Whether to write the final deformed image when elastix has optimised the +// transformation. + +(ResultImageFormat "tiff") // commented out as not writing any images +// What image format to write the image as +// can use: "tiff" "dcm" "nrrd" "png" + +// (CompressResultImage "false") +// whether lossless compression of the written image is performed + + +(WriteTransformParametersEachIteration "false") +// whether to save a transform parameter file to disk in every iteration + +(WriteResultImageAfterEachResolution "false" "false" "false" "false") +// whether the intermediate result image is resampled and written after +// each resolution diff --git a/src/brainglobe_registration/parameters/elastix_default/affine.txt b/src/brainglobe_registration/parameters/elastix_default/affine.txt new file mode 100644 index 0000000..77e2e87 --- /dev/null +++ b/src/brainglobe_registration/parameters/elastix_default/affine.txt @@ -0,0 +1,24 @@ +(AutomaticParameterEstimation "true") +(AutomaticScalesEstimation "true") +(CheckNumberOfSamples "true") +(DefaultPixelValue 0) +(FinalBSplineInterpolationOrder 3) +(FixedImagePyramid "FixedSmoothingImagePyramid") +(ImageSampler "RandomCoordinate") +(Interpolator "LinearInterpolator") +(MaximumNumberOfIterations 256) +(MaximumNumberOfSamplingAttempts 8) +(Metric "AdvancedMattesMutualInformation") +(MovingImagePyramid "MovingSmoothingImagePyramid") +(NewSamplesEveryIteration "true") +(NumberOfResolutions 4) +(NumberOfSamplesForExactGradient 4096) +(NumberOfSpatialSamples 2048) +(Optimizer "AdaptiveStochasticGradientDescent") +(Registration "MultiResolutionRegistration") +(ResampleInterpolator "FinalBSplineInterpolator") +(Resampler "DefaultResampler") +(ResultImageFormat "nii") +(Transform "AffineTransform") +(WriteIterationInfo "false") +(WriteResultImage "true") diff --git a/src/brainglobe_registration/parameters/elastix_default/bspline.txt b/src/brainglobe_registration/parameters/elastix_default/bspline.txt new file mode 100644 index 0000000..c14fa00 --- /dev/null +++ b/src/brainglobe_registration/parameters/elastix_default/bspline.txt @@ -0,0 +1,27 @@ +(AutomaticParameterEstimation "true") +(CheckNumberOfSamples "true") +(DefaultPixelValue 0) +(FinalBSplineInterpolationOrder 3) +(FinalGridSpacingInPhysicalUnits 10.000000) +(FixedImagePyramid "FixedSmoothingImagePyramid") +(GridSpacingSchedule 2.803221 1.988100 1.410000 1.000000) +(ImageSampler "RandomCoordinate") +(Interpolator "LinearInterpolator") +(MaximumNumberOfIterations 256) +(MaximumNumberOfSamplingAttempts 8) +(Metric "AdvancedMattesMutualInformation" "TransformBendingEnergyPenalty") +(Metric0Weight 1.0) +(Metric1Weight 1.0) +(MovingImagePyramid "MovingSmoothingImagePyramid") +(NewSamplesEveryIteration "true") +(NumberOfResolutions 4) +(NumberOfSamplesForExactGradient 4096) +(NumberOfSpatialSamples 2048) +(Optimizer "AdaptiveStochasticGradientDescent") +(Registration "MultiMetricMultiResolutionRegistration") +(ResampleInterpolator "FinalBSplineInterpolator") +(Resampler "DefaultResampler") +(ResultImageFormat "nii") +(Transform "BSplineTransform") +(WriteIterationInfo "false") +(WriteResultImage "true") diff --git a/src/brainglobe_registration/parameters/elastix_default/rigid.txt b/src/brainglobe_registration/parameters/elastix_default/rigid.txt new file mode 100644 index 0000000..80ed2c9 --- /dev/null +++ b/src/brainglobe_registration/parameters/elastix_default/rigid.txt @@ -0,0 +1,24 @@ +(AutomaticParameterEstimation "true") +(AutomaticScalesEstimation "true") +(CheckNumberOfSamples "true") +(DefaultPixelValue 0) +(FinalBSplineInterpolationOrder 3) +(FixedImagePyramid "FixedSmoothingImagePyramid") +(ImageSampler "RandomCoordinate") +(Interpolator "LinearInterpolator") +(MaximumNumberOfIterations 256) +(MaximumNumberOfSamplingAttempts 8) +(Metric "AdvancedMattesMutualInformation") +(MovingImagePyramid "MovingSmoothingImagePyramid") +(NewSamplesEveryIteration "true") +(NumberOfResolutions 4) +(NumberOfSamplesForExactGradient 4096) +(NumberOfSpatialSamples 2048) +(Optimizer "AdaptiveStochasticGradientDescent") +(Registration "MultiResolutionRegistration") +(ResampleInterpolator "FinalBSplineInterpolator") +(Resampler "DefaultResampler") +(ResultImageFormat "nii") +(Transform "EulerTransform") +(WriteIterationInfo "false") +(WriteResultImage "true") diff --git a/src/brainglobe_registration/registration_widget.py b/src/brainglobe_registration/registration_widget.py new file mode 100644 index 0000000..793c723 --- /dev/null +++ b/src/brainglobe_registration/registration_widget.py @@ -0,0 +1,307 @@ +""" +A napari widget to view atlases. + +Atlases that are exposed by the Brainglobe atlas API are +shown in a table view using the Qt model/view framework +[Qt Model/View framework](https://doc.qt.io/qt-6/model-view-programming.html) + +Users can download and add the atlas images/structures as layers to the viewer. +""" + +from pathlib import Path + +import numpy as np + +from brainglobe_registration.elastix.register import run_registration +from brainglobe_registration.widgets.select_images_view import SelectImagesView +from brainglobe_registration.widgets.adjust_moving_image_view import ( + AdjustMovingImageView, +) +from brainglobe_registration.widgets.parameter_list_view import ( + RegistrationParameterListView, +) +from brainglobe_registration.widgets.transform_select_view import ( + TransformSelectView, +) +from brainglobe_registration.utils.utils import ( + adjust_napari_image_layer, + open_parameter_file, + find_layer_index, + get_image_layer_names, +) + +from bg_atlasapi import BrainGlobeAtlas +from bg_atlasapi.list_atlases import get_downloaded_atlases +from napari.viewer import Viewer +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QGroupBox, + QVBoxLayout, + QWidget, + QTabWidget, + QPushButton, +) +from skimage.segmentation import find_boundaries + +from brainglobe_registration.utils.brainglobe_logo import header_widget + + +class RegistrationWidget(QWidget): + def __init__(self, napari_viewer: Viewer): + super().__init__() + + self._viewer = napari_viewer + self._atlas: BrainGlobeAtlas = None + self._moving_image = None + + self.transform_params = {"rigid": {}, "affine": {}, "bspline": {}} + self.transform_selections = [] + + for transform_type in self.transform_params: + file_path = ( + Path(__file__).parent.resolve() + / "parameters" + / "elastix_default" + / f"{transform_type}.txt" + ) + + if file_path.exists(): + self.transform_params[transform_type] = open_parameter_file( + file_path + ) + self.transform_selections.append( + (transform_type, self.transform_params[transform_type]) + ) + + # Hacky way of having an empty first option for the dropdown + self._available_atlases = ["------"] + get_downloaded_atlases() + self._sample_images = get_image_layer_names(self._viewer) + + if len(self._sample_images) > 0: + self._moving_image = self._viewer.layers[0] + else: + self._moving_image = None + + self.setLayout(QVBoxLayout()) + self.layout().addWidget(header_widget()) + + self.main_tabs = QTabWidget(parent=self) + self.main_tabs.setTabPosition(QTabWidget.West) + + self.settings_tab = QGroupBox() + self.settings_tab.setLayout(QVBoxLayout()) + self.parameters_tab = QTabWidget() + + self.get_atlas_widget = SelectImagesView( + available_atlases=self._available_atlases, + sample_image_names=self._sample_images, + parent=self, + ) + self.get_atlas_widget.atlas_index_change.connect( + self._on_atlas_dropdown_index_changed + ) + self.get_atlas_widget.moving_image_index_change.connect( + self._on_sample_dropdown_index_changed + ) + self.get_atlas_widget.sample_image_popup_about_to_show.connect( + self._on_sample_popup_about_to_show + ) + + self.adjust_moving_image_widget = AdjustMovingImageView(parent=self) + self.adjust_moving_image_widget.adjust_image_signal.connect( + self._on_adjust_moving_image + ) + + self.adjust_moving_image_widget.reset_image_signal.connect( + self._on_adjust_moving_image_reset_button_click + ) + + self.transform_select_view = TransformSelectView() + self.transform_select_view.transform_type_added_signal.connect( + self._on_transform_type_added + ) + self.transform_select_view.transform_type_removed_signal.connect( + self._on_transform_type_removed + ) + self.transform_select_view.file_option_changed_signal.connect( + self._on_default_file_selection_change + ) + + self.run_button = QPushButton("Run") + self.run_button.clicked.connect(self._on_run_button_click) + self.run_button.setEnabled(False) + + self.settings_tab.layout().addWidget(self.get_atlas_widget) + self.settings_tab.layout().addWidget(self.adjust_moving_image_widget) + self.settings_tab.layout().addWidget(self.transform_select_view) + self.settings_tab.layout().addWidget(self.run_button) + self.settings_tab.layout().setAlignment(Qt.AlignTop) + + self.parameter_setting_tabs_lists = [] + + for transform_type in self.transform_params: + new_tab = RegistrationParameterListView( + param_dict=self.transform_params[transform_type], + transform_type=transform_type, + ) + + self.parameters_tab.addTab(new_tab, transform_type) + self.parameter_setting_tabs_lists.append(new_tab) + + self.main_tabs.addTab(self.settings_tab, "Settings") + self.main_tabs.addTab(self.parameters_tab, "Parameters") + + self.layout().addWidget(self.main_tabs) + + def _on_atlas_dropdown_index_changed(self, index): + # Hacky way of having an empty first dropdown + if index == 0: + if self._atlas: + curr_atlas_layer_index = find_layer_index( + self._viewer, self._atlas.atlas_name + ) + + self._viewer.layers.pop(curr_atlas_layer_index) + self._atlas = 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: + curr_atlas_layer_index = find_layer_index( + self._viewer, self._atlas.atlas_name + ) + + self._viewer.layers.pop(curr_atlas_layer_index) + else: + self.run_button.setEnabled(True) + + self._viewer.add_image( + atlas.reference, + name=atlas_name, + colormap="gray", + blending="translucent", + ) + + self._atlas = BrainGlobeAtlas(atlas_name=atlas_name) + self._viewer.grid.enabled = True + + def _on_sample_dropdown_index_changed(self, index): + viewer_index = find_layer_index( + self._viewer, self._sample_images[index] + ) + self._moving_image = self._viewer.layers[viewer_index] + + def _on_adjust_moving_image(self, x: int, y: int, rotate: float): + adjust_napari_image_layer(self._moving_image, x, y, rotate) + + def _on_adjust_moving_image_reset_button_click(self): + 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._moving_image.data, + self._atlas.annotation[current_atlas_slice, :, :], + self.transform_selections, + ) + + boundaries = find_boundaries( + registered_annotation_image, mode="inner" + ).astype(np.int8, copy=False) + + self._viewer.add_image(result, name="Registered Image", visible=False) + + atlas_layer_index = find_layer_index( + self._viewer, self._atlas.atlas_name + ) + self._viewer.layers[atlas_layer_index].visible = False + + self._viewer.add_labels( + registered_annotation_image.astype(np.uint32, copy=False), + name="Registered Annotations", + visible=False, + ) + self._viewer.add_image( + boundaries, + name="Registered Boundaries", + visible=True, + blending="additive", + opacity=0.8, + ) + + self._viewer.grid.enabled = False + + def _on_transform_type_added( + self, transform_type: str, transform_order: int + ) -> None: + if transform_order > len(self.transform_selections): + raise IndexError( + f"Transform added out of order index: {transform_order}" + f" is greater than length: {len(self.transform_selections)}" + ) + elif len(self.parameter_setting_tabs_lists) == transform_order: + self.transform_selections.append( + (transform_type, self.transform_params[transform_type].copy()) + ) + new_tab = RegistrationParameterListView( + param_dict=self.transform_selections[transform_order][1], + transform_type=transform_type, + ) + self.parameters_tab.addTab(new_tab, transform_type) + self.parameter_setting_tabs_lists.append(new_tab) + + else: + self.transform_selections[transform_order] = ( + transform_type, + self.transform_params[transform_type], + ) + self.parameters_tab.setTabText(transform_order, transform_type) + self.parameter_setting_tabs_lists[transform_order].set_data( + self.transform_params[transform_type].copy() + ) + + def _on_transform_type_removed(self, transform_order: int) -> None: + if transform_order >= len(self.transform_selections): + raise IndexError("Transform removed out of order") + else: + self.transform_selections.pop(transform_order) + self.parameters_tab.removeTab(transform_order) + self.parameter_setting_tabs_lists.pop(transform_order) + + def _on_default_file_selection_change( + self, default_file_type: str, index: int + ) -> None: + if index >= len(self.transform_selections): + raise IndexError("Transform file selection out of order") + + transform_type = self.transform_selections[index][0] + file_path = ( + Path(__file__).parent.resolve() + / "parameters" + / default_file_type + / f"{transform_type}.txt" + ) + + if not file_path.exists(): + file_path = ( + Path(__file__).parent.resolve() + / "parameters" + / "elastix_default" + / f"{transform_type}.txt" + ) + + param_dict = open_parameter_file(file_path) + + self.transform_selections[index] = (transform_type, param_dict) + self.parameter_setting_tabs_lists[index].set_data(param_dict) + + def _on_sample_popup_about_to_show(self): + self._sample_images = get_image_layer_names(self._viewer) + self.get_atlas_widget.update_sample_image_names(self._sample_images) diff --git a/src/brainglobe_registration/resources/brainglobe.png b/src/brainglobe_registration/resources/brainglobe.png new file mode 100644 index 0000000..427bdab Binary files /dev/null and b/src/brainglobe_registration/resources/brainglobe.png differ diff --git a/src/brainglobe_registration/resources/sample_hipp.tif b/src/brainglobe_registration/resources/sample_hipp.tif new file mode 100644 index 0000000..d30ff69 Binary files /dev/null and b/src/brainglobe_registration/resources/sample_hipp.tif differ diff --git a/src/brainglobe_registration/_tests/__init__.py b/src/brainglobe_registration/utils/__init__.py similarity index 100% rename from src/brainglobe_registration/_tests/__init__.py rename to src/brainglobe_registration/utils/__init__.py diff --git a/src/brainglobe_registration/utils/brainglobe_logo.py b/src/brainglobe_registration/utils/brainglobe_logo.py new file mode 100644 index 0000000..66740f1 --- /dev/null +++ b/src/brainglobe_registration/utils/brainglobe_logo.py @@ -0,0 +1,51 @@ +""" +Can this be imorted from brainrender-napari? We can also move this to a bgutils maybe? +""" + + +from importlib.resources import files + +from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QWidget, QVBoxLayout + +brainglobe_logo = files("brainglobe_registration").joinpath( + "resources/brainglobe.png" +) + +_logo_html = f""" +