diff --git a/install_elastix_R.sh b/install_elastix_R.sh index e0403d28..8a292a62 100644 --- a/install_elastix_R.sh +++ b/install_elastix_R.sh @@ -8,9 +8,9 @@ sudo apt install python3-pip sudo apt install r-base # Install elastix -wget https://github.com/SuperElastix/elastix/releases/download/4.9.0/elastix-4.9.0-linux.tar.bz2 +wget https://github.com/SuperElastix/elastix/releases/download/5.0.0/elastix-5.0.0-linux.tar.bz2 mkdir ~/elastix -tar xjf elastix-4.9.0-linux.tar.bz2 -C ~/elastix +tar xjf elastix-5.0.0-linux.tar.bz2 -C ~/elastix home=~/ echo -e "\n# paths added by my LAMA installation" >> ~/.bashrc diff --git a/lama/__init__.py b/lama/__init__.py index 83cdd0a8..ef6522a4 100755 --- a/lama/__init__.py +++ b/lama/__init__.py @@ -5,4 +5,4 @@ warnings.filterwarnings("ignore") -matplotlib.use('Agg') +#matplotlib.use('Agg') diff --git a/lama/common.py b/lama/common.py index 75b735ff..a76eef7a 100755 --- a/lama/common.py +++ b/lama/common.py @@ -42,6 +42,7 @@ ORGAN_VOLUME_CSV_FILE = 'organ_volumes.csv' STAGING_INFO_FILENAME = 'staging_info_volume.csv' +FOLDING_FILE_NAME = 'folding_report.csv' lama_root_dir = Path(lama.__file__).parent @@ -116,7 +117,7 @@ def excepthook_overide(exctype, value, traceback): def command_line_agrs(): - return ', '.join(sys.argv) + return ' '.join(sys.argv) def touch(file_: Path): @@ -238,7 +239,7 @@ def write_array(array: np.ndarray, path: Union[str, Path], compressed=True, ras= img = sitk.GetImageFromArray(array) if ras: img.SetDirection((-1, 0, 0, 0, -1, 0, 0, 0, 1)) - sitk.WriteImage(sitk.GetImageFromArray(array), path, compressed) + sitk.WriteImage(img, path, compressed) def read_array( path: Union[str, Path]): @@ -373,7 +374,7 @@ def mkdir_if_not_exists(dir_: Union[str, Path]): def get_file_paths(folder: Union[str, Path], extension_tuple=('.nrrd', '.tiff', '.tif', '.nii', '.bmp', 'jpg', 'mnc', 'vtk', 'bin', 'npy'), - pattern: str = None, ignore_folder: str = "") -> Union[List[str], List[Path]]: + pattern: str = None, ignore_folders: Union[List, str] = []) -> Union[List[str], List[Path]]: """ Given a directory return all image paths within all sibdirectories. @@ -385,8 +386,8 @@ def get_file_paths(folder: Union[str, Path], extension_tuple=('.nrrd', '.tiff', Select only images with these extensions pattern Do a simple `pattern in filename` filter on filenames - ignore_folder - do not look in folder with this name + ignore_folders + do not look in folder with these names Notes ----- @@ -395,32 +396,33 @@ def get_file_paths(folder: Union[str, Path], extension_tuple=('.nrrd', '.tiff', Do not include hidden filenames """ + paths = [] - if not os.path.isdir(folder): - return False - else: - paths = [] + if isinstance(ignore_folders, str): + ignore_folders = [ignore_folders] - for root, subfolders, files in os.walk(folder): - if ignore_folder in subfolders: - subfolders.remove(ignore_folder) + for root, subfolders, files in os.walk(folder): - for filename in files: + for f in ignore_folders: + if f in subfolders: + subfolders.remove(f) - if filename.lower().endswith(extension_tuple) and not filename.startswith('.'): + for filename in files: - if pattern: + if filename.lower().endswith(extension_tuple) and not filename.startswith('.'): - if pattern and pattern not in filename: - continue + if pattern: - paths.append(os.path.abspath(os.path.join(root, filename))) + if pattern and pattern not in filename: + continue - if isinstance(folder, str): - return paths - else: - return [Path(x) for x in paths] + paths.append(os.path.abspath(os.path.join(root, filename))) + + if isinstance(folder, str): + return paths + else: + return [Path(x) for x in paths] def check_config_entry_path(dict_, key): @@ -466,6 +468,14 @@ def getfile_endswith(dir_: Path, suffix: str): raise FileNotFoundError(f'cannot find path file ending with {suffix} in {dir_}') from e +def getfile_startswith_endswith(dir_: Path, prefix: str, suffix: str): + try: + return [x for x in dir_.iterdir() if x.name.endswith(suffix) and x.name.startswith(prefix)][0] + except IndexError as e: + raise FileNotFoundError(f'cannot find path starting with {prefix} and ending with {suffix} in {dir_}') from e + + + def get_inputs_from_file_list(file_list_path, config_dir): """ Gte the input files @@ -956,3 +966,21 @@ def cfg_load(cfg) -> Dict: else: raise ValueError('Config file should end in .toml or .yaml') + + +def bytesToGb(numberOfBytes, precision = 3): + return round(numberOfBytes / (1024 ** 3), precision) + +def logMemoryUsageInfo(): + proc = psutil.Process(os.getpid()) + + procMemInfo = proc.memory_info() + globalMemInfo = psutil.virtual_memory() + + totalMem = bytesToGb(globalMemInfo.total, 5) + totalMemAvailable = bytesToGb(globalMemInfo.available, 5) + procMemRSS = bytesToGb(procMemInfo.rss, 5) + procMemVMS = bytesToGb(procMemInfo.vms, 5) + procMemData = bytesToGb(procMemInfo.data, 5) + + logging.info(f"Memory: Process Resident: {procMemRSS}, Process Virtual: {procMemVMS}, Process data: {procMemData}. Total Available: {totalMemAvailable}") diff --git a/lama/elastix/__init__.py b/lama/elastix/__init__.py index aea6cf6d..37997906 100644 --- a/lama/elastix/__init__.py +++ b/lama/elastix/__init__.py @@ -13,5 +13,6 @@ VOLUME_CALCULATIONS_FILENAME = "organvolumes.csv" INVERT_CONFIG = 'invert.yaml' REG_DIR_ORDER = 'reg_order.txt' -IGNORE_FOLDER = 'resolution_images' # When reading images from dir and subdirs, ignore images in this folder +RESOLUTION_IMGS_DIR = 'resolution_images' # When reading images from dir and subdirs, ignore images in this folder TRANSFORMIX_OUT = 'result.nrrd' # This will not be correct if filetype is not nrrd +IMG_PYRAMID_DIR = 'pyramid_images' \ No newline at end of file diff --git a/lama/elastix/deformations.py b/lama/elastix/deformations.py index 1d882cde..04f91ec1 100755 --- a/lama/elastix/deformations.py +++ b/lama/elastix/deformations.py @@ -22,18 +22,40 @@ TRANSFORMIX_LOG = 'transformix.log' -def make_deformations_at_different_scales(config: Union[LamaConfig, dict]): +def make_deformations_at_different_scales(config: Union[LamaConfig, dict]) -> Union[None, np.array]: """ - Generate jacobian determinants ans optionaly defromation vectors + Generate jacobian determinants and optionaly defromation vectors Parameters ---------- config: LamaConfig object if running from other lama module Path to config file if running this module independently + + Notes + ----- + How to generate the hacobian determinants is defined by the config['generate_deformation_fields'] entry. + + toml representation from the LAMA config: + + [generate_deformation_fields] + 192_to_10 = [ "deformable_192_to_10",] + + This will create a set of jacobian determinants and optional deformation fields called 192_to_10 and using the + the named regisrtation stages in the list. + + Multiple sets of key/value pairs are allowed so that diffrent jacobians determinatnts might be made. For eaxmple + you may want to include the affine transformation in the jacobians, which would look like so: + + affine_192_to_10 = [ "affine", "deformable_192_to_10"] + + Returns + ------- + jacobian array if there are any negative values """ - if isinstance(config, Path): - config = LamaConfig(config) + + if isinstance(config, (str, Path)): + config = LamaConfig(Path(config)) if not config['generate_deformation_fields']: return @@ -76,8 +98,9 @@ def make_deformations_at_different_scales(config: Union[LamaConfig, dict]): log_jacobians_scale_dir = log_jacobians_dir / deformation_id log_jacobians_scale_dir.mkdir() - generate_deformation_fields(reg_stage_dirs, resolutions, deformation_scale_dir, jacobians_scale_dir, + neg_jac_arr = generate_deformation_fields(reg_stage_dirs, resolutions, deformation_scale_dir, jacobians_scale_dir, log_jacobians_scale_dir, make_vectors, threads=config['threads'], filetype=config['filetype']) + return neg_jac_arr def generate_deformation_fields(registration_dirs: List, @@ -150,8 +173,9 @@ def generate_deformation_fields(registration_dirs: List, # Copy the tp files into the temp directory and then modify to add initail transform # pass in the last tp file [-1] as the other tp files are intyernally referenced - get_deformations(transform_params[-1], deformation_dir, jacobian_dir, log_jacobians_dir, filetype, specimen_id, + neg_jac_array = get_deformations(transform_params[-1], deformation_dir, jacobian_dir, log_jacobians_dir, filetype, specimen_id, threads, jacmat, get_vectors) + return neg_jac_array def _modfy_tforms(tforms: List): @@ -166,7 +190,7 @@ def _modfy_tforms(tforms: List): for i, tp in enumerate(tforms[1:]): initial_tp = tforms[i] - with open(tp, 'rb') as fh: + with open(tp, 'r') as fh: lines = [] for line in fh: if line.startswith('(InitialTransformParametersFileName'): @@ -175,7 +199,7 @@ def _modfy_tforms(tforms: List): previous_tp_str = '(InitialTransformParametersFileName "{}")'.format(initial_tp) lines.insert(0, previous_tp_str + '\n') - with open(tp, 'wb') as wh: + with open(tp, 'w') as wh: for line in lines: wh.write(line) @@ -188,9 +212,13 @@ def get_deformations(tform: Path, specimen_id: str, threads: int, make_jacmat: bool, - get_vectors: bool = False): + get_vectors: bool = False) -> Union[None, np.array]: """ Generate spatial jacobians and optionally deformation files. + + Returns + ------- + the jacobian array if there are any values < 0 """ cmd = ['transformix', @@ -265,5 +293,8 @@ def get_deformations(tform: Path, logging.info('Finished generating deformation fields') + if jac_min <=0: + return jac_arr + diff --git a/lama/elastix/elastix_registration.py b/lama/elastix/elastix_registration.py index 7d9cfe7a..1dd2e48a 100755 --- a/lama/elastix/elastix_registration.py +++ b/lama/elastix/elastix_registration.py @@ -1,19 +1,19 @@ -from lama import common from logzero import logger as logging -import sys -from os.path import join, isdir, splitext, basename, relpath, exists, abspath, dirname, realpath +from os.path import join, isdir, splitext, basename, relpath import subprocess import os import shutil from collections import defaultdict from pathlib import Path -from typing import Union import yaml import SimpleITK as sitk -REOLSUTION_TP_PREFIX = 'TransformParameters.0.R' +from lama.elastix.folding import unfold_bsplines +from lama import common +from lama.elastix import ELX_TRANSFORM_PREFIX, RESOLUTION_IMGS_DIR, IMG_PYRAMID_DIR + +RESOLUTION_TP_PREFIX = 'TransformParameters.0.R' FULL_STAGE_TP_FILENAME = 'TransformParameters.0.txt' -RESOLUTION_IMG_FOLDER = 'resolution_images' class ElastixRegistration(object): @@ -49,14 +49,15 @@ def __init__(self, elxparam_file: Path, self.fixed_mask = fixed_mask self.filetype = filetype self.threads = threads - # A subset of volumes from folder to register + self.rename_output = True # Bodge for pairwise reg, or we end up filling all the disks + def make_average(self, out_path): """ Create an average of the the input embryo volumes. This will search subfolders for all the registered volumes within them """ - vols = common.get_file_paths(self.stagedir, ignore_folder=RESOLUTION_IMG_FOLDER) + vols = common.get_file_paths(self.stagedir, ignore_folders=[RESOLUTION_IMGS_DIR, IMG_PYRAMID_DIR]) #logging.info("making average from following volumes\n {}".format('\n'.join(vols))) average = common.average(vols) @@ -68,6 +69,7 @@ class TargetBasedRegistration(ElastixRegistration): def __init__(self, *args): super(TargetBasedRegistration, self).__init__(*args) self.fixed = None + self.fix_folding = False def set_target(self, target): self.fixed = target @@ -77,7 +79,7 @@ def run(self): if self.movdir.is_file(): moving_imgs = [self.movdir] else: - moving_imgs = common.get_file_paths(self.movdir, ignore_folder=RESOLUTION_IMG_FOLDER) # This breaks if not ran from config dir + moving_imgs = common.get_file_paths(self.movdir, ignore_folders=[RESOLUTION_IMGS_DIR, IMG_PYRAMID_DIR]) # This breaks if not ran from config dir if len(moving_imgs) < 1: raise common.LamaDataException("No volumes in {}".format(self.movdir)) @@ -99,16 +101,17 @@ def run(self): run_elastix(cmd) # Rename the registered output. - elx_outfile = outdir / f'result.0.{self.filetype}' - new_out_name = outdir / f'{mov_basename}.{self.filetype}' + if self.rename_output: + elx_outfile = outdir / f'result.0.{self.filetype}' + new_out_name = outdir / f'{mov_basename}.{self.filetype}' - try: - shutil.move(elx_outfile, new_out_name) - except IOError: - logging.error('Cannot find elastix output. Ensure the following is not set: (WriteResultImage "false")') - raise + try: + shutil.move(elx_outfile, new_out_name) + except IOError: + logging.error('Cannot find elastix output. Ensure the following is not set: (WriteResultImage "false")') + raise - move_intemediate_volumes(outdir) + move_intemediate_volumes(outdir) # add registration metadata reg_metadata_path = outdir / common.INDV_REG_METADATA @@ -118,6 +121,23 @@ def run(self): with open(reg_metadata_path, 'w') as fh: fh.write(yaml.dump(reg_metadata, default_flow_style=False)) + if self.fix_folding: + # Remove any folds folds in the Bsplines, overwtite inplace + tform_param_file = outdir / ELX_TRANSFORM_PREFIX + unfold_bsplines(tform_param_file, tform_param_file) + + # Retransform the moving image with corrected tform file + cmd = [ + 'transformix', + '-in', str(mov), + '-out', str(outdir), + '-tp', tform_param_file + ] + subprocess.call(cmd) + unfolded_moving_img = outdir / 'result.nrrd' + new_out_name.unlink() + shutil.move(unfolded_moving_img, new_out_name) + class PairwiseBasedRegistration(ElastixRegistration): @@ -156,7 +176,7 @@ def run(self): 'threads': self.threads, 'fixed': fixed}) # Get the resolution tforms - tforms = list(sorted([x for x in os.listdir(outdir) if x .startswith(REOLSUTION_TP_PREFIX)])) + tforms = list(sorted([x for x in os.listdir(outdir) if x .startswith(RESOLUTION_TP_PREFIX)])) # get the full tform that spans all resolutions full_tp_file_paths.append(join(outdir, FULL_STAGE_TP_FILENAME)) @@ -173,7 +193,7 @@ def run(self): fh.write(yaml.dump(reg_metadata, default_flow_style=False)) for i, files_ in tp_file_paths.items(): - mean_tfom_name = "{}{}.txt".format(REOLSUTION_TP_PREFIX, i) + mean_tfom_name = "{}{}.txt".format(RESOLUTION_TP_PREFIX, i) self.generate_mean_tranform(files_, fixed, fixed_dir, mean_tfom_name, self.filetype) self.generate_mean_tranform(full_tp_file_paths, fixed, fixed_dir, FULL_STAGE_TP_FILENAME, self.filetype) @@ -233,7 +253,7 @@ def generate_mean_tranform(tp_files, fixed_vol, out_dir, tp_out_name, filetype): def run_elastix(args): cmd = ['elastix', '-f', args['fixed'], - '-m', args['mov'], + '-m', f'"{args["mov"]}"', '-out', args['outdir'], '-p', args['elxparam_file'], ] @@ -247,7 +267,6 @@ def run_elastix(args): try: a = subprocess.check_output(cmd) except Exception as e: # can't seem to log CalledProcessError - logging.exception('registration falied:\n\ncommand: {}\n\n error:{}'.format(cmd, e.output)) raise @@ -255,14 +274,26 @@ def run_elastix(args): def move_intemediate_volumes(reg_outdir: Path): """ If using elastix multi-resolution registration and outputting image each resolution, put the intermediate files - in a separate folder + in a separate folder. + + Do the same with pyramid images """ - imgs = common.get_file_paths(reg_outdir) - intermediate_imgs = [x for x in imgs if basename(x).startswith('result.')] + + intermediate_imgs = list(reg_outdir.rglob('*result.0.R*')) #[x for x in imgs if basename(x).startswith('result.')] if len(intermediate_imgs) > 0: - int_dir = join(reg_outdir, RESOLUTION_IMG_FOLDER) - common.mkdir_force(int_dir) + + reolution_img_dir = reg_outdir / RESOLUTION_IMGS_DIR + common.mkdir_force(reolution_img_dir) for int_img in intermediate_imgs: - shutil.move(str(int_img), str(int_dir)) - # convert_16_to_8.convert_16_bit_to_8bit(int_dir, int_dir) + shutil.move(str(int_img), str(reolution_img_dir)) + + pyramid_imgs = list(reg_outdir.rglob('*ImagePyramid*')) + if len(pyramid_imgs) > 0: + + img_pyramid_dir = reg_outdir / IMG_PYRAMID_DIR + common.mkdir_force(img_pyramid_dir) + for pyr_img in pyramid_imgs: + shutil.move(str(pyr_img), str(img_pyramid_dir)) + + diff --git a/lama/elastix/folding.py b/lama/elastix/folding.py new file mode 100644 index 00000000..ee43ac6f --- /dev/null +++ b/lama/elastix/folding.py @@ -0,0 +1,216 @@ +""" +Folding in the deformations cn casue problems with the inversions. +This module corrects overafolding and ensures injectivity of the transform +""" +import numpy as np +from pathlib import Path +from typing import Union + + +K2 = 2.046392675 +K3 = 2.479472335 +A2 = np.sqrt( + (3/2)**2 + (K2 - (3/2))**2 +) +A3 = np.sqrt( + ((3/2)**2 + (K2 - (3/2))**2 + (K3 - K2)**2) +) + + +class BSplineParse(): + + def __init__(self, tform_file: str): + """ + Read in the coefficients from an elastix tform parameter files + + Parameters + ---------- + tform_file + the path to the elastix TranformParameter file + + Attributes + ---------- + coefs: np.ndarray + m*n array. m number of control points, n = num axes + + Returns + ------- + + tuple + + 0: + if Bspline: + n*m np.array where n is number of control points and m is vector of coordinates (xyz) + if Euler, similarity, affine + 1d array of transormation parameters + 1: + list of non-transform parameter lines from the tform file + + """ + + with open(tform_file, 'r') as fh: + + other_data = [] + + bspline = False + + for line in fh: + if line.startswith('(TransformParameters '): + tform_str = line.strip() + tform_str = tform_str.strip(')') + tform_str = tform_str.lstrip('(TransformParameters ') + tform_params = tform_str.split(' ') + + tform_params= [float(x) for x in tform_params] + else: + other_data.append(line) + + if line.startswith('(Transform "BSplineTransform")'): + bspline = True + + if bspline: + tform_params = np.array(np.array_split(tform_params, 3)).T + + self.coefs = tform_params + self.elastix_params = other_data + + def control_point_coords(self): + + for line in self.elastix_params: + + if line.startswith('(Size '): + size = [int(x) for x in line.strip().split(' ')[1:]] + + elif line.startswith('GridOrigin'): + grid_origin = [int(x) for x in line.strip().split(' ')[1:]] + + elif line.startswith('GridSpacing'): + grid_spacing = [int(x) for x in line.strip().split(' ')[1:]] + + +def condition_1(coefs): + """ + numpy array of tform coefs of shape n_coefs * dims + + Returns + ------- + + """ + t = np.abs(coefs) < 1/K3 + condition_met = np.apply_along_axis(np.all, 1, t) + return condition_met + + +def condition_2(coefs): + # def f(x): + # # s = np.sum(x) < (1 / A3) ** 2 + # s = np.linalg.norm(x) < (1 / A3) + # return s + # t = coefs**2 + # condition_met = np.apply_along_axis(f, 1, t) + # return condition_met + return np.linalg.norm(coefs, axis=1) < (1 / A3) + + +# def correct_1(coefs, potential_fold_indices): +# for idx in potential_fold_indices: +# bounds = (1 / A3) ** 2 +# coefs[idx, :] = bounds +# return coefs + + +def correct(coefs): + coefs = np.copy(coefs) + + result = [] + for c in coefs: + + # Make this vector satisfy condition 2 + unfolded = np.array(c) / np.linalg.norm(c) * (1 / A3) + # unfolded = c * (max_bound / np.sum(c)) + + # Replace the vector of coefs + result.append(unfolded) + + return result + + +def unfold_bsplines(bs: Union[BSplineParse, str], outfile=None) -> np.ndarray: + + if isinstance(bs, (str, Path)): + bs = BSplineParse(bs) + + # Scale the coeficents by the grid size + spacing_str = bs.elastix_params[21].split(' ')[1:4] + grid_size = [float(x.strip().strip(')')) for x in spacing_str] + + # this conversion may be needed if bs.coefs doesn't accept numpy array operations + # scale to (1, 1)-spacing grid + + bs.coefs[:, 0] /= grid_size[0] + bs.coefs[:, 1] /= grid_size[1] + bs.coefs[:, 2] /= grid_size[2] + # grid points that need to be corrected + idx_to_correct = ~(condition_1(bs.coefs) | condition_2(bs.coefs)) + + if not np.any(idx_to_correct): # No folding so write or return orginal + if outfile: + write_tform(bs.coefs, bs.elastix_params, outfile) + return + else: + return bs + # correct potentially problematic grid points + bs.coefs[idx_to_correct, :] = correct(bs.coefs[idx_to_correct, :]) + # rescale to original grid spacing + bs.coefs[:, 0] *= grid_size[0] + bs.coefs[:, 1] *= grid_size[1] + bs.coefs[:, 2] *= grid_size[2] + + if outfile: + write_tform(bs.coefs, bs.elastix_params, outfile) + + return bs.coefs + + +def write_tform(tform_params, other_data, tform_file, init_tform=None): + """ + Write elastix transform file + + Parameters + ---------- + tform_params + The spline coefficient or tranform parameters + m*n array. m = num control points, n = dims of coefs (3) + other_data + Lines of the other data from the transform parameer file + + tform_file + Where to write the tform + + init_tform + + Returns + ------- + + """ + + with open(tform_file, 'w') as fh: + + for line in other_data: + + if init_tform and line.startswith('(InitialTransformParametersFileName'): + line = f'(InitialTransformParametersFileName "{init_tform}")\n' + + if line.startswith('(NumberOfParameters '): + num_params = int(line.strip().strip(')').split(' ')[1]) + + fh.write(line) + + if tform_params.size != num_params: + raise ValueError(f"stated num params: {num_params} does not match actual {tform_params.size}") + # write out all the x column first by using fortran mode. Set print options to prevent ... shortening + np.set_printoptions(threshold=np.prod(tform_params.shape), suppress=True) + + fh.write(f"(TransformParameters ") + tform_params.flatten(order='f').tofile(fh, sep=" ", format="%.6f") + fh.write(')') diff --git a/lama/elastix/invert_transforms.py b/lama/elastix/invert_transforms.py index f17e235f..27248f61 100644 --- a/lama/elastix/invert_transforms.py +++ b/lama/elastix/invert_transforms.py @@ -15,7 +15,7 @@ from lama.registration_pipeline.validate_config import LamaConfig from . import (ELX_TRANSFORM_PREFIX, ELX_PARAM_PREFIX, LABEL_INVERTED_TRANFORM, - IMAGE_INVERTED_TRANSFORM, INVERT_CONFIG, IGNORE_FOLDER) + IMAGE_INVERTED_TRANSFORM, INVERT_CONFIG, RESOLUTION_IMGS_DIR, IMG_PYRAMID_DIR) def batch_invert_transform_parameters(config: Union[str, LamaConfig], @@ -48,7 +48,7 @@ def batch_invert_transform_parameters(config: Union[str, LamaConfig], # Get the image basenames from the first stage registration folder (usually rigid) # ignore images in non-relevent folder that may be present - volume_names = [x.stem for x in common.get_file_paths(reg_dirs[0], ignore_folder=IGNORE_FOLDER)] + volume_names = [x.stem for x in common.get_file_paths(reg_dirs[0], ignore_folders=[RESOLUTION_IMGS_DIR, IMG_PYRAMID_DIR])] inv_outdir = config.mkdir('inverted_transforms') diff --git a/lama/img_processing/dicom_to_nrrd.sh b/lama/img_processing/dicom_to_nrrd.sh new file mode 100644 index 00000000..24245e5d --- /dev/null +++ b/lama/img_processing/dicom_to_nrrd.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +#Bash script to convert DICOM files to NRRD format in batches. +#Currenty converts DICOMs to 16-bit utype (and as such may need modifiying). + +#Dependencies: +# Slicer +# dcm2niix module for Slicer (path to executable module depends on the host) - you need to also install the SlicerDcm2nii extension within Slicer + + +#TODO: add genotype detection by identifying 'wt' in the parent directory +#(uCT scans are performed before genotyping to reduce bias and therefore +#a method of simplified labelling post scanning will be required) + +#TODO: double check headers are compatible with LAMA. + +#Make directory +mkdir nrrd_out + +#loops through folders +for directory in */; +do + #Go within the specific DICOM directory: + dir_name=${directory%/*// /_} + cd ${dir_name} + + #Error trap for spaces in dicom filenames from previous PhD student + for f in *\ *; do mv "$f" "${f// /_}"; done + + #Perform the conversion + #TODO: find code to identify where dcm2niix is located. + cd ../ + /home/minc/.config/NA-MIC/Extensions-28257/SlicerDcm2nii/lib/Slicer-4.10/qt-scripted-modules/Resources/bin/dcm2niix -1 -d 0 -f ${dir_name%/} -o nrrd_out -e y -z n ${dir_name} +done + + + + + + + + + + + + + + + + + + + + diff --git a/lama/paths.py b/lama/paths.py index d18fb24c..747d79fe 100755 --- a/lama/paths.py +++ b/lama/paths.py @@ -9,13 +9,14 @@ # TODO: Link up this code with where the folders are cerated during a LAMA run. Then when changes to folder names occur +# TODO: raise error when a nonlama folder is suplied # they are replfected in this iterator def specimen_iterator(reg_out_dir: Path) -> Iterator[Tuple[Path, Path]]: """ Given a registration output root folder , iterate over the speciemns of each line in the subfolders - Note: lama considers the basliene as a single line. + Note: lama considers the baseliene as a single line. Parameters ---------- @@ -29,7 +30,28 @@ def specimen_iterator(reg_out_dir: Path) -> Iterator[Tuple[Path, Path]]: The path to the line directory The path to the specimen directory + Eaxmple folder structure showing which folder to use + ---------------------------------------------------- + ├── baseline + │ └── output # This folder can be used as reg_out_dir + │ ├── baseline + │ └── staging_info_volume.csv + └── mutants + └── output # This folder can be used as reg_out_dir + ├── Ascc1 + ├── Bbox1 + ├── Copb2 + ├── Shmt2 + ├── staging_info_volume.csv + ├── Synrg + ├── Tm2d1 + ├── Tm2d2 + ├── Vars2 + └── Vps37d + + """ + reg_out_dir = Path(reg_out_dir) if not reg_out_dir.is_dir(): raise FileNotFoundError(f'Cannot find output directory {reg_out_dir}') @@ -77,6 +99,7 @@ def setup(self): self.inverted_labels_dirs = self.get_multistage_data(self.outroot / 'inverted_labels') self.qc = self.specimen_root / 'output' / 'qc' self.qc_red_cyan_dirs = self.qc / 'red_cyan_overlays' + self.qc_inverted_labels = self.qc / 'inverted_label_overlay' self.qc_grey_dirs = self.qc / 'greyscales' return self diff --git a/lama/qc/collate_qc.py b/lama/qc/collate_qc.py index fd39911b..32ef99db 100644 --- a/lama/qc/collate_qc.py +++ b/lama/qc/collate_qc.py @@ -1,6 +1,6 @@ """ -Upon completion of a a lama run, this scrpt will collate all the QC images into a single file format x to enable -the fast identification of issues +Upon completion of a a lama run, this scrpt will collate all the QC images into a single html to enable +the rapid identification of issues. """ from pathlib import Path @@ -19,15 +19,17 @@ from lama.paths import DataIterator, SpecimenDataPaths -def make_grid(root: Path, outdir, qc_type='red_cyan'): +def make_grid(root: Path, outdir, qc_type='red_cyan', height='auto'): """ Parameters ---------- root - A Lama registrtion root for a line (or the baselines) containing multiple + A Lama registrtion root for a line (or the baselines) containing multiple specimens outpath Where to put the final image. Filetype is from extension (can use .png and .pdf at least) + height: + the css height property for the img (Use 'auto' or px. Percentage sclaing messes things up """ # Create series of images specimen-based view @@ -36,9 +38,11 @@ def make_grid(root: Path, outdir, qc_type='red_cyan'): spec: SpecimenDataPaths - oris = [HtmlGrid('axial'), - HtmlGrid('coronal'), - HtmlGrid('sagittal')] + single_file = True + + oris = [HtmlGrid('axial', single_file=single_file), + HtmlGrid('coronal', single_file=single_file), + HtmlGrid('sagittal', single_file=single_file)] for i, spec in enumerate(d): @@ -52,36 +56,63 @@ def make_grid(root: Path, outdir, qc_type='red_cyan'): rc_qc_dir = spec.qc_red_cyan_dirs elif qc_type == 'grey': rc_qc_dir = spec.qc_grey_dirs + elif qc_type == 'labels': + rc_qc_dir = spec.qc_inverted_labels for grid in oris: spec.specimen_id spec_title = f'{spec.line_id}: {spec.specimen_id}' grid.next_row(title=spec_title) + # s = list((rc_qc_dir / grid.title).iterdir()) for img_path in natsorted((rc_qc_dir / grid.title).iterdir(), key=lambda x: x.stem): - relpath = Path(os.path.relpath(img_path, outdir)) + img_path = img_path.resolve() + outdir = outdir.resolve() + + if single_file: # abspath + path_to_use = img_path + else: # Relative path + path_to_use = Path(os.path.relpath(img_path, outdir)) + img_caption = f'{truncate_str(img_path.stem, 30)}' tooltip = f'{spec.line_id}:{spec.specimen_id}:{img_path.stem}' - grid.next_image(relpath, img_caption, tooltip) + grid.next_image(path_to_use, img_caption, tooltip) for grid in oris: ori_out = outdir / f'{grid.title}.html' - grid.save(ori_out) + grid.save(ori_out, height) -def run(reg_root: Path, out_root: Path): +def run(reg_root: Path, out_root: Path, height): rc_dir = out_root / 'red_cyan' rc_dir.mkdir(exist_ok=True) - make_grid(reg_root, rc_dir, 'red_cyan') - + make_grid(reg_root, rc_dir, 'red_cyan', height=height) + # g_dir = out_root / 'greyscales' g_dir.mkdir(exist_ok=True) - make_grid(reg_root, g_dir, 'grey') + make_grid(reg_root, g_dir, 'grey', height=height) + g_dir = out_root / 'inverted_labels' + g_dir.mkdir(exist_ok=True) + try: + make_grid(reg_root, g_dir, 'labels', height) + except FileNotFoundError: + print('Cannot find inverted label overlays. Skipping') if __name__ =='__main__': - import sys - run(Path(sys.argv[1]), Path(sys.argv[2])) \ No newline at end of file + + import argparse + parser = argparse.ArgumentParser("Genate HTML registration image reports") + + parser.add_argument('-i', '--indir', dest='indir', help='A lama registration output directory containing one or more line directories', + required=True) + parser.add_argument('-o', '--out', dest='out', help='output directory', + required=True) + parser.add_argument('-height', '--height', dest='height', help='The height of the images. eg "auto", 200px', + required=False, default='auto') + + args = parser.parse_args() + run(Path(args.indir), Path(args.out), args.height) \ No newline at end of file diff --git a/lama/qc/common.py b/lama/qc/common.py new file mode 100644 index 00000000..2c8818c6 --- /dev/null +++ b/lama/qc/common.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import Tuple + + +def final_red_cyan_iterator(root, orientation='sagittal') -> Tuple[Path, str, str]: + """ + Get the red/cyan overlay of the final registered image + + Parameters + ---------- + root + Directory containing one or more lama lines directories + orientation + sagittal, coronal or axial + Returns + ------- + 0: Path to the image + 1: line_id + 2: specimen id + """ + for line_dir in root.it \ No newline at end of file diff --git a/lama/qc/folding.py b/lama/qc/folding.py new file mode 100644 index 00000000..646e690c --- /dev/null +++ b/lama/qc/folding.py @@ -0,0 +1,40 @@ +import numpy as np +import pandas as pd +from lama import common +from typing import Union +from pathlib import Path + + +def folding_report(jac_array, label_map: np.ndarray, label_info: Union[pd.DataFrame, str, Path] = None, outdir=None): + """ + Write out csv detailing the presence of folding per organ + """ + if jac_array is None: + return + labels = np.unique(label_map) + + if not isinstance(label_info, pd.DataFrame): + label_info = pd.read_csv(label_info, index_col=0) + + result = [] + for label in labels: + # print(label) + if label == 0: + continue + jac_label = jac_array[label_map == label] + label_size = jac_label.size + num_neg_vox = jac_label[jac_label < 0].size + total_folding = np.sum(jac_label[jac_label < 0]) + result.append([label, label_size, num_neg_vox, total_folding]) + + df = pd.DataFrame.from_records(result) + df.columns = ['label', 'label_size', 'num_neg_voxels', 'summed_folding'] + df.set_index('label', drop=True, inplace=True) + + if label_info is not None: + df = df.merge(label_info[['label_name']], left_index=True, right_index=True) + + if outdir: + df.to_csv(outdir / common.FOLDING_FILE_NAME) + else: + return df \ No newline at end of file diff --git a/lama/qc/formatting.py b/lama/qc/formatting.py new file mode 100644 index 00000000..c0bb9e8d --- /dev/null +++ b/lama/qc/formatting.py @@ -0,0 +1,33 @@ +""" +Add the scientific notation to the axes label +Adapted from: https://peytondmurray.github.io/coding/fixing-matplotlibs-scientific-notation/# +""" + + +def label_offset(ax, axis=None): + + if axis == "y" or not axis: + fmt = ax.yaxis.get_major_formatter() + ax.yaxis.offsetText.set_visible(False) + set_label = ax.set_ylabel + label = ax.get_ylabel() + + elif axis == "x" or not axis: + fmt = ax.xaxis.get_major_formatter() + ax.xaxis.offsetText.set_visible(False) + set_label = ax.set_xlabel + label = ax.get_xlabel() + + def update_label(event_axes): + offset = fmt.get_offset() + if offset == '': + set_label("{}".format(label)) + else: + set_label("{} {}".format(label, offset)) + return + + ax.callbacks.connect("ylim_changed", update_label) + ax.callbacks.connect("xlim_changed", update_label) + ax.figure.canvas.draw() + update_label(None) + return diff --git a/lama/qc/img_grid.py b/lama/qc/img_grid.py index 62efc033..01ea4f97 100644 --- a/lama/qc/img_grid.py +++ b/lama/qc/img_grid.py @@ -4,6 +4,7 @@ from pathlib import Path import shutil +import base64 class HtmlGrid: @@ -11,7 +12,7 @@ class HtmlGrid: Create a grid of images """ - def __init__(self, title): + def __init__(self, title, single_file=False): self.title = title padding = 20 margin_bottom = 20 @@ -24,6 +25,7 @@ def __init__(self, title): '
\n' '