#!/usr/bin/env python3
"""
Use ``io_reorient_nii`` (``reorient``) from UNRAVEL to set the orientation of a .nii.gz or its affine matrix.
Input:
- path/input_image.nii.gz
Output:
- A .nii.gz images with the new orientation (e.g., image_PIR.nii.gz or image_PIR_applied.nii.gz)
The axis codes are:
R: Right / L: Left
A: Anterior / P: Posterior
S: Superior / I: Inferior
The orientation code is a 3-letter code that indicates the direction of the axes in the image.
For the RAS+ orientation (default for NIfTI):
- The right side is at the positive direction of the x-axis
- The anterior side is at the positive direction of the y-axis
- The superior side is at the positive direction of the z-axis
The orientation code also indicates the orientation of the axes in the affine matrix.
Example affine for RAS+ orientation:
[[1 0 0 0]
[ 0 1 0 0]
[ 0 0 1 0]
[ 0 0 0 1]]
- The 1st letter is R since the 1st diagonal value is positive (the right side is at the positive direction of the x-axis)
- The 2nd letter is A since the 2nd diagonal value is positive (the anterior side is at the positive direction of the y-axis)
- The 3rd letter is S since the 3rd diagonal value is positive (the superior side is at the positive direction of the z-axis)
Example affine for LPS+ orientation:
[[-1 0 0 0]
[ 0 -1 0 0]
[ 0 0 1 0]
[ 0 0 0 1]]
-For LPS, the 1st letter is L since the 1st diagonal value is negative.
-The 2nd letter is P since the 2nd diagonal value is negative.
-The 3rd letter is S since the 3rd diagonal value is positive.
Example affine for PIR+ orientation (default for CCFv3):
[[ 0 0 1 0]
[ -1 0 0 0]
[ 0 -1 0 0]
[ 0 0 0 1]]
For PIR:
First letter determination:
-The 1st column has a non-zero value at the 2nd row, so the 1st letter is either A or P (2nd letter of the default 'RAS' orientation code).
-Since the valud is negative, the 1st letter is P
Second letter determination:
-The 2nd column has a non-zero value at the 3rd row, so the 2nd letter is either S or I (3rd letter of the default 'RAS' orientation code).
-Since the value is negative, the 2nd letter is I
Third letter determination:
-The 3rd column has a non-zero value at the 1st row, so the 3rd letter is either R or L (1st letter of the default 'RAS' orientation code).
-Since the value is positive, the 3rd letter is R
Usage:
------
io_reorient_nii -i image.nii.gz -t PIR [-o image_PIR.nii.gz] [-z] [-a] [-fc 2] [-v]
"""
import nibabel as nib
import numpy as np
from nibabel.orientations import axcodes2ornt, ornt_transform, io_orientation, aff2axcodes, apply_orientation
from rich import print
from rich.traceback import install
from unravel.core.help_formatter import RichArgumentParser, SuppressMetavar, SM
from unravel.core.config import Configuration
from unravel.core.utils import log_command, verbose_start_msg, verbose_end_msg
[docs]
def parse_args():
parser = RichArgumentParser(formatter_class=SuppressMetavar, add_help=False, docstring=__doc__)
reqs = parser.add_argument_group('Required arguments')
reqs.add_argument('-i', '--input', help='path/img.nii.gz', required=True, action=SM)
reqs.add_argument('-t', '--target_ort', help='Target orientation axis codes (e.g., RAS)', required=True, action=SM)
opts = parser.add_argument_group('Optional args')
reqs.add_argument('-o', '--output', help='path/img.nii.gz', required=True, action=SM)
opts.add_argument('-z', '--zero_origin', help='Provide flag to zero the origin of the affine matrix.', action='store_true', default=False)
opts.add_argument('-a', '--apply', help='Provide flag to apply the new orientation to the ndarray data.', action='store_true', default=False)
opts.add_argument('-fc', '--form_code', help='Set the sform and qform codes for spatial coordinate type (1 = scanner; 2 = aligned)', type=int, default=None)
general = parser.add_argument_group('General arguments')
general.add_argument('-v', '--verbose', help='Increase verbosity. Default: False', action='store_true', default=False)
return parser.parse_args()
# TODO: Since .nii files from Fiji may have a data type that does not match the actual data type, we may need to infer the data type based on the actual min and max values. This could be used in img_io.py as well. nii.get_fdata() uses could be updated in other scripts as well.
[docs]
def reorient_nii(nii, target_ort, zero_origin=False, apply=False, form_code=None):
"""Reorient a NIfTI image or its affine matrix to a target orientation.
Args:
nii (nibabel.nifti1.Nifti1Image): Input NIfTI image.
target_ort (str): Target orientation axis codes (e.g., RAS).
zero_origin (bool): Whether to zero the origin of the affine matrix.
apply (bool): Apply the orientation change to the image data.
form_code (int): Code for spatial coordinate type (e.g., 1 = scanner; 2 = aligned).
Returns:
nib.Nifti1Image: New NIfTI image with reoriented data and affine.
"""
# Load image data
data_type = nii.header.get_data_dtype()
img = np.asanyarray(nii.dataobj).squeeze()
# Determine the actual min and max values
actual_min, actual_max = img.min(), img.max()
# Infer true data type based on actual value range
if actual_min >= 0 and actual_max <= 255:
inferred_type = np.uint8 # 8-bit unsigned
elif actual_min >= 0 and actual_max <= 65535:
inferred_type = np.uint16 # 16-bit unsigned
elif np.issubdtype(data_type, np.floating) or actual_min < 0 or actual_max > 65535:
inferred_type = np.float32 # 32-bit floating-point
else:
inferred_type = data_type # Fall back to metadata-specified type
if apply:
print('Applying orientation change to the image data...')
current_orientation = io_orientation(nii.affine)
target_orientation = axcodes2ornt(target_ort)
orientation_change = ornt_transform(current_orientation, target_orientation)
img = apply_orientation(img, orientation_change)
# Round values for integer types before casting
if np.issubdtype(inferred_type, np.integer):
img = np.round(img).astype(inferred_type)
else:
# For floating-point types, preserve precision
img = img.astype(inferred_type)
# Generate the new affine matrix
new_affine = transform_nii_affine(nii, target_ort, zero_origin=zero_origin)
# Create a new NIfTI image with the processed data and new affine
new_nii = nib.Nifti1Image(img, new_affine)
new_nii.header.set_data_dtype(inferred_type) # Preserve inferred data type
new_nii.header['xyzt_units'] = 10 # mm, s
new_nii.header['regular'] = b'r'
# Update sform and qform codes if specified
if form_code:
new_nii.header.set_qform(new_affine, code=form_code)
new_nii.header.set_sform(new_affine, code=form_code)
else:
new_nii.header.set_qform(new_affine, code=int(nii.header['qform_code']))
new_nii.header.set_sform(new_affine, code=int(nii.header['sform_code']))
return new_nii
[docs]
@log_command
def main():
install()
args = parse_args()
Configuration.verbose = args.verbose
verbose_start_msg()
nii = nib.load(args.input)
new_nii = reorient_nii(nii, args.target_ort, zero_origin=args.zero_origin, apply=args.apply, form_code=args.form_code)
# Save the new .nii.gz file
if args.output:
nib.save(new_nii, args.output)
else:
if args.apply:
nib.save(new_nii, args.input.replace('.nii.gz', f'_{args.target_ort}_applied.nii.gz'))
else:
nib.save(new_nii, args.input.replace('.nii.gz', f'_{args.target_ort}.nii.gz'))
verbose_end_msg()
if __name__ == '__main__':
main()