diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 608eb53..01ed631 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -3,14 +3,15 @@ name: tests on: push: branches: - - 'main' + - "main" tags: - - 'v*' + - "v**" pull_request: workflow_dispatch: jobs: linting: + name: Check Linting runs-on: ubuntu-latest steps: - uses: neuroinformatics-unit/actions/lint@v2 @@ -60,7 +61,6 @@ jobs: steps: - uses: neuroinformatics-unit/actions/build_sdist_wheels@v2 - upload_all: name: Publish build distributions needs: [build_sdist_wheels] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 750e857..870798b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,4 +28,7 @@ repos: hooks: - id: mypy args: [--config-file, pyproject.toml] - additional_dependencies: [numpy] + additional_dependencies: + - numpy + - types-setuptools + - types-requests diff --git a/README.md b/README.md index 2046e41..90a2f30 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # brainglobe-utils + Shared general purpose tools for the BrainGlobe project ## Installation @@ -6,12 +7,15 @@ Shared general purpose tools for the BrainGlobe project ```bash pip install brainglobe-utils ``` + To also include the dependencies required for `napari`, use: + ```bash pip install brainglobe-utils[napari] ``` For development, clone this repository and install the dependencies with one of the following commands: + ```bash pip install -e .[dev] pip install -e .[dev,napari] diff --git a/brainglobe_utils/brainreg/transform.py b/brainglobe_utils/brainreg/transform.py index 7fe9400..703514f 100644 --- a/brainglobe_utils/brainreg/transform.py +++ b/brainglobe_utils/brainreg/transform.py @@ -2,13 +2,14 @@ import os from typing import List, Optional, Tuple -import bg_space as bgs -import imio +import brainglobe_space as bgs import numpy as np import pandas as pd import tifffile from bg_atlasapi import BrainGlobeAtlas +from brainglobe_utils.image_io import get_size_image_from_file_paths + def transform_points_from_downsampled_to_atlas_space( downsampled_points: np.ndarray, @@ -91,7 +92,7 @@ def get_anatomical_space_from_image_planes( image_plane : np.ndarray A numpy-like array representing a single image plane from a 3D image. orientation : str - The orientation of the image following the bg-space + The orientation of the image following the brainglobe-space three-letter convention (e.g., 'asr', 'psl'). voxel_sizes : List[float] A list of floats representing the voxel sizes (e.g., [5, 2, 2]). @@ -103,7 +104,7 @@ def get_anatomical_space_from_image_planes( """ - shape = tuple(imio.get_size_image_from_file_paths(image_plane).values()) + shape = tuple(get_size_image_from_file_paths(image_plane).values()) space = bgs.AnatomicalSpace( orientation, @@ -132,7 +133,7 @@ def transform_points_from_raw_to_downsampled_space( source_image_plane : np.ndarray A numpy-like array representing a single image. orientation : str - The orientation of the image following the bg-space + The orientation of the image following the brainglobe-space three letter convention (e.g. 'asr', 'psl') voxel_sizes : List[float] A list of floats representing the voxel sizes (e.g. [5, 2, 2]) @@ -182,7 +183,7 @@ def transform_points_to_atlas_space( source_image_plane : np.ndarray A numpy-like array representing a single image. orientation : str - The orientation of the image following the bg-space + The orientation of the image following the brainglobe-space three letter convention (e.g. 'asr', 'psl') voxel_sizes : List[float] A list of floats representing the voxel sizes (e.g. [5, 2, 2]) diff --git a/brainglobe_utils/image/heatmap.py b/brainglobe_utils/image/heatmap.py index 9c306b5..51e7b37 100644 --- a/brainglobe_utils/image/heatmap.py +++ b/brainglobe_utils/image/heatmap.py @@ -1,7 +1,6 @@ from pathlib import Path from typing import Tuple, Union -import imio import numpy as np from scipy.ndimage import zoom from skimage.filters import gaussian @@ -10,6 +9,7 @@ from brainglobe_utils.image.binning import get_bins from brainglobe_utils.image.masking import mask_image_threshold from brainglobe_utils.image.scale import scale_and_convert_to_16_bits +from brainglobe_utils.image_io import to_tiff def rescale_array(source_array, target_array, order=1): @@ -104,6 +104,6 @@ def heatmap_from_points( if output_filename is not None: ensure_directory_exists(Path(output_filename).parent) - imio.to_tiff(heatmap_array, output_filename) + to_tiff(heatmap_array, output_filename) return heatmap_array diff --git a/brainglobe_utils/image_io/__init__.py b/brainglobe_utils/image_io/__init__.py new file mode 100644 index 0000000..6d3bc46 --- /dev/null +++ b/brainglobe_utils/image_io/__init__.py @@ -0,0 +1,6 @@ +__author__ = "Charly Rousseau, Adam Tyson" +__version__ = "0.2.4" + +from brainglobe_utils.image_io.load import * +from brainglobe_utils.image_io.save import * +from brainglobe_utils.image_io.utils import * diff --git a/brainglobe_utils/image_io/load.py b/brainglobe_utils/image_io/load.py new file mode 100644 index 0000000..52ab4a2 --- /dev/null +++ b/brainglobe_utils/image_io/load.py @@ -0,0 +1,488 @@ +import logging +import math +import os +import warnings +from concurrent.futures import ProcessPoolExecutor + +import nrrd +import numpy as np +import tifffile +from natsort import natsorted +from skimage import transform +from tqdm import tqdm + +from brainglobe_utils.general.system import ( + get_num_processes, + get_sorted_file_paths, +) + +from .utils import check_mem, scale_z + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + import nibabel as nib + + +def load_any( + src_path, + x_scaling_factor=1.0, + y_scaling_factor=1.0, + z_scaling_factor=1.0, + anti_aliasing=True, + load_parallel=False, + sort_input_file=False, + as_numpy=False, + n_free_cpus=2, +): + """ + This function will guess the type of data and hence call the appropriate + function from this module to load the given brain. + + .. warning:: x and y scaling not used at the moment if loading a + complete image + + :param str src_path: Can be the path of a nifty file, tiff file, + tiff files folder or text file containing a list of paths + :param float x_scaling_factor: The scaling of the brain along the x + dimension (applied on loading before return) + :param float y_scaling_factor: The scaling of the brain along the y + dimension (applied on loading before return) + :param float z_scaling_factor: The scaling of the brain along the z + dimension (applied on loading before return) + :param bool anti_aliasing: Whether to apply a Gaussian filter to smooth + the image prior to down-scaling. It is crucial to filter when + down-sampling the image to avoid aliasing artifacts. + :param bool load_parallel: Load planes in parallel using multiprocessing + for faster data loading + :param bool sort_input_file: If set to true and the input is a filepaths + file, it will be naturally sorted + :param bool as_numpy: Whether to convert the image to a numpy array in + memory (rather than a memmap). Only relevant for .nii files. + :param bool verbose: Print more information about the process + :param int n_free_cpus: Number of cpu cores to leave free. + :return: The loaded brain + :rtype: np.ndarray + """ + src_path = str(src_path) + + if os.path.isdir(src_path): + logging.debug("Data type is: directory of files") + img = load_from_folder( + src_path, + x_scaling_factor, + y_scaling_factor, + z_scaling_factor, + anti_aliasing=anti_aliasing, + file_extension=".tif", + load_parallel=load_parallel, + n_free_cpus=n_free_cpus, + ) + elif src_path.endswith(".txt"): + logging.debug("Data type is: list of file paths") + img = load_img_sequence( + src_path, + x_scaling_factor, + y_scaling_factor, + z_scaling_factor, + anti_aliasing=anti_aliasing, + load_parallel=load_parallel, + sort=sort_input_file, + n_free_cpus=n_free_cpus, + ) + elif src_path.endswith((".tif", ".tiff")): + logging.debug("Data type is: tif stack") + img = load_img_stack( + src_path, + x_scaling_factor, + y_scaling_factor, + z_scaling_factor, + anti_aliasing=anti_aliasing, + ) + elif src_path.endswith(".nrrd"): + logging.debug("Data type is: nrrd") + img = load_nrrd(src_path) + elif src_path.endswith((".nii", ".nii.gz")): + logging.debug("Data type is: NifTI") + img = load_nii(src_path, as_array=True, as_numpy=as_numpy) + else: + raise NotImplementedError( + "Could not guess data type for path {}".format(src_path) + ) + + return img + + +def load_nrrd(src_path): + """ + Load an .nrrd file as a numpy array + + :param str src_path: The path of the image to be loaded + :return: The loaded brain array + :rtype: np.ndarray + """ + src_path = str(src_path) + stack, _ = nrrd.read(src_path) + return stack + + +def load_img_stack( + stack_path, + x_scaling_factor, + y_scaling_factor, + z_scaling_factor, + anti_aliasing=True, +): + """ + Load a tiff stack as a numpy array + + :param str stack_path: The path of the image to be loaded + :param float x_scaling_factor: The scaling of the brain along the x + dimension (applied on loading before return) + :param float y_scaling_factor: The scaling of the brain along the y + dimension (applied on loading before return) + :param float z_scaling_factor: The scaling of the brain along the z + dimension (applied on loading before return) + :param bool anti_aliasing: Whether to apply a Gaussian filter to smooth + the image prior to down-scaling. It is crucial to filter when + down-sampling the image to avoid aliasing artifacts. + :return: The loaded brain array + :rtype: np.ndarray + """ + stack_path = str(stack_path) + logging.debug(f"Loading: {stack_path}") + stack = tifffile.imread(stack_path) + + # Downsampled plane by plane because the 3D downsampling in scipy etc + # uses too much RAM + + if not (x_scaling_factor == y_scaling_factor == 1): + downsampled_stack = [] + logging.debug("Downsampling stack in X/Y") + for plane in tqdm(range(0, len(stack))): + downsampled_stack.append( + transform.rescale( + stack[plane], + (y_scaling_factor, x_scaling_factor), + mode="constant", + preserve_range=True, + anti_aliasing=anti_aliasing, + ) + ) + + logging.debug("Converting downsampled stack to array") + stack = np.array(downsampled_stack) + + if stack.ndim == 3: + # stack = np.rollaxis(stack, 0, 3) + if z_scaling_factor != 1: + logging.debug("Downsampling stack in Z") + stack = scale_z(stack, z_scaling_factor) + return stack + + +def load_nii(src_path, as_array=False, as_numpy=False): + """ + Load a brain from a nifti file + + :param str src_path: The path to the nifty file on the filesystem + :param bool as_array: Whether to convert the brain to a numpy array of + keep it as nifty object + :param bool as_numpy: Whether to convert the image to a numpy array in + memory (rather than a memmap) + :return: The loaded brain (format depends on the above flag) + """ + src_path = str(src_path) + nii_img = nib.load(src_path) + if as_array: + image = nii_img.get_fdata() + if as_numpy: + image = np.array(image) + + return image + else: + return nii_img + + +def load_from_folder( + src_folder, + x_scaling_factor=1, + y_scaling_factor=1, + z_scaling_factor=1, + anti_aliasing=True, + file_extension="", + load_parallel=False, + n_free_cpus=2, +): + """ + Load a brain from a folder. All tiff files will be read sorted and assumed + to belong to the same sample. + Optionally a name_filter string can be supplied which will have to be + present in the file names for them + to be considered part of the sample + + :param str src_folder: + :param float x_scaling_factor: The scaling of the brain along the x + dimension (applied on loading before return) + :param float y_scaling_factor: The scaling of the brain along the y + dimension (applied on loading before return) + :param float z_scaling_factor: The scaling of the brain along the z + dimension + :param bool anti_aliasing: Whether to apply a Gaussian filter to smooth + the image prior to down-scaling. It is crucial to filter when + down-sampling the image to avoid aliasing artifacts. + :param str file_extension: will have to be present in the file names for + them to be considered part of the sample + :param bool load_parallel: Use multiprocessing to speedup image loading + :param int n_free_cpus: Number of cpu cores to leave free. + :return: The loaded and scaled brain + :rtype: np.ndarray + """ + paths = get_sorted_file_paths(src_folder, file_extension=file_extension) + + return load_image_series( + paths, + x_scaling_factor, + y_scaling_factor, + z_scaling_factor, + load_parallel=load_parallel, + n_free_cpus=n_free_cpus, + anti_aliasing=anti_aliasing, + ) + + +def load_img_sequence( + img_sequence_file_path, + x_scaling_factor=1, + y_scaling_factor=1, + z_scaling_factor=1, + anti_aliasing=True, + load_parallel=False, + sort=False, + n_free_cpus=2, +): + """ + Load a brain from a sequence of files specified in a text file containing + an ordered list of paths + + :param str img_sequence_file_path: The path to the file containing the + ordered list of image paths (one per line) + :param float x_scaling_factor: The scaling of the brain along the x + dimension (applied on loading before return) + :param float y_scaling_factor: The scaling of the brain along the y + dimension (applied on loading before return) + :param float z_scaling_factor: The scaling of the brain along the z + dimension + :param bool anti_aliasing: Whether to apply a Gaussian filter to smooth + the image prior to down-scaling. It is crucial to filter when + down-sampling the image to avoid aliasing artifacts. + :param bool load_parallel: Use multiprocessing to speedup image loading + :param bool sort: If set to true will perform a natural sort of the + file paths in the list + :param int n_free_cpus: Number of cpu cores to leave free. + :return: The loaded and scaled brain + :rtype: np.ndarray + """ + img_sequence_file_path = str(img_sequence_file_path) + with open(img_sequence_file_path, "r") as in_file: + paths = in_file.readlines() + paths = [p.strip() for p in paths] + if sort: + paths = natsorted(paths) + + return load_image_series( + paths, + x_scaling_factor, + y_scaling_factor, + z_scaling_factor, + load_parallel=load_parallel, + n_free_cpus=n_free_cpus, + anti_aliasing=anti_aliasing, + ) + + +def load_image_series( + paths, + x_scaling_factor=1, + y_scaling_factor=1, + z_scaling_factor=1, + anti_aliasing=True, + load_parallel=False, + n_free_cpus=2, +): + """ + Load a brain from a sequence of files specified in a text file containing + an ordered list of paths + + :param lost paths: Ordered list of image paths + :param float x_scaling_factor: The scaling of the brain along the x + dimension (applied on loading before return) + :param float y_scaling_factor: The scaling of the brain along the y + dimension (applied on loading before return) + :param float z_scaling_factor: The scaling of the brain along the z + dimension + :param bool anti_aliasing: Whether to apply a Gaussian filter to smooth + the image prior to down-scaling. It is crucial to filter when + down-sampling the image to avoid aliasing artifacts. + :param bool load_parallel: Use multiprocessing to speedup image loading + :param int n_free_cpus: Number of cpu cores to leave free. + :return: The loaded and scaled brain + :rtype: np.ndarray + """ + + if load_parallel: + img = threaded_load_from_sequence( + paths, + x_scaling_factor, + y_scaling_factor, + n_free_cpus=n_free_cpus, + anti_aliasing=anti_aliasing, + ) + else: + img = load_from_paths_sequence( + paths, + x_scaling_factor, + y_scaling_factor, + anti_aliasing=anti_aliasing, + ) + + img = np.moveaxis(img, 2, 0) # back to z first + if z_scaling_factor != 1: + img = scale_z(img, z_scaling_factor) + + return img + + +def threaded_load_from_sequence( + paths_sequence, + x_scaling_factor=1.0, + y_scaling_factor=1.0, + anti_aliasing=True, + n_free_cpus=2, +): + """ + Use multiprocessing to load a brain from a sequence of image paths. + + :param list paths_sequence: The sorted list of the planes paths on the + filesystem + :param float x_scaling_factor: The scaling of the brain along the x + dimension (applied on loading before return) + :param float y_scaling_factor: The scaling of the brain along the y + dimension (applied on loading before return) + :param bool anti_aliasing: Whether to apply a Gaussian filter to smooth + the image prior to down-scaling. It is crucial to filter when + down-sampling the image to avoid aliasing artifacts. + :param int n_free_cpus: Number of cpu cores to leave free. + :return: The loaded and scaled brain + :rtype: np.ndarray + """ + + stacks = [] + n_processes = get_num_processes(min_free_cpu_cores=n_free_cpus) + + # WARNING: will not work with interactive interpreter. + pool = ProcessPoolExecutor(max_workers=n_processes) + # FIXME: should detect and switch to other method + + n_paths_per_subsequence = math.ceil(len(paths_sequence) / n_processes) + for i in range(n_processes): + start_idx = i * n_paths_per_subsequence + if start_idx >= len(paths_sequence): + break + else: + end_idx = start_idx + n_paths_per_subsequence + + if end_idx <= len(paths_sequence): + sub_paths = paths_sequence[start_idx:end_idx] + else: + sub_paths = paths_sequence[start_idx:] + + process = pool.submit( + load_from_paths_sequence, + sub_paths, + x_scaling_factor, + y_scaling_factor, + anti_aliasing=anti_aliasing, + ) + stacks.append(process) + stack = np.dstack([s.result() for s in stacks]) + return stack + + +def load_from_paths_sequence( + paths_sequence, + x_scaling_factor=1.0, + y_scaling_factor=1.0, + anti_aliasing=True, +): + # TODO: Optimise - load threaded and process by batch + """ + A single core version of the function to load a brain from a sequence of + image paths. + + :param list paths_sequence: The sorted list of the planes paths on the + filesystem + :param float x_scaling_factor: The scaling of the brain along the x + dimension (applied on loading before return) + :param float y_scaling_factor: The scaling of the brain along the y + dimension (applied on loading before return) + :param bool anti_aliasing: Whether to apply a Gaussian filter to smooth + the image prior to down-scaling. It is crucial to filter when + down-sampling the image to avoid aliasing artifacts. + :return: The loaded and scaled brain + :rtype: np.ndarray + """ + for i, p in enumerate( + tqdm(paths_sequence, desc="Loading images", unit="plane") + ): + img = tifffile.imread(p) + if i == 0: + check_mem( + img.nbytes * x_scaling_factor * y_scaling_factor, + len(paths_sequence), + ) + # TEST: add test case for shape rounding + volume = np.empty( + ( + int(round(img.shape[0] * x_scaling_factor)), + int(round(img.shape[1] * y_scaling_factor)), + len(paths_sequence), + ), + dtype=img.dtype, + ) + if x_scaling_factor != 1 or y_scaling_factor != 1: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + img = transform.rescale( + img, + (x_scaling_factor, y_scaling_factor), + mode="constant", + preserve_range=True, + anti_aliasing=anti_aliasing, + ) + volume[:, :, i] = img + return volume + + +def get_size_image_from_file_paths(file_path, file_extension="tif"): + """ + Returns the size of an image (which is a list of 2D files), without loading + the whole image + :param str file_path: File containing file_paths in a text file, + or as a list. + :param str file_extension: Optional file extension (if a directory + is passed) + :return: Dict of image sizes + """ + file_path = str(file_path) + + img_paths = get_sorted_file_paths(file_path, file_extension=file_extension) + z_shape = len(img_paths) + + logging.debug( + "Loading file: {} to check raw image size" "".format(img_paths[0]) + ) + image_0 = load_any(img_paths[0]) + y_shape, x_shape = image_0.shape + + image_shape = {"x": x_shape, "y": y_shape, "z": z_shape} + return image_shape diff --git a/brainglobe_utils/image_io/save.py b/brainglobe_utils/image_io/save.py new file mode 100644 index 0000000..8991873 --- /dev/null +++ b/brainglobe_utils/image_io/save.py @@ -0,0 +1,76 @@ +import warnings + +import nrrd +import numpy as np +import tifffile + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + import nibabel as nib + + +def to_nii(img, dest_path, scale=None, affine_transform=None): + # TODO: see if we want also real units scale + + """ + Write the brain volume to disk as nifty image. + + :param img: A nifty image object or numpy array brain + :param str dest_path: The path where to save the brain. + :param tuple scale: A tuple of floats to indicate the 'zooms' of the nifty + image + :param np.ndarray affine_transform: A 4x4 matrix indicating the transform + to save in the metadata of the image + (required only if not nibabel input) + :return: + """ + dest_path = str(dest_path) + if affine_transform is None: + affine_transform = np.eye(4) + if not isinstance(img, nib.Nifti1Image): + img = nib.Nifti1Image(img, affine_transform) + if scale is not None: + img.header.set_zooms(scale) + nib.save(img, dest_path) + + +def to_tiff(img_volume, dest_path): + """ + Saves the image volume (numpy array) to a tiff stack + + :param np.ndarray img_volume: The image to be saved + :param dest_path: Where to save the tiff stack + """ + dest_path = str(dest_path) + tifffile.imwrite(dest_path, img_volume) + + +def to_tiffs(img_volume, path_prefix, path_suffix="", extension=".tif"): + """ + Save the image volume (numpy array) as a sequence of tiff planes. + Each plane will have a filepath of the following for: + pathprefix_zeroPaddedIndex_suffix.tif + + :param np.ndarray img_volume: The image to be saved + :param str path_prefix: The prefix for each plane + :param str path_suffix: The suffix for each plane + """ + z_size = img_volume.shape[0] + pad_width = int(round(z_size / 10)) + 1 + for i in range(z_size): + img = img_volume[i, :, :] + dest_path = ( + f"{path_prefix}_{str(i).zfill(pad_width)}{path_suffix}{extension}" + ) + tifffile.imwrite(dest_path, img) + + +def to_nrrd(img_volume, dest_path): + """ + Saves the image volume (numpy array) as nrrd + + :param np.ndarray img_volume: The image to be saved + :param dest_path: Where to save the nrrd image + """ + dest_path = str(dest_path) + nrrd.write(dest_path, img_volume) diff --git a/brainglobe_utils/image_io/utils.py b/brainglobe_utils/image_io/utils.py new file mode 100644 index 0000000..afc5269 --- /dev/null +++ b/brainglobe_utils/image_io/utils.py @@ -0,0 +1,39 @@ +import psutil +from scipy.ndimage import zoom + + +class ImageIOLoadException(Exception): + pass + + +def check_mem(img_byte_size, n_imgs): + """ + Check how much memory is available on the system and compares it to the + size the stack specified by img_byte_size and n_imgs would take + once loaded + + Raises an error in case memory is insufficient to load that stack + + :param int img_byte_size: The size in bytes of an individual image plane + :param int n_imgs: The number of image planes to load + :raises: BrainLoadException if not enough memory is available + """ + total_size = img_byte_size * n_imgs + free_mem = psutil.virtual_memory().available + if total_size >= free_mem: + raise ImageIOLoadException( + "Not enough memory on the system to complete loading operation" + "Needed {}, only {} available.".format(total_size, free_mem) + ) + + +def scale_z(volume, scaling_factor): + """ + Scale the given brain along the z dimension + + :param np.ndarray volume: A brain typically as a numpy array + :param float scaling_factor: + :return: + """ + + return zoom(volume, (scaling_factor, 1, 1), order=1) diff --git a/brainglobe_utils/qtpy/logo.py b/brainglobe_utils/qtpy/logo.py index 003b669..78d9d75 100644 --- a/brainglobe_utils/qtpy/logo.py +++ b/brainglobe_utils/qtpy/logo.py @@ -29,7 +29,7 @@ def _logo_widget(package_name: str, parent: QWidget = None):

{package_name}

- <\h1> +

""" # noqa W605 return QLabel(_logo_html, parent=None) diff --git a/pyproject.toml b/pyproject.toml index 98ce8ed..d3a45a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,35 +3,39 @@ name = "brainglobe-utils" authors = [{ name = "Adam Tyson", email = "code@adamltyson.com" }] description = "Shared general purpose tools for the BrainGlobe project" readme = "README.md" +license = { file = "LICENSE" } requires-python = ">=3.9.0" -dynamic = ["version"] - +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] dependencies = [ - "natsort", - "pandas", - "psutil", - "configobj", - "tqdm", - "PyYAML", - "scipy", - "numpy", - "slurmio", - "tifffile", - "imio", - "bg-atlasapi", + "bg-atlasapi", + "brainglobe-space", + "configobj", + "natsort", + "nibabel >= 2.1.0", + "numpy", + "pandas", + "psutil", + "pyarrow", + "pynrrd", + "PyYAML", + "scikit-image", + "scipy", + "slurmio", + "tifffile", + "tqdm", ] +dynamic = ["version"] -license = { text = "MIT" } - -classifiers = [ - "Development Status :: 2 - Pre-Alpha", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Operating System :: OS Independent", -] [project.urls] homepage = "https://brainglobe.info" @@ -43,20 +47,20 @@ user_support = "https://github.com/brainglobe/brainglobe-utils/issues" napari = ["napari>=0.4.18", "qtpy", "superqt"] dev = [ - "qtpy", - "pytest", - "pytest-cov", - "pytest-qt", - "coverage", - "tox", - "black", - "mypy", - "pre-commit", - "ruff", - "setuptools_scm", - "pyqt5", - "superqt", - "scikit-image", + "black", + "coverage", + "mypy", + "pre-commit", + "pyqt5", + "pytest-cov", + "pytest-qt", + "pytest", + "qtpy", + "ruff", + "scikit-image", + "setuptools_scm", + "superqt", + "tox", ] @@ -71,9 +75,22 @@ include-package-data = true include = ["brainglobe_utils*"] exclude = ["tests", "docs*"] - [tool.pytest.ini_options] addopts = "--cov=brainglobe_utils" +filterwarnings = [ + "error", + # Emitted by scikit-image on import, see https://github.com/scikit-image/scikit-image/issues/6663 + # This filter should be removed when scikit-image 0.20 is released + "ignore:`np.bool8` is a deprecated alias for `np.bool_`", + # Emitted by nptyping, see https://github.com/ramonhagenaars/nptyping/issues/102 + # for upstream issue + "ignore:`np.object0` is a deprecated alias for ``np.object0`", + "ignore:`np.int0` is a deprecated alias for `np.intp`", + "ignore:`np.uint0` is a deprecated alias for `np.uintp`", + "ignore:`np.void0` is a deprecated alias for `np.void`", + "ignore:`np.bytes0` is a deprecated alias for `np.bytes_`", + "ignore:`np.str0` is a deprecated alias for `np.str_`", +] [tool.black] target-version = ['py39', 'py310', 'py311'] @@ -82,31 +99,21 @@ line-length = 79 [tool.setuptools_scm] -[tool.check-manifest] -ignore = [ - ".yaml", - "tox.ini", - "tests/", - "tests/test_unit/", - "tests/test_integration/", - "docs/", - "docs/source/", -] - - [tool.ruff] line-length = 79 exclude = ["__init__.py", "build", ".eggs"] -select = ["I", "E", "F"] fix = true +[tool.ruff.lint] +select = ["I", "E", "F"] + [tool.mypy] ignore_errors = true -[[tool.mypy.overrides]] -module = ["imlib.cells.*"] -ignore_errors = false -strict = true +# [[tool.mypy.overrides]] +# module = ["imlib.cells.*"] +# ignore_errors = false +# strict = true [tool.tox] legacy_tox_ini = """ diff --git a/tests/tests/test_image_io.py b/tests/tests/test_image_io.py new file mode 100644 index 0000000..17f8438 --- /dev/null +++ b/tests/tests/test_image_io.py @@ -0,0 +1,63 @@ +import os + +import numpy as np +import pytest +from tifffile import tifffile + +from brainglobe_utils.image_io import load, save, utils + + +@pytest.fixture() +def layer(): + return np.tile(np.array([1, 2, 3, 4], dtype=np.int32), (4, 1)) + + +@pytest.fixture() +def start_array(layer): + volume = np.dstack((layer, 2 * layer, 3 * layer, 4 * layer)) + return volume + + +def test_tiff_io(tmpdir, layer): + folder = str(tmpdir) + dest_path = os.path.join(folder, "layer.tiff") + tifffile.imwrite(dest_path, layer) + reloaded = tifffile.imread(dest_path) + assert (reloaded == layer).all() + + +def test_to_tiffs(tmpdir, start_array): + folder = str(tmpdir) + save.to_tiffs(start_array, os.path.join(folder, "start_array")) + reloaded_array = load.load_from_folder(folder, 1, 1, 1) + assert (reloaded_array == start_array).all() + + +def test_load_img_sequence(tmpdir, start_array): + folder = str(tmpdir.mkdir("sub")) + save.to_tiffs(start_array, os.path.join(folder, "start_array")) + img_sequence_file = tmpdir.join("imgs_file.txt") + img_sequence_file.write( + "\n".join( + [ + os.path.join(folder, fname) + for fname in sorted(os.listdir(folder)) + ] + ) + ) + reloaded_array = load.load_img_sequence(str(img_sequence_file), 1, 1, 1) + assert (reloaded_array == start_array).all() + + +def test_to_nii(tmpdir, start_array): # Also tests load_nii + folder = str(tmpdir) + nii_path = os.path.join(folder, "test_array.nii") + save.to_nii(start_array, nii_path) + assert (load.load_nii(nii_path).get_fdata() == start_array).all() + + +def test_scale_z(start_array): + assert ( + utils.scale_z(start_array, 0.5).shape[0] == start_array.shape[-1] / 2 + ) + assert utils.scale_z(start_array, 2).shape[0] == start_array.shape[-1] * 2