view tomo.py @ 18:b0a816c8f66c draft

"planemo upload for repository https://github.com/rolfverberg/galaxytools commit 1743a2a14a40c3a9ea949c36dc53e5d448646e19-dirty"
author rv43
date Mon, 18 Apr 2022 15:13:20 +0000
parents 7f723407beb3
children 4eba03628711
line wrap: on
line source

#!/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 argparse
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, num_core):
        cpu_count = mp.cpu_count()
        logging.debug(f'start: num_core={num_core} cpu_count={cpu_count}')
        if num_core is None or num_core < 1 or num_core > cpu_count:
            self.num_core = cpu_count
        else:
            self.num_core = num_core
        logging.debug(f'self.num_core={self.num_core}')

    def __enter__(self):
        self.num_core_org = ne.set_num_threads(self.num_core)
        logging.debug(f'self.num_core={self.num_core}')

    def __exit__(self, exc_type, exc_value, traceback):
        ne.set_num_threads(self.num_core_org)

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}')
            is_valid = False

        # 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,
            num_core=-1):
        """Initialize with optional config input file or dictionary
        """
        self.num_core = None
        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)}')
        if not isinstance(num_core, int) or num_core < -1 or num_core == 0:
            raise ValueError(f'Invalid num_core input {num_core} {type(num_core)}')
        if num_core == -1:
            self.num_core = mp.cpu_count()
        else:
            self.num_core = num_core

        # 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.INFO, force=True)
                        #format=logging_format, level=logging.WARNING, force=True)
            elif isinstance(log_stream, io.TextIOWrapper):
                #logging.basicConfig(filemode='w', format=logging_format, level=logging.WARNING,
                logging.basicConfig(filemode='w', format=logging_format, level=logging.INFO,
                        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}'

        # 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:
            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'num_core = {self.num_core}')
        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}')
        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')
            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"]}')

        # 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 self.galaxy_flag:
            msnc.quickImshow(self.tdf, title='dark field', name=dark_field_pngname,
                    save_fig=True, save_only=True)
        elif not self.test_mode:
            msnc.quickImshow(self.tdf, title='dark field', path=self.output_folder,
                    save_fig=self.save_plots, save_only=self.save_plots_only)

    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 self.galaxy_flag:
            msnc.quickImshow(self.tbf, title='bright field', name=bright_field_pngname,
                    save_fig=True, save_only=True)
        elif not self.test_mode:
            msnc.quickImshow(self.tbf, title='bright field', path=self.output_folder,
                    save_fig=self.save_plots, save_only=self.save_plots_only)

    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 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 msnc.is_index_range(img_x_bounds, 0, self.tbf.shape[0]):
                    msnc.illegal_value('img_x_bounds[1]', img_x_bounds[1], 'config file')
                    img_x_bounds[1] = 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)
            x_sum_min = x_sum.min()
            x_sum_max = x_sum.max()
            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, show_grid=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, show_grid=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)
            del x_sum
            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:
                tmp = np.copy(tomo_stack[0,:,:])
                tmp_max = tmp.max()
                tmp[img_x_bounds[0],:] = tmp_max
                tmp[img_x_bounds[1]-1,:] = tmp_max
                msnc.quickImshow(tmp, title=title)
                del tmp
                x_sum_min = x_sum.min()
                x_sum_max = x_sum.max()
                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')
                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)
            x_sum_min = x_sum.min()
            x_sum_max = x_sum.max()
            use_bounds = 'no'
            if img_x_bounds[0] is not None and img_x_bounds[1] is not None:
                tmp = np.copy(self.tbf)
                tmp_max = tmp.max()
                tmp[img_x_bounds[0],:] = tmp_max
                tmp[img_x_bounds[1]-1,:] = tmp_max
                title = 'Bright field'
                msnc.quickImshow(tmp, title=title)
                del tmp
                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
                    tmp = np.copy(self.tbf)
                    tmp_max = tmp.max()
                    tmp[x_low,:] = tmp_max
                    tmp[x_upp-1,:] = tmp_max
                    title = 'Bright field'
                    msnc.quickImshow(tmp, title=title)
                    del tmp
                    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)
            del x_sum
        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, num_core=None):
        """Generate tomography fields.
        """
        if num_core is None:
            num_core = self.num_core
        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 = 100
            num_theta_skip = 0
        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 = preprocess.get('zoom_perc', 100)
            num_theta_skip = preprocess.get('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
            index = stack['index']
            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 stack {index} took {time()-t0:.2f} seconds!')

            # Subtract dark field
            if self.tdf.size:
                t0 = time()
                with set_numexpr_threads(self.num_core):
                    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.num_core):
                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.num_core):
                ne.evaluate('where(tomo_stack<cutoff, cutoff, tomo_stack)', out=tomo_stack)
            with set_numexpr_threads(self.num_core):
                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:
                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(f'{self.output_folder}/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
            stack.pop('reconstructed', 'reconstructed not found')
            find_center = self.config.get('find_center')
            if find_center:
                find_center.pop('completed', 'completed not found')
                if self.test_mode:
                    find_center.pop('lower_center_offset', 'lower_center_offset not found')
                    find_center.pop('upper_center_offset', 'upper_center_offset not found')
            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, num_core=1):
        """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 and not self.test_mode:
            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, ncore=num_core)
        logging.debug(f'filtering and removing ring artifact took {time()-t0:.2f} seconds!')
        return recon_clean

    def _plotEdgesOnePlane(self, recon_plane, title, path=None, 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
        if self.galaxy_flag:
            msnc.quickImshow(edges[0,:,:], title, path=path, save_fig=True, save_only=True,
                    cmap='gray', vmin=vmin, vmax=vmax)
        else:
            if path is None:
                path=self.output_folder
            msnc.quickImshow(edges[0,:,:], f'{title} coolwarm', path=path,
                    save_fig=self.save_plots, save_only=self.save_plots_only, cmap='coolwarm')
            msnc.quickImshow(edges[0,:,:], f'{title} gray', path=path,
                    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, num_core=1, galaxy_param=None):
        """Find center for a single tomography plane.
        """
        if self.galaxy_flag:
            assert(galaxy_param)
            if not os.path.exists('find_center_pngs'):
                os.mkdir('find_center_pngs')
        # 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, ncore=num_core)
        center_offset_vo = tomo_center-center
        if self.test_mode:
            logging.info(f'Center at row {row} using Nghia Vo’s method = {center_offset_vo:.2f}')
            del sinogram_T
            return float(center_offset_vo)
        elif self.galaxy_flag:
            logging.info(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, num_core)
            title = f'edges row{row} center offset{center_offset_vo:.2f} Vo'
            self._plotEdgesOnePlane(recon_plane, title, path='find_center_pngs')
            del recon_plane
            if not galaxy_param['center_type_selector']:
                del sinogram_T
                return float(center_offset_vo)
        else:
            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, num_core)
            title = f'edges row{row} center offset{center_offset_vo:.2f} Vo'
            self._plotEdgesOnePlane(recon_plane, title)
        if not self.galaxy_flag:
            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, num_core)
                title = f'edges row{row} center_offset{center_offset:.2f} PC'
                self._plotEdgesOnePlane(recon_plane, title)
            if (pyip.inputYesNo('Accept a center location ([y]) or continue '+
                    'search (n)? ', blank=True) != 'no'):
                center_offset = pyip.inputNum(
                        f'    Enter chosen center offset [{-int(center)}, {int(center)}] '+
                        f'([{center_offset_vo:.2f}])): ', blank=True)
                if center_offset == '':
                    center_offset = center_offset_vo
                del sinogram_T
                del recon_plane
                return float(center_offset)

        # perform center finding search
        while True:
            if self.galaxy_flag and galaxy_param and galaxy_param['center_type_selector']:
                set_center = center_offset_vo
                if galaxy_param['center_type_selector'] == 'user':
                    set_center = galaxy_param['set_center']
                set_range = galaxy_param['set_range']
                center_offset_step = galaxy_param['set_step']
                if (not msnc.is_num(set_range, 0) or not msnc.is_num(center_offset_step) or
                        center_offset_step <= 0):
                    logging.warning('Illegal center finding search parameter, skip search')
                    del sinogram_T
                    return float(center_offset_vo)
                set_range = center_offset_step*max(1, int(set_range/center_offset_step))
                center_offset_low = set_center-set_range
                center_offset_upp = set_center+set_range
            else:
                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)
            num_center_offset = 1+int((center_offset_upp-center_offset_low)/center_offset_step)
            center_offsets = np.linspace(center_offset_low, center_offset_upp, num_center_offset)
            for center_offset in center_offsets:
                if center_offset == center_offset_vo:
                    continue
                logging.info(f'center_offset = {center_offset:.2f}')
                recon_plane = self._reconstructOnePlane(sinogram_T, center_offset+center,
                        thetas_deg, eff_pixel_size, cross_sectional_dim, False, num_core)
                title = f'edges row{row} center_offset{center_offset:.2f}'
                if self.galaxy_flag:
                    self._plotEdgesOnePlane(recon_plane, title, path='find_center_pngs')
                else:
                    self._plotEdgesOnePlane(recon_plane, title)
            if self.galaxy_flag or pyip.inputInt('\nContinue (0) or end the search (1): ',
                    min=0, max=1):
                break

        del sinogram_T
        del recon_plane
        if self.galaxy_flag:
            center_offset = center_offset_vo
        else:
            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, num_core=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 msnc.is_index_range(row_bounds, 0, 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=num_core)
        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=num_core)
        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=num_core)
        if True:
            tomopy.misc.corr.remove_ring(tomo_recon_stack, rwidth=rwidth, out=tomo_recon_stack,
                    ncore=num_core)
        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
        img_start_old = dark_field.get('img_start')
        num_imgs_old = dark_field.get('num')
        if num_imgs_old is None:
            dark_field['num'] = num_imgs
        else:
            if num_imgs_old > num_imgs:
                logging.error('Inconsistent number of availaible dark field images')
                if dark_field['data_path']:
                    self.is_valid = False
        if img_start_old is None:
            dark_field['img_start'] = img_start
        else:
            if img_start_old < img_start:
                logging.error('Inconsistent image start index for dark field images')
                if dark_field['data_path']:
                    self.is_valid = False
        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
        img_start_old = bright_field.get('img_start')
        num_imgs_old = bright_field.get('num')
        if num_imgs_old is None:
            bright_field['num'] = num_imgs
        else:
            if num_imgs_old > num_imgs:
                logging.error('Inconsistent number of availaible bright field images')
                self.is_valid = False
        if img_start_old is None:
            bright_field['img_start'] = img_start
        else:
            if img_start_old < img_start:
                logging.warning('Inconsistent image start index for bright field images')
                self.is_valid = False
        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
            img_start_old = stack.get('img_start')
            num_imgs_old = stack.get('num')
            if num_imgs_old is None:
                stack['num'] = num_imgs
            else:
                if num_imgs_old > num_imgs:
                    logging.error('Inconsistent number of availaible tomography images')
                    self.is_valid = False
            if img_start_old is None:
                stack['img_start'] = img_start
            else:
                if img_start_old < img_start:
                    logging.warning('Inconsistent image start index for tomography images')
                    self.is_valid = False
            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 loadTomoStacks(self, input_name):
        """Load tomography stacks (only for Galaxy).
        """
        assert(self.galaxy_flag)
        t0 = time()
        logging.info(f'Loading preprocessed tomography stack from {input_name} ...')
        stack_info = self.config['stack_info']
        stacks = stack_info.get('stacks')
        assert(len(self.tomo_stacks) == stack_info['num'])
        with np.load(input_name) as f:
            for i,stack in enumerate(stacks):
                self.tomo_stacks[i] = f[f'set_{stack["index"]}']
                logging.info(f'loaded stack {i}: index = {stack["index"]}, shape = '+
                        f'{self.tomo_stacks[i].shape}')
        logging.info(f'... done in {time()-t0:.2f} seconds!')

    def genTomoStacks(self, galaxy_param=None, num_core=None):
        """Preprocess tomography images.
        """
        if num_core is None:
            num_core = self.num_core
        # 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(isinstance(galaxy_param, dict))
            tdf_files = galaxy_param['tdf_files']
            tbf_files = galaxy_param['tbf_files']
            tomo_stack_files = galaxy_param['tomo_stack_files']
            assert(num_tomo_stacks == len(tomo_stack_files))
        else:
            if galaxy_param:
                logging.warning('Ignoring galaxy_param in findCenters (only for Galaxy)')
                galaxy_param = None
            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'])

        # 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, galaxy_param['dark_field_pngname'])

            # Generate bright field
            self._genBright(tbf_files, galaxy_param['bright_field_pngname'])

            # Set vertical detector bounds for image stack
            self._setDetectorBounds(tomo_stack_files, galaxy_param['tomo_field_pngname'],
                    galaxy_param['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, num_core)

            # Save tomography stack to file
            if self.galaxy_flag:
                t0 = time()
                output_name = galaxy_param['output_name']
                logging.info(f'Saving preprocessed tomography stack to {output_name} ...')
                save_stacks = {f'set_{stack["index"]}':tomo_stack
                        for stack,tomo_stack in zip(stacks,self.tomo_stacks)}
                logging.info(f'output_name = {output_name}')
                logging.info(f'save_stacks = {save_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, galaxy_param=None, num_core=None):
        """Find rotation axis centers for the tomography stacks.
        """
        if num_core is None:
            num_core = self.num_core
        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('Available stacks: {available_stacks}')
        if self.galaxy_flag:
            assert(isinstance(galaxy_param, dict))
            row_bounds = galaxy_param['row_bounds']
            center_rows = galaxy_param['center_rows']
            if center_rows is None:
                logging.error('Missing center_rows entry in galaxy_param')
                return
            center_type_selector = galaxy_param['center_type_selector']
            if center_type_selector:
                if center_type_selector == 'vo':
                    set_center = None
                elif center_type_selector == 'user':
                    set_center = galaxy_param['set_center']
                else:
                    logging.error('Illegal center_type_selector entry in galaxy_param '+
                            f'({center_type_selector})')
                    galaxy_param['center_type_selector'] = None
        else:
            if galaxy_param:
                logging.warning('Ignoring galaxy_param in findCenters (only for Galaxy)')
                galaxy_param = None

        # Check for valid available center stack index
        find_center = self.config.get('find_center')
        center_stack_index = None
        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:
                    assert(find_center.get('completed', False) == False)
                else:
                    print('\nFound calibration center offset info for stack '+
                            f'{center_stack_index}')
                    if (pyip.inputYesNo('Do you want to use this again ([y]/n)? ',
                            blank=True) != 'no' and find_center.get('completed', False) == True):
                        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:
            if not center_stack_index:
                center_stack_index = stacks[0]['index']
            elif center_stack_index != stacks[0]['index']:
                raise ValueError(f'Inconsistent center_stack_index {center_stack_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:
                stacks[0]['preprocessed'] = False
                raise OSError('Unable to load the required preprocessed tomography stack')
            assert(stacks[0].get('preprocessed', False) == True)
        elif self.galaxy_flag:
            logging.error('CHECK/FIX FOR GALAXY')
            #center_stack_index = stacks[int(num_tomo_stacks/2)]['index']
        else:
            while True:
                if not center_stack_index:
                    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:
                    stacks[tomo_stack_index]['preprocessed'] = False
                    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']
        else:
            find_center['center_stack_index'] = center_stack_index
        if not self.galaxy_flag:
            row_bounds = find_center.get('row_bounds', None)
            center_rows = [find_center.get('lower_row', None),
                    find_center.get('upper_row', None)]
        if row_bounds is None:
            row_bounds = [0, center_stack.shape[0]]
        if row_bounds[0] == -1:
            row_bounds[0] = 0
        if row_bounds[1] == -1:
            row_bounds[1] = center_stack.shape[0]
        if not msnc.is_index_range(row_bounds, 0, center_stack.shape[0]):
            msnc.illegal_value('row_bounds', row_bounds)
            return

        # 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}')
        if num_tomo_stacks == 1:
             accept = 'yes'
             if not self.test_mode and not self.galaxy_flag:
                 accept = 'no'
                 print('\nSelect bounds for image reconstruction')
                 if msnc.is_index_range(row_bounds, 0, center_stack.shape[0]):
                     a_tmp = np.copy(center_stack[:,0,:])
                     a_tmp_max = a_tmp.max()
                     a_tmp[row_bounds[0],:] = a_tmp_max
                     a_tmp[row_bounds[1]-1,:] = a_tmp_max
                     print(f'lower bound = {row_bounds[0]} (inclusive)')
                     print(f'upper bound = {row_bounds[1]} (exclusive)')
                     msnc.quickImshow(a_tmp, title=f'center stack theta={theta_start}',
                         aspect='auto')
                     del a_tmp
                     accept = pyip.inputYesNo('Accept these bounds ([y]/n)?: ', blank=True)
             if accept == 'no':
                 (n1, n2) = msnc.selectImageBounds(center_stack[:,0,:], 0,
                         title=f'center stack theta={theta_start}')
             else:
                 n1 = row_bounds[0]
                 n2 = row_bounds[1]
        else:
            logging.error('CHECK/FIX FOR GALAXY')
            tomo_ref_heights = [stack['ref_height'] for stack in stacks]
            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')
        if not self.test_mode and not self.galaxy_flag:
            tmp = center_stack[:,0,:]
            tmp_max = tmp.max()
            tmp[n1,:] = tmp_max
            tmp[n2-1,:] = tmp_max
            if msnc.is_index_range(center_rows, 0, tmp.shape[0]):
                tmp[center_rows[0],:] = tmp_max
                tmp[center_rows[1]-1,:] = tmp_max
            extent = [0, tmp.shape[1], tmp.shape[0], 0]
            msnc.quickImshow(tmp, title=f'center stack theta={theta_start}',
                    path=self.output_folder, extent=extent, save_fig=self.save_plots,
                    save_only=self.save_plots_only, aspect='auto')
            del tmp
            #extent = [0, center_stack.shape[2], n2, n1]
            #msnc.quickImshow(center_stack[n1:n2,0,:], title=f'center stack theta={theta_start}',
            #        path=self.output_folder, extent=extent, save_fig=self.save_plots,
            #        save_only=self.save_plots_only, show_grid=True, aspect='auto')

        # 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 = 'no'
        use_center = 'no'
        row = center_rows[0]
        if self.test_mode or self.galaxy_flag:
            assert(msnc.is_int(row, n1, n2-2))
        if msnc.is_int(row, n1, n2-2):
            if self.test_mode or self.galaxy_flag:
                use_row = 'yes'
            else:
                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)? ', blank=True)
                if self.save_plots_only:
                    msnc.clearFig(f'theta={theta_start}')
                if use_row != 'no':
                    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)? ', blank=True)
        if use_center == 'no':
            if use_row == 'no':
                if not self.test_mode:
                    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, num_core=num_core,
                    galaxy_param=galaxy_param)
        logging.info(f'lower_center_offset = {center_offset:.2f} {type(center_offset)}')
        print(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 = 'no'
        use_center = 'no'
        row = center_rows[1]
        if self.test_mode or self.galaxy_flag:
            assert(msnc.is_int(row, lower_row+1, n2-1))
        if msnc.is_int(row, lower_row+1, n2-1):
            if self.test_mode or self.galaxy_flag:
                use_row = 'yes'
            else:
                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)? ', blank=True)
                if self.save_plots_only:
                    msnc.clearFig(f'theta={theta_start}')
                if use_row != 'no':
                    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)? ', blank=True)
        if use_center == 'no':
            if use_row == 'no':
                if not self.test_mode:
                    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, num_core=num_core,
                    galaxy_param=galaxy_param)
        logging.info(f'upper_center_offset = {center_offset:.2f}')
        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, galaxy_param=None, num_core=None):
        """Reconstruct tomography stacks.
        """
        print('OK1')
        if num_core is None:
            num_core = self.num_core
        logging.debug('Reconstruct tomography stacks')
        stacks = self.config['stack_info']['stacks']
        assert(len(self.tomo_stacks) == self.config['stack_info']['num'])
        assert(len(self.tomo_stacks) == len(stacks))
        assert(len(self.tomo_recon_stacks) == len(stacks))
        print('OK2')
        if self.galaxy_flag:
            assert(isinstance(galaxy_param, dict))
            # Get rotation axis centers
            center_offsets = galaxy_param['center_offsets']
            assert(isinstance(center_offsets, list) and len(center_offsets) == 2)
            lower_center_offset = center_offsets[0]
            assert(msnc.is_num(lower_center_offset))
            upper_center_offset = center_offsets[1]
            assert(msnc.is_num(upper_center_offset))
        else:
            if galaxy_param:
                logging.warning('Ignoring galaxy_param in reconstructTomoStacks (only for Galaxy)')
                galaxy_param = None
            lower_center_offset = None
            upper_center_offset = None
        print('OK3')

        # 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
        upper_row = find_center.get('upper_row')
        if upper_row is None:
            logging.error('Unable to read upper_row from config')
            return
        logging.debug(f'lower_row = {lower_row} upper_row = {upper_row}')
        assert(lower_row < upper_row)
        if lower_center_offset is None:
            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
        if upper_center_offset is None:
            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
        center_slope = (upper_center_offset-lower_center_offset)/(upper_row-lower_row)

        # Set thetas (in radians)
        print('OK4')
        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))

        print('OK5')
        # 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']
            if not self.galaxy_flag:
                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('preprocessed', False) == True)
                    assert(stack.get('reconstructed', False) == True)
                    continue
            stack['reconstructed'] = False
            if not self.tomo_stacks[i].size:
                self.tomo_stacks[i], available = self._loadTomo('red stack', index,
                        required=True)
            print(f'self.tomo_stacks.shape = {self.tomo_stacks[i].shape}')
            if not self.tomo_stacks[i].size:
                logging.error(f'Unable to load tomography stack {index} for reconstruction')
                stack[i]['preprocessed'] = False
                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, num_core=num_core,
                    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 and not self.galaxy_flag:
                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([])
            print('OK6')

            # Update config and save to file
            stack['reconstructed'] = True
            combine_stacks = self.config.get('combine_stacks')
            if combine_stacks and index in combine_stacks.get('stacks', []):
                combine_stacks['stacks'].remove(index)
            self.cf.saveFile(self.config_out)

        print('OK7')
        # Save reconstructed tomography stack to file
        if self.galaxy_flag:
            t0 = time()
            output_name = galaxy_param['output_name']
            logging.info(f'Saving reconstructed tomography stack to {output_name} ...')
            save_stacks = {f'set_{stack["index"]}':tomo_stack
                    for stack,tomo_stack in zip(stacks,self.tomo_recon_stacks)}
            np.savez(output_name, **save_stacks)
            logging.info(f'... done in {time()-t0:.2f} seconds!')

    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
        stack_info = self.config['stack_info']
        stacks = stack_info['stacks']
        for i,stack in enumerate(stacks):
            available = False
            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:
                logging.error(f'Unable to load reconstructed stack {stack["index"]}')
                stack['reconstructed'] = False
                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 x bounds (in yz-plane)
        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(f'{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')

        # Selecting y bounds (in xz-plane)
        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(y_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(f'{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 = 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!')
        combined_stacks = [stack['index'] for stack in stacks]

        # Wrap up if in test_mode
        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(f'{self.output_folder}/recon_combined_sum_xy.txt',
                        tomosum, fmt='%.6e')
            np.savetxt(f'{self.output_folder}/recon_combined.txt',
                    tomo_recon_combined[int(tomo_recon_combined.shape[0]/2),:,:], fmt='%.6e')

            # 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)
            return

        # Selecting z bounds (in xy-plane)
        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'
        for stack in stacks:
            base_name += f' {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['z_bounds'] = z_bounds
            combine_stacks['stacks'] = combined_stacks
        else:
            self.config['combine_stacks'] = {'x_bounds' : x_bounds, 'y_bounds' : y_bounds,
                    'z_bounds' : z_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, num_core=-1):
    """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, num_core=num_core)
    if not tomo.is_valid:
        raise ValueError('Invalid config and/or detector file provided.')

    # Preprocess the image files
    assert(tomo.config['stack_info'])
    num_tomo_stacks = tomo.config['stack_info']['num']
    assert(num_tomo_stacks == len(tomo.tomo_stacks))
    preprocessed_stacks = []
    if not tomo.test_mode:
        preprocess = tomo.config.get('preprocess', None)
        if preprocess:
            preprocessed_stacks = [stack['index'] for stack in tomo.config['stack_info']['stacks']
                    if stack.get('preprocessed', False)]
    if len(preprocessed_stacks) != num_tomo_stacks:
        tomo.genTomoStacks()
        if not tomo.is_valid:
            IOError('Unable to load all required image files.')
        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
    assert(tomo.config['stack_info']['stacks'])
    reconstructed_stacks = [stack['index'] for stack in tomo.config['stack_info']['stacks']
            if stack.get('reconstructed', False)]
    if len(reconstructed_stacks) != num_tomo_stacks:
        tomo.reconstructTomoStacks()

    # Combine reconstructed tomography stacks
    reconstructed_stacks = [stack['index'] for stack in tomo.config['stack_info']['stacks']
            if stack.get('reconstructed', False)]
    combine_stacks = tomo.config.get('combine_stacks')
    if len(reconstructed_stacks) and (combine_stacks is None or
            combine_stacks.get('stacks') != reconstructed_stacks):
        tomo.combineTomoStacks()

#%%============================================================================
if __name__ == '__main__':

    # Parse command line arguments
    parser = argparse.ArgumentParser(
            description='Tomography reconstruction')
    parser.add_argument('-c', '--config',
            default=None,
            help='Input config')
    parser.add_argument('-o', '--output_folder',
            default='.',
            help='Output folder')
    parser.add_argument('-l', '--log_level',
            default='INFO',
            help='Log level')
    parser.add_argument('-t', '--test_mode',
            action='store_true',
            default=False,
            help='Test mode flag')
    parser.add_argument('--num_core',
            type=int,
            default=-1,
            help='Number of cores')
    args = parser.parse_args()

    if args.config is None:
        if os.path.isfile('config.yaml'):
            args.config = 'config.yaml'
        else:
            args.config = 'config.txt'

    # Set basic log configuration
    logging_format = '%(asctime)s : %(levelname)s - %(module)s : %(funcName)s - %(message)s'
    if not args.test_mode:
        level = getattr(logging, args.log_level.upper(), None)
        if not isinstance(level, int):
            raise ValueError(f'Invalid log_level: {args.log_level}')
        logging.basicConfig(format=logging_format, level=level, force=True,
                handlers=[logging.StreamHandler()])

    logging.debug(f'config = {args.config}')
    logging.debug(f'output_folder = {args.output_folder}')
    logging.debug(f'log_level = {args.log_level}')
    logging.debug(f'test_mode = {args.test_mode}')
    logging.debug(f'num_core = {args.num_core}')

    # Run tomography analysis
    runTomo(config_file=args.config, output_folder=args.output_folder, log_level=args.log_level,
            test_mode=args.test_mode, num_core=args.num_core)

#%%============================================================================
#    input('Press any key to continue')
#%%============================================================================