diff --git a/nabu/app/fullfield.py b/nabu/app/fullfield.py index 7e7acec6f95be08fcdc842ba5197c6e4bddbd9d4..d15fbce6bdafd0dc75bf2fd81025a105e90dc365 100644 --- a/nabu/app/fullfield.py +++ b/nabu/app/fullfield.py @@ -10,6 +10,7 @@ from ..preproc.shift import VerticalShift from ..preproc.double_flatfield import DoubleFlatField from ..preproc.phase import PaganinPhaseRetrieval from ..preproc.sinogram import SinoProcessing, SinoNormalization +from ..misc.rotation import Rotation from ..preproc.rings import MunchDeringer from ..misc.unsharp import UnsharpMask from ..misc.histogram import PartialHistogram, hist_as_2Darray @@ -29,6 +30,7 @@ class FullFieldPipeline: CCDCorrectionClass = CCDCorrection PaganinPhaseRetrievalClass = PaganinPhaseRetrieval UnsharpMaskClass = UnsharpMask + ImageRotationClass = Rotation VerticalShiftClass = VerticalShift SinoProcessingClass = SinoProcessing SinoDeringerClass = MunchDeringer @@ -249,6 +251,8 @@ class FullFieldPipeline: shape = self.radios.shape elif step_name == "double_flatfield": shape = self.radios.shape + elif step_name == "rotate_projections": + shape = self.radios.shape[1:] elif step_name == "phase": shape = self.radios.shape[1:] elif step_name == "ccd_correction": @@ -322,6 +326,7 @@ class FullFieldPipeline: self._init_flatfield() self._init_double_flatfield() self._init_ccd_corrections() + self._init_radios_rotation() self._init_phase() self._init_unsharp() self._init_radios_movements() @@ -428,6 +433,25 @@ class FullFieldPipeline: clip_max=options["log_max_clip"] ) + @use_options("rotate_projections", "projs_rot") + def _init_radios_rotation(self): + options = self.processing_options["rotate_projections"] + center = options["center"] + if center is None: + nx, ny = self.dataset_infos.radio_dims + center = (nx/2 - 0.5, ny/2 - 0.5) + center = (center[0], center[1] - self.z_min) + self.projs_rot = self.ImageRotationClass( + self._get_shape("rotate_projections"), + options["angle"], + center=center, + mode="edge", + reshape=False + ) + self._tmp_rotated_radio = self._allocate_array( + self._get_shape("rotate_projections"), "f", name="tmp_rotated_radio" + ) + @use_options("radios_movements", "radios_movements") def _init_radios_movements(self): options = self.processing_options["radios_movements"] @@ -618,6 +642,15 @@ class FullFieldPipeline: ) radios[i][:] = _tmp_radio[:] + @pipeline_step("projs_rot", "Rotating projections") + def _rotate_projections(self, radios=None): + if radios is None: + radios = self.radios + tmp_radio = self._tmp_rotated_radio + for i in range(radios.shape[0]): + self.projs_rot.rotate(radios[i], output=tmp_radio) + radios[i][:] = tmp_radio[:] + @pipeline_step("phase_retrieval", "Performing phase retrieval") def _retrieve_phase(self): if "unsharp_mask" in self.processing_steps: @@ -716,6 +749,7 @@ class FullFieldPipeline: self._flatfield() self._double_flatfield() self._ccd_corrections() + self._rotate_projections() self._retrieve_phase() self._apply_unsharp() self._take_log() diff --git a/nabu/app/fullfield_cuda.py b/nabu/app/fullfield_cuda.py index c69cca18d452c2960d26d1c7b4f66f5b39dc0719..8ef46a763fd0ebb35e5480349cfc07ece5a3e497 100644 --- a/nabu/app/fullfield_cuda.py +++ b/nabu/app/fullfield_cuda.py @@ -10,6 +10,7 @@ from ..preproc.sinogram_cuda import CudaSinoProcessing, CudaSinoNormalization from ..preproc.sinogram import SinoProcessing, SinoNormalization from ..preproc.rings_cuda import CudaMunchDeringer from ..misc.unsharp_cuda import CudaUnsharpMask +from ..misc.rotation_cuda import CudaRotation from ..misc.histogram_cuda import CudaPartialHistogram from ..reconstruction.fbp import Backprojector from ..cuda.utils import get_cuda_context, __has_pycuda__, __pycuda_error_msg__, copy_big_gpuarray, replace_array_memory @@ -30,6 +31,7 @@ class CudaFullFieldPipeline(FullFieldPipeline): CCDCorrectionClass = CudaCCDCorrection PaganinPhaseRetrievalClass = CudaPaganinPhaseRetrieval UnsharpMaskClass = CudaUnsharpMask + ImageRotationClass = CudaRotation VerticalShiftClass = CudaVerticalShift SinoProcessingClass = CudaSinoProcessing SinoDeringerClass = CudaMunchDeringer @@ -214,6 +216,8 @@ class CudaFullFieldPipelineLimitedMemory(CudaFullFieldPipeline): shape = (n_a, ) + self.radios.shape[1:] elif step_name == "ccd_correction": shape = self.radios.shape[1:] + elif step_name == "rotate_projections": + shape = self.radios.shape[1:] elif step_name == "phase": # Phase retrieval is done on device. Shape: (group_size, delta_z, width) shape = self.radios.shape[1:] @@ -398,6 +402,7 @@ class CudaFullFieldPipelineLimitedMemory(CudaFullFieldPipeline): if not(self._flatfield_is_done): self._flatfield_radios_group(start_idx, end_idx, transfer_size) self._ccd_corrections() + self._rotate_projections() self._retrieve_phase() self._apply_unsharp() self._take_log() diff --git a/nabu/app/local_reconstruction.py b/nabu/app/local_reconstruction.py index 94a76c3ea5190c355983b27be32b0c7d8b5db571..86be6c8fe67b25cdefdee3007c0af3f928c21f83 100644 --- a/nabu/app/local_reconstruction.py +++ b/nabu/app/local_reconstruction.py @@ -116,6 +116,7 @@ class LocalReconstruction: user_phase_margin = self.extra_options["phase_margin"] if user_phase_margin is not None and user_phase_margin > 0: margin_v, margin_h = user_phase_margin, user_phase_margin + self.logger.info("Using user-defined phase margin: %d" % user_phase_margin) else: margin_v, margin_h = compute_paganin_margin( radio_shape, diff --git a/nabu/cuda/processing.py b/nabu/cuda/processing.py index dad1f21337bf1c79a1921eb22022753e87911898..2e215754c8db9590cb85ac9795472f2e5f956843 100644 --- a/nabu/cuda/processing.py +++ b/nabu/cuda/processing.py @@ -64,7 +64,7 @@ class CudaProcessing(object): def _allocate_array(self, array_name, shape, dtype=np.float32): - if not(self._allocated[array_name]): + if (array_name not in self._allocated) or not(self._allocated[array_name]): new_gpuarr = garray.zeros(shape, dtype=dtype) setattr(self, array_name, new_gpuarr) self._allocated[array_name] = True diff --git a/nabu/cuda/src/rotation.cu b/nabu/cuda/src/rotation.cu new file mode 100644 index 0000000000000000000000000000000000000000..a2238b07aa0d3e2873f3f5ecc6658a330e74381f --- /dev/null +++ b/nabu/cuda/src/rotation.cu @@ -0,0 +1,25 @@ +typedef unsigned int uint; +texture tex_image; + +__global__ void rotate( + float* output, + int Nx, + int Ny, + float cos_angle, + float sin_angle, + float rotc_x, + float rotc_y +) { + uint gidx = blockDim.x * blockIdx.x + threadIdx.x; + uint gidy = blockDim.y * blockIdx.y + threadIdx.y; + if (gidx >= Nx || gidy >= Ny) return; + + float x = (gidx - rotc_x)*cos_angle - (gidy - rotc_y)*sin_angle; + float y = (gidx - rotc_x)*sin_angle + (gidy - rotc_y)*cos_angle; + x += rotc_x; + y += rotc_y; + + float out_val = tex2D(tex_image, x + 0.5f, y + 0.5f); + output[gidy * Nx + gidx] = out_val; + +} diff --git a/nabu/misc/rotation.py b/nabu/misc/rotation.py new file mode 100644 index 0000000000000000000000000000000000000000..6b0c4eb78f88dfcc394f9848930e73602d7f19b2 --- /dev/null +++ b/nabu/misc/rotation.py @@ -0,0 +1,71 @@ +import numpy as np + +try: + from skimage.transform import rotate + __have__skimage__ = True +except ImportError: + __have__skimage__ = False + + +class Rotation: + + supported_modes = { + "constant": "constant", + "zeros": "constant", + "edge": "edge", + "edges": "edge", + "symmetric": "symmetric", + "sym": "symmetric", + "reflect": "reflect", + "wrap": "wrap", + "periodic": "wrap", + } + + def __init__(self, shape, angle, center=None, mode="edge", reshape=False, **sk_kwargs): + """ + Initiate a Rotation object. + + Parameters + ---------- + shape: tuple of int + Shape of the images to process + angle: float + Rotation angle in DEGREES + center: tuple of float, optional + Coordinates of the center of rotation, in the format (X, Y) (mind the non-python + convention !). + Default is ((Nx - 1)/2.0, (Ny - 1)/2.0) + mode: str, optional + Padding mode. Default is "edge". + reshape: bool, optional + + + Other Paremeters + ----------------- + All the other parameters are passed directly to scikit image 'rotate' function: + order, cval, clip, preserve_range. + """ + self.shape = shape + self.angle = angle + self.center = center + self.mode = mode + self.reshape = reshape + self.sk_kwargs = sk_kwargs + + + def rotate(self, img, output=None): + res = rotate( + img, self.angle, + resize=self.reshape, + center=self.center, + mode=self.mode, + **self.sk_kwargs + ) + if output is not None: + output[:] = res[:] + return output + else: + return res + + + __call__ = rotate diff --git a/nabu/misc/rotation_cuda.py b/nabu/misc/rotation_cuda.py new file mode 100644 index 0000000000000000000000000000000000000000..7562c3f16233d53c48288c9ae8f75fb9db04af96 --- /dev/null +++ b/nabu/misc/rotation_cuda.py @@ -0,0 +1,76 @@ +import numpy as np +from .rotation import Rotation +from ..utils import get_cuda_srcfile, updiv +from ..cuda.utils import __has_pycuda__, copy_array +from ..cuda.processing import CudaProcessing +if __has_pycuda__: + from ..cuda.kernel import CudaKernel + import pycuda.gpuarray as garray + import pycuda.driver as cuda + + +class CudaRotation(Rotation): + def __init__(self, shape, angle, center=None, mode="edge", reshape=False, cuda_options=None, **sk_kwargs): + if center is None: + center = ((shape[1] - 1)/2., (shape[0] - 1)/2.) + super().__init__( + shape, angle, + center=center, mode=mode, reshape=reshape, **sk_kwargs + ) + self._init_cuda_rotation(cuda_options) + + + def _init_cuda_rotation(self, cuda_options): + cuda_options = cuda_options or {} + self.cuda_processing = CudaProcessing(**cuda_options) + self._allocate_arrays() + self._init_rotation_kernel() + + + def _allocate_arrays(self): + self._d_image_cua = cuda.np_to_array(np.zeros(self.shape, "f"), "C") + self.cuda_processing._init_arrays_to_none(["d_output"]) + + + def _init_rotation_kernel(self): + self.cuda_rotation_kernel = CudaKernel( + "rotate", + get_cuda_srcfile("rotation.cu") + ) + self.texref_image = self.cuda_rotation_kernel.module.get_texref("tex_image") + self.texref_image.set_filter_mode(cuda.filter_mode.LINEAR) # bilinear + self.texref_image.set_address_mode(0, cuda.address_mode.CLAMP) # TODO tune + self.texref_image.set_address_mode(1, cuda.address_mode.CLAMP) # TODO tune + self.cuda_rotation_kernel.prepare("Piiffff", [self.texref_image]) + self.texref_image.set_array(self._d_image_cua) + self._cos_theta = np.cos(np.deg2rad(self.angle)) + self._sin_theta = np.sin(np.deg2rad(self.angle)) + self._Nx = np.int32(self.shape[1]) + self._Ny = np.int32(self.shape[0]) + self._center_x = np.float32(self.center[0]) + self._center_y = np.float32(self.center[1]) + self._block = (32, 32, 1) # tune ? + self._grid = ( + updiv(self.shape[1], self._block[1]), + updiv(self.shape[0], self._block[0]), + 1 + ) + + + def rotate(self, img, output=None, do_checks=True): + copy_array(self._d_image_cua, img, check=do_checks) + if output is not None: + d_out = output + else: + self.cuda_processing._allocate_array("d_output", self.shape, np.float32) + d_out = self.cuda_processing.d_output + self.cuda_rotation_kernel( + d_out, self._Nx, self._Ny, + self._cos_theta, self._sin_theta, + self._center_x, self._center_y, + grid=self._grid, + block=self._block, + ) + return d_out + + __call__ = rotate diff --git a/nabu/misc/tests/test_rotation.py b/nabu/misc/tests/test_rotation.py new file mode 100644 index 0000000000000000000000000000000000000000..a12cc4d41b108a477cadeb91b3891760d7c5e6ed --- /dev/null +++ b/nabu/misc/tests/test_rotation.py @@ -0,0 +1,75 @@ +import numpy as np +import pytest +from nabu.testutils import generate_tests_scenarios +from nabu.misc.rotation import Rotation, __have__skimage__ +from nabu.misc.rotation_cuda import CudaRotation, __has_pycuda__ + +if __have__skimage__: + from skimage.transform import rotate + from skimage.data import chelsea + ny, nx = chelsea().shape[:2] +if __has_pycuda__: + from nabu.cuda.utils import get_cuda_context + import pycuda.gpuarray as garray + +if __have__skimage__: + scenarios = generate_tests_scenarios({ + # ~ "output_is_none": [False, True], + "mode": ["edge"], + "angle": [5., 10., 45., 57., 90.], + "center": [None, ((nx-1)/2., (ny-1)/2.), ((nx-1)/2., ny-1)], + }) +else: + scenarios = {} + +@pytest.fixture(scope='class') +def bootstrap(request): + cls = request.cls + cls.image = chelsea().mean(axis=-1, dtype=np.float32) + if __has_pycuda__: + cls.ctx = get_cuda_context() + cls.d_image = garray.to_gpu(cls.image) + + +@pytest.mark.skipif(not(__have__skimage__), reason="Need scikit-image for rotation") +@pytest.mark.usefixtures('bootstrap') +class TestRotation: + + def _get_reference_rotation(self, config): + return rotate( + self.image, + config["angle"], + resize=False, + center=config["center"], + order=1, + mode=config["mode"], + clip=False, # + preserve_range=False + ) + + def _check_result(self, res, config, tol): + ref = self._get_reference_rotation(config) + mae = np.max(np.abs(res - ref)) + err_msg = str( + "Max error is too high for this configuration: %s" % str(config) + ) + assert mae < tol, err_msg + + # parametrize on a class method will use the same class, and launch this + # method with different scenarios. + @pytest.mark.parametrize("config", scenarios) + def test_rotation(self, config): + R = Rotation(self.image.shape, config["angle"], center=config["center"], mode=config["mode"]) + res = R(self.image) + self._check_result(res, config, 1e-6) + + @pytest.mark.parametrize("config", scenarios) + def test_cuda_rotation(self, config): + R = CudaRotation( + self.image.shape, + config["angle"], center=config["center"], mode=config["mode"], + cuda_options={"ctx": self.ctx}, + ) + d_res = R(self.d_image) + res = d_res.get() + self._check_result(res, config, 0.5) diff --git a/nabu/resources/cli/cli_configs.py b/nabu/resources/cli/cli_configs.py index cbb90193c56a4701a1288846e41c643cf6f275e4..ed60715aa997bd1bf5d2bb100fa8b86e2b35c468 100644 --- a/nabu/resources/cli/cli_configs.py +++ b/nabu/resources/cli/cli_configs.py @@ -187,3 +187,47 @@ GenerateInfoConfig = { }, } + +RotateRadiosConfig = { + "dataset": { + "help": "Path to the dataset. Only HDF5 format is supported for now.", + "default": "", + "mandatory": True, + }, + "entry": { + "help": "HDF5 entry. By default, the first entry is taken.", + "default": "", + }, + "angle": { + "help": "Rotation angle in degrees", + "default": 0., + "mandatory": True, + "type": float, + }, + "center": { + "help": "Rotation center, in the form (x, y) where x (resp. y) is the horizontal (resp. vertical) dimension, i.e along the columns (resp. lines). Default is (Nx/2 - 0.5, Ny/2 - 0.5).", + "default": "", + }, + "output": { + "help": "Path to the output file. Only HDF5 output is supported. In the case of HDF5 input, the output file will have the same structure.", + "default": "", + "mandatory": True, + }, + "loglevel": { + "help": "Logging level. Can be 'debug', 'info', 'warning', 'error'. Default is 'info'.", + "default": "info", + }, + "batchsize": { + "help": "Size of the batch of images to process. Default is 100", + "default": 100, + "type": int, + }, + "use_cuda": { + "help": "Whether to use Cuda if available", + "default": "1", + }, + "use_multiprocessing": { + "help": "Whether to use multiprocessing if available", + "default": "1", + }, +} diff --git a/nabu/resources/cli/rotate.py b/nabu/resources/cli/rotate.py new file mode 100644 index 0000000000000000000000000000000000000000..1639cfcecbd57a822f1a1adfed0ddae2254d57f2 --- /dev/null +++ b/nabu/resources/cli/rotate.py @@ -0,0 +1,157 @@ +import posixpath +from os import path +from math import ceil +from shutil import copy +from multiprocessing import cpu_count +from multiprocessing.pool import ThreadPool + +import numpy as np + +from tomoscan.io import HDF5File +from tomoscan.esrf.hdf5scan import HDF5TomoScan + +from ...io.utils import get_first_hdf5_entry +from ...misc.rotation import Rotation +from ..logger import Logger, LoggerOrPrint +from .utils import parse_params_values +from .cli_configs import RotateRadiosConfig +from ...resources.validators import optional_tuple_of_floats_validator, boolean_validator +from ...misc.rotation_cuda import CudaRotation, __has_pycuda__ + + +class HDF5ImagesStackRotation: + def __init__(self, input_file, output_file, angle, center=None, entry=None, logger=None, batch_size=100, use_cuda=True, use_multiprocessing=True): + self.logger = LoggerOrPrint(logger) + self.use_cuda = use_cuda & __has_pycuda__ + self.batch_size = batch_size + self.use_multiprocessing = use_multiprocessing + self._browse_dataset(input_file, entry) + self._get_rotation(angle, center) + self._init_output_dataset(output_file) + + def _browse_dataset(self, input_file, entry): + self.input_file = input_file + if entry is None or entry == "": + entry = get_first_hdf5_entry(input_file) + self.entry = entry + self.dataset_info = HDF5TomoScan(input_file, entry=entry) + + def _get_rotation(self, angle, center): + if self.use_cuda: + self.logger.info("Using Cuda rotation") + rot_cls = CudaRotation + else: + self.logger.info("Using skimage rotation") + rot_cls = Rotation + if self.use_multiprocessing: + self.thread_pool = ThreadPool(processes=cpu_count() - 2) + self.logger.info("Using multiprocessing with %d cores" % self.thread_pool._processes) + + self.rotation = rot_cls( + (self.dataset_info.dim_2, self.dataset_info.dim_1), + angle, + center=center, + mode="edge" + ) + + def _init_output_dataset(self, output_file): + self.output_file = output_file + copy(self.input_file, output_file) + + first_proj_url = self.dataset_info.projections[list(self.dataset_info.projections.keys())[0]] + self.data_path = first_proj_url.data_path() + dirname, basename = posixpath.split(self.data_path) + self._data_path_dirname = dirname + self._data_path_basename = basename + + def _rotate_stack_cuda(self, images, output): + self.rotation.cuda_processing._allocate_array("tmp_images_stack", images.shape) + self.rotation.cuda_processing._allocate_array("tmp_images_stack_rot", images.shape) + d_in = self.rotation.cuda_processing.tmp_images_stack + d_out = self.rotation.cuda_processing.tmp_images_stack_rot + n_imgs = images.shape[0] + d_in[:n_imgs].set(images) + for j in range(n_imgs): + self.rotation.rotate(d_in[j], output=d_out[j]) + d_out[:n_imgs].get(ary=output[:n_imgs]) + + def _rotate_stack(self, images, output): + if self.use_cuda: + output = self._rotate_stack_cuda(images, output) + elif self.use_multiprocessing: + out_tmp = self.thread_pool.map(self.rotation.rotate, images) + print(out_tmp[0]) + output[:] = np.array(out_tmp, dtype="f") # list -> np array... consumes twice as much memory + else: + for j in range(images.shape[0]): + output[j] = self.rotation.rotate(images[j]) + + def rotate_images(self, suffix="_rot"): + data_path = self.data_path + fid = HDF5File(self.input_file, "r") + fid_out = HDF5File(self.output_file, "a") + + try: + data_ptr = fid[data_path] + n_images = data_ptr.shape[0] + data_out_ptr = fid_out[data_path] + + # Delete virtual dataset in output file, create "data_rot" dataset + del fid_out[data_path] + fid_out[self._data_path_dirname].create_dataset( + self._data_path_basename + suffix, shape=data_ptr.shape, dtype=data_ptr.dtype + ) + data_out_ptr = fid_out[data_path + suffix] + + # read by group of images to hide latency + group_size = self.batch_size + images_rot = np.zeros((group_size, data_ptr.shape[1], data_ptr.shape[2]), dtype="f") + n_groups = ceil(n_images / group_size) + for i in range(n_groups): + self.logger.info("Processing radios group %d/%d" % (i+1, n_groups)) + i_min = i * group_size + i_max = min((i + 1) * group_size, n_images) + images = data_ptr[i_min:i_max, :, :].astype("f") + self._rotate_stack(images, images_rot) + data_out_ptr[i_min:i_max, :, :] = images_rot[:i_max-i_min, :, :].astype(data_ptr.dtype) + finally: + fid_out[self._data_path_dirname].move(posixpath.basename(data_path) + suffix, self._data_path_basename) + fid_out[data_path].attrs["interpretation"] = "image" + fid.close() + fid_out.close() + + + + +def rotate_cli(): + args = parse_params_values( + RotateRadiosConfig, + parser_description="A command-line utility for performing a rotation on all the radios of a dataset." + ) + logger = Logger( + "nabu_rotate", level=args["loglevel"], logfile="nabu_rotate.log" + ) + + dataset_path = args["dataset"] + h5_entry = args["entry"] + output_file = args["output"] + center = optional_tuple_of_floats_validator("", "", args["center"]) + use_cuda = boolean_validator("", "", args["use_cuda"]) + use_multiprocessing = boolean_validator("", "", args["use_multiprocessing"]) + + if path.exists(output_file): + logger.fatal("Output file %s already exists, not overwriting it" % output_file) + exit(1) + + h5rot = HDF5ImagesStackRotation( + dataset_path, output_file, args["angle"], center=center, entry=h5_entry, logger=logger, + batch_size=args["batchsize"], use_cuda=use_cuda, use_multiprocessing=use_multiprocessing + ) + h5rot.rotate_images() + + + + + +if __name__ == "__main__": + rotate_cli() diff --git a/nabu/resources/nabu_config.py b/nabu/resources/nabu_config.py index 94915bfe816fe5cae1eb595839f41df9f6e40e90..68143218752828a64b7fa48eea588f334f7b241e 100644 --- a/nabu/resources/nabu_config.py +++ b/nabu/resources/nabu_config.py @@ -127,6 +127,18 @@ nabu_config = { "validator": cor_options_validator, "type": "advanced", }, + "rotate_projections": { + "default": "", + "help": "Whether to rotate each projection image with a certain angle (in degree). By default (empty) no rotation is done.", + "validator": optional_float_validator, + "type": "advanced", + }, + "rotate_projections_center": { + "default": "", + "help": "Center of rotation when 'rotate_projections' is non-empty. By default the center of rotation is the middle of each radio, i.e ((Nx-1)/2.0, (Ny-1)/2.0).", + "validator": optional_tuple_of_floats_validator, + "type": "advanced", + }, }, "phase": { "method": { diff --git a/nabu/resources/processconfig.py b/nabu/resources/processconfig.py index 9fdeb8777592bfc231c310a8701de9269e8d1a22..13e5960eabfa57892a0371a09089192b0edfc84e 100644 --- a/nabu/resources/processconfig.py +++ b/nabu/resources/processconfig.py @@ -188,6 +188,15 @@ class ProcessConfig: "sigma": nabu_config["preproc"]["dff_sigma"], } # + # Radios rotation + # + if nabu_config["preproc"]["rotate_projections"] is not None: + tasks.append("rotate_projections") + options["rotate_projections"] = { + "angle": nabu_config["preproc"]["rotate_projections"], + "center": nabu_config["preproc"]["rotate_projections_center"], + } + # # # Phase retrieval # diff --git a/nabu/resources/validators.py b/nabu/resources/validators.py index 1e1ddf42c25bb2f81702e3b395446054c49aeeac..fdda76f99a9602b938c857b7eba27b3fe658387d 100644 --- a/nabu/resources/validators.py +++ b/nabu/resources/validators.py @@ -220,6 +220,19 @@ def optional_float_validator(val): val_float = None return val_float +@validator +def optional_tuple_of_floats_validator(val): + if len(val.strip()) == 0: + return None + err_msg = "Expected a tuple of two numbers, but got %s" % val + try: + res = tuple(float(x) for x in val.strip("()").split(",")) + except Exception as exc: + raise ValueError(err_msg) + if len(res) != 2: + raise ValueError(err_msg) + return res + @validator def cor_validator(val): val_float, error = convert_to_float(val) diff --git a/setup.py b/setup.py index b452405a26797c393bca95db4b76a5a4398d4c47..2fb9d66fcbeb14c0591220bb2556d1344e36f8f7 100644 --- a/setup.py +++ b/setup.py @@ -73,6 +73,7 @@ def setup_package(): "nabu-config=nabu.resources.cli.bootstrap:bootstrap", "nabu-zsplit=nabu.resources.cli.nx_z_splitter:zsplit", "nabu-histogram=nabu.resources.cli.histogram:histogram_cli", + "nabu-rotate=nabu.resources.cli.rotate:rotate_cli", "nabu-generate-info=nabu.resources.cli.generate_header:generate_merged_info_file", "nabu=nabu.resources.cli.reconstruct:main", ],