diff tomo.py @ 0:cb1b0d757704 draft

"planemo upload for repository https://github.com/rolfverberg/galaxytools commit 2da52c7db6def807073a1d437a00e0e2a8e7e72e"
author rv43
date Tue, 29 Mar 2022 16:10:16 +0000
parents
children e4778148df6b
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tomo.py	Tue Mar 29 16:10:16 2022 +0000
@@ -0,0 +1,2037 @@
+#!/usr/bin/env python3
+
+# -*- coding: utf-8 -*-
+"""
+Created on Fri Dec 10 09:54:37 2021
+
+@author: rv43
+"""
+
+import logging
+
+import os
+import sys
+import getopt
+import re
+import io
+try:
+    import pyinputplus as pyip
+except:
+    pass
+import numpy as np
+import numexpr as ne
+import multiprocessing as mp
+import scipy.ndimage as spi
+import tomopy
+from time import time
+from skimage.transform import iradon
+from skimage.restoration import denoise_tv_chambolle
+
+import msnc_tools as msnc
+
+class set_numexpr_threads:
+
+    def __init__(self, nthreads):
+        cpu_count = mp.cpu_count()
+        if nthreads is None or nthreads > cpu_count:
+            self.n = cpu_count
+        else:
+            self.n = nthreads
+
+    def __enter__(self):
+        self.oldn = ne.set_num_threads(self.n)
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        ne.set_num_threads(self.oldn)
+
+class ConfigTomo(msnc.Config):
+    """Class for processing a config file.
+    """
+
+    def __init__(self, config_file=None, config_dict=None):
+        super().__init__(config_file, config_dict)
+
+    def _validate_txt(self):
+        """Returns False if any required config parameter is illegal or missing.
+        """
+        is_valid = True
+
+        # Check for required first-level keys
+        pars_required = ['tdf_data_path', 'tbf_data_path', 'detector_id']
+        pars_missing = []
+        is_valid = super().validate(pars_required, pars_missing)
+        if len(pars_missing) > 0:
+            logging.error(f'Missing item(s) in config file: {", ".join(pars_missing)}')
+        self.detector_id = self.config.get('detector_id')
+
+        # Find tomography dark field images file/folder
+        self.tdf_data_path = self.config.get('tdf_data_path')
+
+        # Find tomography bright field images file/folder
+        self.tbf_data_path = self.config.get('tbf_data_path')
+
+        # Check number of tomography image stacks
+        self.num_tomo_stacks = self.config.get('num_tomo_stacks', 1)
+        if not msnc.is_int(self.num_tomo_stacks, 1):
+            self.num_tomo_stacks = None
+            msnc.illegal_value('num_tomo_stacks', self.num_tomo_stacks, 'config file')
+            return False
+        logging.info(f'num_tomo_stacks = {self.num_tomo_stacks}')
+
+        # Find tomography images file/folders and stack parameters
+        tomo_data_paths_indices = sorted({key:value for key,value in self.config.items()
+            if 'tomo_data_path' in key}.items())
+        if len(tomo_data_paths_indices) != self.num_tomo_stacks:
+            logging.error(f'Incorrect number of tomography data path names in config file')
+            is_valid = False
+        self.tomo_data_paths = [tomo_data_paths_indices[i][1] for i in range(self.num_tomo_stacks)]
+        self.tomo_data_indices = [msnc.get_trailing_int(tomo_data_paths_indices[i][0])
+                if msnc.get_trailing_int(tomo_data_paths_indices[i][0]) else None
+                for i in range(self.num_tomo_stacks)]
+        tomo_ref_height_indices = sorted({key:value for key,value in self.config.items()
+                if 'z_pos' in key}.items())
+        if self.num_tomo_stacks > 1 and len(tomo_ref_height_indices) != self.num_tomo_stacks:
+            logging.error(f'Incorrect number of tomography reference heights in config file')
+            is_valid = False
+        if len(tomo_ref_height_indices):
+            self.tomo_ref_heights = [
+                    tomo_ref_height_indices[i][1] for i in range(self.num_tomo_stacks)]
+        else:
+            self.tomo_ref_heights = [0.0]*self.num_tomo_stacks
+
+        # Check tomo angle (theta) range
+        self.start_theta = self.config.get('start_theta', 0.)
+        if not msnc.is_num(self.start_theta, 0.):
+            msnc.illegal_value('start_theta', self.start_theta, 'config file')
+            is_valid = False
+        logging.debug(f'start_theta = {self.start_theta}')
+        self.end_theta = self.config.get('end_theta', 180.)
+        if not msnc.is_num(self.end_theta, self.start_theta):
+            msnc.illegal_value('end_theta', self.end_theta, 'config file')
+            is_valid = False
+        logging.debug(f'end_theta = {self.end_theta}')
+        self.num_thetas = self.config.get('num_thetas')
+        if not (self.num_thetas is None or msnc.is_int(self.num_thetas, 1)):
+            msnc.illegal_value('num_thetas', self.num_thetas, 'config file')
+            self.num_thetas = None
+            is_valid = False
+        logging.debug(f'num_thetas = {self.num_thetas}')
+
+        return is_valid
+
+    def _validate_yaml(self):
+        """Returns False if any required config parameter is illegal or missing.
+        """
+        is_valid = True
+
+        # Check for required first-level keys
+        pars_required = ['dark_field', 'bright_field', 'stack_info', 'detector']
+        pars_missing = []
+        is_valid = super().validate(pars_required, pars_missing)
+        if len(pars_missing) > 0:
+            logging.error(f'Missing item(s) in config file: {", ".join(pars_missing)}')
+        self.detector_id = self.config['detector'].get('id')
+
+        # Find tomography dark field images file/folder
+        self.tdf_data_path = self.config['dark_field'].get('data_path')
+
+        # Find tomography bright field images file/folder
+        self.tbf_data_path = self.config['bright_field'].get('data_path')
+
+        # Check number of tomography image stacks
+        stack_info = self.config['stack_info']
+        self.num_tomo_stacks = stack_info.get('num', 1)
+        if not msnc.is_int(self.num_tomo_stacks, 1):
+            self.num_tomo_stacks = None
+            msnc.illegal_value('stack_info:num', self.num_tomo_stacks, 'config file')
+            return False
+        logging.info(f'num_tomo_stacks = {self.num_tomo_stacks}')
+
+        # Find tomography images file/folders and stack parameters
+        stacks = stack_info.get('stacks')
+        if stacks is None or len(stacks) is not self.num_tomo_stacks:
+            msnc.illegal_value('stack_info:stacks', stacks, 'config file')
+            return False
+        self.tomo_data_paths = []
+        self.tomo_data_indices = []
+        self.tomo_ref_heights = []
+        for stack in stacks:
+            self.tomo_data_paths.append(stack.get('data_path'))
+            self.tomo_data_indices.append(stack.get('index'))
+            self.tomo_ref_heights.append(stack.get('ref_height'))
+
+        # Check tomo angle (theta) range
+        theta_range = self.config.get('theta_range')
+        if theta_range is None:
+            self.start_theta = 0.
+            self.end_theta = 180.
+            self.num_thetas = None
+        else:
+            self.start_theta = theta_range.get('start', 0.)
+            if not msnc.is_num(self.start_theta, 0.):
+                msnc.illegal_value('theta_range:start', self.start_theta, 'config file')
+                is_valid = False
+            logging.debug(f'start_theta = {self.start_theta}')
+            self.end_theta = theta_range.get('end', 180.)
+            if not msnc.is_num(self.end_theta, self.start_theta):
+                msnc.illegal_value('theta_range:end', self.end_theta, 'config file')
+                is_valid = False
+            logging.debug(f'end_theta = {self.end_theta}')
+            self.num_thetas = theta_range.get('num')
+            if self.num_thetas and not msnc.is_int(self.num_thetas, 1):
+                msnc.illegal_value('theta_range:num', self.num_thetas, 'config file')
+                self.num_thetas = None
+                is_valid = False
+            logging.debug(f'num_thetas = {self.num_thetas}')
+
+        return is_valid
+
+    def validate(self):
+        """Returns False if any required config parameter is illegal or missing.
+        """
+        is_valid = True
+
+        # Check work_folder (shared by both file formats)
+        work_folder = os.path.abspath(self.config.get('work_folder', ''))
+        if not os.path.isdir(work_folder):
+            msnc.illegal_value('work_folder', work_folder, 'config file')
+            is_valid = False
+        logging.info(f'work_folder: {work_folder}')
+
+        # Check data filetype (shared by both file formats)
+        self.data_filetype = self.config.get('data_filetype', 'tif')
+        if not isinstance(self.data_filetype, str) or (self.data_filetype != 'tif' and
+                self.data_filetype != 'h5'):
+            msnc.illegal_value('data_filetype', self.data_filetype, 'config file')
+
+        if self.suffix == '.yml' or self.suffix == '.yaml':
+            is_valid = self._validate_yaml()
+        elif self.suffix == '.txt':
+            is_valid = self._validate_txt()
+        else:
+            logging.error(f'Undefined or illegal config file extension: {self.suffix}')
+
+        # Find tomography bright field images file/folder
+        if self.tdf_data_path:
+            if self.data_filetype == 'h5':
+                if isinstance(self.tdf_data_path, str):
+                    if not os.path.isabs(self.tdf_data_path):
+                        self.tdf_data_path = os.path.abspath(
+                                f'{work_folder}/{self.tdf_data_path}')
+                else:
+                    msnc.illegal_value('tdf_data_path', tdf_data_fil, 'config file')
+                    is_valid = False
+            else:
+                if isinstance(self.tdf_data_path, int):
+                    self.tdf_data_path = os.path.abspath(
+                            f'{work_folder}/{self.tdf_data_path}/nf')
+                elif isinstance(self.tdf_data_path, str):
+                    if not os.path.isabs(self.tdf_data_path):
+                        self.tdf_data_path = os.path.abspath(
+                                f'{work_folder}/{self.tdf_data_path}')
+                else:
+                    msnc.illegal_value('tdf_data_path', self.tdf_data_path, 'config file')
+                    is_valid = False
+        logging.info(f'dark field images path = {self.tdf_data_path}')
+
+        # Find tomography bright field images file/folder
+        if self.tbf_data_path:
+            if self.data_filetype == 'h5':
+                if isinstance(self.tbf_data_path, str):
+                    if not os.path.isabs(self.tbf_data_path):
+                        self.tbf_data_path = os.path.abspath(
+                                f'{work_folder}/{self.tbf_data_path}')
+                else:
+                    msnc.illegal_value('tbf_data_path', tbf_data_fil, 'config file')
+                    is_valid = False
+            else:
+                if isinstance(self.tbf_data_path, int):
+                    self.tbf_data_path = os.path.abspath(
+                            f'{work_folder}/{self.tbf_data_path}/nf')
+                elif isinstance(self.tbf_data_path, str):
+                    if not os.path.isabs(self.tbf_data_path):
+                        self.tbf_data_path = os.path.abspath(
+                                f'{work_folder}/{self.tbf_data_path}')
+                else:
+                    msnc.illegal_value('tbf_data_path', self.tbf_data_path, 'config file')
+                    is_valid = False
+        logging.info(f'bright field images path = {self.tbf_data_path}')
+
+        # Find tomography images file/folders and stack parameters
+        tomo_data_paths = []
+        tomo_data_indices = []
+        tomo_ref_heights = []
+        for data_path, index, ref_height in zip(self.tomo_data_paths, self.tomo_data_indices,
+                self.tomo_ref_heights):
+            if self.data_filetype == 'h5':
+                if isinstance(data_path, str):
+                    if not os.path.isabs(data_path):
+                        data_path = os.path.abspath(f'{work_folder}/{data_path}')
+                else:
+                    msnc.illegal_value(f'stack_info:stacks:data_path', data_path, 'config file')
+                    is_valid = False
+                    data_path = None
+            else:
+                if isinstance(data_path, int):
+                    data_path = os.path.abspath(f'{work_folder}/{data_path}/nf')
+                elif isinstance(data_path, str):
+                    if not os.path.isabs(data_path):
+                        data_path = os.path.abspath(f'{work_folder}/{data_path}')
+                else:
+                    msnc.illegal_value(f'stack_info:stacks:data_path', data_path, 'config file')
+                    is_valid = False
+                    data_path = None
+            tomo_data_paths.append(data_path)
+            if index is None:
+                if self.num_tomo_stacks > 1:
+                    logging.error('Missing stack_info:stacks:index in config file')
+                    is_valid = False
+                    index = None
+                else:
+                    index = 1
+            elif not isinstance(index, int):
+                msnc.illegal_value(f'stack_info:stacks:index', index, 'config file')
+                is_valid = False
+                index = None
+            tomo_data_indices.append(index)
+            if ref_height is None:
+                if self.num_tomo_stacks > 1:
+                    logging.error('Missing stack_info:stacks:ref_height in config file')
+                    is_valid = False
+                    ref_height = None
+                else:
+                    ref_height = 0.
+            elif not msnc.is_num(ref_height):
+                msnc.illegal_value(f'stack_info:stacks:ref_height', ref_height, 'config file')
+                is_valid = False
+                ref_height = None
+            # Set reference heights relative to first stack
+            if (len(tomo_ref_heights) and msnc.is_num(ref_height) and
+                    msnc.is_num(tomo_ref_heights[0])):
+                ref_height = (round(ref_height-tomo_ref_heights[0], 3))
+            tomo_ref_heights.append(ref_height)
+        tomo_ref_heights[0] = 0.0
+        logging.info('tomography data paths:')
+        for i in range(self.num_tomo_stacks):
+            logging.info(f'    {tomo_data_paths[i]}')
+        logging.info(f'tomography data path indices: {tomo_data_indices}')
+        logging.info(f'tomography reference heights: {tomo_ref_heights}')
+
+        # Update config in memory
+        if self.suffix == '.txt':
+            self.config = {}
+        dark_field = self.config.get('dark_field')
+        if dark_field is None:
+            self.config['dark_field'] = {'data_path' : self.tdf_data_path}
+        else:
+            self.config['dark_field']['data_path'] = self.tdf_data_path
+        bright_field = self.config.get('bright_field')
+        if bright_field is None:
+            self.config['bright_field'] = {'data_path' : self.tbf_data_path}
+        else:
+            self.config['bright_field']['data_path'] = self.tbf_data_path
+        detector = self.config.get('detector')
+        if detector is None:
+            self.config['detector'] = {'id' : self.detector_id}
+        else:
+            detector['id'] = self.detector_id
+        self.config['work_folder'] = work_folder
+        self.config['data_filetype'] = self.data_filetype
+        stack_info = self.config.get('stack_info')
+        if stack_info is None:
+            stacks = []
+            for i in range(self.num_tomo_stacks):
+                stacks.append({'data_path' : tomo_data_paths[i], 'index' : tomo_data_indices[i],
+                        'ref_height' : tomo_ref_heights[i]})
+            self.config['stack_info'] = {'num' : self.num_tomo_stacks, 'stacks' : stacks}
+        else:
+            stack_info['num'] = self.num_tomo_stacks
+            stacks = stack_info.get('stacks')
+            for i,stack in enumerate(stacks):
+                stack['data_path'] = tomo_data_paths[i]
+                stack['index'] = tomo_data_indices[i]
+                stack['ref_height'] = tomo_ref_heights[i]
+        if self.num_thetas:
+            theta_range = {'start' : self.start_theta, 'end' : self.end_theta,
+                    'num' : self.num_thetas}
+        else:
+            theta_range = {'start' : self.start_theta, 'end' : self.end_theta}
+        self.config['theta_range'] = theta_range
+
+        # Cleanup temporary validation variables
+        del self.tdf_data_path
+        del self.tbf_data_path
+        del self.detector_id
+        del self.data_filetype
+        del self.num_tomo_stacks
+        del self.tomo_data_paths
+        del self.tomo_data_indices
+        del self.tomo_ref_heights
+        del self.start_theta
+        del self.end_theta
+        del self.num_thetas
+
+        return is_valid
+
+class Tomo:
+    """Processing tomography data with misalignment.
+    """
+    
+    def __init__(self, config_file=None, config_dict=None, config_out=None, output_folder='.',
+            log_level='INFO', log_stream='tomo.log', galaxy_flag=False, test_mode=False):
+        """Initialize with optional config input file or dictionary
+        """
+        self.ncore = mp.cpu_count()
+        self.config_out = config_out
+        self.output_folder = output_folder
+        self.galaxy_flag = galaxy_flag
+        self.test_mode = test_mode
+        self.save_plots = True # Make input argument?
+        self.save_plots_only = True # Make input argument?
+        self.cf = None
+        self.config = None
+        self.is_valid = True
+        self.tdf = np.array([])
+        self.tbf = np.array([])
+        self.tomo_stacks = []
+        self.tomo_recon_stacks = []
+
+        # Validate input parameters
+        if config_file is not None and not os.path.isfile(config_file):
+            raise OSError(f'Invalid config_file input {config_file} {type(config_file)}')
+        if config_dict is not None and not isinstance(config_dict, dict):
+            raise ValueError(f'Invalid config_dict input {config_dict} {type(config_dict)}')
+        if config_out is not None:
+            if isinstance(config_out, str):
+                if isinstance(log_stream, str):
+                    path = os.path.split(log_stream)[0]
+                    if path and not os.path.isdir(path):
+                        raise OSError(f'Invalid log_stream path')
+            else:
+                raise OSError(f'Invalid config_out input {config_out} {type(config_out)}')
+        if not os.path.isdir(output_folder):
+            raise OSError(f'Invalid output_folder input {output_folder} {type(output_folder)}')
+        if isinstance(log_stream, str):
+            path = os.path.split(log_stream)[0]
+            if path and not os.path.isdir(path):
+                raise OSError(f'Invalid log_stream path')
+            if not os.path.isabs(path):
+                log_stream = f'{output_folder}/{log_stream}'
+        if not isinstance(galaxy_flag, bool):
+            raise ValueError(f'Invalid galaxy_flag input {galaxy_flag} {type(galaxy_flag)}')
+        if not isinstance(test_mode, bool):
+            raise ValueError(f'Invalid test_mode input {test_mode} {type(test_mode)}')
+
+        # Set log configuration
+        logging_format = '%(asctime)s : %(levelname)s - %(module)s : %(funcName)s - %(message)s'
+        if self.test_mode:
+            self.save_plots_only = True
+            if isinstance(log_stream, str):
+                logging.basicConfig(filename=f'{log_stream}', filemode='w',
+                        format=logging_format, level=logging.WARNING, force=True)
+            elif isinstance(log_stream, io.TextIOWrapper):
+                logging.basicConfig(filemode='w', format=logging_format, level=logging.WARNING,
+                        stream=log_stream, force=True)
+            else:
+                raise ValueError(f'Invalid log_stream: {log_stream}')
+            logging.warning('Ignoring log_level argument in test mode')
+        else:
+            level = getattr(logging, log_level.upper(), None)
+            if not isinstance(level, int):
+                raise ValueError(f'Invalid log_level: {log_level}')
+            if log_stream is sys.stdout:
+                logging.basicConfig(format=logging_format, level=level, force=True,
+                        handlers=[logging.StreamHandler()])
+            else:
+                if isinstance(log_stream, str):
+                    logging.basicConfig(filename=f'{log_stream}', filemode='w',
+                            format=logging_format, level=level, force=True)
+                elif isinstance(log_stream, io.TextIOWrapper):
+                    logging.basicConfig(filemode='w', format=logging_format, level=level,
+                            stream=log_stream, force=True)
+                else:
+                    raise ValueError(f'Invalid log_stream: {log_stream}')
+                stream_handler = logging.StreamHandler()
+                logging.getLogger().addHandler(stream_handler)
+                stream_handler.setLevel(logging.WARNING)
+                stream_handler.setFormatter(logging.Formatter(logging_format))
+
+        # Check/set output config file name
+        if self.config_out is None:
+            self.config_out = f'{self.output_folder}/config.yaml'
+        elif (self.config_out is os.path.basename(self.config_out) and
+                not os.path.isabs(self.config_out)):
+            self.config_out = f'{self.output_folder}/{self.config_out}'
+
+        logging.info(f'ncore = {self.ncore}')
+        logging.debug(f'config_file = {config_file}')
+        logging.debug(f'config_dict = {config_dict}')
+        logging.debug(f'config_out = {self.config_out}')
+        logging.debug(f'output_folder = {self.output_folder}')
+        logging.debug(f'log_stream = {log_stream}')
+        logging.debug(f'log_level = {log_level}')
+        logging.debug(f'galaxy_flag = {self.galaxy_flag}')
+        logging.debug(f'test_mode = {self.test_mode}')
+
+        # Create config object and load config file 
+        self.cf = ConfigTomo(config_file, config_dict)
+        if not self.cf.load_flag:
+            self.is_valid = False
+            return
+
+        if self.galaxy_flag:
+            self.ncore = 1 #RV can I set this? mp.cpu_count()
+            assert(self.output_folder == '.')
+            assert(self.test_mode is False)
+            self.save_plots = True
+            self.save_plots_only = True
+        else:
+            # Input validation is already performed during link_data_to_galaxy
+
+            # Check config file parameters
+            self.is_valid =  self.cf.validate()
+
+            # Load detector info file
+            df = msnc.Detector(self.cf.config['detector']['id'])
+
+            # Check detector info file parameters
+            if df.validate():
+                pixel_size = df.getPixelSize()
+                num_rows, num_columns = df.getDimensions()
+                if not pixel_size or not num_rows or not num_columns:
+                    self.is_valid = False
+            else:
+                pixel_size = None
+                num_rows = None
+                num_columns = None
+                self.is_valid = False
+
+            # Update config
+            self.cf.config['detector']['pixel_size'] = pixel_size
+            self.cf.config['detector']['rows'] = num_rows
+            self.cf.config['detector']['columns'] = num_columns
+            logging.debug(f'pixel_size = self.cf.config["detector"]["pixel_size"]')
+            logging.debug(f'num_rows: {self.cf.config["detector"]["rows"]}')
+            logging.debug(f'num_columns: {self.cf.config["detector"]["columns"]}')
+
+        # Safe config to file
+        if self.is_valid:
+            self.cf.saveFile(self.config_out)
+
+        # Initialize shortcut to config
+        self.config = self.cf.config
+
+        # Initialize tomography stack
+        num_tomo_stacks = self.config['stack_info']['num']
+        if num_tomo_stacks:
+            self.tomo_stacks = [np.array([]) for _ in range(num_tomo_stacks)]
+            self.tomo_recon_stacks = [np.array([]) for _ in range(num_tomo_stacks)]
+
+        logging.debug(f'save_plots = {self.save_plots}')
+        logging.debug(f'save_plots_only = {self.save_plots_only}')
+
+    def _selectImageRanges(self, available_stacks=None):
+        """Select image files to be included in analysis.
+        """
+        self.is_valid = True
+        stack_info = self.config['stack_info']
+        if available_stacks is None:
+            available_stacks = [False]*stack_info['num']
+        elif len(available_stacks) != stack_info['num']:
+            logging.warning('Illegal dimension of available_stacks in getImageFiles '+
+                    f'({len(available_stacks)}');
+            available_stacks = [False]*stack_info['num']
+
+        # Check number of tomography angles/thetas
+        num_thetas = self.config['theta_range'].get('num')
+        if num_thetas is None:
+            num_thetas = pyip.inputInt('\nEnter the number of thetas (>0): ', greaterThan=0)
+        elif not msnc.is_int(num_thetas, 0):
+            msnc.illegal_value('num_thetas', num_thetas, 'config file')
+            self.is_valid = False
+            return
+        self.config['theta_range']['num'] = num_thetas
+        logging.debug(f'num_thetas = {self.config["theta_range"]["num"]}')
+
+        # Find tomography dark field images
+        dark_field = self.config['dark_field']
+        img_start = dark_field.get('img_start', -1)
+        img_offset = dark_field.get('img_offset', -1)
+        num_imgs = dark_field.get('num', 0)
+        if not self.test_mode:
+            img_start, img_offset, num_imgs = msnc.selectImageRange(img_start, img_offset,
+                num_imgs, 'dark field')
+        if img_start < 0 or num_imgs < 1:
+            logging.error('Unable to find suitable dark field images')
+            if dark_field['data_path']:
+                self.is_valid = False
+        dark_field['img_start'] = img_start
+        dark_field['img_offset'] = img_offset
+        dark_field['num'] = num_imgs
+        logging.debug(f'Dark field image start index: {dark_field["img_start"]}')
+        logging.debug(f'Dark field image offset: {dark_field["img_offset"]}')
+        logging.debug(f'Number of dark field images: {dark_field["num"]}')
+
+        # Find tomography bright field images
+        bright_field = self.config['bright_field']
+        img_start = bright_field.get('img_start', -1)
+        img_offset = bright_field.get('img_offset', -1)
+        num_imgs = bright_field.get('num', 0)
+        if not self.test_mode:
+            img_start, img_offset, num_imgs = msnc.selectImageRange(img_start, img_offset,
+                num_imgs, 'bright field')
+        if img_start < 0 or num_imgs < 1:
+            logging.error('Unable to find suitable bright field images')
+            if bright_field['data_path']:
+                self.is_valid = False
+        bright_field['img_start'] = img_start
+        bright_field['img_offset'] = img_offset
+        bright_field['num'] = num_imgs
+        logging.debug(f'Bright field image start index: {bright_field["img_start"]}')
+        logging.debug(f'Bright field image offset: {bright_field["img_offset"]}')
+        logging.debug(f'Number of bright field images: {bright_field["num"]}')
+
+        # Find tomography images
+        for i,stack in enumerate(stack_info['stacks']):
+            # Check if stack is already loaded or available
+            if self.tomo_stacks[i].size or available_stacks[i]:
+                continue
+            index = stack['index']
+            img_start = stack.get('img_start', -1)
+            img_offset = stack.get('img_offset', -1)
+            num_imgs = stack.get('num', 0)
+            if not self.test_mode:
+                img_start, img_offset, num_imgs = msnc.selectImageRange(img_start, img_offset,
+                        num_imgs, f'tomography stack {index}', num_thetas)
+                if img_start < 0 or num_imgs != num_thetas:
+                    logging.error('Unable to find suitable tomography images')
+                    self.is_valid = False
+            stack['img_start'] = img_start
+            stack['img_offset'] = img_offset
+            stack['num'] = num_imgs
+            logging.debug(f'Tomography stack {index} image start index: {stack["img_start"]}')
+            logging.debug(f'Tomography stack {index} image offset: {stack["img_offset"]}')
+            logging.debug(f'Number of tomography images for stack {index}: {stack["num"]}')
+            available_stacks[i] = True
+
+        # Safe updated config to file
+        if self.is_valid:
+            self.cf.saveFile(self.config_out)
+
+        return
+
+    def _genDark(self, tdf_files, dark_field_pngname):
+        """Generate dark field.
+        """
+        # Load the dark field images
+        logging.debug('Loading dark field...')
+        dark_field = self.config['dark_field']
+        tdf_stack = msnc.loadImageStack(tdf_files, self.config['data_filetype'],
+                dark_field['img_offset'], dark_field['num'])
+
+        # Take median
+        self.tdf = np.median(tdf_stack, axis=0)
+        del tdf_stack
+
+        # RV make input of some kind (not always needed)
+        tdf_cutoff = 21
+        self.tdf[self.tdf > tdf_cutoff] = np.nan
+        tdf_mean = np.nanmean(self.tdf)
+        logging.debug(f'tdf_cutoff = {tdf_cutoff}')
+        logging.debug(f'tdf_mean = {tdf_mean}')
+        np.nan_to_num(self.tdf, copy=False, nan=tdf_mean, posinf=tdf_mean, neginf=0.)
+        if not self.test_mode and not self.galaxy_flag:
+            msnc.quickImshow(self.tdf, title='dark field', path=self.output_folder,
+                    save_fig=self.save_plots, save_only=self.save_plots_only)
+        elif self.galaxy_flag:
+            msnc.quickImshow(self.tdf, title='dark field', name=dark_field_pngname,
+                    save_fig=True, save_only=True)
+
+    def _genBright(self, tbf_files, bright_field_pngname):
+        """Generate bright field.
+        """
+        # Load the bright field images
+        logging.debug('Loading bright field...')
+        bright_field = self.config['bright_field']
+        tbf_stack = msnc.loadImageStack(tbf_files, self.config['data_filetype'],
+                bright_field['img_offset'], bright_field['num'])
+
+        # Take median
+        """Median or mean: It may be best to try the median because of some image 
+           artifacts that arise due to crinkles in the upstream kapton tape windows 
+           causing some phase contrast images to appear on the detector.
+           One thing that also may be useful in a future implementation is to do a 
+           brightfield adjustment on EACH frame of the tomo based on a ROI in the 
+           corner of the frame where there is no sample but there is the direct X-ray 
+           beam because there is frame to frame fluctuations from the incoming beam. 
+           We don’t typically account for them but potentially could.
+        """
+        self.tbf = np.median(tbf_stack, axis=0)
+        del tbf_stack
+
+        # Subtract dark field
+        if self.tdf.size:
+            self.tbf -= self.tdf
+        else:
+            logging.warning('Dark field unavailable')
+        if not self.test_mode and not self.galaxy_flag:
+            msnc.quickImshow(self.tbf, title='bright field', path=self.output_folder,
+                    save_fig=self.save_plots, save_only=self.save_plots_only)
+        elif self.galaxy_flag:
+            msnc.quickImshow(self.tbf, title='bright field', name=bright_field_pngname,
+                    save_fig=True, save_only=True)
+
+    def _setDetectorBounds(self, tomo_stack_files, tomo_field_pngname, detectorbounds_pngname):
+        """Set vertical detector bounds for image stack.
+        """
+        preprocess = self.config.get('preprocess')
+        if preprocess is None:
+            img_x_bounds = [None, None]
+        else:
+            img_x_bounds = preprocess.get('img_x_bounds', [0, self.tbf.shape[0]])
+        if self.test_mode:
+            # Update config and save to file
+            if preprocess is None:
+                self.cf.config['preprocess'] = {'img_x_bounds' : [0, self.tbf.shape[0]]}
+            else:
+                preprocess['img_x_bounds'] = img_x_bounds
+            self.cf.saveFile(self.config_out)
+            return
+
+        # Check reference heights
+        pixel_size = self.config['detector']['pixel_size']
+        if pixel_size is None:
+            raise ValueError('Detector pixel size unavailable')
+        if not self.tbf.size:
+            raise ValueError('Bright field unavailable')
+        num_x_min = None
+        num_tomo_stacks = self.config['stack_info']['num']
+        stacks = self.config['stack_info']['stacks']
+        if num_tomo_stacks > 1:
+            delta_z = stacks[1]['ref_height']-stacks[0]['ref_height']
+            for i in range(2, num_tomo_stacks):
+                delta_z = min(delta_z, stacks[i]['ref_height']-stacks[i-1]['ref_height'])
+            logging.debug(f'delta_z = {delta_z}')
+            num_x_min = int(delta_z/pixel_size)+1
+            logging.debug(f'num_x_min = {num_x_min}')
+            if num_x_min > self.tbf.shape[0]:
+                logging.warning('Image bounds and pixel size prevent seamless stacking')
+                num_x_min = self.tbf.shape[0]
+
+        # Select image bounds
+        if self.galaxy_flag:
+            if num_x_min is None or num_x_min >= self.tbf.shape[0]:
+                img_x_bounds = [0, self.tbf.shape[0]]
+            else:
+                margin = int(num_x_min/10)
+                offset = min(0, int((self.tbf.shape[0]-num_x_min)/2-margin))
+                img_x_bounds = [offset, num_x_min+offset+2*margin]
+            tomo_stack = msnc.loadImageStack(tomo_stack_files[0], self.config['data_filetype'],
+                stacks[0]['img_offset'], 1)
+            x_sum = np.sum(tomo_stack[0,:,:], 1)
+            title = f'tomography image at theta={self.config["theta_range"]["start"]}'
+            msnc.quickImshow(tomo_stack[0,:,:], title=title, name=tomo_field_pngname,
+                    save_fig=True, save_only=True)
+            msnc.quickPlot((range(x_sum.size), x_sum),
+                    ([img_x_bounds[0], img_x_bounds[0]], [x_sum.min(), x_sum.max()], 'r-'),
+                    ([img_x_bounds[1]-1, img_x_bounds[1]-1], [x_sum.min(), x_sum.max()], 'r-'),
+                    title='sum over theta and y', name=detectorbounds_pngname,
+                    save_fig=True, save_only=True)
+            
+            # Update config and save to file
+            if preprocess is None:
+                self.cf.config['preprocess'] = {'img_x_bounds' : img_x_bounds}
+            else:
+                preprocess['img_x_bounds'] = img_x_bounds
+            self.cf.saveFile(self.config_out)
+            return
+
+        # For one tomography stack only: load the first image
+        title = None
+        msnc.quickImshow(self.tbf, title='bright field')
+        if num_tomo_stacks == 1:
+            tomo_stack = msnc.loadImageStack(tomo_stack_files[0], self.config['data_filetype'],
+                stacks[0]['img_offset'], 1)
+            title = f'tomography image at theta={self.config["theta_range"]["start"]}'
+            msnc.quickImshow(tomo_stack[0,:,:], title=title)
+            tomo_or_bright = pyip.inputNum('\nSelect image bounds from bright field (0) or '+
+                    'from first tomography image (1): ', min=0, max=1)
+        else:
+            print('\nSelect image bounds from bright field')
+            tomo_or_bright = 0
+        if tomo_or_bright:
+            x_sum = np.sum(tomo_stack[0,:,:], 1)
+            use_bounds = 'no'
+            if img_x_bounds[0] is not None and img_x_bounds[1] is not None:
+                if img_x_bounds[0] < 0:
+                    msnc.illegal_value('img_x_bounds[0]', img_x_bounds[0], 'config file')
+                    img_x_bounds[0] = 0
+                if not img_x_bounds[0] < img_x_bounds[1] <= x_sum.size:
+                    msnc.illegal_value('img_x_bounds[1]', img_x_bounds[1], 'config file')
+                    img_x_bounds[1] = x_sum.size
+                tomo_tmp = tomo_stack[0,:,:]
+                tomo_tmp[img_x_bounds[0],:] = tomo_stack[0,:,:].max()
+                tomo_tmp[img_x_bounds[1]-1,:] = tomo_stack[0,:,:].max()
+                title = f'tomography image at theta={self.config["theta_range"]["start"]}'
+                msnc.quickImshow(tomo_stack[0,:,:], title=title)
+                msnc.quickPlot((range(x_sum.size), x_sum),
+                        ([img_x_bounds[0], img_x_bounds[0]], [x_sum.min(), x_sum.max()], 'r-'),
+                        ([img_x_bounds[1]-1, img_x_bounds[1]-1], [x_sum.min(), x_sum.max()], 'r-'),
+                        title='sum over theta and y')
+                print(f'lower bound = {img_x_bounds[0]} (inclusive)\n'+
+                        f'upper bound = {img_x_bounds[1]} (exclusive)]')
+                use_bounds =  pyip.inputYesNo('Accept these bounds ([y]/n)?: ', blank=True)
+            if use_bounds == 'no':
+                img_x_bounds = msnc.selectImageBounds(tomo_stack[0,:,:], 0,
+                        img_x_bounds[0], img_x_bounds[1], num_x_min,
+                        f'tomography image at theta={self.config["theta_range"]["start"]}')
+                if num_x_min is not None and img_x_bounds[1]-img_x_bounds[0]+1 < num_x_min:
+                    logging.warning('Image bounds and pixel size prevent seamless stacking')
+                tomo_tmp = tomo_stack[0,:,:]
+                tomo_tmp[img_x_bounds[0],:] = tomo_stack[0,:,:].max()
+                tomo_tmp[img_x_bounds[1]-1,:] = tomo_stack[0,:,:].max()
+                title = f'tomography image at theta={self.config["theta_range"]["start"]}'
+                msnc.quickImshow(tomo_stack[0,:,:], title=title, path=self.output_folder,
+                        save_fig=self.save_plots, save_only=True)
+                msnc.quickPlot(range(img_x_bounds[0], img_x_bounds[1]),
+                        x_sum[img_x_bounds[0]:img_x_bounds[1]],
+                        title='sum over theta and y', path=self.output_folder,
+                        save_fig=self.save_plots, save_only=True)
+        else:
+            x_sum = np.sum(self.tbf, 1)
+            use_bounds = 'no'
+            if img_x_bounds[0] is not None and img_x_bounds[1] is not None:
+                if img_x_bounds[0] < 0:
+                    msnc.illegal_value('img_x_bounds[0]', img_x_bounds[0], 'config file')
+                    img_x_bounds[0] = 0
+                if not img_x_bounds[0] < img_x_bounds[1] <= x_sum.size:
+                    msnc.illegal_value('img_x_bounds[1]', img_x_bounds[1], 'config file')
+                    img_x_bounds[1] = x_sum.size
+                msnc.quickPlot((range(x_sum.size), x_sum),
+                        ([img_x_bounds[0], img_x_bounds[0]], [x_sum.min(), x_sum.max()], 'r-'),
+                        ([img_x_bounds[1]-1, img_x_bounds[1]-1], [x_sum.min(), x_sum.max()], 'r-'),
+                        title='sum over theta and y')
+                print(f'lower bound = {img_x_bounds[0]} (inclusive)\n'+
+                        f'upper bound = {img_x_bounds[1]} (exclusive)]')
+                use_bounds =  pyip.inputYesNo('Accept these bounds ([y]/n)?: ', blank=True)
+            if use_bounds == 'no':
+                fit = msnc.fitStep(y=x_sum, model='rectangle', form='atan')
+                x_low = fit.get('center1', None)
+                x_upp = fit.get('center2', None)
+                if (x_low is not None and x_low >= 0 and x_upp is not None and
+                        x_low < x_upp < x_sum.size):
+                    x_low = int(x_low-(x_upp-x_low)/10)
+                    if x_low < 0:
+                        x_low = 0
+                    x_upp = int(x_upp+(x_upp-x_low)/10)
+                    if x_upp >= x_sum.size:
+                        x_upp = x_sum.size
+                    msnc.quickPlot((range(x_sum.size), x_sum),
+                            ([x_low, x_low], [x_sum.min(), x_sum.max()], 'r-'),
+                            ([x_upp, x_upp], [x_sum.min(), x_sum.max()], 'r-'),
+                            title='sum over theta and y')
+                    print(f'lower bound = {x_low} (inclusive)\nupper bound = {x_upp} (exclusive)]')
+                    use_fit =  pyip.inputYesNo('Accept these bounds ([y]/n)?: ', blank=True)
+                if use_fit == 'no':
+                    img_x_bounds = msnc.selectArrayBounds(x_sum, img_x_bounds[0], img_x_bounds[1],
+                            num_x_min, 'sum over theta and y')
+                else:
+                    img_x_bounds = [x_low, x_upp]
+                if num_x_min is not None and img_x_bounds[1]-img_x_bounds[0]+1 < num_x_min:
+                    logging.warning('Image bounds and pixel size prevent seamless stacking')
+                msnc.quickPlot(range(img_x_bounds[0], img_x_bounds[1]),
+                        x_sum[img_x_bounds[0]:img_x_bounds[1]],
+                        title='sum over theta and y', path=self.output_folder,
+                        save_fig=self.save_plots, save_only=True)
+        logging.debug(f'img_x_bounds: {img_x_bounds}')
+
+        if self.save_plots_only:
+            msnc.clearFig('bright field')
+            msnc.clearFig('sum over theta and y')
+            if title:
+                msnc.clearFig(title)
+
+        # Update config and save to file
+        if preprocess is None:
+            self.cf.config['preprocess'] = {'img_x_bounds' : img_x_bounds}
+        else:
+            preprocess['img_x_bounds'] = img_x_bounds
+        self.cf.saveFile(self.config_out)
+
+    def _setZoomOrSkip(self):
+        """Set zoom and/or theta skip to reduce memory the requirement for the analysis.
+        """
+        preprocess = self.config.get('preprocess')
+        zoom_perc = 100
+        if not self.galaxy_flag:
+            if preprocess is None or 'zoom_perc' not in preprocess:
+                if pyip.inputYesNo(
+                        '\nDo you want to zoom in to reduce memory requirement (y/[n])? ',
+                        blank=True) == 'yes':
+                    zoom_perc = pyip.inputInt('    Enter zoom percentage [1, 100]: ',
+                            min=1, max=100)
+            else:
+                zoom_perc = preprocess['zoom_perc']
+                if msnc.is_num(zoom_perc, 1., 100.):
+                    zoom_perc = int(zoom_perc)
+                else:
+                    msnc.illegal_value('zoom_perc', zoom_perc, 'config file')
+                    zoom_perc = 100
+        num_theta_skip = 0
+        if not self.galaxy_flag:
+            if preprocess is None or 'num_theta_skip' not in preprocess:
+                if pyip.inputYesNo(
+                        'Do you want to skip thetas to reduce memory requirement (y/[n])? ',
+                        blank=True) == 'yes':
+                    num_theta_skip = pyip.inputInt('    Enter the number skip theta interval'+
+                            f' [0, {self.num_thetas-1}]: ', min=0, max=self.num_thetas-1)
+            else:
+                num_theta_skip = preprocess['num_theta_skip']
+                if not msnc.is_int(num_theta_skip, 0):
+                    msnc.illegal_value('num_theta_skip', num_theta_skip, 'config file')
+                    num_theta_skip = 0
+        logging.info(f'zoom_perc = {zoom_perc}')
+        logging.info(f'num_theta_skip = {num_theta_skip}')
+
+        # Update config and save to file
+        if preprocess is None:
+            self.cf.config['preprocess'] = {'zoom_perc' : zoom_perc,
+                    'num_theta_skip' : num_theta_skip}
+        else:
+            preprocess['zoom_perc'] = zoom_perc
+            preprocess['num_theta_skip'] = num_theta_skip
+        self.cf.saveFile(self.config_out)
+
+    def _loadTomo(self, base_name, index, required=False):
+        """Load a tomography stack.
+        """
+        # stack order: row,theta,column
+        zoom_perc = None
+        preprocess = self.config.get('preprocess')
+        if preprocess:
+            zoom_perc = preprocess.get('zoom_perc')
+        if zoom_perc is None or zoom_perc == 100:
+            title = f'{base_name} fullres'
+        else:
+            title = f'{base_name} {zoom_perc}p'
+        title += f'_{index}'
+        tomo_file = re.sub(r"\s+", '_', f'{self.output_folder}/{title}.npy')
+        load_flag = 'no'
+        available = False
+        if os.path.isfile(tomo_file):
+            available = True
+            if required:
+                load_flag = 'yes'
+            else:
+                load_flag = pyip.inputYesNo(f'\nDo you want to load {tomo_file} (y/n)? ')
+        stack = np.array([])
+        if load_flag == 'yes':
+            t0 = time()
+            logging.info(f'Loading {tomo_file} ...')
+            try:
+                stack = np.load(tomo_file)
+            except IOError or ValueError:
+                stack = np.array([])
+                logging.error(f'Error loading {tomo_file}')
+            logging.info(f'... done in {time()-t0:.2f} seconds!')
+        if stack.size:
+            msnc.quickImshow(stack[:,0,:], title=title, path=self.output_folder,
+                    save_fig=self.save_plots, save_only=self.save_plots_only)
+        return stack, available
+
+    def _saveTomo(self, base_name, stack, index=None):
+        """Save a tomography stack.
+        """
+        zoom_perc = None
+        preprocess = self.config.get('preprocess')
+        if preprocess:
+            zoom_perc = preprocess.get('zoom_perc')
+        if zoom_perc is None or zoom_perc == 100:
+            title = f'{base_name} fullres'
+        else:
+            title = f'{base_name} {zoom_perc}p'
+        if index:
+            title += f'_{index}'
+        tomo_file = re.sub(r"\s+", '_', f'{self.output_folder}/{title}.npy')
+        t0 = time()
+        logging.info(f'Saving {tomo_file} ...')
+        np.save(tomo_file, stack)
+        logging.info(f'... done in {time()-t0:.2f} seconds!')
+
+    def _genTomo(self, tomo_stack_files, available_stacks):
+        """Generate tomography fields.
+        """
+        stacks = self.config['stack_info']['stacks']
+        assert(len(self.tomo_stacks) == self.config['stack_info']['num'])
+        assert(len(self.tomo_stacks) == len(stacks))
+        if len(available_stacks) != len(stacks):
+            logging.warning('Illegal dimension of available_stacks in _genTomo'+
+                    f'({len(available_stacks)}');
+            available_stacks = [False]*self.num_tomo_stacks
+
+        preprocess = self.config.get('preprocess')
+        if preprocess is None:
+            img_x_bounds = [0, self.tbf.shape[0]]
+            img_y_bounds = [0, self.tbf.shape[1]]
+            zoom_perc = preprocess['zoom_perc']
+            num_theta_skip = preprocess['num_theta_skip']
+        else:
+            img_x_bounds = preprocess.get('img_x_bounds', [0, self.tbf.shape[0]])
+            img_y_bounds = preprocess.get('img_y_bounds', [0, self.tbf.shape[1]])
+            zoom_perc = 100
+            num_theta_skip = 0
+
+        if self.tdf.size:
+            tdf = self.tdf[img_x_bounds[0]:img_x_bounds[1],img_y_bounds[0]:img_y_bounds[1]]
+        else:
+            logging.warning('Dark field unavailable')
+        if not self.tbf.size:
+            raise ValueError('Bright field unavailable')
+        tbf = self.tbf[img_x_bounds[0]:img_x_bounds[1],img_y_bounds[0]:img_y_bounds[1]]
+
+        for i,stack in enumerate(stacks):
+            # Check if stack is already loaded or available
+            if self.tomo_stacks[i].size or available_stacks[i]:
+                continue
+
+            # Load a stack of tomography images
+            t0 = time()
+            tomo_stack = msnc.loadImageStack(tomo_stack_files[i], self.config['data_filetype'],
+                    stack['img_offset'], self.config['theta_range']['num'], num_theta_skip,
+                    img_x_bounds, img_y_bounds)
+            tomo_stack = tomo_stack.astype('float64')
+            logging.debug(f'loading took {time()-t0:.2f} seconds!')
+
+            # Subtract dark field
+            if self.tdf.size:
+                t0 = time()
+                with set_numexpr_threads(self.ncore):
+                    ne.evaluate('tomo_stack-tdf', out=tomo_stack)
+                logging.debug(f'subtracting dark field took {time()-t0:.2f} seconds!')
+
+            # Normalize
+            t0 = time()
+            with set_numexpr_threads(self.ncore):
+                ne.evaluate('tomo_stack/tbf', out=tomo_stack, truediv=True)
+            logging.debug(f'normalizing took {time()-t0:.2f} seconds!')
+
+            # Remove non-positive values and linearize data
+            t0 = time()
+            cutoff = 1.e-6
+            with set_numexpr_threads(self.ncore):
+                ne.evaluate('where(tomo_stack<cutoff, cutoff, tomo_stack)', out=tomo_stack)
+            with set_numexpr_threads(self.ncore):
+                ne.evaluate('-log(tomo_stack)', out=tomo_stack)
+            logging.debug('removing non-positive values and linearizing data took '+
+                    f'{time()-t0:.2f} seconds!')
+
+            # Get rid of nans/infs that may be introduced by normalization
+            t0 = time()
+            np.where(np.isfinite(tomo_stack), tomo_stack, 0.)
+            logging.debug(f'remove nans/infs took {time()-t0:.2f} seconds!')
+
+            # Downsize tomography stack to smaller size
+            tomo_stack = tomo_stack.astype('float32')
+            if not self.galaxy_flag:
+                index = stack['index']
+                title = f'red stack fullres {index}'
+                if not self.test_mode:
+                    msnc.quickImshow(tomo_stack[0,:,:], title=title, path=self.output_folder,
+                            save_fig=self.save_plots, save_only=self.save_plots_only)
+            if zoom_perc != 100:
+                t0 = time()
+                logging.info(f'Zooming in ...')
+                tomo_zoom_list = []
+                for j in range(tomo_stack.shape[0]):
+                    tomo_zoom = spi.zoom(tomo_stack[j,:,:], 0.01*zoom_perc)
+                    tomo_zoom_list.append(tomo_zoom)
+                tomo_stack = np.stack([tomo_zoom for tomo_zoom in tomo_zoom_list])
+                logging.info(f'... done in {time()-t0:.2f} seconds!')
+                del tomo_zoom_list
+                if not self.galaxy_flag:
+                    title = f'red stack {zoom_perc}p {index}'
+                    if not self.test_mode:
+                        msnc.quickImshow(tomo_stack[0,:,:], title=title, path=self.output_folder,
+                                save_fig=self.save_plots, save_only=self.save_plots_only)
+    
+            # Convert tomography stack from theta,row,column to row,theta,column
+            tomo_stack = np.swapaxes(tomo_stack, 0, 1)
+
+            # Save tomography stack to file
+            if not self.galaxy_flag:
+                if not self.test_mode:
+                    self._saveTomo('red stack', tomo_stack, index)
+                else:
+                    np.savetxt(self.output_folder+f'red_stack_{index}.txt',
+                            tomo_stack[0,:,:], fmt='%.6e')
+                
+            # Combine stacks
+            t0 = time()
+            self.tomo_stacks[i] = tomo_stack
+            logging.debug(f'combining nstack took {time()-t0:.2f} seconds!')
+
+            # Update config and save to file
+            stack['preprocessed'] = True
+            self.cf.saveFile(self.config_out)
+
+        if self.tdf.size:
+            del tdf
+        del tbf
+
+    def _reconstructOnePlane(self, tomo_plane_T, center, thetas_deg, eff_pixel_size,
+            cross_sectional_dim, plot_sinogram=True):
+        """Invert the sinogram for a single tomography plane.
+        """
+        # tomo_plane_T index order: column,theta
+        assert(0 <= center < tomo_plane_T.shape[0])
+        center_offset = center-tomo_plane_T.shape[0]/2
+        two_offset = 2*int(np.round(center_offset))
+        two_offset_abs = np.abs(two_offset)
+        max_rad = int(0.5*(cross_sectional_dim/eff_pixel_size)*1.1) # 10% slack to avoid edge effects
+        dist_from_edge = max(1, int(np.floor((tomo_plane_T.shape[0]-two_offset_abs)/2.)-max_rad))
+        if two_offset >= 0:
+            logging.debug(f'sinogram range = [{two_offset+dist_from_edge}, {-dist_from_edge}]')
+            sinogram = tomo_plane_T[two_offset+dist_from_edge:-dist_from_edge,:]
+        else:
+            logging.debug(f'sinogram range = [{dist_from_edge}, {two_offset-dist_from_edge}]')
+            sinogram = tomo_plane_T[dist_from_edge:two_offset-dist_from_edge,:]
+        if plot_sinogram:
+            msnc.quickImshow(sinogram.T, f'sinogram center offset{center_offset:.2f}',
+                    path=self.output_folder, save_fig=self.save_plots,
+                    save_only=self.save_plots_only, aspect='auto')
+
+        # Inverting sinogram
+        t0 = time()
+        recon_sinogram = iradon(sinogram, theta=thetas_deg, circle=True)
+        logging.debug(f'inverting sinogram took {time()-t0:.2f} seconds!')
+        del sinogram
+
+        # Removing ring artifacts
+        # RV parameters for the denoise, gaussian, and ring removal will be different for different feature sizes
+        t0 = time()
+#        recon_sinogram = filters.gaussian(recon_sinogram, 3.0)
+        recon_sinogram = spi.gaussian_filter(recon_sinogram, 0.5)
+        recon_clean = np.expand_dims(recon_sinogram, axis=0)
+        del recon_sinogram
+        recon_clean = tomopy.misc.corr.remove_ring(recon_clean, rwidth=17)
+        logging.debug(f'filtering and removing ring artifact took {time()-t0:.2f} seconds!')
+        return recon_clean
+
+    def _plotEdgesOnePlane(self, recon_plane, base_name, weight=0.001):
+        # RV parameters for the denoise, gaussian, and ring removal will be different for different feature sizes
+        edges = denoise_tv_chambolle(recon_plane, weight = weight)
+        vmax = np.max(edges[0,:,:])
+        vmin = -vmax
+        msnc.quickImshow(edges[0,:,:], f'{base_name} coolwarm', path=self.output_folder,
+                save_fig=self.save_plots, save_only=self.save_plots_only, cmap='coolwarm')
+        msnc.quickImshow(edges[0,:,:], f'{base_name} gray', path=self.output_folder,
+                save_fig=self.save_plots, save_only=self.save_plots_only, cmap='gray',
+                vmin=vmin, vmax=vmax)
+        del edges
+
+    def _findCenterOnePlane(self, sinogram, row, thetas_deg, eff_pixel_size, cross_sectional_dim,
+            tol=0.1):
+        """Find center for a single tomography plane.
+        """
+        # sinogram index order: theta,column
+        # need index order column,theta for iradon, so take transpose
+        sinogram_T = sinogram.T
+        center = sinogram.shape[1]/2
+
+        # try automatic center finding routines for initial value
+        tomo_center = tomopy.find_center_vo(sinogram)
+        center_offset_vo = tomo_center-center
+        print(f'Center at row {row} using Nghia Vo’s method = {center_offset_vo:.2f}')
+        recon_plane = self._reconstructOnePlane(sinogram_T, tomo_center, thetas_deg,
+                eff_pixel_size, cross_sectional_dim, False)
+        base_name=f'edges row{row} center_offset_vo{center_offset_vo:.2f}'
+        self._plotEdgesOnePlane(recon_plane, base_name)
+        if pyip.inputYesNo('Try finding center using phase correlation (y/[n])? ',
+                    blank=True) == 'yes':
+            tomo_center = tomopy.find_center_pc(sinogram, sinogram, tol=0.1,
+                    rotc_guess=tomo_center)
+            error = 1.
+            while error > tol:
+                prev = tomo_center
+                tomo_center = tomopy.find_center_pc(sinogram, sinogram, tol=tol,
+                        rotc_guess=tomo_center)
+                error = np.abs(tomo_center-prev)
+            center_offset = tomo_center-center
+            print(f'Center at row {row} using phase correlation = {center_offset:.2f}')
+            recon_plane = self._reconstructOnePlane(sinogram_T, tomo_center, thetas_deg,
+                    eff_pixel_size, cross_sectional_dim, False)
+            base_name=f'edges row{row} center_offset{center_offset:.2f}'
+            self._plotEdgesOnePlane(recon_plane, base_name)
+        if pyip.inputYesNo('Accept a center location ([y]) or continue search (n)? ',
+                    blank=True) != 'no':
+            del sinogram_T
+            del recon_plane
+            center_offset = pyip.inputNum(
+                    f'    Enter chosen center offset [{-int(center)}, {int(center)}] '+
+                    f'([{center_offset_vo}])): ', blank=True)
+            if center_offset == '':
+                center_offset = center_offset_vo
+            return float(center_offset)
+
+        while True:
+            center_offset_low = pyip.inputInt('\nEnter lower bound for center offset '+
+                    f'[{-int(center)}, {int(center)}]: ', min=-int(center), max=int(center))
+            center_offset_upp = pyip.inputInt('Enter upper bound for center offset '+
+                    f'[{center_offset_low}, {int(center)}]: ',
+                    min=center_offset_low, max=int(center))
+            if center_offset_upp == center_offset_low:
+                center_offset_step = 1
+            else:
+                center_offset_step = pyip.inputInt('Enter step size for center offset search '+
+                        f'[1, {center_offset_upp-center_offset_low}]: ',
+                        min=1, max=center_offset_upp-center_offset_low)
+            for center_offset in range(center_offset_low, center_offset_upp+center_offset_step, 
+                        center_offset_step):
+                logging.info(f'center_offset = {center_offset}')
+                recon_plane = self._reconstructOnePlane(sinogram_T, center_offset+center,
+                        thetas_deg, eff_pixel_size, cross_sectional_dim, False)
+                base_name=f'edges row{row} center_offset{center_offset}'
+                self._plotEdgesOnePlane(recon_plane, base_name)
+            if pyip.inputInt('\nContinue (0) or end the search (1): ', min=0, max=1):
+                break
+
+        del sinogram_T
+        del recon_plane
+        center_offset = pyip.inputNum(f'    Enter chosen center offset '+
+                f'[{-int(center)}, {int(center)}]: ', min=-int(center), max=int(center))
+        return float(center_offset)
+
+    def _reconstructOneTomoStack(self, tomo_stack, thetas, row_bounds=None,
+            center_offsets=[], sigma=0.1, rwidth=30, ncore=1, algorithm='gridrec',
+            run_secondary_sirt=False, secondary_iter=100):
+        """reconstruct a single tomography stack.
+        """
+        # stack order: row,theta,column
+        # thetas must be in radians
+        # centers_offset: tomography axis shift in pixels relative to column center
+        # RV should we remove stripes?
+        # https://tomopy.readthedocs.io/en/latest/api/tomopy.prep.stripe.html
+        # RV should we remove rings?
+        # https://tomopy.readthedocs.io/en/latest/api/tomopy.misc.corr.html
+        # RV: Add an option to do (extra) secondary iterations later or to do some sort of convergence test?
+        if row_bounds is None:
+            row_bounds = [0, tomo_stack.shape[0]]
+        else:
+            if not (0 <= row_bounds[0] <= row_bounds[1] <= tomo_stack.shape[0]):
+                raise ValueError('Illegal row bounds in reconstructOneTomoStack')
+        if thetas.size != tomo_stack.shape[1]:
+            raise ValueError('theta dimension mismatch in reconstructOneTomoStack')
+        if not len(center_offsets):
+            centers = np.zeros((row_bounds[1]-row_bounds[0]))
+        elif len(center_offsets) == 2:
+            centers = np.linspace(center_offsets[0], center_offsets[1],
+                    row_bounds[1]-row_bounds[0])
+        else:
+            if center_offsets.size != row_bounds[1]-row_bounds[0]:
+                raise ValueError('center_offsets dimension mismatch in reconstructOneTomoStack')
+            centers = center_offsets
+        centers += tomo_stack.shape[2]/2
+        if True:
+            tomo_stack = tomopy.prep.stripe.remove_stripe_fw(tomo_stack[row_bounds[0]:row_bounds[1]],
+                    sigma=sigma, ncore=ncore)
+        else:
+            tomo_stack = tomo_stack[row_bounds[0]:row_bounds[1]]
+        tomo_recon_stack = tomopy.recon(tomo_stack, thetas, centers, sinogram_order=True,
+                algorithm=algorithm, ncore=ncore)
+        if run_secondary_sirt and secondary_iter > 0:
+            #options = {'method':'SIRT_CUDA', 'proj_type':'cuda', 'num_iter':secondary_iter}
+            #RV: doesn't work for me: "Error: CUDA error 803: system has unsupported display driver /
+            #                          cuda driver combination."
+            #options = {'method':'SIRT', 'proj_type':'linear', 'MinConstraint': 0, 'num_iter':secondary_iter}
+            #SIRT did not finish while running overnight
+            #options = {'method':'SART', 'proj_type':'linear', 'num_iter':secondary_iter}
+            options = {'method':'SART', 'proj_type':'linear', 'MinConstraint': 0, 'num_iter':secondary_iter}
+            tomo_recon_stack  = tomopy.recon(tomo_stack, thetas, centers, init_recon=tomo_recon_stack,
+                    options=options, sinogram_order=True, algorithm=tomopy.astra, ncore=ncore)
+        if True:
+            tomopy.misc.corr.remove_ring(tomo_recon_stack, rwidth=rwidth, out=tomo_recon_stack)
+        return tomo_recon_stack
+
+    def findImageFiles(self):
+        """Find all available image files.
+        """
+        self.is_valid = True
+
+        # Find dark field images
+        dark_field = self.config['dark_field']
+        img_start, num_imgs, dark_files = msnc.findImageFiles(
+                dark_field['data_path'], self.config['data_filetype'], 'dark field')
+        if img_start < 0 or num_imgs < 1:
+            logging.error('Unable to find suitable dark field images')
+            if dark_field['data_path']:
+                self.is_valid = False
+        dark_field['num'] = num_imgs
+        dark_field['img_start'] = img_start
+        logging.info(f'Number of dark field images = {dark_field["num"]}')
+        logging.info(f'Dark field image start index = {dark_field["img_start"]}')
+
+        # Find bright field images
+        bright_field = self.config['bright_field']
+        img_start, num_imgs, bright_files  = msnc.findImageFiles(
+                bright_field['data_path'], self.config['data_filetype'], 'bright field')
+        if img_start < 0 or num_imgs < 1:
+            logging.error('Unable to find suitable bright field images')
+            self.is_valid = False
+        bright_field['num'] = num_imgs
+        bright_field['img_start'] = img_start
+        logging.info(f'Number of bright field images = {bright_field["num"]}')
+        logging.info(f'Bright field image start index = {bright_field["img_start"]}')
+
+        # Find tomography images
+        tomo_stack_files = []
+        for stack in self.config['stack_info']['stacks']:
+            index = stack['index']
+            img_start, num_imgs, tomo_files = msnc.findImageFiles(
+                    stack['data_path'], self.config['data_filetype'], f'tomography set {index}')
+            if img_start < 0 or num_imgs < 1:
+                logging.error('Unable to find suitable tomography images')
+                self.is_valid = False
+            stack['num'] = num_imgs
+            stack['img_start'] = img_start
+            logging.info(f'Number of tomography images for set {index} = {stack["num"]}')
+            logging.info(f'Tomography set {index} image start index = {stack["img_start"]}')
+            tomo_stack_files.append(tomo_files)
+            del tomo_files
+
+        # Safe updated config
+        if self.is_valid:
+            self.cf.saveFile(self.config_out)
+
+        return dark_files, bright_files, tomo_stack_files
+
+    def genTomoStacks(self, tdf_files=None, tbf_files=None, tomo_stack_files=None,
+            dark_field_pngname=None, bright_field_pngname=None, tomo_field_pngname=None,
+            detectorbounds_pngname=None, output_name=None):
+        """Preprocess tomography images.
+        """
+        # Try loading any already preprocessed stacks (skip in Galaxy)
+        # preprocessed stack order for each one in stack: row,theta,column
+        stack_info = self.config['stack_info']
+        stacks = stack_info['stacks']
+        num_tomo_stacks = stack_info['num']
+        assert(num_tomo_stacks == len(self.tomo_stacks))
+        available_stacks = [False]*num_tomo_stacks
+        if self.galaxy_flag:
+            assert(tdf_files is None or isinstance(tdf_files, list))
+            assert(isinstance(tbf_files, list))
+            assert(isinstance(tomo_stack_files, list))
+            assert(num_tomo_stacks == len(tomo_stack_files))
+            assert(isinstance(dark_field_pngname, str))
+            assert(isinstance(bright_field_pngname, str))
+            assert(isinstance(tomo_field_pngname, str))
+            assert(isinstance(detectorbounds_pngname, str))
+            assert(isinstance(output_name, str))
+        else:
+            if tdf_files:
+                logging.warning('Ignoring tdf_files in genTomoStacks (only for Galaxy)')
+            if tbf_files:
+                logging.warning('Ignoring tbf_files in genTomoStacks (only for Galaxy)')
+            if tomo_stack_files:
+                logging.warning('Ignoring tomo_stack_files in genTomoStacks (only for Galaxy)')
+            tdf_files, tbf_files, tomo_stack_files = self.findImageFiles()
+            if not self.is_valid:
+                return
+            for i,stack in enumerate(stacks):
+                if not self.tomo_stacks[i].size and stack.get('preprocessed', False):
+                    self.tomo_stacks[i], available_stacks[i] = \
+                            self._loadTomo('red stack', stack['index'])
+            if dark_field_pngname:
+                logging.warning('Ignoring dark_field_pngname in genTomoStacks (only for Galaxy)')
+            if bright_field_pngname:
+                logging.warning('Ignoring bright_field_pngname in genTomoStacks (only for Galaxy)')
+            if tomo_field_pngname:
+                logging.warning('Ignoring tomo_field_pngname in genTomoStacks (only for Galaxy)')
+            if detectorbounds_pngname:
+                logging.warning('Ignoring detectorbounds_pngname in genTomoStacks '+
+                    '(only used in Galaxy)')
+            if output_name:
+                logging.warning('Ignoring output_name in genTomoStacks '+
+                    '(only used in Galaxy)')
+
+        # Preprocess any unloaded stacks
+        if False in available_stacks:
+            logging.debug('Preprocessing tomography images')
+
+            # Check required image files (skip in Galaxy)
+            if not self.galaxy_flag:
+                self._selectImageRanges(available_stacks)
+                if not self.is_valid:
+                    return
+
+            # Generate dark field
+            if tdf_files:
+                self._genDark(tdf_files, dark_field_pngname)
+
+            # Generate bright field
+            self._genBright(tbf_files, bright_field_pngname)
+
+            # Set vertical detector bounds for image stack
+            self._setDetectorBounds(tomo_stack_files, tomo_field_pngname, detectorbounds_pngname)
+
+            # Set zoom and/or theta skip to reduce memory the requirement
+            self._setZoomOrSkip()
+
+            # Generate tomography fields
+            self._genTomo(tomo_stack_files, available_stacks)
+
+            # Save tomography stack to file
+            if self.galaxy_flag:
+                t0 = time()
+                logging.info(f'Saving preprocessed tomography stack to file ...')
+                save_stacks = {f'set_{stack["index"]}':tomo_stack
+                        for stack,tomo_stack in zip(stacks,self.tomo_stacks)}
+                np.savez(output_name, **save_stacks)
+                logging.info(f'... done in {time()-t0:.2f} seconds!')
+
+        del available_stacks
+
+        # Adjust sample reference height, update config and save to file
+        preprocess = self.config.get('preprocess')
+        if preprocess is None:
+            img_x_bounds = [0, self.tbf.shape[0]]
+        else:
+            img_x_bounds = preprocess.get('img_x_bounds', [0, self.tbf.shape[0]])
+        pixel_size = self.config['detector']['pixel_size']
+        if pixel_size is None:
+            raise ValueError('Detector pixel size unavailable')
+        pixel_size *= img_x_bounds[0]
+        for stack in stacks:
+            stack['ref_height'] = stack['ref_height']+pixel_size
+        self.cf.saveFile(self.config_out)
+
+    def findCenters(self):
+        """Find rotation axis centers for the tomography stacks.
+        """
+        logging.debug('Find centers for tomography stacks')
+        stacks = self.config['stack_info']['stacks']
+        available_stacks = [stack['index'] for stack in stacks if stack.get('preprocessed', False)]
+        logging.debug('Avaliable stacks: {available_stacks}')
+
+        # Check for valid available center stack index
+        find_center = self.config.get('find_center')
+        if find_center and 'center_stack_index' in find_center:
+            center_stack_index = find_center['center_stack_index']
+            if (not isinstance(center_stack_index, int) or
+                    center_stack_index not in available_stacks):
+                msnc.illegal_value('center_stack_index', center_stack_index, 'config file')
+            else:
+                if self.test_mode:
+                    find_center['completed'] = True
+                    self.cf.saveFile(self.config_out)
+                    return
+                print('\nFound calibration center offset info for stack '+
+                        f'{center_stack_index}')
+                if pyip.inputYesNo('Do you want to use this again (y/n)? ') == 'yes':
+                    find_center['completed'] = True
+                    self.cf.saveFile(self.config_out)
+                    return
+
+        # Load the required preprocessed stack
+        # preprocessed stack order: row,theta,column
+        num_tomo_stacks = self.config['stack_info']['num']
+        assert(len(stacks) == num_tomo_stacks)
+        assert(len(self.tomo_stacks) == num_tomo_stacks)
+        if num_tomo_stacks == 1:
+            center_stack_index = stacks[0]['index']
+            if not self.tomo_stacks[0].size:
+                self.tomo_stacks[0], available = self._loadTomo('red stack', center_stack_index,
+                    required=True)
+            center_stack = self.tomo_stacks[0]
+            if not center_stack.size:
+                logging.error('Unable to load the required preprocessed tomography stack')
+                return
+            assert(stacks[0].get('preprocessed', False) == True)
+        else:
+            while True:
+                center_stack_index = pyip.inputInt('\nEnter tomography stack index to get '
+                        f'rotation axis centers {available_stacks}: ')
+                while center_stack_index not in available_stacks:
+                    center_stack_index = pyip.inputInt('\nEnter tomography stack index to get '
+                            f'rotation axis centers {available_stacks}: ')
+                tomo_stack_index = available_stacks.index(center_stack_index)
+                if not self.tomo_stacks[tomo_stack_index].size:
+                    self.tomo_stacks[tomo_stack_index], available = self._loadTomo(
+                            'red stack', center_stack_index, required=True)
+                center_stack = self.tomo_stacks[tomo_stack_index]
+                if not center_stack.size:
+                    logging.error(f'Unable to load the {center_stack_index}th '+
+                        'preprocessed tomography stack, pick another one')
+                else:
+                    break
+                assert(stacks[tomo_stack_index].get('preprocessed', False) == True)
+        if find_center is None:
+            self.config['find_center'] = {'center_stack_index' : center_stack_index}
+        find_center = self.config['find_center']
+
+        # Set thetas (in degrees)
+        theta_range = self.config['theta_range']
+        theta_start = theta_range['start']
+        theta_end = theta_range['end']
+        num_theta = theta_range['num']
+        num_theta_skip = self.config['preprocess'].get('num_theta_skip', 0)
+        thetas_deg = np.linspace(theta_start, theta_end, int(num_theta/(num_theta_skip+1)),
+            endpoint=False)
+
+        # Get non-overlapping sample row boundaries
+        zoom_perc = self.config['preprocess'].get('zoom_perc', 100)
+        pixel_size = self.config['detector']['pixel_size']
+        if pixel_size is None:
+            raise ValueError('Detector pixel size unavailable')
+        eff_pixel_size = 100.*pixel_size/zoom_perc
+        logging.debug(f'eff_pixel_size = {eff_pixel_size}')
+        tomo_ref_heights = [stack['ref_height'] for stack in stacks]
+        if num_tomo_stacks == 1:
+            n1 = 0
+            height = center_stack.shape[0]*eff_pixel_size
+            if pyip.inputYesNo('\nDo you want to reconstruct the full samply height '+
+                    f'({height:.3f} mm) (y/n)? ') == 'no':
+                height = pyip.inputNum('\nEnter the desired reconstructed sample height '+
+                        f'in mm [0, {height:.3f}]: ', min=0, max=height)
+                n1 = int(0.5*(center_stack.shape[0]-height/eff_pixel_size))
+        else:
+            n1 = int((1.+(tomo_ref_heights[0]+center_stack.shape[0]*eff_pixel_size-
+                tomo_ref_heights[1])/eff_pixel_size)/2)
+        n2 = center_stack.shape[0]-n1
+        logging.info(f'n1 = {n1}, n2 = {n2} (n2-n1) = {(n2-n1)*eff_pixel_size:.3f} mm')
+        if not center_stack.size:
+            RuntimeError('Center stack not loaded')
+        msnc.quickImshow(center_stack[:,0,:], title=f'center stack theta={theta_start}',
+                path=self.output_folder, save_fig=self.save_plots, save_only=self.save_plots_only)
+
+        # Get cross sectional diameter in mm
+        cross_sectional_dim = center_stack.shape[2]*eff_pixel_size
+        logging.debug(f'cross_sectional_dim = {cross_sectional_dim}')
+
+        # Determine center offset at sample row boundaries
+        logging.info('Determine center offset at sample row boundaries')
+
+        # Lower row center
+        use_row = False
+        use_center = False
+        row = find_center.get('lower_row')
+        if msnc.is_int(row, n1, n2-2):
+            msnc.quickImshow(center_stack[:,0,:], title=f'theta={theta_start}', aspect='auto')
+            use_row = pyip.inputYesNo('\nCurrent row index for lower center = '
+                    f'{row}, use this value (y/n)? ')
+            if self.save_plots_only:
+                msnc.clearFig(f'theta={theta_start}')
+            if use_row:
+                center_offset = find_center.get('lower_center_offset')
+                if msnc.is_num(center_offset):
+                    use_center = pyip.inputYesNo('Current lower center offset = '+
+                            f'{center_offset}, use this value (y/n)? ')
+        if not use_center:
+            if not use_row:
+                msnc.quickImshow(center_stack[:,0,:], title=f'theta={theta_start}', aspect='auto')
+                row = pyip.inputInt('\nEnter row index to find lower center '+
+                        f'[[{n1}], {n2-2}]: ', min=n1, max=n2-2, blank=True)
+                if row == '':
+                    row = n1
+                if self.save_plots_only:
+                    msnc.clearFig(f'theta={theta_start}')
+            # center_stack order: row,theta,column
+            center_offset = self._findCenterOnePlane(center_stack[row,:,:], row, thetas_deg,
+                    eff_pixel_size, cross_sectional_dim)
+        logging.info(f'Lower center offset = {center_offset}')
+
+        # Update config and save to file
+        find_center['row_bounds'] = [n1, n2]
+        find_center['lower_row'] = row
+        find_center['lower_center_offset'] = center_offset
+        self.cf.saveFile(self.config_out)
+        lower_row = row
+
+        # Upper row center
+        use_row = False
+        use_center = False
+        row = find_center.get('upper_row')
+        if msnc.is_int(row, lower_row+1, n2-1):
+            msnc.quickImshow(center_stack[:,0,:], title=f'theta={theta_start}', aspect='auto')
+            use_row = pyip.inputYesNo('\nCurrent row index for upper center = '
+                    f'{row}, use this value (y/n)? ')
+            if self.save_plots_only:
+                msnc.clearFig(f'theta={theta_start}')
+            if use_row:
+                center_offset = find_center.get('upper_center_offset')
+                if msnc.is_num(center_offset):
+                    use_center = pyip.inputYesNo('Current upper center offset = '+
+                            f'{center_offset}, use this value (y/n)? ')
+        if not use_center:
+            if not use_row:
+                msnc.quickImshow(center_stack[:,0,:], title=f'theta={theta_start}', aspect='auto')
+                row = pyip.inputInt('\nEnter row index to find upper center '+
+                        f'[{lower_row+1}, [{n2-1}]]: ', min=lower_row+1, max=n2-1, blank=True)
+                if row == '':
+                    row = n2-1
+                if self.save_plots_only:
+                    msnc.clearFig(f'theta={theta_start}')
+            # center_stack order: row,theta,column
+            center_offset = self._findCenterOnePlane(center_stack[row,:,:], row, thetas_deg,
+                    eff_pixel_size, cross_sectional_dim)
+        logging.info(f'upper_center_offset = {center_offset}')
+        del center_stack
+
+        # Update config and save to file
+        find_center['upper_row'] = row
+        find_center['upper_center_offset'] = center_offset
+        find_center['completed'] = True
+        self.cf.saveFile(self.config_out)
+
+    def checkCenters(self):
+        """Check centers for the tomography stacks.
+        """
+        #RV TODO load all stacks and check at all stack boundaries
+        return
+        logging.debug('Check centers for tomography stacks')
+        center_stack_index = self.config.get('center_stack_index')
+        if center_stack_index is None:
+            raise ValueError('Unable to read center_stack_index from config')
+        center_stack_index = self.tomo_stacks[self.tomo_data_indices.index(center_stack_index)]
+        lower_row = self.config.get('lower_row')
+        if lower_row is None:
+            raise ValueError('Unable to read lower_row from config')
+        lower_center_offset = self.config.get('lower_center_offset')
+        if lower_center_offset is None:
+            raise ValueError('Unable to read lower_center_offset from config')
+        upper_row = self.config.get('upper_row')
+        if upper_row is None:
+            raise ValueError('Unable to read upper_row from config')
+        upper_center_offset = self.config.get('upper_center_offset')
+        if upper_center_offset is None:
+            raise ValueError('Unable to read upper_center_offset from config')
+        center_slope = (upper_center_offset-lower_center_offset)/(upper_row-lower_row)
+        shift = upper_center_offset-lower_center_offset
+        if lower_row == 0:
+            logging.warning(f'lower_row == 0: one row offset between both planes')
+        else:
+            lower_row -= 1
+            lower_center_offset -= center_slope
+
+        # stack order: stack,row,theta,column
+        if center_stack_index:
+            stack1 = self.tomo_stacks[center_stack_index-1]
+            stack2 = self.tomo_stacks[center_stack_index]
+            if not stack1.size:
+                logging.error(f'Unable to load required tomography stack {stack1}')
+            elif not stack2.size:
+                logging.error(f'Unable to load required tomography stack {stack1}')
+            else:
+                assert(0 <= lower_row < stack2.shape[0])
+                assert(0 <= upper_row < stack1.shape[0])
+                plane1 = stack1[upper_row,:]
+                plane2 = stack2[lower_row,:]
+                for i in range(-2, 3):
+                    shift_i = shift+2*i
+                    plane1_shifted = spi.shift(plane2, [0, shift_i])
+                    msnc.quickPlot((plane1[0,:],), (plane1_shifted[0,:],),
+                            title=f'stacks {stack1} {stack2} shifted {2*i} theta={self.start_theta}',
+                            path=self.output_folder, save_fig=self.save_plots,
+                            save_only=self.save_plots_only)
+        if center_stack_index < self.num_tomo_stacks-1:
+            stack1 = self.tomo_stacks[center_stack_index]
+            stack2 = self.tomo_stacks[center_stack_index+1]
+            if not stack1.size:
+                logging.error(f'Unable to load required tomography stack {stack1}')
+            elif not stack2.size:
+                logging.error(f'Unable to load required tomography stack {stack1}')
+            else:
+                assert(0 <= lower_row < stack2.shape[0])
+                assert(0 <= upper_row < stack1.shape[0])
+                plane1 = stack1[upper_row,:]
+                plane2 = stack2[lower_row,:]
+                for i in range(-2, 3):
+                    shift_i = -shift+2*i
+                    plane1_shifted = spi.shift(plane2, [0, shift_i])
+                    msnc.quickPlot((plane1[0,:],), (plane1_shifted[0,:],), 
+                            title=f'stacks {stack1} {stack2} shifted {2*i} theta={start_theta}',
+                            path=self.output_folder, save_fig=self.save_plots,
+                            save_only=self.save_plots_only)
+        del plane1, plane2, plane1_shifted
+
+        # Update config file
+        self.config = msnc.update('config.txt', 'check_centers', True, 'find_centers')
+
+    def reconstructTomoStacks(self):
+        """Reconstruct tomography stacks.
+        """
+        logging.debug('Reconstruct tomography stacks')
+
+        # Get rotation axis rows and centers
+        find_center = self.config['find_center']
+        lower_row = find_center.get('lower_row')
+        if lower_row is None:
+            logging.error('Unable to read lower_row from config')
+            return
+        lower_center_offset = find_center.get('lower_center_offset')
+        if lower_center_offset is None:
+            logging.error('Unable to read lower_center_offset from config')
+            return
+        upper_row = find_center.get('upper_row')
+        if upper_row is None:
+            logging.error('Unable to read upper_row from config')
+            return
+        upper_center_offset = find_center.get('upper_center_offset')
+        if upper_center_offset is None:
+            logging.error('Unable to read upper_center_offset from config')
+            return
+        logging.debug(f'lower_row = {lower_row} upper_row = {upper_row}')
+        assert(lower_row < upper_row)
+        center_slope = (upper_center_offset-lower_center_offset)/(upper_row-lower_row)
+
+        # Set thetas (in radians)
+        theta_range = self.config['theta_range']
+        theta_start = theta_range['start']
+        theta_end = theta_range['end']
+        num_theta = theta_range['num']
+        num_theta_skip = self.config['preprocess'].get('num_theta_skip', 0)
+        thetas = np.radians(np.linspace(theta_start, theta_end,
+                int(num_theta/(num_theta_skip+1)), endpoint=False))
+
+        # Reconstruct tomo stacks
+        zoom_perc = self.config['preprocess'].get('zoom_perc', 100)
+        if zoom_perc == 100:
+            basetitle = 'recon stack full'
+        else:
+            basetitle = f'recon stack {zoom_perc}p'
+        load_error = False
+        stacks = self.config['stack_info']['stacks']
+        for i,stack in enumerate(stacks):
+            # Check if stack can be loaded
+            # reconstructed stack order for each one in stack : row/z,x,y
+            # preprocessed stack order for each one in stack: row,theta,column
+            index = stack['index']
+            available = False
+            if stack.get('reconstructed', False):
+                self.tomo_recon_stacks[i], available = self._loadTomo('recon stack', index)
+            if self.tomo_recon_stacks[i].size or available:
+                if self.tomo_stacks[i].size:
+                    self.tomo_stacks[i] = np.array([])
+                assert(stack.get('reconstructed', False) == True)
+                continue
+            else:
+                stack['reconstructed'] = False
+                if not self.tomo_stacks[i].size:
+                    self.tomo_stacks[i], available = self._loadTomo('red stack', index,
+                            required=True)
+                if not self.tomo_stacks[i].size:
+                    logging.error(f'Unable to load tomography stack {index} for reconstruction')
+                    load_error = True
+                    continue
+                assert(0 <= lower_row < upper_row < self.tomo_stacks[i].shape[0])
+                center_offsets = [lower_center_offset-lower_row*center_slope,
+                        upper_center_offset+(self.tomo_stacks[i].shape[0]-1-upper_row)*center_slope]
+                t0 = time()
+                self.tomo_recon_stacks[i]= self._reconstructOneTomoStack(self.tomo_stacks[i],
+                        thetas, center_offsets=center_offsets, sigma=0.1, ncore=self.ncore,
+                        algorithm='gridrec', run_secondary_sirt=True, secondary_iter=25)
+                logging.info(f'Reconstruction of stack {index} took {time()-t0:.2f} seconds!')
+                if not self.test_mode:
+                    row_slice = int(self.tomo_stacks[i].shape[0]/2) 
+                    title = f'{basetitle} {index} slice{row_slice}'
+                    msnc.quickImshow(self.tomo_recon_stacks[i][row_slice,:,:], title=title,
+                            path=self.output_folder, save_fig=self.save_plots,
+                            save_only=self.save_plots_only)
+                    msnc.quickPlot(self.tomo_recon_stacks[i]
+                            [row_slice,int(self.tomo_recon_stacks[i].shape[2]/2),:],
+                            title=f'{title} cut{int(self.tomo_recon_stacks[i].shape[2]/2)}',
+                            path=self.output_folder, save_fig=self.save_plots,
+                            save_only=self.save_plots_only)
+                    self._saveTomo('recon stack', self.tomo_recon_stacks[i], index)
+#                else:
+#                    np.savetxt(self.output_folder+f'recon_stack_{index}.txt',
+#                            self.tomo_recon_stacks[i][row_slice,:,:], fmt='%.6e')
+                self.tomo_stacks[i] = np.array([])
+
+                # Update config and save to file
+                stack['reconstructed'] = True
+                self.cf.saveFile(self.config_out)
+
+    def combineTomoStacks(self):
+        """Combine the reconstructed tomography stacks.
+        """
+        # stack order: stack,row(z),x,y
+        logging.debug('Combine reconstructed tomography stacks')
+        # Load any unloaded reconstructed stacks
+        stacks = self.config['stack_info']['stacks']
+        for i,stack in enumerate(stacks):
+            if not self.tomo_recon_stacks[i].size and stack.get('reconstructed', False):
+                self.tomo_recon_stacks[i], available = self._loadTomo('recon stack',
+                        stack['index'], required=True)
+            if not self.tomo_recon_stacks[i].size or not available:
+                logging.error(f'Unable to load reconstructed stack {stack["index"]}')
+                return
+            if i:
+                if (self.tomo_recon_stacks[i].shape[1] != self.tomo_recon_stacks[0].shape[1] or
+                        self.tomo_recon_stacks[i].shape[1] != self.tomo_recon_stacks[0].shape[1]):
+                    logging.error('Incompatible reconstructed tomography stack dimensions')
+                    return
+
+        # Get center stack boundaries
+        row_bounds = self.config['find_center']['row_bounds']
+        if not msnc.is_index_range(row_bounds, 0, self.tomo_recon_stacks[0].shape[0]):
+            msnc.illegal_value('row_bounds', row_bounds, 'config file')
+            return
+
+        # Selecting xy bounds
+        tomosum = 0
+        #RV FIX :=
+        #[tomosum := tomosum+np.sum(tomo_recon_stack, axis=(0,2)) for tomo_recon_stack in
+        #        self.tomo_recon_stacks]
+        combine_stacks =self.config.get('combine_stacks')
+        if combine_stacks and 'x_bounds' in combine_stacks:
+            x_bounds = combine_stacks['x_bounds']
+            if not msnc.is_index_range(x_bounds, 0, self.tomo_recon_stacks[0].shape[1]):
+                msnc.illegal_value('x_bounds', x_bounds, 'config file')
+            elif not self.test_mode:
+                msnc.quickPlot(tomosum, title='recon stack sum yz')
+                if pyip.inputYesNo('\nCurrent image x-bounds: '+
+                        f'[{x_bounds[0]}, {x_bounds[1]}], use these values ([y]/n)? ',
+                        blank=True) == 'no':
+                    if pyip.inputYesNo(
+                            'Do you want to change the image x-bounds ([y]/n)? ',
+                            blank=True) == 'no':
+                        x_bounds = [0, self.tomo_recon_stacks[0].shape[1]]
+                    else:
+                        x_bounds = msnc.selectArrayBounds(tomosum, title='recon stack sum yz')
+        else:
+            msnc.quickPlot(tomosum, title='recon stack sum yz')
+            if pyip.inputYesNo('\nDo you want to change the image x-bounds (y/n)? ') == 'no':
+                x_bounds = [0, self.tomo_recon_stacks[0].shape[1]]
+            else:
+                x_bounds = msnc.selectArrayBounds(tomosum, title='recon stack sum yz')
+        if False and self.test_mode:
+            np.savetxt(self.output_folder+'recon_stack_sum_yz.txt',
+                    tomosum[x_bounds[0]:x_bounds[1]], fmt='%.6e')
+        if self.save_plots_only:
+            msnc.clearFig('recon stack sum yz')
+        tomosum = 0
+        #RV FIX :=
+        #[tomosum := tomosum+np.sum(tomo_recon_stack, axis=(0,1)) for tomo_recon_stack in
+        #        self.tomo_recon_stacks]
+        if combine_stacks and 'y_bounds' in combine_stacks:
+            y_bounds = combine_stacks['y_bounds']
+            if not msnc.is_index_range(x_bounds, 0, self.tomo_recon_stacks[0].shape[1]):
+                msnc.illegal_value('y_bounds', y_bounds, 'config file')
+            elif not self.test_mode:
+                msnc.quickPlot(tomosum, title='recon stack sum xz')
+                if pyip.inputYesNo('\nCurrent image y-bounds: '+
+                        f'[{y_bounds[0]}, {y_bounds[1]}], use these values ([y]/n)? ',
+                        blank=True) == 'no':
+                    if pyip.inputYesNo(
+                            'Do you want to change the image y-bounds ([y]/n)? ',
+                            blank=True) == 'no':
+                        y_bounds = [0, self.tomo_recon_stacks[0].shape[1]]
+                    else:
+                        y_bounds = msnc.selectArrayBounds(tomosum, title='recon stack sum yz')
+        else:
+            msnc.quickPlot(tomosum, title='recon stack sum xz')
+            if pyip.inputYesNo('\nDo you want to change the image y-bounds (y/n)? ') == 'no':
+                y_bounds = [0, self.tomo_recon_stacks[0].shape[1]]
+            else:
+                y_bounds = msnc.selectArrayBounds(tomosum, title='recon stack sum xz')
+        if False and self.test_mode:
+            np.savetxt(self.output_folder+'recon_stack_sum_xz.txt',
+                    tomosum[y_bounds[0]:y_bounds[1]], fmt='%.6e')
+        if self.save_plots_only:
+            msnc.clearFig('recon stack sum xz')
+
+        # Combine reconstructed tomography stacks
+        logging.info(f'Combining reconstructed stacks ...')
+        t0 = time()
+        num_tomo_stacks = self.config['stack_info']['num']
+        if num_tomo_stacks == 1:
+            low_bound = row_bounds[0]
+        else:
+            low_bound = 0
+        tomo_recon_combined = self.tomo_recon_stacks[0][low_bound:row_bounds[1]:,
+                x_bounds[0]:x_bounds[1],y_bounds[0]:y_bounds[1]]
+        if num_tomo_stacks > 2:
+            tomo_recon_combined = np.concatenate([tomo_recon_combined]+
+                    [self.tomo_recon_stacks[i][row_bounds[0]:row_bounds[1],
+                    x_bounds[0]:x_bounds[1],y_bounds[0]:y_bounds[1]]
+                    for i in range(1, num_tomo_stacks-1)])
+        if num_tomo_stacks > 1:
+            tomo_recon_combined = np.concatenate([tomo_recon_combined]+
+                    [self.tomo_recon_stacks[num_tomo_stacks-1][row_bounds[0]:,
+                    x_bounds[0]:x_bounds[1],y_bounds[0]:y_bounds[1]]])
+        logging.info(f'... done in {time()-t0:.2f} seconds!')
+        tomosum = np.sum(tomo_recon_combined, axis=(1,2))
+        if self.test_mode:
+            zoom_perc = self.config['preprocess'].get('zoom_perc', 100)
+            filename = 'recon combined sum xy'
+            if zoom_perc is None or zoom_perc == 100:
+                filename += ' fullres.dat'
+            else:
+                filename += f' {zoom_perc}p.dat'
+            msnc.quickPlot(tomosum, title='recon combined sum xy',
+                    path=self.output_folder, save_fig=self.save_plots,
+                    save_only=self.save_plots_only)
+            if False:
+                np.savetxt(self.output_folder+'recon_combined_sum_xy.txt',
+                        tomosum, fmt='%.6e')
+            np.savetxt(self.output_folder+'recon_combined.txt',
+                    tomo_recon_combined[int(tomo_recon_combined.shape[0]/2),:,:], fmt='%.6e')
+            combine_stacks =self.config.get('combine_stacks')
+
+            # Update config and save to file
+            if combine_stacks:
+                combine_stacks['x_bounds'] = x_bounds
+                combine_stacks['y_bounds'] = y_bounds
+            else:
+                self.config['combine_stacks'] = {'x_bounds' : x_bounds, 'y_bounds' : y_bounds}
+            self.cf.saveFile(self.config_out)
+            return
+        msnc.quickPlot(tomosum, title='recon combined sum xy')
+        if pyip.inputYesNo(
+                '\nDo you want to change the image z-bounds (y/[n])? ',
+                blank=True) != 'yes':
+            z_bounds = [0, tomo_recon_combined.shape[0]]
+        else:
+            z_bounds = msnc.selectArrayBounds(tomosum, title='recon combined sum xy')
+        if z_bounds[0] != 0 or z_bounds[1] != tomo_recon_combined.shape[0]:
+            tomo_recon_combined = tomo_recon_combined[z_bounds[0]:z_bounds[1],:,:]
+        logging.info(f'tomo_recon_combined.shape = {tomo_recon_combined.shape}')
+        if self.save_plots_only:
+            msnc.clearFig('recon combined sum xy')
+
+        # Plot center slices
+        msnc.quickImshow(tomo_recon_combined[int(tomo_recon_combined.shape[0]/2),:,:],
+                title=f'recon combined xslice{int(tomo_recon_combined.shape[0]/2)}',
+                path=self.output_folder, save_fig=self.save_plots,
+                save_only=self.save_plots_only)
+        msnc.quickImshow(tomo_recon_combined[:,int(tomo_recon_combined.shape[1]/2),:],
+                title=f'recon combined yslice{int(tomo_recon_combined.shape[1]/2)}',
+                path=self.output_folder, save_fig=self.save_plots,
+                save_only=self.save_plots_only)
+        msnc.quickImshow(tomo_recon_combined[:,:,int(tomo_recon_combined.shape[2]/2)],
+                title=f'recon combined zslice{int(tomo_recon_combined.shape[2]/2)}',
+                path=self.output_folder, save_fig=self.save_plots,
+                save_only=self.save_plots_only)
+
+        # Save combined reconstructed tomo stacks
+        base_name = 'recon combined'
+        combined_stacks = []
+        for stack in stacks:
+            base_name += f' {stack["index"]}'
+            combined_stacks.append(stack['index'])
+        self._saveTomo(base_name, tomo_recon_combined)
+
+        # Update config and save to file
+        if combine_stacks:
+            combine_stacks['x_bounds'] = x_bounds
+            combine_stacks['y_bounds'] = y_bounds
+            combine_stacks['stacks'] = combined_stacks
+        else:
+            self.config['combine_stacks'] = {'x_bounds' : x_bounds, 'y_bounds' : y_bounds,
+                    'stacks' : combined_stacks}
+        self.cf.saveFile(self.config_out)
+
+def runTomo(config_file=None, config_dict=None, output_folder='.', log_level='INFO',
+        test_mode=False):
+    """Run a tomography analysis.
+    """
+    # Instantiate Tomo object
+    tomo = Tomo(config_file=config_file, output_folder=output_folder, log_level=log_level,
+            test_mode=test_mode)
+    if not tomo.is_valid:
+        raise ValueError('Invalid config and/or detector file provided.')
+
+    # Preprocess the image files
+    num_tomo_stacks = tomo.config['stack_info']['num']
+    assert(num_tomo_stacks == len(tomo.tomo_stacks))
+    preprocess = tomo.config.get('preprocess', None)
+    preprocessed_stacks = []
+    if preprocess:
+        preprocessed_stacks = [stack['index'] for stack in tomo.config['stack_info']['stacks']
+            if stack.get('preprocessed', False)]
+    if not len(preprocessed_stacks):
+        tomo.genTomoStacks()
+        if not tomo.is_valid:
+            IOError('Unable to load all required image files.')
+        find_center = tomo.config.get('find_center')
+        if find_center and find_center.get('completed', False):
+            center_stack_index = find_center['center_stack_index']
+            if not center_stack_index in preprocessed_stacks:
+                find_center['completed'] = False
+#RV FIX
+#        tomo.config.pop('check_center', 'check_center not found')
+#        combined_stacks = tomo.config.get('combined_stacks')
+#        if combined_stacks:
+#            combined_stacks['completed'] = False
+        tomo.cf.saveFile(tomo.config_out)
+
+    # Find centers
+    find_center = tomo.config.get('find_center')
+    if find_center is None or not find_center.get('completed', False):
+        tomo.findCenters()
+
+    # Check centers
+    #if num_tomo_stacks > 1 and not tomo.config.get('check_centers', False):
+    #    tomo.checkCenters()
+
+    # Reconstruct tomography stacks
+    if len(tomo.config.get('reconstructed_stacks', [])) != tomo.config['stack_info']['num']:
+        tomo.reconstructTomoStacks()
+
+    # Combine reconstructed tomography stacks
+    combined_stacks = tomo.config.get('combined_stacks')
+    if combined_stacks is None or not combined_stacks.get('completed', False):
+        tomo.combineTomoStacks()
+
+#%%============================================================================
+if __name__ == '__main__':
+    # Parse command line arguments
+    arguments = sys.argv[1:]
+    config_file = None
+    output_folder = '.'
+    log_level = 'INFO'
+    test_mode = False
+    try:
+        opts, args = getopt.getopt(arguments,"hc:o:l:t")
+    except getopt.GetoptError:
+        print('usage: tomo.py -c <config_file> -o <output_folder> -l <log_level> -t')
+        sys.exit(2)
+    for opt, arg in opts:
+        if opt == '-h':
+            print('usage: tomo.py -c <config_file> -o <output_folder> -l <log_level> -t')
+            sys.exit()
+        elif opt in ("-c"):
+            config_file = arg
+        elif opt in ("-o"):
+            output_folder = arg
+        elif opt in ("-l"):
+            log_level = arg
+        elif opt in ("-t"):
+            test_mode = True
+    if config_file is None:
+        if os.path.isfile('config.yaml'):
+            config_file = 'config.yaml'
+        else:
+            config_file = 'config.txt'
+
+    # Set basic log configuration
+    logging_format = '%(asctime)s : %(levelname)s - %(module)s : %(funcName)s - %(message)s'
+    if not test_mode:
+        level = getattr(logging, log_level.upper(), None)
+        if not isinstance(level, int):
+            raise ValueError(f'Invalid log_level: {log_level}')
+        logging.basicConfig(format=logging_format, level=level, force=True,
+                handlers=[logging.StreamHandler()])
+
+    logging.debug(f'config_file = {config_file}')
+    logging.debug(f'output_folder = {output_folder}')
+    logging.debug(f'log_level = {log_level}')
+    logging.debug(f'test_mode = {test_mode}')
+
+    # Run tomography analysis
+    runTomo(config_file=config_file, output_folder=output_folder, log_level=log_level,
+            test_mode=test_mode)
+
+#%%============================================================================
+    input('Press any key to continue')
+#%%============================================================================