"""Script to validate atlases."""
import json
import os
import re
from pathlib import Path
import numpy as np
from brainglobe_atlasapi import BrainGlobeAtlas
from brainglobe_atlasapi.config import get_brainglobe_dir
from brainglobe_atlasapi.descriptors import METADATA_TEMPLATE, REFERENCE_DTYPE
from brainglobe_atlasapi.list_atlases import (
get_all_atlases_lastversions,
get_atlases_lastversions,
)
from brainglobe_atlasapi.update_atlases import update_atlas
[docs]
def validate_atlas_files(atlas: BrainGlobeAtlas):
"""
Check if essential files exist in the atlas folder.
This function verifies the presence of core files such as
'annotation.tiff', 'reference.tiff', 'metadata.json', 'structures.json',
and the 'meshes' directory within the atlas's root directory.
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if all expected files and directories are found.
Raises
------
AssertionError
If any expected file or directory is missing.
"""
atlas_path = atlas.root_dir
assert atlas_path.is_dir(), f"Atlas path {atlas_path} not found"
expected_files = [
"annotation.tiff",
"reference.tiff",
"metadata.json",
"structures.json",
]
for expected_file_name in expected_files:
expected_path = Path(atlas_path / expected_file_name)
assert (
expected_path.is_file()
), f"Expected file not found at {expected_path}"
meshes_path = atlas_path / "meshes"
assert meshes_path.is_dir(), f"Meshes path {meshes_path} not found"
return True
def _assert_close(mesh_coord, annotation_coord, pixel_size, diff_tolerance=10):
"""
Check if mesh and annotation coordinates are sufficiently close.
Compares a mesh coordinate to an annotation coordinate, considering
the pixel size and an arbitrary tolerance.
The default tolerance value is 10.
Parameters
----------
mesh_coord : float
The coordinate from the mesh.
annotation_coord : float
The coordinate from the annotation image, scaled by pixel size.
pixel_size : float
The size of a pixel in the dimension being checked.
diff_tolerance : int, optional
The maximum allowed difference between coordinates as a multiple
of pixel size. By default, 10.
Returns
-------
bool
True if the coordinates are within the specified tolerance.
Raises
------
AssertionError
If the absolute difference between `mesh_coord` and
`annotation_coord` exceeds `diff_tolerance * pixel_size`.
"""
assert abs(mesh_coord - annotation_coord) <= diff_tolerance * pixel_size, (
f"Mesh coordinate {mesh_coord} and "
f"annotation coordinate {annotation_coord}",
f"differ by more than {diff_tolerance} times pixel size {pixel_size}",
)
return True
[docs]
def validate_mesh_matches_image_extents(atlas: BrainGlobeAtlas):
"""Check if the mesh and the image extents are similar.
Validates that the spatial extents of the `root` mesh align with the
extents of the non-zero voxels in the annotation image, considering
the atlas resolution.
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if the mesh and image extents are sufficiently similar.
Raises
------
AssertionError
If the extents differ by more than the allowed tolerance
(default 10 times pixel size).
"""
root_mesh = atlas.mesh_from_structure("root")
annotation_image = atlas.annotation
resolution = atlas.resolution
# minimum and maximum values of the annotation image (z, y, x)
z_range, y_range, x_range = np.nonzero(annotation_image)
z_min, z_max = np.min(z_range), np.max(z_range)
y_min, y_max = np.min(y_range), np.max(y_range)
x_min, x_max = np.min(x_range), np.max(x_range)
# minimum and maximum values of the annotation image
# scaled by the atlas resolution
z_min_scaled, z_max_scaled = z_min * resolution[0], z_max * resolution[0]
y_min_scaled, y_max_scaled = y_min * resolution[1], y_max * resolution[1]
x_min_scaled, x_max_scaled = x_min * resolution[2], x_max * resolution[2]
# z, y and x coordinates of the root mesh (extent of the whole object)
mesh_points = root_mesh.points
z_coords, y_coords, x_coords = (
mesh_points[:, 0],
mesh_points[:, 1],
mesh_points[:, 2],
)
# minimum and maximum coordinates of the root mesh
z_min_mesh, z_max_mesh = np.min(z_coords), np.max(z_coords)
y_min_mesh, y_max_mesh = np.min(y_coords), np.max(y_coords)
x_min_mesh, x_max_mesh = np.min(x_coords), np.max(x_coords)
# checking if root mesh and image are on the same scale
_assert_close(z_min_mesh, z_min_scaled, resolution[0])
_assert_close(z_max_mesh, z_max_scaled, resolution[0])
_assert_close(y_min_mesh, y_min_scaled, resolution[1])
_assert_close(y_max_mesh, y_max_scaled, resolution[1])
_assert_close(x_min_mesh, x_min_scaled, resolution[2])
_assert_close(x_max_mesh, x_max_scaled, resolution[2])
return True
[docs]
def open_for_visual_check(atlas: BrainGlobeAtlas):
"""Open the atlas for visual inspection (not implemented).
This function is a placeholder for future visual validation routines.
Parameters
----------
atlas : BrainGlobeAtlas
The atlas to be visually checked.
Returns
-------
bool
Always True, as visual checks are not yet implemented.
"""
# implement visual checks later
return True
[docs]
def validate_checksum(atlas: BrainGlobeAtlas):
"""Validate the atlas checksum (not implemented).
This function is a placeholder for future checksum validation routines.
Parameters
----------
atlas : BrainGlobeAtlas
The atlas to be validated.
Returns
-------
bool
Always True, as checksum validation is not yet implemented.
"""
# implement later
return True
[docs]
def validate_image_dimensions(atlas: BrainGlobeAtlas):
"""Check that annotation and reference images have identical dimensions.
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if the dimensions match.
Raises
------
AssertionError
If the `annotation` and `reference` image arrays have different shapes.
"""
assert atlas.annotation.shape == atlas.reference.shape, (
"Annotation and reference image have different dimensions. \n"
f"Annotation image has dimension: {atlas.annotation.shape}, "
f"while reference image has dimension {atlas.reference.shape}."
)
return True
[docs]
def validate_additional_references(atlas: BrainGlobeAtlas):
"""Check that additional references have expected properties.
Verifies that all additional reference images:
1. Have the same dimensions as the main reference image.
2. Are not identical to the main reference image (i.e., contain different
data).
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if all additional references pass the validation checks.
Raises
------
AssertionError
If an additional reference has unexpected dimensions or is identical
to the main reference.
"""
for (
additional_reference_name
) in atlas.additional_references.references_list:
additional_reference = atlas.additional_references[
additional_reference_name
]
assert additional_reference.shape == atlas.reference.shape, (
f"Additional reference {additional_reference} "
"has unexpected dimension."
)
assert not np.all(
additional_reference == atlas.reference
), "Additional reference is not different to main reference."
return True
[docs]
def catch_missing_mesh_files(atlas: BrainGlobeAtlas):
"""Check if all structures in the atlas have a corresponding mesh file.
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if all structures listed in the atlas have a
corresponding mesh file.
Raises
------
AssertionError
If any structure ID found in `atlas.structures` does not have a
matching `.obj` file in the atlas's `meshes` directory.
"""
ids_from_bg_atlas_api = list(atlas.structures.keys())
atlas_path = atlas.root_dir
obj_path = Path(atlas_path / "meshes")
ids_from_mesh_files = [
int(Path(file).stem)
for file in os.listdir(obj_path)
if file.endswith(".obj")
]
in_bg_not_mesh = []
for id in ids_from_bg_atlas_api:
if id not in ids_from_mesh_files:
in_bg_not_mesh.append(id)
if len(in_bg_not_mesh) != 0:
raise AssertionError(
f"Structures with IDs {in_bg_not_mesh} are in the atlas, "
"but don't have a corresponding mesh file."
)
return True
[docs]
def catch_missing_structures(atlas: BrainGlobeAtlas):
"""Check if all mesh files in the atlas folder are listed as a structure.
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if all mesh files have a corresponding entry in the atlas's
structures.
Raises
------
AssertionError
If any .obj file found in the atlas's 'meshes' directory does not
have a corresponding structure ID in `atlas.structures`.
"""
ids_from_bg_atlas_api = list(atlas.structures.keys())
atlas_path = atlas.root_dir
obj_path = Path(atlas_path / "meshes")
ids_from_mesh_files = [
int(Path(file).stem)
for file in os.listdir(obj_path)
if file.endswith(".obj")
]
in_mesh_not_bg = []
for id in ids_from_mesh_files:
if id not in ids_from_bg_atlas_api:
in_mesh_not_bg.append(id)
if len(in_mesh_not_bg) != 0:
raise AssertionError(
f"Structures with IDs {in_mesh_not_bg} have a mesh file, "
"but are not accessible through the atlas."
)
return True
[docs]
def validate_reference_image_pixels(atlas: BrainGlobeAtlas):
"""Validate that the reference image was correctly rescaled.
This check aims to catch issues where a float64 reference image (e.g., from
MRI) might have been incorrectly rescaled or cast to the target integer
data type (e.g., `REFERENCE_DTYPE`), resulting in pixel values that are
too low. It asserts that not all pixel values are below 128
(assuming 8-bit range).
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if the reference image's pixel values appear to be
correctly scaled.
Raises
------
AssertionError
If all pixel values in the reference image are less than 128,
suggesting incorrect scaling.
"""
assert not np.all(
atlas.reference < 128
), f"Reference image is likely wrongly rescaled to {REFERENCE_DTYPE}"
return True
[docs]
def validate_annotation_symmetry(atlas: BrainGlobeAtlas):
"""Validate that equivalent regions in left and right hemispheres have the
same annotation value.
This is done by comparing annotation values at two pixels equidistant
from the mid-sagittal plane along the central horizontal axis, near the
center of the image.
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if the selected annotation labels across the mid-sagittal plane
are symmetric.
Raises
------
AssertionError
If the annotation labels at the chosen symmetric points are different.
"""
annotation = atlas.annotation
centre = np.array(annotation.shape) // 2
central_leftright_axis_annotations = annotation[centre[0], centre[1], :]
label_5_left_of_centre = central_leftright_axis_annotations[centre[2] + 5]
label_5_right_of_centre = central_leftright_axis_annotations[centre[2] - 5]
assert (
label_5_left_of_centre == label_5_right_of_centre
), "Annotation labels are asymmetric."
return True
[docs]
def validate_atlas_name(atlas: BrainGlobeAtlas):
"""Validate the naming convention of the atlas.
Checks if the atlas name adheres to specific rules:
- Must be entirely lowercase.
- Can only contain lowercase letters, digits, underscores, hyphens,
and periods.
- Must end with a resolution string (e.g., "5um", "37.5um", "1mm").
Parameters
----------
atlas : BrainGlobeAtlas
The BrainGlobeAtlas object to validate.
Returns
-------
bool
True if the atlas name follows the specified conventions.
Raises
------
AssertionError
If the atlas name contains capital letters, invalid characters, or
does not end with a valid resolution string.
"""
name = atlas.atlas_name
allowed_chars = r"^[a-z0-9_.-]+$"
res = name.split("_").pop()
assert name == name.lower(), f"Atlas name {name} cannot contain capitals."
assert re.match(
allowed_chars, name
), f"Atlas name {name} contains invalid characters."
resolution_pattern = r"\d+(\.\d+)?(nm|um|mm)$"
assert re.search(resolution_pattern, res), (
f"Atlas name {name} should end with a valid resolution "
"(e.g., 5um, 1.5mm)."
)
return True
[docs]
def get_all_validation_functions():
"""Return all individual validation functions as a list.
All functions returned by this method are expected to accept
a single argument: a `BrainGlobeAtlas` instance.
Returns
-------
list of callable
A list of functions that can be used to validate a BrainGlobe atlas.
"""
return [
validate_atlas_files,
validate_mesh_matches_image_extents,
open_for_visual_check,
validate_checksum,
validate_image_dimensions,
validate_additional_references,
catch_missing_mesh_files,
catch_missing_structures,
validate_reference_image_pixels,
validate_annotation_symmetry,
validate_atlas_name,
]
[docs]
def validate_atlas(atlas_name, version, validation_functions):
"""Validate the latest version of a given atlas.
This function attempts to load the specified atlas, updates it if
necessary, and then runs a suite of provided validation functions
against it. It collects and reports the results of each validation check.
Parameters
----------
atlas_name : str
The name of the atlas to validate.
version : str
The version of the atlas to validate. (Currently not directly used
for loading, but passed from `get_all_atlases_lastversions`).
validation_functions : list of callable
A list of functions, each expecting a `BrainGlobeAtlas` object
as input and designed to perform a specific validation check.
Returns
-------
dict
A dictionary containing the validation results for the specified atlas.
The format is `{atlas_name: [(function_name, error_message, status)]}`.
`error_message` is None if the check passes.
"""
print(atlas_name, version)
BrainGlobeAtlas(atlas_name)
updated = get_atlases_lastversions()[atlas_name]["updated"]
if not updated:
update_atlas(atlas_name)
validation_results = {atlas_name: []}
for i, validation_function in enumerate(validation_functions):
try:
validation_function(BrainGlobeAtlas(atlas_name))
validation_results[atlas_name].append(
(validation_function.__name__, None, str("Pass"))
)
except AssertionError as error:
validation_results[atlas_name].append(
(validation_function.__name__, str(error), str("Fail"))
)
return validation_results
if __name__ == "__main__":
"""Main execution block for running atlas validations."""
# list to store the validation functions
all_validation_functions = [
validate_atlas_files,
validate_mesh_matches_image_extents,
open_for_visual_check,
validate_checksum,
validate_image_dimensions,
validate_additional_references,
catch_missing_mesh_files,
catch_missing_structures,
validate_reference_image_pixels,
validate_annotation_symmetry,
validate_atlas_name,
]
valid_atlases = []
invalid_atlases = []
validation_results = {}
for atlas_name, version in get_all_atlases_lastversions().items():
temp_validation_results = validate_atlas(
atlas_name, version, all_validation_functions
)
validation_results.update(temp_validation_results)
print("Validation has been completed")
print("Find validation_results.json in ~/.brainglobe/atlases/validation/")
# Get the directory path
output_dir_path = str(get_brainglobe_dir() / "atlases/validation")
# Create the directory if it doesn't exist
if not os.path.exists(output_dir_path):
os.makedirs(output_dir_path)
# Open a file for writing (will overwrite any files from previous runs!)
with open(
str(
get_brainglobe_dir() / "atlases/validation/validation_results.json"
),
"w",
) as file:
json.dump(validation_results, file)