From df8d44d096b26bf91a7fef525f985fdaa093c340 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Mon, 13 Jul 2020 17:45:05 -0500 Subject: [PATCH 001/109] NEW: Flow operator for CuPy --- src/tike/operators/cupy/__init__.py | 2 ++ src/tike/operators/cupy/flow.py | 15 +++++++++++++++ src/tike/operators/numpy/flow.py | 8 ++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/tike/operators/cupy/flow.py diff --git a/src/tike/operators/cupy/__init__.py b/src/tike/operators/cupy/__init__.py index f8f4e972..8720d6a8 100644 --- a/src/tike/operators/cupy/__init__.py +++ b/src/tike/operators/cupy/__init__.py @@ -6,6 +6,7 @@ """ from .convolution import * +from .flow import * from .lamino import * from .operator import * from .propagation import * @@ -14,6 +15,7 @@ __all__ = ( 'Convolution', + 'Flow', 'Lamino', 'Operator', 'Propagation', diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py new file mode 100644 index 00000000..b900ca03 --- /dev/null +++ b/src/tike/operators/cupy/flow.py @@ -0,0 +1,15 @@ +__author__ = "Daniel Ching, Viktor Nikitin" +__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." + +from cupyx.scipy.ndimage import map_coordinates + +from tike.operators import numpy +from .operator import Operator + +class Flow(Operator, numpy.Flow): + + @classmethod + def _map_coordinates(cls, *args, **kwargs): + # https://github.com/cupy/cupy/pull/2813 + # Will not work until CuPy>=8 + return map_coordinates(*args, **kwargs) diff --git a/src/tike/operators/numpy/flow.py b/src/tike/operators/numpy/flow.py index abf51e05..ef7924cf 100644 --- a/src/tike/operators/numpy/flow.py +++ b/src/tike/operators/numpy/flow.py @@ -14,6 +14,10 @@ class Flow(Operator): deformation of a series of 2D images. """ + @classmethod + def _map_coordinates(cls, *args, **kwargs): + return map_coordinates(*args, **kwargs) + def fwd(self, f, flow): """Apply arbitary shifts to individuals pixels of f. @@ -39,12 +43,12 @@ def fwd(self, f, flow): for i in range(len(f)): # Move flow dimension to front for map_coordinates API - g.real[i] = map_coordinates( + g.real[i] = self._map_coordinates( input=f.real[i], coordinates=self.xp.moveaxis(coords[i], -1, 0), output=g.real[i], ) - g.imag[i] = map_coordinates( + g.imag[i] = self._map_coordinates( input=f.imag[i], coordinates=self.xp.moveaxis(coords[i], -1, 0), output=g.imag[i], From 632343c840156ae2fb857ca0c8c241668e935e64 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 14 Jul 2020 16:22:15 -0500 Subject: [PATCH 002/109] BUG: Copy cupy.map_coordinates from future version --- src/tike/operators/cupy/flow.py | 160 +++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 3 deletions(-) diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index b900ca03..502d5de5 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -1,15 +1,169 @@ __author__ = "Daniel Ching, Viktor Nikitin" __copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." -from cupyx.scipy.ndimage import map_coordinates +import itertools +import warnings + +import cupy +import six from tike.operators import numpy from .operator import Operator +# This code is taken verbatim from the CuPy repository because it contains a +# bug fix which will not be released until CuPy>=8 +# https://github.com/cupy/cupy/pull/2813 + + +def _get_output(output, input, shape=None): + if shape is None: + shape = input.shape + if isinstance(output, cupy.ndarray): + if output.shape != tuple(shape): + raise ValueError('output shape is not correct') + else: + dtype = output + if dtype is None: + dtype = input.dtype + output = cupy.zeros(shape, dtype) + return output + + +def _check_parameter(func_name, order, mode): + if order is None: + warnings.warn('In the current feature the default order of {} is 1. ' + 'It is different from scipy.ndimage and can change in ' + 'the future.'.format(func_name)) + elif order < 0 or 5 < order: + raise ValueError('spline order is not supported') + elif 1 < order: + # SciPy supports order 0-5, but CuPy supports only order 0 and 1. Other + # orders will be implemented, therefore it raises NotImplementedError + # instead of ValueError. + raise NotImplementedError('spline order is not supported') + + if mode in ('reflect', 'wrap'): + raise NotImplementedError( + '\'{}\' mode is not supported. See ' + 'https://github.com/scipy/scipy/issues/8465'.format(mode)) + elif mode not in ('constant', 'nearest', 'mirror', 'opencv', + '_opencv_edge'): + raise ValueError('boundary mode is not supported') + + +def map_coordinates(input, + coordinates, + output=None, + order=None, + mode='constant', + cval=0.0, + prefilter=True): + """Map the input array to new coordinates by interpolation. + The array of coordinates is used to find, for each point in the output, the + corresponding coordinates in the input. The value of the input at those + coordinates is determined by spline interpolation of the requested order. + The shape of the output is derived from that of the coordinate array by + dropping the first axis. The values of the array along the first axis are + the coordinates in the input array at which the output value is found. + Args: + input (cupy.ndarray): The input array. + coordinates (array_like): The coordinates at which ``input`` is + evaluated. + output (cupy.ndarray or ~cupy.dtype): The array in which to place the + output, or the dtype of the returned array. + order (int): The order of the spline interpolation. If it is not given, + order 1 is used. It is different from :mod:`scipy.ndimage` and can + change in the future. Currently it supports only order 0 and 1. + mode (str): Points outside the boundaries of the input are filled + according to the given mode (``'constant'``, ``'nearest'``, + ``'mirror'`` or ``'opencv'``). Default is ``'constant'``. + cval (scalar): Value used for points outside the boundaries of + the input if ``mode='constant'`` or ``mode='opencv'``. Default is + 0.0 + prefilter (bool): It is not used yet. It just exists for compatibility + with :mod:`scipy.ndimage`. + Returns: + cupy.ndarray: + The result of transforming the input. The shape of the output is + derived from that of ``coordinates`` by dropping the first axis. + .. seealso:: :func:`scipy.ndimage.map_coordinates` + """ + + _check_parameter('map_coordinates', order, mode) + + if mode == 'opencv' or mode == '_opencv_edge': + input = cupy.pad(input, [(1, 1)] * input.ndim, + 'constant', + constant_values=cval) + coordinates = cupy.add(coordinates, 1) + mode = 'constant' + + ret = _get_output(output, input, coordinates.shape[1:]) + + if mode == 'nearest': + for i in six.moves.range(input.ndim): + coordinates[i] = coordinates[i].clip(0, input.shape[i] - 1) + elif mode == 'mirror': + for i in six.moves.range(input.ndim): + length = input.shape[i] - 1 + if length == 0: + coordinates[i] = 0 + else: + coordinates[i] = cupy.remainder(coordinates[i], 2 * length) + coordinates[i] = 2 * cupy.minimum(coordinates[i], + length) - coordinates[i] + + if input.dtype.kind in 'iu': + input = input.astype(cupy.float32) + + if order == 0: + out = input[tuple(cupy.rint(coordinates).astype(cupy.int32))] + else: + coordinates_floor = cupy.floor(coordinates).astype(cupy.int32) + coordinates_ceil = coordinates_floor + 1 + + sides = [] + for i in six.moves.range(input.ndim): + # TODO(mizuno): Use array_equal after it is implemented + if cupy.all(coordinates[i] == coordinates_floor[i]): + sides.append([0]) + else: + sides.append([0, 1]) + + out = cupy.zeros(coordinates.shape[1:], dtype=input.dtype) + if input.dtype in (cupy.float64, cupy.complex128): + weight = cupy.empty(coordinates.shape[1:], dtype=cupy.float64) + else: + weight = cupy.empty(coordinates.shape[1:], dtype=cupy.float32) + for side in itertools.product(*sides): + weight.fill(1) + ind = [] + for i in six.moves.range(input.ndim): + if side[i] == 0: + ind.append(coordinates_floor[i]) + weight *= coordinates_ceil[i] - coordinates[i] + else: + ind.append(coordinates_ceil[i]) + weight *= coordinates[i] - coordinates_floor[i] + out += input[ind] * weight + del weight + + if mode == 'constant': + mask = cupy.zeros(coordinates.shape[1:], dtype=cupy.bool_) + for i in six.moves.range(input.ndim): + mask += coordinates[i] < 0 + mask += coordinates[i] > input.shape[i] - 1 + out[mask] = cval + del mask + + if ret.dtype.kind in 'iu': + out = cupy.rint(out) + ret[:] = out + return ret + + class Flow(Operator, numpy.Flow): @classmethod def _map_coordinates(cls, *args, **kwargs): - # https://github.com/cupy/cupy/pull/2813 - # Will not work until CuPy>=8 return map_coordinates(*args, **kwargs) From 360059bacdda0e5d7e82463fd14b0cf027b5240d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 14 Jul 2020 16:29:12 -0500 Subject: [PATCH 003/109] TST: Skip Farneback on GPU --- tests/test_align.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_align.py b/tests/test_align.py index 47346861..21c02c7f 100644 --- a/tests/test_align.py +++ b/tests/test_align.py @@ -76,6 +76,9 @@ def test_align_cross_correlation(self): np.testing.assert_array_equal(shift.shape, self.shift.shape) np.testing.assert_allclose(shift, self.shift, atol=1e-3) + @unittest.skipIf('TIKE_BACKEND' in os.environ + and os.environ['TIKE_BACKEND'] == 'cupy', + "Farneback method only available on CPU.") def test_align_farneback(self): """Check that align.solvers.farneback works.""" result = tike.align.reconstruct( From d33efe58f62d71e59b1e118f6a9857c71494f2c4 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 14 Jul 2020 16:34:42 -0500 Subject: [PATCH 004/109] DOC: Add CuPy License to thier code section --- src/tike/operators/cupy/flow.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index 502d5de5..8ce5308a 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -1,5 +1,26 @@ -__author__ = "Daniel Ching, Viktor Nikitin" -__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." +# Copyright (c) 2015 Preferred Infrastructure, Inc. +# Copyright (c) 2015 Preferred Networks, Inc. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2015 Preferred Networks, Inc." import itertools import warnings From bf9bf797f93893a5c56f5cf40ee046b3a9c308d7 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 14 Jul 2020 18:48:08 -0500 Subject: [PATCH 005/109] REF: Use 'original' and 'unaligned' variable names --- src/tike/align/align.py | 41 ++++++++++----------- src/tike/align/solvers/cross_correlation.py | 10 ++--- src/tike/align/solvers/farneback.py | 12 +++--- tests/test_align.py | 8 ++-- 4 files changed, 35 insertions(+), 36 deletions(-) diff --git a/src/tike/align/align.py b/src/tike/align/align.py index 7f2294e3..4e25c6e8 100644 --- a/src/tike/align/align.py +++ b/src/tike/align/align.py @@ -16,44 +16,43 @@ def simulate( - unaligned, + original, shift, **kwargs ): # yapf: disable - """Return unaligned shifted by shift.""" - assert unaligned.ndim > 2 - if shift.shape == (*unaligned.shape, 2): + """Return original shifted by shift.""" + assert original.ndim > 2 + if shift.shape == (*original.shape, 2): Operator = Flow - elif shift.shape == (*unaligned.shape[:-2], 2): + elif shift.shape == (*original.shape[:-2], 2): Operator = Shift else: raise ValueError( 'There must be one shift per image or one shift per pixel.') with Operator() as operator: - data = operator.fwd( - operator.asarray(unaligned, dtype='complex64'), + unaligned = operator.fwd( + operator.asarray(original, dtype='complex64'), operator.asarray(shift, dtype='float32'), ) - assert data.dtype == 'complex64', data.dtype - return operator.asnumpy(data) + assert unaligned.dtype == 'complex64', unaligned.dtype + return operator.asnumpy(unaligned) def reconstruct( - data, + original, unaligned, algorithm, - shift=None, num_iter=1, rtol=-1, **kwargs ): # yapf: disable - """Solve the alignment problem. + """Solve the alignment problem; returning either the original or the shift. Parameters ---------- - unaligned : (..., H, W) complex64 - The images to be aligned with data. + unaligned, original: (..., H, W) complex64 + The images to be aligned. shift : (..., 2), (..., H, W, 2) float32 - The inital guesses for the shifts. + The displacements of pixels from original to unaligned. rtol : float Terminate early if the relative decrease of the cost function is less than this amount. @@ -61,26 +60,26 @@ def reconstruct( """ if algorithm in solvers.__all__: - Operator = Flow if algorithm == 'farneback' else Shift - # Initialize an operator. - with Operator() as operator: + with Flow() as operator: # send any array-likes to device - data = operator.asarray(data, dtype='complex64') unaligned = operator.asarray(unaligned, dtype='complex64') + original = operator.asarray(original, dtype='complex64') result = {} for key, value in kwargs.items(): if np.ndim(value) > 0: kwargs[key] = operator.asarray(value) logger.info("{} on {:,d} - {:,d} by {:,d} images for {:,d} " - "iterations.".format(algorithm, *data.shape, num_iter)) + "iterations.".format(algorithm, *unaligned.shape, + num_iter)) kwargs.update(result) result = getattr(solvers, algorithm)( operator, - data=data, + original=original, unaligned=unaligned, + num_iter=num_iter, **kwargs, ) diff --git a/src/tike/align/solvers/cross_correlation.py b/src/tike/align/solvers/cross_correlation.py index fc3f3ec2..11f44e9f 100644 --- a/src/tike/align/solvers/cross_correlation.py +++ b/src/tike/align/solvers/cross_correlation.py @@ -29,7 +29,7 @@ import numpy as np -def cross_correlation(op, data, unaligned, upsample_factor=1, space="real", +def cross_correlation(op, original, unaligned, upsample_factor=1, space="real", **kwargs): # yapf: disable """Efficient subpixel image translation alignment by cross-correlation. @@ -56,12 +56,12 @@ def cross_correlation(op, data, unaligned, upsample_factor=1, space="real", """ # assume complex data is already in Fourier space if space.lower() == 'fourier': - src_freq = data - target_freq = unaligned + src_freq = unaligned + target_freq = original # real data needs to be fft'd. elif space.lower() == 'real': - src_freq = op.xp.fft.fft2(data) - target_freq = op.xp.fft.fft2(unaligned) + src_freq = op.xp.fft.fft2(unaligned) + target_freq = op.xp.fft.fft2(original) else: raise ValueError(f"space must be 'fourier' or 'real' not '{space}'.") diff --git a/src/tike/align/solvers/farneback.py b/src/tike/align/solvers/farneback.py index 80806840..2fff3768 100644 --- a/src/tike/align/solvers/farneback.py +++ b/src/tike/align/solvers/farneback.py @@ -28,7 +28,7 @@ def _rescale_8bit(a, b): def farneback( op, - data, + original, unaligned, pyr_scale=0.5, levels=5, @@ -39,14 +39,14 @@ def farneback( flow=None, **kwargs, ): - """Find the flow from unaligned to data using Farneback's algorithm + """Find the flow from unaligned to original using Farneback's algorithm For parameter descriptions see https://docs.opencv.org/4.3.0/dc/d6b/group__video__track.html Parameters ---------- - data, unaligned (L, M, N) + original, unaligned (L, M, N) The images to be aligned. flow : (L, M, N, 2) float32 The inital guess for the displacement field. @@ -56,7 +56,7 @@ def farneback( Farneback, Gunnar "Two-Frame Motion Estimation Based on Polynomial Expansion" 2003. """ - shape = data.shape + shape = original.shape if flow is None: flow = np.zeros((*shape, 2), dtype='float32') @@ -65,9 +65,9 @@ def farneback( # NOTE: Passing a reshaped view as any of the parameters breaks OpenCV's # Farneback implementation. - for i in range(len(data)): + for i in range(len(original)): flow[i] = calcOpticalFlowFarneback( - *_rescale_8bit(np.abs(unaligned[i]), np.abs(data[i])), + *_rescale_8bit(np.abs(original[i]), np.abs(unaligned[i])), flow=flow[i], pyr_scale=pyr_scale, levels=levels, diff --git a/tests/test_align.py b/tests/test_align.py index 21c02c7f..26d6fd35 100644 --- a/tests/test_align.py +++ b/tests/test_align.py @@ -66,8 +66,8 @@ def test_consistent_simulate(self): def test_align_cross_correlation(self): """Check that align.solvers.cross_correlation works.""" result = tike.align.reconstruct( - self.data, - self.original, + unaligned=self.data, + original=self.original, algorithm='cross_correlation', upsample_factor=1e3, ) @@ -82,8 +82,8 @@ def test_align_cross_correlation(self): def test_align_farneback(self): """Check that align.solvers.farneback works.""" result = tike.align.reconstruct( - self.data, - self.original, + unaligned=self.data, + original=self.original, algorithm='farneback', ) shift = result['shift'] From f29af01ee4b6f4830ecbfc9c80d36b0446ce130c Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 15 Jul 2020 14:31:08 -0500 Subject: [PATCH 006/109] NEW: Add solver for recovering distorted image --- src/tike/align/solvers/__init__.py | 2 ++ src/tike/align/solvers/cgrad.py | 35 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/tike/align/solvers/cgrad.py diff --git a/src/tike/align/solvers/__init__.py b/src/tike/align/solvers/__init__.py index dcd40d4d..3ea3ced5 100644 --- a/src/tike/align/solvers/__init__.py +++ b/src/tike/align/solvers/__init__.py @@ -2,8 +2,10 @@ from .cross_correlation import cross_correlation from .farneback import farneback +from .cgrad import cgrad __all__ = [ "cross_correlation", "farneback", + "cgrad", ] diff --git a/src/tike/align/solvers/cgrad.py b/src/tike/align/solvers/cgrad.py new file mode 100644 index 00000000..b1b7b97c --- /dev/null +++ b/src/tike/align/solvers/cgrad.py @@ -0,0 +1,35 @@ +import logging + +from tike.opt import conjugate_gradient + +logger = logging.getLogger(__name__) + + +def cgrad( + op, + original, + unaligned, + flow, + num_iter=4, + reg=0, rho=0, + **kwargs +): # yapf: disable + """Recover an undistorted image from a given flow.""" + + def cost_function(original): + return op.xp.linalg.norm((op.fwd(original, flow) - unaligned).ravel())**2 + rho * op.xp.linalg.norm((original - reg).ravel())**2 + + def grad(original): + return op.fwd(op.fwd(original, flow) - unaligned, -flow) + rho * (original - reg) + + cost = 0 + original, cost = conjugate_gradient( + op.xp, + x=original, + cost_function=cost_function, + grad=grad, + num_iter=num_iter, + ) + + logger.info('%10s cost is %+12.5e', 'original', cost) + return {'original': original, 'cost': cost} From 3ee242465cb7498a3f3dbe12d7ce0b545b362028 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 15 Jul 2020 14:32:57 -0500 Subject: [PATCH 007/109] BUG: Lamino angles must by converted to CuPy arrays --- src/tike/operators/numpy/lamino.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tike/operators/numpy/lamino.py b/src/tike/operators/numpy/lamino.py index 6fe9a503..366b5c2d 100644 --- a/src/tike/operators/numpy/lamino.py +++ b/src/tike/operators/numpy/lamino.py @@ -37,9 +37,9 @@ def __init__(self, n, theta, tilt, eps=1e-3, """Please see help(Lamino) for more info.""" self.n = n self.ntheta = len(theta) - self.tilt = tilt + self.tilt = self.asarray(tilt) self.eps = eps - self.xi = self._make_grids(theta) + self.xi = self._make_grids(self.asarray(theta)) def fwd(self, u, **kwargs): """Perform the forward Laminography transform.""" From 2b51915d1c400849080eb67c251fc9fa2ce02092 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Mon, 20 Jul 2020 11:00:36 -0500 Subject: [PATCH 008/109] API: Use average of complex and real farneback results --- src/tike/align/solvers/farneback.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/tike/align/solvers/farneback.py b/src/tike/align/solvers/farneback.py index 2fff3768..254766a0 100644 --- a/src/tike/align/solvers/farneback.py +++ b/src/tike/align/solvers/farneback.py @@ -33,7 +33,7 @@ def farneback( pyr_scale=0.5, levels=5, winsize=19, - iterations=16, + num_iter=16, poly_n=5, poly_sigma=1.1, flow=None, @@ -66,15 +66,27 @@ def farneback( # NOTE: Passing a reshaped view as any of the parameters breaks OpenCV's # Farneback implementation. for i in range(len(original)): - flow[i] = calcOpticalFlowFarneback( - *_rescale_8bit(np.abs(original[i]), np.abs(unaligned[i])), + aflow = calcOpticalFlowFarneback( + *_rescale_8bit(np.real(original[i]), np.real(unaligned[i])), flow=flow[i], pyr_scale=pyr_scale, levels=levels, winsize=winsize, - iterations=iterations, + iterations=num_iter, poly_n=poly_n, poly_sigma=poly_sigma, flags=4, - )[..., ::-1] - return {'shift': flow, 'cost': -1} + ) + pflow = calcOpticalFlowFarneback( + *_rescale_8bit(np.imag(original[i]), np.imag(unaligned[i])), + flow=flow[i], + pyr_scale=pyr_scale, + levels=levels, + winsize=winsize, + iterations=num_iter, + poly_n=poly_n, + poly_sigma=poly_sigma, + flags=4, + ) + flow[i] = 0.5 * (aflow + pflow) + return {'shift': flow[..., ::-1], 'cost': -1} From 0b816804441d5ab7ee0dd6a0dfc4aecaa4043b04 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Mon, 20 Jul 2020 11:02:07 -0500 Subject: [PATCH 009/109] API: Add normalization to align solver using rho --- src/tike/align/solvers/cgrad.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tike/align/solvers/cgrad.py b/src/tike/align/solvers/cgrad.py index b1b7b97c..2f752f8a 100644 --- a/src/tike/align/solvers/cgrad.py +++ b/src/tike/align/solvers/cgrad.py @@ -17,10 +17,14 @@ def cgrad( """Recover an undistorted image from a given flow.""" def cost_function(original): - return op.xp.linalg.norm((op.fwd(original, flow) - unaligned).ravel())**2 + rho * op.xp.linalg.norm((original - reg).ravel())**2 + return ( + op.xp.linalg.norm((op.fwd(original, flow) - unaligned).ravel())**2 + + rho * op.xp.linalg.norm((original - reg).ravel())**2 + ) def grad(original): - return op.fwd(op.fwd(original, flow) - unaligned, -flow) + rho * (original - reg) + return (op.fwd(op.fwd(original, flow) - unaligned, -flow) + + rho * (original - reg)) / max(rho, 1) cost = 0 original, cost = conjugate_gradient( From bb9a69d3a30ebcb51758326c5537c8fb708e29c8 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 17 Jul 2020 15:49:14 -0500 Subject: [PATCH 010/109] REF: Use Lanzcos remapping instead of scipy.map_coordinates Because map_coordinates is not implemented for higher orders in CuPy. --- src/tike/operators/numpy/flow.py | 71 +++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/src/tike/operators/numpy/flow.py b/src/tike/operators/numpy/flow.py index ef7924cf..9e24ac60 100644 --- a/src/tike/operators/numpy/flow.py +++ b/src/tike/operators/numpy/flow.py @@ -7,6 +7,51 @@ from .operator import Operator +def _lanzcos(xp, x, a): + return xp.sinc(x) * xp.sinc(x / a) + + +def _remap_lanzcos(xp, Fe, x, m, F=None): + """Lanzcos resampling from grid Fe to points x. + + At the edges, the Lanzcos filter wraps around. + + Parameters + ---------- + xp : module + The array module for this implementation + Fe : (H, W) + The function at equally spaced samples. + x : (N, 2) float32 + The non-uniform sample positions on the grid. + m : int > 0 + The lanzcos filter is 2m + 1 wide. + + Returns + ------- + F : (N, ) + The values at the non-uniform samples. + """ + # NOTE: This irregular convolution is very similar to the gather function + # from usfft + assert Fe.ndim == 2 + assert x.ndim == 2 and x.shape[-1] == 2 + assert m > 0 + F = xp.zeros(x.shape[:-1], dtype=Fe.dtype) if F is None else F + assert F.shape == x.shape[:-1], F.dtype == Fe.dtype + n = Fe.shape[-2:] + # ell is the integer center of the kernel + ell = xp.floor(x).astype('int32') + for i0 in range(-m, m + 1): + kern0 = _lanzcos(xp, ell[..., 0] + i0 - x[..., 0], m) + for i1 in range(-m, m + 1): + kern1 = _lanzcos(xp, ell[..., 1] + i1 - x[..., 1], m) + # Indexing Fe here causes problems for a stack of images + F += Fe[(ell[..., 0] + i0) % n[0], + (ell[..., 1] + i1) % n[1]] * kern0 * kern1 + return F + + class Flow(Operator): """Map input 2D array to new coordinates by interpolation. @@ -18,8 +63,8 @@ class Flow(Operator): def _map_coordinates(cls, *args, **kwargs): return map_coordinates(*args, **kwargs) - def fwd(self, f, flow): - """Apply arbitary shifts to individuals pixels of f. + def fwd(self, f, flow, filter_size=5): + """Remap individual pixels of f with Lanzcos filtering. Parameters ---------- @@ -28,7 +73,9 @@ def fwd(self, f, flow): flow (..., H, W, 2) float32 The displacements to be applied to each pixel along the last two dimensions. - + filter_size : int + The width of the Lanzcos filter. Automatically rounded up to an + odd positive integer. """ # Convert from displacements to coordinates h, w = flow.shape[-3:-1] @@ -36,22 +83,14 @@ def fwd(self, f, flow): coords[..., 0] += self.xp.arange(h)[:, None] coords[..., 1] += self.xp.arange(w) - coords = coords.reshape(-1, h, w, 2) + # Reshape into stack of 2D images shape = f.shape + coords = coords.reshape(-1, h * w, 2) f = f.reshape(-1, h, w) - g = self.xp.empty_like(f) + g = self.xp.zeros_like(f).reshape(-1, h * w) + a = max(0, (filter_size) // 2) for i in range(len(f)): - # Move flow dimension to front for map_coordinates API - g.real[i] = self._map_coordinates( - input=f.real[i], - coordinates=self.xp.moveaxis(coords[i], -1, 0), - output=g.real[i], - ) - g.imag[i] = self._map_coordinates( - input=f.imag[i], - coordinates=self.xp.moveaxis(coords[i], -1, 0), - output=g.imag[i], - ) + _remap_lanzcos(self.xp, f[i], coords[i], a, g[i]) return g.reshape(shape) From 11698a6c7fc6e772ed9a01681d51a846faf2d833 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 22 Jul 2020 12:28:21 -0500 Subject: [PATCH 011/109] NEW: Use CachedFFT for Laminography operator In theory, this should improve runtimes for long-lived Lamino operators. --- src/tike/operators/cupy/cache.py | 7 ++++++- src/tike/operators/cupy/lamino.py | 28 +++++++++++++++------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/tike/operators/cupy/cache.py b/src/tike/operators/cupy/cache.py index 36d27a11..f988f631 100644 --- a/src/tike/operators/cupy/cache.py +++ b/src/tike/operators/cupy/cache.py @@ -14,8 +14,9 @@ def __exit__(self, type, value, traceback): self.plan_cache.clear() del self.plan_cache - def _get_fft_plan(self, a, axes, **kwargs): + def _get_fft_plan(self, a, axes=None, **kwargs): """Cache multiple FFT plans at the same time.""" + axes = tuple(range(a.ndim)) if axes is None else axes key = (*a.shape, *axes) if key in self.plan_cache: plan = self.plan_cache[key] @@ -31,3 +32,7 @@ def _fft2(self, a, *args, overwrite=False, **kwargs): def _ifft2(self, a, *args, overwrite=False, **kwargs): with self._get_fft_plan(a, **kwargs): return ifftn(a, *args, overwrite_x=overwrite, **kwargs) + + def _fftn(self, a, *args, overwrite=False, **kwargs): + with self._get_fft_plan(a, **kwargs): + return fftn(a, *args, overwrite_x=overwrite, **kwargs) diff --git a/src/tike/operators/cupy/lamino.py b/src/tike/operators/cupy/lamino.py index 15e90b29..53718973 100644 --- a/src/tike/operators/cupy/lamino.py +++ b/src/tike/operators/cupy/lamino.py @@ -1,21 +1,16 @@ from importlib_resources import files import cupy as cp -from cupyx.scipy.fft import fft2, ifft2, fftn from tike.operators import numpy from tike.operators.numpy.usfft import eq2us, us2eq, checkerboard +from .cache import CachedFFT from .operator import Operator _cu_source = files('tike.operators.cupy').joinpath('usfft.cu').read_text() -def _fftn(*args, **kwargs): - """Partial function so in-place fft is used in usfft.""" - return fftn(*args, **kwargs, overwrite_x=True) - - -class Lamino(Operator, numpy.Lamino): +class Lamino(Operator, CachedFFT, numpy.Lamino): def __init__(self, *args, **kwargs): super(Lamino, self).__init__( @@ -25,6 +20,7 @@ def __init__(self, *args, **kwargs): def __enter__(self): """Return self at start of a with-block.""" + CachedFFT.__enter__(self) # Call the __enter__ methods for any composed operators. # Allocate special memory objects. self.scatter_kernel = cp.RawKernel(_cu_source, "scatter") @@ -37,21 +33,24 @@ def fwd(self, u, **kwargs): def gather(xp, Fe, x, n, m, mu): return self.gather(Fe, x, n, m, mu) + def fftn(*args, **kwargs): + return self._fftn(*args, overwrite=True, **kwargs) + # USFFT from equally-spaced grid to unequally-spaced grid F = eq2us(u, self.xi, self.n, self.eps, self.xp, gather, - _fftn).reshape([self.ntheta, self.n, self.n]) + fftn).reshape([self.ntheta, self.n, self.n]) # Inverse 2D FFT data = checkerboard( self.xp, - ifft2( + self._ifft2( checkerboard( self.xp, F, axes=(1, 2), ), axes=(1, 2), - overwrite_x=True, + overwrite=True, ), axes=(1, 2), inverse=True, @@ -64,24 +63,27 @@ def adj(self, data, overwrite=False, **kwargs): def scatter(xp, f, x, n, m, mu): return self.scatter(f, x, n, m, mu) + def fftn(*args, **kwargs): + return self._fftn(*args, overwrite=True, **kwargs) + # Forward 2D FFT F = checkerboard( self.xp, - fft2( + self._fft2( checkerboard( self.xp, data.copy() if not overwrite else data, axes=(1, 2), ), axes=(1, 2), - overwrite_x=True, + overwrite=True, ), axes=(1, 2), inverse=True, ).ravel() # Inverse (x->-x) USFFT from unequally-spaced grid to equally-spaced # grid - u = us2eq(F, -self.xi, self.n, self.eps, self.xp, scatter, _fftn) + u = us2eq(F, -self.xi, self.n, self.eps, self.xp, scatter, fftn) u /= self.n**2 return u From a7014c21485841fdd2986c7ccc1623c9172fa84a Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 22 Jul 2020 12:29:35 -0500 Subject: [PATCH 012/109] BUG: Rescale farneback input by unaligned instead of original --- src/tike/align/solvers/farneback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tike/align/solvers/farneback.py b/src/tike/align/solvers/farneback.py index 254766a0..c04809a4 100644 --- a/src/tike/align/solvers/farneback.py +++ b/src/tike/align/solvers/farneback.py @@ -7,7 +7,7 @@ def _rescale_8bit(a, b): """Return a, b rescaled into the same 8-bit range""" - h, e = np.histogram(a, 1000) + h, e = np.histogram(b, 1000) stend = np.where(h > np.max(h) * 0.005) st = stend[0][0] end = stend[0][-1] From 7bb86bc28d88e91c70033d5ed7328ab42ce5f96f Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 22 Jul 2020 15:11:53 -0500 Subject: [PATCH 013/109] REF: Lamino scatter gather kernel for any dimensions Make the CUDA scatter gather kernels for the Lamino operator work for any number of dimensions because we will reuse it for the Tomo operator. --- src/tike/operators/cupy/usfft.cu | 71 +++++++++++++++++--------------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/src/tike/operators/cupy/usfft.cu b/src/tike/operators/cupy/usfft.cu index 270332ac..d70972be 100644 --- a/src/tike/operators/cupy/usfft.cu +++ b/src/tike/operators/cupy/usfft.cu @@ -31,31 +31,28 @@ _1d_to_nd(int d, int s, int* nd, int ndim, int diameter, int radius) { } } -// Helper function that lets us switch the index variables (si, gi) easily. -__device__ void -_gather_scatter(float2* gather, int gi, const float2* scatter, int si, - float kernel) { - atomicAdd(&gather[gi].x, scatter[si].x * kernel); - atomicAdd(&gather[gi].y, scatter[si].y * kernel); -} +typedef void +scatterOrGather(float2*, int, const float2*, int, float); // grid shape (-(-kernel_size // max_threads), 0, nf) // block shape (min(kernel_size, max_threads), 0, 0) __device__ void -_loop_over_kernels(bool eq2us, float2* gathered, const float2* scattered, - int nf, const float* x, int n, int radius, - const float* cons) { - const int ndim = 3; +_loop_over_kernels(scatterOrGather operation, float2* gathered, + const float2* scattered, int nf, const float* x, int n, + int radius, const float* cons, int ndim) { const int diameter = 2 * radius; // kernel width const int nk = pow(diameter, ndim); const int gw = 2 * n; // width of G along each dimension + const int max_dim = 3; + assert(0 < ndim && ndim <= max_dim); // non-uniform frequency index (fi) for (int fi = blockIdx.z; fi < nf; fi += gridDim.z) { - int center[ndim]; // closest ND coord to kernel center + int center[max_dim]; // closest ND coord to kernel center for (int dim = 0; dim < ndim; dim++) { - center[dim] = int(floor(2 * n * x[3 * fi + dim])); + center[dim] = int(floor(2 * n * x[ndim * fi + dim])); } + // intra-kernel index (ki) // clang-format off for ( @@ -64,45 +61,51 @@ _loop_over_kernels(bool eq2us, float2* gathered, const float2* scattered, ki += blockDim.x * gridDim.x ) { // clang-format on + // Convert linear index to 3D intra-kernel index - int k[ndim]; // ND kernel coord + int k[max_dim]; // ND kernel coord _1d_to_nd(ki, nk, k, ndim, diameter, radius); - // Compute sum square value for kernel + // Compute sum square value for kernel and equally-spaced grid index (gi) float ssdelta = 0; float delta; - for (int dim = 0; dim < ndim; dim++) { - delta = (float)(center[dim] + k[dim]) / (2 * n) - - x[3 * fi + dim]; + int gi = 0; + int stride = 1; + for (int dim = ndim - 1; dim >= 0; dim--) { + delta = (float)(center[dim] + k[dim]) / (2 * n) - x[3 * fi + dim]; ssdelta += delta * delta; + gi += mod((n + center[dim] + k[dim]), gw) * stride; + stride *= gw; } - float kernel = cons[0] * exp(cons[1] * ssdelta); - - // clang-format off - int gi = ( // equally-spaced grid index (gi) - + mod((n + center[0] + k[0]), gw) * gw * gw - + mod((n + center[1] + k[1]), gw) * gw - + mod((n + center[2] + k[2]), gw) - ); - // clang-format on - if (eq2us) { - _gather_scatter(gathered, fi, scattered, gi, kernel); + const float kernel = cons[0] * exp(cons[1] * ssdelta); - } else { - _gather_scatter(gathered, gi, scattered, fi, kernel); - } + operation(gathered, fi, scattered, gi, kernel); } } } +// Helper functions _gather and _scatter let us switch the index variables +// (si, gi) without an if statement. +__device__ void +_gather(float2* gather, int gi, const float2* scatter, int si, float kernel) { + atomicAdd(&gather[gi].x, scatter[si].x * kernel); + atomicAdd(&gather[gi].y, scatter[si].y * kernel); +} + extern "C" __global__ void gather(float2* F, const float2* Fe, int nf, const float* x, int n, int radius, const float* cons) { - _loop_over_kernels(true, F, Fe, nf, x, n, radius, cons); + _loop_over_kernels(_gather, F, Fe, nf, x, n, radius, cons, 3); +} + +__device__ void +_scatter(float2* gather, int si, const float2* scatter, int gi, float kernel) { + atomicAdd(&gather[gi].x, scatter[si].x * kernel); + atomicAdd(&gather[gi].y, scatter[si].y * kernel); } extern "C" __global__ void scatter(float2* G, const float2* f, int nf, const float* x, int n, int radius, const float* cons) { - _loop_over_kernels(false, G, f, nf, x, n, radius, cons); + _loop_over_kernels(_scatter, G, f, nf, x, n, radius, cons, 3); } From adea3bbae9c99a2180c52a96c3f96e3bf4097998 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 22 Jul 2020 18:48:56 -0500 Subject: [PATCH 014/109] NEW: Lamino alignment problem in admm module --- src/tike/admm.py | 123 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/tike/admm.py diff --git a/src/tike/admm.py b/src/tike/admm.py new file mode 100644 index 00000000..10032a19 --- /dev/null +++ b/src/tike/admm.py @@ -0,0 +1,123 @@ +import logging + +import numpy as np + +import tike.align +import tike.lamino + + +def update_penalty(psi, h, h0, rho): + r = np.linalg.norm(psi - h)**2 + s = np.linalg.norm(rho * (h - h0))**2 + if (r > 10 * s): + rho *= 2 + elif (s > 10 * r): + rho *= 0.5 + return rho + + +def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): + """Solve the joint lamino-alignment problem using ADMM. + + Parameters + ---------- + data : (ntheta, detector, detector) float32 + tilt : radians float32 + The laminography tilt angle in radians. + theta : float32 + The rotation angle of each data frame in radians. + u : (detector, detector, detector) complex64 + An initial guess for the object + lamd : (ntheta, detector, detector) float32 + flow : (ntheta, detector, detector, 2) float32 + An initial guess for the alignmnt displacement field. + """ + ntheta, _, det = data.shape + u = np.zeros([det, det, det], dtype='complex64') if u is None else u + lamd = np.zeros([ntheta, det, det], dtype='float32') + flow = np.zeros([ntheta, det, det, 2], + dtype='float32') if flow is None else flow + + psi = data.copy() + h0 = psi.copy() + # Start with large winsize and decrease each ADMM iteration. + winsize = min(*data.shape[1:]) + error0 = np.inf + for k in range(niter): + + logging.info("Find flow using farneback.") + result = tike.align.solvers.farneback( + op=None, + unaligned=data, + original=psi, + flow=flow, + pyr_scale=0.5, + levels=3, + winsize=winsize, + num_iter=4, + ) + # flow = result['shift'] + #TODO: Only accept flow updates that reduce the error + error1 = np.linalg.norm( + (tike.align.simulate(psi, result['shift']) - data), axis=(1, 2))**2 + keep = np.where(error1 < error0) + flow[keep] = result['shift'][keep] + error0 = error1 + + logging.info("Recover original/aligned projections.") + result = tike.align.reconstruct( + unaligned=data, + original=psi, + flow=flow, + num_iter=4, + algorithm='cgrad', + reg=tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + ) + lamd / rho, + rho=rho, + ) + psi = result['original'] + + logging.info('Solve the laminography problem.') + result = tike.lamino.reconstruct( + data=psi - lamd / rho, + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + ) + u = result['obj'] + + logging.info('Update lambda and rho.') + h = tike.lamino.simulate(obj=u, theta=theta, tilt=tilt) + lamd = lamd + rho * (h - psi) + rho = update_penalty(psi, h, h0, rho) + h0 = h + + # checking intermediate results + lagr = [ + np.linalg.norm((tike.align.simulate(psi, flow) - data))**2, + np.sum(np.real(np.conj(lamd) * (h - psi))), + rho * np.linalg.norm(h - psi)**2, + ] + print( + "k: {:03d}, ρ: {:.3e}, winsize: {:03d}, flow: {:.3e}, " + " lagrangian: {:.3e}, {:.3e}, {:.3e} = {:.3e}".format( + k, + rho, + winsize, + np.linalg.norm(flow), + *lagr, + np.sum(lagr), + ), + flush=True, + ) + + # Limit winsize to larger value. 20? + if winsize > 20: + winsize -= 2 + + return u From 441ecb6deba2db3f0449127f6e9037e8eb12c16d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 7 Aug 2020 11:49:16 -0500 Subject: [PATCH 015/109] DEV: Save intermediate ADMM results and allow precomputing scales --- src/tike/admm.py | 43 ++++++++++++++++++++++------- src/tike/align/solvers/farneback.py | 34 ++++++++++++++++------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index 10032a19..5ab028d5 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -5,6 +5,7 @@ import tike.align import tike.lamino +import dxchange def update_penalty(psi, h, h0, rho): r = np.linalg.norm(psi - h)**2 @@ -16,6 +17,21 @@ def update_penalty(psi, h, h0, rho): return rho +def find_min_max(data): + mmin = np.zeros(data.shape[0], dtype='float32') + mmax = np.zeros(data.shape[0], dtype='float32') + + for k in range(data.shape[0]): + h, e = np.histogram(data[k][:], 1000) + stend = np.where(h > np.max(h) * 0.005) + st = stend[0][0] + end = stend[0][-1] + mmin[k] = e[st] + mmax[k] = e[end + 1] + + return mmin, mmax + + def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): """Solve the joint lamino-alignment problem using ADMM. @@ -42,7 +58,8 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): h0 = psi.copy() # Start with large winsize and decrease each ADMM iteration. winsize = min(*data.shape[1:]) - error0 = np.inf + mmin, mmax = find_min_max(data.real) + for k in range(niter): logging.info("Find flow using farneback.") @@ -52,17 +69,13 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): original=psi, flow=flow, pyr_scale=0.5, - levels=3, + levels=1, winsize=winsize, num_iter=4, + hi=mmax, + lo=mmin, ) - # flow = result['shift'] - #TODO: Only accept flow updates that reduce the error - error1 = np.linalg.norm( - (tike.align.simulate(psi, result['shift']) - data), axis=(1, 2))**2 - keep = np.where(error1 < error0) - flow[keep] = result['shift'][keep] - error0 = error1 + flow = result['shift'] logging.info("Recover original/aligned projections.") result = tike.align.reconstruct( @@ -97,6 +110,9 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): rho = update_penalty(psi, h, h0, rho) h0 = h + np.save(f"flow-tike-{(k+1):03d}", flow) + # np.save(f"flow-tike-v-{(k+1):03d}", vflow[..., ::-1]) + # checking intermediate results lagr = [ np.linalg.norm((tike.align.simulate(psi, flow) - data))**2, @@ -118,6 +134,13 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): # Limit winsize to larger value. 20? if winsize > 20: - winsize -= 2 + winsize -= 1 + + if (k+1) % 10 == 0: + dxchange.write_tiff( + u.real, + f'particle-{(k+1):03d}.tiff', + dtype='float32', + ) return u diff --git a/src/tike/align/solvers/farneback.py b/src/tike/align/solvers/farneback.py index c04809a4..d8d01073 100644 --- a/src/tike/align/solvers/farneback.py +++ b/src/tike/align/solvers/farneback.py @@ -4,15 +4,22 @@ from cv2 import calcOpticalFlowFarneback -def _rescale_8bit(a, b): - """Return a, b rescaled into the same 8-bit range""" +def _rescale_8bit(a, b, hi=None, lo=None): + """Return a, b rescaled into the same 8-bit range. + + The images are rescaled into the range [lo, hi] if provided; otherwise, the + range is decided by clipping the histogram of all bins that are less than + 0.5 percent of the fullest bin. + + """ - h, e = np.histogram(b, 1000) - stend = np.where(h > np.max(h) * 0.005) - st = stend[0][0] - end = stend[0][-1] - lo = e[st] - hi = e[end + 1] + if hi is None or lo is None: + h, e = np.histogram(b, 1000) + stend = np.where(h > np.max(h) * 0.005) + st = stend[0][0] + end = stend[0][-1] + lo = e[st] + hi = e[end + 1] # Force all values into range [0, 255] a = (255 * (a - lo) / (hi - lo)) @@ -37,6 +44,8 @@ def farneback( poly_n=5, poly_sigma=1.1, flow=None, + hi=None, + lo=None, **kwargs, ): """Find the flow from unaligned to original using Farneback's algorithm @@ -61,13 +70,18 @@ def farneback( if flow is None: flow = np.zeros((*shape, 2), dtype='float32') else: - flow = np.copy(np.flip(flow, axis=-1)) + flow = flow[..., ::-1].copy() # NOTE: Passing a reshaped view as any of the parameters breaks OpenCV's # Farneback implementation. for i in range(len(original)): aflow = calcOpticalFlowFarneback( - *_rescale_8bit(np.real(original[i]), np.real(unaligned[i])), + *_rescale_8bit( + np.real(original[i]), + np.real(unaligned[i]), + hi = hi[i] if hi is not None else None, + lo = lo[i] if lo is not None else None, + ), flow=flow[i], pyr_scale=pyr_scale, levels=levels, From 8076349c362a03848200ff87662e4fc3aaad8d99 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 27 Aug 2020 16:40:19 -0500 Subject: [PATCH 016/109] NEW: Allow specifying specific GPUs for ThreadPool --- src/tike/pool.py | 65 ++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/src/tike/pool.py b/src/tike/pool.py index 655ce6d0..ecca130f 100644 --- a/src/tike/pool.py +++ b/src/tike/pool.py @@ -10,29 +10,46 @@ import warnings import cupy as cp -import numpy as np -class NumPyThreadPool(ThreadPoolExecutor): +class ThreadPool(ThreadPoolExecutor): """Python thread pool plus scatter gather methods. A Pool is a context manager which provides access to and communications amongst workers. + Attributes + ---------- + workers : int, tuple(int) + The number of GPUs to use or a tuple of the device numbers of the GPUs + to use. If the number of GPUs is less than the requested number, only + workers for the available GPUs are allocated. """ - def __init__(self, num_workers: int, device_count=1): - super().__init__(num_workers) - self.device_count = device_count - self.num_workers = num_workers - self.workers = list(range(num_workers)) - self.xp = np + def __init__(self, workers): + self.device_count = cp.cuda.runtime.getDeviceCount() + if type(workers) is int and workers > 0: + if workers > self.device_count: + warnings.warn( + "Not enough CUDA devices for workers!" + f" Requested {workers} of {self.device_count} devices.") + workers = min(workers, self.device_count) + workers = tuple(range(workers)) + else: + raise ValueError(f"Provide workers > 0, not {workers}.") + for w in workers: + if w < 0 or w >= self.device_count: + raise ValueError(f'{w} is not a valid GPU device number.') + self.workers = workers + self.num_workers = len(workers) + self.xp = cp + super().__init__(self.num_workers) - def _copy_to(self, x: np.array, worker: int) -> np.array: - """Copy x to the given worker.""" - return self.xp.array(x, copy=True) + def _copy_to(self, x, worker: int) -> cp.array: + with cp.cuda.Device(worker): + return self.xp.asarray(x) - def bcast(self, x: np.array) -> list: + def bcast(self, x: cp.array) -> list: """Send a copy of x to all workers.""" def f(worker): @@ -40,11 +57,7 @@ def f(worker): return list(self.map(f, self.workers)) - # def scatter(self, x: np.array) -> list: - # """Divide x amongst all workers along the 0th dimension.""" - # return list(self.map(self._copy_to, x, self.workers)) - - def gather(self, x: list, worker=0, axis=0) -> np.array: + def gather(self, x: list, worker=0, axis=0) -> cp.array: """Concatenate x on a single worker along the given axis.""" return self.xp.concatenate( [self._copy_to(part, worker) for part in x], @@ -59,21 +72,6 @@ def f(worker): return list(self.map(f, self.workers)) - -class CuPyThreadPool(NumPyThreadPool): - - def __init__(self, num_workers): - device_count = cp.cuda.runtime.getDeviceCount() - if num_workers > device_count: - warnings.warn("Not enough CUDA devices for workers!") - num_workers = device_count - super().__init__(num_workers, device_count) - self.xp = cp - - def _copy_to(self, x: np.array, worker: int) -> np.array: - with cp.cuda.Device(worker): - return self.xp.asarray(x) - def map(self, func, *iterables, **kwargs): """ThreadPoolExecutor.map, but wraps call in a cuda.Device context.""" @@ -82,6 +80,3 @@ def f(worker, *args): return func(*args) return super().map(f, self.workers, *iterables, **kwargs) - - -ThreadPool = CuPyThreadPool From 9409e29f89c427961cea071246f258dc2de5ce0b Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Mon, 24 Aug 2020 17:31:46 -0500 Subject: [PATCH 017/109] BUG: Wrong indexing in CUDA kernel --- src/tike/operators/cupy/usfft.cu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tike/operators/cupy/usfft.cu b/src/tike/operators/cupy/usfft.cu index d70972be..66667e6d 100644 --- a/src/tike/operators/cupy/usfft.cu +++ b/src/tike/operators/cupy/usfft.cu @@ -72,7 +72,7 @@ _loop_over_kernels(scatterOrGather operation, float2* gathered, int gi = 0; int stride = 1; for (int dim = ndim - 1; dim >= 0; dim--) { - delta = (float)(center[dim] + k[dim]) / (2 * n) - x[3 * fi + dim]; + delta = (float)(center[dim] + k[dim]) / (2 * n) - x[ndim * fi + dim]; ssdelta += delta * delta; gi += mod((n + center[dim] + k[dim]), gw) * stride; stride *= gw; From 87d8e60a25ecbdb95a1a2ddf1bcc183a4b293874 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 27 Aug 2020 17:03:15 -0500 Subject: [PATCH 018/109] STUB: Change how lamino-alignment intermediates are saved --- src/tike/admm.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index 5ab028d5..b5ff8cb7 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -138,8 +138,13 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): if (k+1) % 10 == 0: dxchange.write_tiff( - u.real, - f'particle-{(k+1):03d}.tiff', + np.imag(u), + f'particle-i-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + np.real(u), + f'particle-r-{(k+1):03d}.tiff', dtype='float32', ) From e44c82cdcdb1f59b9f59e963974b5b15050cfbd8 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 2 Sep 2020 12:42:49 -0500 Subject: [PATCH 019/109] STUB: ptycho-lamino-alignment --- src/tike/admm.py | 177 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 2 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index 5ab028d5..8433a1ac 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -1,11 +1,15 @@ import logging +import multiprocessing +import cupy as cp +import dxchange import numpy as np +import skimage.transform import tike.align import tike.lamino +import tike.ptycho -import dxchange def update_penalty(psi, h, h0, rho): r = np.linalg.norm(psi - h)**2 @@ -136,7 +140,7 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): if winsize > 20: winsize -= 1 - if (k+1) % 10 == 0: + if (k + 1) % 10 == 0: dxchange.write_tiff( u.real, f'particle-{(k+1):03d}.tiff', @@ -144,3 +148,172 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): ) return u + + +def recon_with_device(device, data, psi, scan, probe): + with cp.cuda.Device(device): + result = tike.ptycho.reconstruct( + data=data, + psi=psi, + scan=scan, + probe=probe, + algorithm='combined', + num_iter=1, + cg_iter=4, + recover_psi=True, + recover_probe=True, + recover_positions=False, + model='gaussian', + ) + return result['psi'], result['scan'], result['probe'] + + +def multi_ptycho(data, probe, scan, psi, num_gpu=8): + + nsplit = len(data) // 6 + data_ = np.array_split(data, nsplit, axis=0) + psi_ = np.array_split(psi, nsplit, axis=0) + scan_ = np.array_split(scan, nsplit, axis=0) + probe_ = np.array_split(probe, nsplit, axis=0) + devices = np.arange(0, len(data_)) % num_gpu + + with multiprocessing.Pool(num_gpu) as processes: + psi_, scan_, probe_ = zip(*processes.starmap( + recon_with_device, + zip(devices, data_, psi_, scan_, probe_), + )) + + return { + 'psi': np.concatenate(psi_, axis=0), + 'scan': np.concatenate(scan_, axis=0), + 'probe': np.concatenate(probe_, axis=0), + } + + +def rotate_and_crop(psi, radius, angle): + # Rotate by desired angle (degrees) + rotate_params = dict( + angle=angle, + clip=False, + preserve_range=True, + resize=False, + ) + psi.real = skimage.transform.rotate(psi.real, **rotate_params) + psi.imag = skimage.transform.rotate(psi.imag, **rotate_params) + phase = np.angle(psi) + phase[phase < 0] = 0 + + # Find the center of mass + M = skimage.measure.moments(phase, order=1) + center = np.array([M[1, 0] / M[0, 0], M[0, 1] / M[0, 0]]).astype('int') + + # Adjust the cropping region so it stays within the image + lo = np.fmax(0, center - radius) + hi = lo + 2 * radius + shift = np.fmin(0, psi.shape - hi) + hi += shift + lo += shift + assert np.all(lo >= 0), lo + assert np.all(hi <= psi.shape), (hi, psi.shape) + # Crop image + patch = psi[lo[0]:hi[0], lo[1]:hi[1]].astype('complex64') + + return psi, patch, lo + + +def uncrop_and_rotate(psi, patch, lo, radius, angle): + + psi[lo[0]:lo[0] + 2 * radius, lo[1]:lo[1] + 2 * radius] = patch + + # Rotate by desired angle (degrees) + rotate_params = dict( + angle=angle, + clip=False, + preserve_range=True, + resize=False, + ) + psi.real = skimage.transform.rotate(psi.real, **rotate_params) + psi.imag = skimage.transform.rotate(psi.imag, **rotate_params) + return psi + + +def ptycho_lamino_align(data, psi, scan, probe, theta, tilt, niter=8): + """Solve the joint ptycho-lamino-alignment problem using ADMM.""" + + presult = { # ptychography result + 'psi': psi, + 'scan': scan, + 'probe': probe, + } + + flow = 0 # FIXME + phi = 0 # FIXME + u = 0 # FIXME + + for k in range(niter): + + logging.info("Solve the ptychography problem.") + presult = multi_ptycho(data=data, **presult) + psi = presult['psi'] + + logging.info("Rotate and crop projections.") + trimmed = rotate_and_crop(psi, radius=256, angle=-72.035) + + logging.info("Convert projections to linear space.") + unaligned = -np.log(trimmed) + + # logging.info("Estimate alignment using Farneback.") + # aresult = tike.align.solvers.farneback( + # op=None, + # unaligned=unaligned, + # original=phi, + # flow=flow, + # pyr_scale=0.5, + # levels=1, + # winsize=winsize, + # num_iter=4, + # ) + # flow = aresult['shift'] + + # logging.info("Recover aligned projections from unaligned.") + # aresult = tike.align.reconstruct( + # unaligned=unaligned, + # original=phi, + # flow=flow, + # num_iter=4, + # algorithm='cgrad', + # ) + # phi = aresult['original'] + + # logging.info('Solve the laminography problem.') + # result = tike.lamino.reconstruct( + # data=phi, + # theta=theta, + # tilt=tilt, + # obj=u, + # algorithm='cgrad', + # num_iter=1, + # cg_iter=4, + # ) + # u = result['obj'] + + # logging.info('Update lambdas and rhos.') + # # lambda rho for ptychography + + # # lamda rho for alignment + # h = tike.lamino.simulate(obj=u, theta=theta, tilt=tilt) + # lamd += rho * (h - phi) + + # # Limit winsize to larger value. 20? + # if winsize > 20: + # winsize -= 1 + + # if (k + 1) % 10 == 0: + # np.save(f"flow-tike-{(k+1):03d}", flow) + # dxchange.write_tiff( + # u.real, + # f'particle-{(k+1):03d}.tiff', + # dtype='float32', + # ) + + return u From 0ddad30b0c876cf48948e9696885e7e9b08fa0d1 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 8 Sep 2020 17:15:42 -0500 Subject: [PATCH 020/109] ADMM that runs, but does it work? --- src/tike/admm.py | 228 +++++++++++++++++++--------- src/tike/align/solvers/cgrad.py | 8 +- src/tike/ptycho/solvers/combined.py | 9 +- 3 files changed, 166 insertions(+), 79 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index 536bb791..d0f0b888 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -155,10 +155,12 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): return u -def recon_with_device(device, data, psi, scan, probe): +def recon_with_device(device, data, psi, scan, probe, reg): with cp.cuda.Device(device): result = tike.ptycho.reconstruct( data=data, + rho=0.5, + reg=reg, psi=psi, scan=scan, probe=probe, @@ -173,20 +175,29 @@ def recon_with_device(device, data, psi, scan, probe): return result['psi'], result['scan'], result['probe'] -def multi_ptycho(data, probe, scan, psi, num_gpu=8): +def multi_ptycho(processes, data, probe, scan, psi, reg, num_gpu=8): nsplit = len(data) // 6 data_ = np.array_split(data, nsplit, axis=0) psi_ = np.array_split(psi, nsplit, axis=0) scan_ = np.array_split(scan, nsplit, axis=0) probe_ = np.array_split(probe, nsplit, axis=0) + reg_ = np.array_split(reg, nsplit, axis=0) devices = np.arange(0, len(data_)) % num_gpu - with multiprocessing.Pool(num_gpu) as processes: - psi_, scan_, probe_ = zip(*processes.starmap( - recon_with_device, - zip(devices, data_, psi_, scan_, probe_), - )) + psi_, scan_, probe_ = zip(*processes.starmap( + recon_with_device, + zip(devices, data_, psi_, scan_, probe_, reg_), + )) + # psi_, scan_, probe_ = zip(*map( + # recon_with_device, + # devices, + # data_, + # psi_, + # scan_, + # probe_, + # reg_, + # )) return { 'psi': np.concatenate(psi_, axis=0), @@ -195,7 +206,7 @@ def multi_ptycho(data, probe, scan, psi, num_gpu=8): } -def rotate_and_crop(psi, radius, angle): +def rotate_and_crop(psi, radius=128, angle=-72.035): # Rotate by desired angle (degrees) rotate_params = dict( angle=angle, @@ -226,7 +237,7 @@ def rotate_and_crop(psi, radius, angle): return psi, patch, lo -def uncrop_and_rotate(psi, patch, lo, radius, angle): +def uncrop_and_rotate(psi, patch, lo, radius=128, angle=72.035): psi[lo[0]:lo[0] + 2 * radius, lo[1]:lo[1] + 2 * radius] = patch @@ -242,83 +253,154 @@ def uncrop_and_rotate(psi, patch, lo, radius, angle): return psi -def ptycho_lamino_align(data, psi, scan, probe, theta, tilt, niter=8): +def ptycho_lamino_align( + processes, + data, + psi, + scan, + probe, + theta, + tilt, + u=None, + flow=None, + niter=1, + folder=None, +): """Solve the joint ptycho-lamino-alignment problem using ADMM.""" + # Set initial values for intermediate variables + w = 256 + u = np.zeros( + [w, w, w], + dtype='complex64', + ) if u is None else u + flow = np.zeros( + [len(theta), w, w, 2], + dtype='float32', + ) if flow is None else flow + winsize = min(*u.shape[:2]) + presult = { # ptychography result 'psi': psi, 'scan': scan, 'probe': probe, } - flow = 0 # FIXME - phi = 0 # FIXME - u = 0 # FIXME + phi = np.exp(1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) + λ_p = np.zeros_like(phi) + λ_a = np.zeros_like(phi) + reg_p = np.zeros_like(psi) for k in range(niter): + logging.info(f"Start ADMM iteration {k}.") logging.info("Solve the ptychography problem.") - presult = multi_ptycho(data=data, **presult) + + if k > 0: + # Skip regularization on zeroth iteration because we don't know + # value of the cropping corner locations + reg_p = np.stack( + list( + processes.starmap( + uncrop_and_rotate, + zip( + psi_rotated, + tike.align.simulate(phi, flow) + λ_p / 0.5, + corners, + ), + )), + axis=0, + ) + presult = multi_ptycho( + processes, + data=data, + reg=reg_p, + **presult, + ) psi = presult['psi'] logging.info("Rotate and crop projections.") - trimmed = rotate_and_crop(psi, radius=256, angle=-72.035) - - logging.info("Convert projections to linear space.") - unaligned = -np.log(trimmed) - - # logging.info("Estimate alignment using Farneback.") - # aresult = tike.align.solvers.farneback( - # op=None, - # unaligned=unaligned, - # original=phi, - # flow=flow, - # pyr_scale=0.5, - # levels=1, - # winsize=winsize, - # num_iter=4, - # ) - # flow = aresult['shift'] - - # logging.info("Recover aligned projections from unaligned.") - # aresult = tike.align.reconstruct( - # unaligned=unaligned, - # original=phi, - # flow=flow, - # num_iter=4, - # algorithm='cgrad', - # ) - # phi = aresult['original'] - - # logging.info('Solve the laminography problem.') - # result = tike.lamino.reconstruct( - # data=phi, - # theta=theta, - # tilt=tilt, - # obj=u, - # algorithm='cgrad', - # num_iter=1, - # cg_iter=4, - # ) - # u = result['obj'] - - # logging.info('Update lambdas and rhos.') - # # lambda rho for ptychography - - # # lamda rho for alignment - # h = tike.lamino.simulate(obj=u, theta=theta, tilt=tilt) - # lamd += rho * (h - phi) - - # # Limit winsize to larger value. 20? - # if winsize > 20: - # winsize -= 1 - - # if (k + 1) % 10 == 0: - # np.save(f"flow-tike-{(k+1):03d}", flow) - # dxchange.write_tiff( - # u.real, - # f'particle-{(k+1):03d}.tiff', - # dtype='float32', - # ) + psi_rotated, trimmed, corners = zip( + *processes.map(rotate_and_crop, psi)) + psi_rotated = np.stack(psi_rotated, axis=0) + trimmed = np.stack(trimmed, axis=0) + corners = np.stack(corners, axis=0) + + logging.info("Recover aligned projections from unaligned.") + aresult = tike.align.reconstruct( + unaligned=trimmed - λ_p / 0.5, + original=phi, + flow=flow, + num_iter=4, + algorithm='cgrad', + reg=np.exp(1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) + + λ_a / 0.5, + rho=0.5, + ) + phi = aresult['original'] - return u + logging.info('Solve the laminography problem.') + lresult = tike.lamino.reconstruct( + data=np.log(λ_a / 0.5 - phi) / (1j), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + cg_iter=4, + ) + u = lresult['obj'] + + logging.info("Estimate alignment using Farneback.") + aresult = tike.align.solvers.farneback( + op=None, + unaligned=trimmed, + original=tike.align.simulate(phi, flow) + λ_p / 0.5, + flow=flow, + pyr_scale=0.5, + levels=1, + winsize=winsize, + num_iter=4, + ) + flow = aresult['shift'] + + logging.info('Update lambdas and rhos.') + + λ_p += 0.5 * (-trimmed + tike.align.simulate(phi, flow)) + λ_a += 0.5 * (-phi + np.exp( + 1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta))) + + # Limit winsize to larger value. 20? + if winsize > 20: + winsize -= 1 + + if (k + 1) % 1 == 0: + dxchange.write_tiff( + skimage.restoration.unwrap_phase(np.angle( + presult['psi'])).astype('float32'), + f'{folder}/object-phase-{(k+1):03d}.tiff', + ) + dxchange.write_tiff( + phi.real, + f'{folder}/phi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + phi.imag, + f'{folder}/phi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + np.save(f"{folder}/flow-tike-{(k+1):03d}", flow) + + result = presult + return result diff --git a/src/tike/align/solvers/cgrad.py b/src/tike/align/solvers/cgrad.py index 2f752f8a..0a45743e 100644 --- a/src/tike/align/solvers/cgrad.py +++ b/src/tike/align/solvers/cgrad.py @@ -18,13 +18,15 @@ def cgrad( def cost_function(original): return ( - op.xp.linalg.norm((op.fwd(original, flow) - unaligned).ravel())**2 + (1 - rho) * op.xp.linalg.norm((op.fwd(original, flow) - unaligned).ravel())**2 + rho * op.xp.linalg.norm((original - reg).ravel())**2 ) def grad(original): - return (op.fwd(op.fwd(original, flow) - unaligned, -flow) + - rho * (original - reg)) / max(rho, 1) + return ( + (1 - rho) * op.fwd(op.fwd(original, flow) - unaligned, -flow) + + rho * (original - reg) + ) cost = 0 original, cost = conjugate_gradient( diff --git a/src/tike/ptycho/solvers/combined.py b/src/tike/ptycho/solvers/combined.py index 4e6cf5c8..b31e9aa8 100644 --- a/src/tike/ptycho/solvers/combined.py +++ b/src/tike/ptycho/solvers/combined.py @@ -11,7 +11,7 @@ def combined( op, pool, - data, probe, scan, psi, + data, probe, scan, psi, rho=0, reg=[0], recover_psi=True, recover_probe=True, recover_positions=False, cg_iter=4, **kwargs @@ -35,6 +35,8 @@ def combined( psi, scan, probe, + rho, + reg, num_iter=cg_iter, ) @@ -94,7 +96,7 @@ def grad(mode): return probe, cost -def update_object(op, pool, data, psi, scan, probe, num_iter=1): +def update_object(op, pool, data, psi, scan, probe, rho, reg, num_iter=1): """Solve the object recovery problem.""" def cost_function_multi(psi, **kwargs): @@ -103,6 +105,7 @@ def cost_function_multi(psi, **kwargs): cost_cpu = 0 for c in cost_out: cost_cpu += op.asnumpy(c) + cost_cpu += op.asnumpy(rho * op.xp.linalg.norm((psi[0] - reg[0]).ravel())**2) return cost_cpu def grad_multi(psi): @@ -113,7 +116,7 @@ def grad_multi(psi): grad_cpu_tmp = op.asnumpy(grad_list[i]) grad_tmp = op.asarray(grad_cpu_tmp) grad_list[0] += grad_tmp - + grad_list[0] += rho * (psi[0] - reg[0]) return grad_list[0] def dir_multi(dir): From 2059b834e8a18009f55e3aa73b1853b3cfa80042 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 8 Sep 2020 18:04:08 -0500 Subject: [PATCH 021/109] Loop through crop and rotate --- src/tike/admm.py | 101 ++++++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index d0f0b888..dad8bbe9 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -206,51 +206,64 @@ def multi_ptycho(processes, data, probe, scan, psi, reg, num_gpu=8): } -def rotate_and_crop(psi, radius=128, angle=-72.035): - # Rotate by desired angle (degrees) +def rotate_and_crop(x, radius=128, angle=-72.035): + """Rotate x in two trailing dimensions then crop around center-of-mass. + + Parameters + ---------- + x : (M, N, O) complex64 + radius : int + angle : float + Rotation angle in degrees. + """ rotate_params = dict( angle=angle, clip=False, preserve_range=True, resize=False, ) - psi.real = skimage.transform.rotate(psi.real, **rotate_params) - psi.imag = skimage.transform.rotate(psi.imag, **rotate_params) - phase = np.angle(psi) - phase[phase < 0] = 0 - - # Find the center of mass - M = skimage.measure.moments(phase, order=1) - center = np.array([M[1, 0] / M[0, 0], M[0, 1] / M[0, 0]]).astype('int') - - # Adjust the cropping region so it stays within the image - lo = np.fmax(0, center - radius) - hi = lo + 2 * radius - shift = np.fmin(0, psi.shape - hi) - hi += shift - lo += shift - assert np.all(lo >= 0), lo - assert np.all(hi <= psi.shape), (hi, psi.shape) - # Crop image - patch = psi[lo[0]:hi[0], lo[1]:hi[1]].astype('complex64') - - return psi, patch, lo - - -def uncrop_and_rotate(psi, patch, lo, radius=128, angle=72.035): - - psi[lo[0]:lo[0] + 2 * radius, lo[1]:lo[1] + 2 * radius] = patch - - # Rotate by desired angle (degrees) + corner = np.zeros((len(x), 2), dtype=int) + patch = np.zeros((len(x), 2 * radius, 2 * radius), dtype='complex64') + for i in range(len(x)): + # Rotate by desired angle (degrees) + x[i].real = skimage.transform.rotate(x[i].real, **rotate_params) + x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params) + + # Find the center of mass + phase = np.angle(x[i]) + phase[phase < 0] = 0 + M = skimage.measure.moments(phase, order=1) + center = np.array([M[1, 0] / M[0, 0], M[0, 1] / M[0, 0]]).astype('int') + + # Adjust the cropping region so it stays within the image + lo = np.fmax(0, center - radius) + hi = lo + 2 * radius + shift = np.fmin(0, x[i].shape - hi) + hi += shift + lo += shift + assert np.all(lo >= 0), lo + assert np.all(hi <= x[i].shape), (hi, x[i].shape) + # Crop image + patch[i] = x[i][lo[0]:hi[0], lo[1]:hi[1]] + corner[i] = lo + + return x, patch, corner + + +def uncrop_and_rotate(x, patch, lo, radius=128, angle=72.035): rotate_params = dict( angle=angle, clip=False, preserve_range=True, resize=False, ) - psi.real = skimage.transform.rotate(psi.real, **rotate_params) - psi.imag = skimage.transform.rotate(psi.imag, **rotate_params) - return psi + for i in range(len(x)): + x[i][lo[i][0]:lo[i][0] + 2 * radius, + lo[i][1]:lo[i][1] + 2 * radius] = patch[i] + # Rotate by desired angle (degrees) + x[i].real = skimage.transform.rotate(x[i].real, **rotate_params) + x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params) + return x def ptycho_lamino_align( @@ -287,6 +300,7 @@ def ptycho_lamino_align( } phi = np.exp(1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) + phi = phi.astype('complex64') λ_p = np.zeros_like(phi) λ_a = np.zeros_like(phi) reg_p = np.zeros_like(psi) @@ -299,17 +313,10 @@ def ptycho_lamino_align( if k > 0: # Skip regularization on zeroth iteration because we don't know # value of the cropping corner locations - reg_p = np.stack( - list( - processes.starmap( - uncrop_and_rotate, - zip( - psi_rotated, - tike.align.simulate(phi, flow) + λ_p / 0.5, - corners, - ), - )), - axis=0, + reg_p = uncrop_and_rotate( + psi_rotated, + tike.align.simulate(phi, flow) + λ_p / 0.5, + corners, ) presult = multi_ptycho( processes, @@ -320,11 +327,7 @@ def ptycho_lamino_align( psi = presult['psi'] logging.info("Rotate and crop projections.") - psi_rotated, trimmed, corners = zip( - *processes.map(rotate_and_crop, psi)) - psi_rotated = np.stack(psi_rotated, axis=0) - trimmed = np.stack(trimmed, axis=0) - corners = np.stack(corners, axis=0) + psi_rotated, trimmed, corners = rotate_and_crop(psi.copy()) logging.info("Recover aligned projections from unaligned.") aresult = tike.align.reconstruct( From e2a5f842dd76f2135be4363f14dfdb4a13989b67 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 9 Sep 2020 15:41:21 -0500 Subject: [PATCH 022/109] ADMM working but diverges --- src/tike/admm.py | 323 ++++++++++++--------------- src/tike/align/solvers/cgrad.py | 8 +- src/{broken => tike}/communicator.py | 37 ++- src/tike/ptycho/solvers/combined.py | 5 +- 4 files changed, 169 insertions(+), 204 deletions(-) rename src/{broken => tike}/communicator.py (82%) diff --git a/src/tike/admm.py b/src/tike/admm.py index dad8bbe9..c703d54b 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -7,6 +7,7 @@ import skimage.transform import tike.align +from tike.communicator import MPICommunicator import tike.lamino import tike.ptycho @@ -155,58 +156,7 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): return u -def recon_with_device(device, data, psi, scan, probe, reg): - with cp.cuda.Device(device): - result = tike.ptycho.reconstruct( - data=data, - rho=0.5, - reg=reg, - psi=psi, - scan=scan, - probe=probe, - algorithm='combined', - num_iter=1, - cg_iter=4, - recover_psi=True, - recover_probe=True, - recover_positions=False, - model='gaussian', - ) - return result['psi'], result['scan'], result['probe'] - - -def multi_ptycho(processes, data, probe, scan, psi, reg, num_gpu=8): - - nsplit = len(data) // 6 - data_ = np.array_split(data, nsplit, axis=0) - psi_ = np.array_split(psi, nsplit, axis=0) - scan_ = np.array_split(scan, nsplit, axis=0) - probe_ = np.array_split(probe, nsplit, axis=0) - reg_ = np.array_split(reg, nsplit, axis=0) - devices = np.arange(0, len(data_)) % num_gpu - - psi_, scan_, probe_ = zip(*processes.starmap( - recon_with_device, - zip(devices, data_, psi_, scan_, probe_, reg_), - )) - # psi_, scan_, probe_ = zip(*map( - # recon_with_device, - # devices, - # data_, - # psi_, - # scan_, - # probe_, - # reg_, - # )) - - return { - 'psi': np.concatenate(psi_, axis=0), - 'scan': np.concatenate(scan_, axis=0), - 'probe': np.concatenate(probe_, axis=0), - } - - -def rotate_and_crop(x, radius=128, angle=-72.035): +def rotate_and_crop(x, radius=128, angle=72.035): """Rotate x in two trailing dimensions then crop around center-of-mass. Parameters @@ -250,7 +200,7 @@ def rotate_and_crop(x, radius=128, angle=-72.035): return x, patch, corner -def uncrop_and_rotate(x, patch, lo, radius=128, angle=72.035): +def uncrop_and_rotate(x, patch, lo, radius=128, angle=-72.035): rotate_params = dict( angle=angle, clip=False, @@ -267,7 +217,6 @@ def uncrop_and_rotate(x, patch, lo, radius=128, angle=72.035): def ptycho_lamino_align( - processes, data, psi, scan, @@ -280,130 +229,154 @@ def ptycho_lamino_align( folder=None, ): """Solve the joint ptycho-lamino-alignment problem using ADMM.""" - - # Set initial values for intermediate variables - w = 256 - u = np.zeros( - [w, w, w], - dtype='complex64', - ) if u is None else u - flow = np.zeros( - [len(theta), w, w, 2], - dtype='float32', - ) if flow is None else flow - winsize = min(*u.shape[:2]) - - presult = { # ptychography result - 'psi': psi, - 'scan': scan, - 'probe': probe, - } - - phi = np.exp(1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) - phi = phi.astype('complex64') - λ_p = np.zeros_like(phi) - λ_a = np.zeros_like(phi) - reg_p = np.zeros_like(psi) - - for k in range(niter): - logging.info(f"Start ADMM iteration {k}.") - - logging.info("Solve the ptychography problem.") - - if k > 0: - # Skip regularization on zeroth iteration because we don't know - # value of the cropping corner locations - reg_p = uncrop_and_rotate( - psi_rotated, - tike.align.simulate(phi, flow) + λ_p / 0.5, - corners, + comm = MPICommunicator() + with cp.cuda.Device(comm.rank): + # Set initial values for intermediate variables + w = 256 + u = np.zeros( + [w, w, w], + dtype='complex64', + ) if u is None else u + winsize = min(*u.shape[:2]) + + # data, psi, scan, probe, theta = [ + # comm.scatter(x) for x in (data, psi, scan, probe, theta) + # ] + + flow = np.zeros( + [len(theta), w, w, 2], + dtype='float32', + ) if flow is None else flow + presult = { # ptychography result + 'psi': psi, + 'scan': scan, + 'probe': probe, + } + phi = np.exp(1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) + phi = phi.astype('complex64') + λ_p = np.zeros_like(phi) + λ_a = np.zeros_like(phi) + reg_p = np.zeros_like(psi) + + for k in range(niter): + logging.info(f"Start ADMM iteration {k}.") + + logging.info("Solve the ptychography problem.") + + if k > 0: + # Skip regularization on zeroth iteration because we don't know + # value of the cropping corner locations + reg_p = uncrop_and_rotate( + psi_rotated, + λ_p / 0.5 - tike.align.simulate(phi, flow), + corners, + ) + presult = tike.ptycho.reconstruct( + data=data, + reg=reg_p, + algorithm='combined', + num_iter=1, + cg_iter=4, + recover_psi=True, + recover_probe=True, + recover_positions=False, + model='gaussian', + **presult, ) - presult = multi_ptycho( - processes, - data=data, - reg=reg_p, - **presult, - ) - psi = presult['psi'] - - logging.info("Rotate and crop projections.") - psi_rotated, trimmed, corners = rotate_and_crop(psi.copy()) - - logging.info("Recover aligned projections from unaligned.") - aresult = tike.align.reconstruct( - unaligned=trimmed - λ_p / 0.5, - original=phi, - flow=flow, - num_iter=4, - algorithm='cgrad', - reg=np.exp(1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) - + λ_a / 0.5, - rho=0.5, - ) - phi = aresult['original'] - - logging.info('Solve the laminography problem.') - lresult = tike.lamino.reconstruct( - data=np.log(λ_a / 0.5 - phi) / (1j), - theta=theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - cg_iter=4, - ) - u = lresult['obj'] - - logging.info("Estimate alignment using Farneback.") - aresult = tike.align.solvers.farneback( - op=None, - unaligned=trimmed, - original=tike.align.simulate(phi, flow) + λ_p / 0.5, - flow=flow, - pyr_scale=0.5, - levels=1, - winsize=winsize, - num_iter=4, - ) - flow = aresult['shift'] - - logging.info('Update lambdas and rhos.') - - λ_p += 0.5 * (-trimmed + tike.align.simulate(phi, flow)) - λ_a += 0.5 * (-phi + np.exp( - 1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta))) - - # Limit winsize to larger value. 20? - if winsize > 20: - winsize -= 1 - - if (k + 1) % 1 == 0: - dxchange.write_tiff( - skimage.restoration.unwrap_phase(np.angle( - presult['psi'])).astype('float32'), - f'{folder}/object-phase-{(k+1):03d}.tiff', - ) - dxchange.write_tiff( - phi.real, - f'{folder}/phi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - phi.imag, - f'{folder}/phi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.real, - f'{folder}/particle-real-{(k+1):03d}.tiff', - dtype='float32', + psi = presult['psi'] + + logging.info("Rotate and crop projections.") + psi_rotated, trimmed, corners = rotate_and_crop(psi.copy()) + + logging.info("Recover aligned projections from unaligned.") + aresult = tike.align.reconstruct( + unaligned=trimmed + λ_p / 0.5, + original=phi, + flow=flow, + num_iter=4, + algorithm='cgrad', + reg=np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) - λ_a / 0.5, ) - dxchange.write_tiff( - u.imag, - f'{folder}/particle-imag-{(k+1):03d}.tiff', - dtype='float32', + phi = aresult['original'] + + logging.info("Estimate alignment using Farneback.") + aresult = tike.align.solvers.farneback( + op=None, + unaligned=trimmed + λ_p / 0.5, + original=phi, + flow=flow, + pyr_scale=0.5, + levels=1, + winsize=winsize, + num_iter=4, ) - np.save(f"{folder}/flow-tike-{(k+1):03d}", flow) + flow = aresult['shift'] + + # Gather all to one thread + λ_a, phi, theta = [comm.gather(x) for x in (λ_a, phi, theta)] + + if comm.rank == 0: + logging.info('Solve the laminography problem.') + lresult = tike.lamino.reconstruct( + data=np.log(phi + λ_a / 0.5) / (1j), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + cg_iter=4, + ) + u = lresult['obj'] + + # We cannot reorder phi, theta without ruining correspondence + # with data, psi, etc, but we can reorder the saved array + order = np.argsort(theta) + dxchange.write_tiff( + phi[order].real, + f'{folder}/phi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + phi[order].imag, + f'{folder}/phi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + # Separate again to multiple threads + λ_a, phi, theta = [comm.scatter(x) for x in (λ_a, phi, theta)] + u = comm.broadcast(u) + + logging.info('Update lambdas and rhos.') + + λ_p += 0.5 * (trimmed - tike.align.simulate(phi, flow)) + λ_a += 0.5 * (phi - np.exp( + 1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta))) + + # Limit winsize to larger value. 20? + if winsize > 20: + winsize -= 1 + + if (k + 1) % 1 == 0 and comm.rank == 0: + dxchange.write_tiff( + skimage.restoration.unwrap_phase(np.angle( + presult['psi'])).astype('float32'), + f'{folder}/object-phase-{(k+1):03d}.tiff', + ) + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + np.save(f"{folder}/flow-tike-{(k+1):03d}", flow) result = presult return result diff --git a/src/tike/align/solvers/cgrad.py b/src/tike/align/solvers/cgrad.py index 0a45743e..c311d0f8 100644 --- a/src/tike/align/solvers/cgrad.py +++ b/src/tike/align/solvers/cgrad.py @@ -18,14 +18,14 @@ def cgrad( def cost_function(original): return ( - (1 - rho) * op.xp.linalg.norm((op.fwd(original, flow) - unaligned).ravel())**2 - + rho * op.xp.linalg.norm((original - reg).ravel())**2 + 0.5 * op.xp.linalg.norm((op.fwd(original, flow) - unaligned).ravel())**2 + + 0.5 * op.xp.linalg.norm((original - reg).ravel())**2 ) def grad(original): return ( - (1 - rho) * op.fwd(op.fwd(original, flow) - unaligned, -flow) + - rho * (original - reg) + op.fwd(op.fwd(original, flow) - unaligned, -flow) + + (original - reg) ) cost = 0 diff --git a/src/broken/communicator.py b/src/tike/communicator.py similarity index 82% rename from src/broken/communicator.py rename to src/tike/communicator.py index 7973cce4..d56df26b 100644 --- a/src/broken/communicator.py +++ b/src/tike/communicator.py @@ -29,32 +29,21 @@ def __init__(self): self.size = self.comm.Get_size() logger.info("Node {:,d} is running.".format(self.rank)) - def scatter(self, *args): + def scatter(self, arg, root=0): """Send and recieve constant data that must be divided.""" - if len(args) == 1: - arg = args[0] - if self.rank == 0: - chunks = np.array_split(arg, self.size) - else: - chunks = None - return self.comm.scatter(chunks, root=0) - out = list() - for arg in args: - if self.rank == 0: - chunks = np.array_split(arg, self.size) - else: - chunks = None - out.append(self.comm.scatter(chunks, root=0)) - return out + if self.rank == root: + chunks = np.array_split(arg, self.size) + else: + chunks = None + chunk = self.comm.scatter(chunks, root=root) + # logger.info(f"Scatter from node {root} to node {self.rank}.") + return chunk - def broadcast(self, *args): + def broadcast(self, arg, root=0): """Synchronize parameters that are the same for all processses.""" - if len(args) == 1: - return self.comm.bcast(args[0], root=0) - out = list() - for arg in args: - out.append(self.comm.bcast(arg, root=0)) - return out + copy = self.comm.bcast(arg, root=root) + # logger.info(f"Broadcast from node {root} to node {self.rank}.") + return copy def get_ptycho_slice(self, tomo_slice): """Switch to slicing for the pytchography problem.""" @@ -81,6 +70,8 @@ def get_tomo_slice(self, ptych_slice): def gather(self, arg, root=0, axis=0): """Gather arg to one node.""" arg = self.comm.gather(arg, root=root) + # logger.info( + # f"Gather from node {self.rank} to node {root} along axis {axis}.") if self.rank == root: return np.concatenate(arg, axis=axis) return None diff --git a/src/tike/ptycho/solvers/combined.py b/src/tike/ptycho/solvers/combined.py index b31e9aa8..1330f215 100644 --- a/src/tike/ptycho/solvers/combined.py +++ b/src/tike/ptycho/solvers/combined.py @@ -105,7 +105,8 @@ def cost_function_multi(psi, **kwargs): cost_cpu = 0 for c in cost_out: cost_cpu += op.asnumpy(c) - cost_cpu += op.asnumpy(rho * op.xp.linalg.norm((psi[0] - reg[0]).ravel())**2) + cost_cpu += op.asnumpy(0.5 * op.xp.linalg.norm( + (psi[0] + reg[0]).ravel())**2) return cost_cpu def grad_multi(psi): @@ -116,7 +117,7 @@ def grad_multi(psi): grad_cpu_tmp = op.asnumpy(grad_list[i]) grad_tmp = op.asarray(grad_cpu_tmp) grad_list[0] += grad_tmp - grad_list[0] += rho * (psi[0] - reg[0]) + grad_list[0] += 0.5 * (psi[0] + reg[0]) return grad_list[0] def dir_multi(dir): From a78e6e8359cd9ec86b886b742295d46fbbfa21a3 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 10 Sep 2020 16:12:30 -0500 Subject: [PATCH 023/109] API: better constants for rotation --- src/tike/admm.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index c703d54b..bc1a7462 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -176,8 +176,8 @@ def rotate_and_crop(x, radius=128, angle=72.035): patch = np.zeros((len(x), 2 * radius, 2 * radius), dtype='complex64') for i in range(len(x)): # Rotate by desired angle (degrees) - x[i].real = skimage.transform.rotate(x[i].real, **rotate_params) - x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params) + x[i].real = skimage.transform.rotate(x[i].real, **rotate_params, cval=1) + x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params, cval=0) # Find the center of mass phase = np.angle(x[i]) @@ -211,8 +211,8 @@ def uncrop_and_rotate(x, patch, lo, radius=128, angle=-72.035): x[i][lo[i][0]:lo[i][0] + 2 * radius, lo[i][1]:lo[i][1] + 2 * radius] = patch[i] # Rotate by desired angle (degrees) - x[i].real = skimage.transform.rotate(x[i].real, **rotate_params) - x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params) + x[i].real = skimage.transform.rotate(x[i].real, **rotate_params, cval=1) + x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params, cval=0) return x @@ -322,7 +322,7 @@ def ptycho_lamino_align( if comm.rank == 0: logging.info('Solve the laminography problem.') lresult = tike.lamino.reconstruct( - data=np.log(phi + λ_a / 0.5) / (1j), + data=-1j * np.log(phi + λ_a / 0.5), theta=theta, tilt=tilt, obj=u, From e76dfd2dc100b8c6917893fa81eb53de7b04e308 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 10 Sep 2020 16:15:15 -0500 Subject: [PATCH 024/109] REF: Move lamino cost functions --- src/tike/lamino/solvers/cgrad.py | 7 +++++-- src/tike/operators/cupy/lamino.py | 8 -------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/tike/lamino/solvers/cgrad.py b/src/tike/lamino/solvers/cgrad.py index c4cbd827..60ed26c0 100644 --- a/src/tike/lamino/solvers/cgrad.py +++ b/src/tike/lamino/solvers/cgrad.py @@ -20,11 +20,14 @@ def cgrad( def update_obj(op, data, obj, num_iter=1): """Solver the object recovery problem.""" + def cost_function(obj): - return op.cost(data, obj) + "Cost function for the least-squres laminography problem" + return op.xp.linalg.norm((op.fwd(obj) - data).ravel())**2 def grad(obj): - return op.grad(data, obj) + "Gradient for the least-squares laminography problem" + return op.adj(data=(op.fwd(obj) - data)) / (op.ntheta * op.n**3) obj, cost = conjugate_gradient( op.xp, diff --git a/src/tike/operators/cupy/lamino.py b/src/tike/operators/cupy/lamino.py index 7f0c6102..59a474e9 100644 --- a/src/tike/operators/cupy/lamino.py +++ b/src/tike/operators/cupy/lamino.py @@ -152,14 +152,6 @@ def gather(self, Fe, x, n, m, mu): )) return F - def cost(self, data, obj): - "Cost function for the least-squres laminography problem" - return self.xp.linalg.norm((self.fwd(obj) - data).ravel())**2 - - def grad(self, data, obj): - "Gradient for the least-squares laminography problem" - return self.adj(data=self.fwd(obj) - data) / (self.ntheta * self.n**3) - def _make_grids(self, theta): """Return (ntheta*n*n, 3) unequally-spaced frequencies for the USFFT.""" [kv, ku] = self.xp.mgrid[-self.n // 2:self.n // 2, From 588573b826503d2ba88359cbf9f2ed37764dd70d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 10 Sep 2020 16:21:35 -0500 Subject: [PATCH 025/109] Two problems only (remove alignment) --- src/tike/admm.py | 97 +++++++++++++++++++++++++++-------------- src/tike/align/align.py | 2 +- 2 files changed, 65 insertions(+), 34 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index bc1a7462..b4c067ad 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -244,7 +244,7 @@ def ptycho_lamino_align( # ] flow = np.zeros( - [len(theta), w, w, 2], + [len(theta), 2], #w, w, 2], dtype='float32', ) if flow is None else flow presult = { # ptychography result @@ -268,14 +268,15 @@ def ptycho_lamino_align( # value of the cropping corner locations reg_p = uncrop_and_rotate( psi_rotated, - λ_p / 0.5 - tike.align.simulate(phi, flow), + # λ_p / 0.5 - tike.align.simulate(phi, flow), + λ_p / 0.5 - Hu, corners, ) presult = tike.ptycho.reconstruct( data=data, reg=reg_p, algorithm='combined', - num_iter=1, + num_iter=, cg_iter=4, recover_psi=True, recover_probe=True, @@ -288,33 +289,43 @@ def ptycho_lamino_align( logging.info("Rotate and crop projections.") psi_rotated, trimmed, corners = rotate_and_crop(psi.copy()) - logging.info("Recover aligned projections from unaligned.") - aresult = tike.align.reconstruct( - unaligned=trimmed + λ_p / 0.5, - original=phi, - flow=flow, - num_iter=4, - algorithm='cgrad', - reg=np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) - λ_a / 0.5, - ) - phi = aresult['original'] - - logging.info("Estimate alignment using Farneback.") - aresult = tike.align.solvers.farneback( - op=None, - unaligned=trimmed + λ_p / 0.5, - original=phi, - flow=flow, - pyr_scale=0.5, - levels=1, - winsize=winsize, - num_iter=4, - ) - flow = aresult['shift'] + # logging.info("Recover aligned projections from unaligned.") + # aresult = tike.align.reconstruct( + # unaligned=trimmed + λ_p / 0.5, + # original=phi, + # flow=flow, + # num_iter=4, + # algorithm='cgrad', + # reg=np.exp(1j * tike.lamino.simulate( + # obj=u, + # tilt=tilt, + # theta=theta, + # )) - λ_a / 0.5, + # ) + # phi = aresult['original'] + phi = trimmed + + # logging.info("Estimate alignment using Farneback.") + # aresult = tike.align.solvers.farneback( + # op=None, + # unaligned=trimmed + λ_p / 0.5, + # original=phi, + # flow=flow, + # pyr_scale=0.5, + # levels=1, + # winsize=winsize, + # num_iter=4, + # ) + + # logging.info("Estimate alignment using cross correlation.") + # aresult = tike.align.reconstruct( + # unaligned=trimmed + λ_p / 0.5, + # original=phi, + # flow=flow, + # algorithm='cross_correlation', + # upsample_factor=10, + # ) + # flow = aresult['shift'] # Gather all to one thread λ_a, phi, theta = [comm.gather(x) for x in (λ_a, phi, theta)] @@ -352,15 +363,35 @@ def ptycho_lamino_align( logging.info('Update lambdas and rhos.') - λ_p += 0.5 * (trimmed - tike.align.simulate(phi, flow)) - λ_a += 0.5 * (phi - np.exp( - 1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta))) + Hu = np.exp(1j * + tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) + φHu = phi - Hu + CψDφ = φHu #trimmed - tike.align.simulate(phi, flow) + + λ_a += 0.5 * φHu + λ_p += 0.5 * CψDφ + + lagrangian = ( + [presult['cost']], + 2 * np.real(λ_p.conj() * CψDφ) + + 0.5 * np.linalg.norm(CψDφ.ravel())**2, + 2 * np.real(λ_a.conj() * φHu) + + 0.5 * np.linalg.norm(φHu.ravel())**2, + ) + + lagrangian = [comm.gather(x) for x in lagrangian] # Limit winsize to larger value. 20? if winsize > 20: winsize -= 1 if (k + 1) % 1 == 0 and comm.rank == 0: + lagrangian = [np.sum(x) for x in lagrangian] + print( + 'Lagrangian = {:+6.3e} = {:+6.3e} {:+6.3e} {:+6.3e}'.format( + np.sum(lagrangian), *lagrangian), + flush=True) + dxchange.write_tiff( skimage.restoration.unwrap_phase(np.angle( presult['psi'])).astype('float32'), diff --git a/src/tike/align/align.py b/src/tike/align/align.py index 4e25c6e8..d6b40635 100644 --- a/src/tike/align/align.py +++ b/src/tike/align/align.py @@ -61,7 +61,7 @@ def reconstruct( if algorithm in solvers.__all__: # Initialize an operator. - with Flow() as operator: + with Shift() as operator: # send any array-likes to device unaligned = operator.asarray(unaligned, dtype='complex64') original = operator.asarray(original, dtype='complex64') From 5b3c4d5091927636130638270ac502b2992390b4 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 16 Sep 2020 18:57:23 -0500 Subject: [PATCH 026/109] BUG: Change edge behavior to nearest for flow operator --- src/tike/operators/cupy/flow.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index c6006a98..3ce8168c 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -8,6 +8,16 @@ def _lanczos(xp, x, a): return xp.sinc(x) * xp.sinc(x / a) +def _nearest(xp, i, n): + """Use nearest on-grid index at the edges.""" + return xp.maximum(xp.minimum(i, n), 0) + + +def _wrap(xp, i, n): + """Wrap indexes at the edges.""" + return i % n + + def _remap_lanczos(xp, Fe, x, m, F=None): """Lanczos resampling from grid Fe to points x. @@ -41,11 +51,13 @@ def _remap_lanczos(xp, Fe, x, m, F=None): ell = xp.floor(x).astype('int32') for i0 in range(-m, m + 1): kern0 = _lanczos(xp, ell[..., 0] + i0 - x[..., 0], m) + i0 = _nearest(xp, ell[..., 0] + i0, n[0]) for i1 in range(-m, m + 1): kern1 = _lanczos(xp, ell[..., 1] + i1 - x[..., 1], m) + i1 = _nearest(xp, ell[..., 1] + i1, n[1]) # Indexing Fe here causes problems for a stack of images - F += Fe[(ell[..., 0] + i0) % n[0], - (ell[..., 1] + i1) % n[1]] * kern0 * kern1 + F += Fe[i0, i1] * kern0 * kern1 + return F From bfeca3e04b48c4adc9bd05b3da24cc30c0692282 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 18 Sep 2020 21:38:35 -0500 Subject: [PATCH 027/109] NEW: Implement correct adjoint Flow operator --- src/tike/operators/cupy/flow.py | 98 ++++++++++------ src/tike/operators/cupy/interp.cu | 182 ++++++++++++++++++++++++++++++ tests/operators/test_flow.py | 46 +++++--- 3 files changed, 274 insertions(+), 52 deletions(-) create mode 100644 src/tike/operators/cupy/interp.cu diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index 3ce8168c..0ae5183d 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -1,64 +1,55 @@ __author__ = "Daniel Ching, Viktor Nikitin" __copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." -from .operator import Operator - - -def _lanczos(xp, x, a): - return xp.sinc(x) * xp.sinc(x / a) - - -def _nearest(xp, i, n): - """Use nearest on-grid index at the edges.""" - return xp.maximum(xp.minimum(i, n), 0) +import cupy as cp +from importlib_resources import files +from .operator import Operator -def _wrap(xp, i, n): - """Wrap indexes at the edges.""" - return i % n +_cu_source = files('tike.operators.cupy').joinpath('interp.cu').read_text() -def _remap_lanczos(xp, Fe, x, m, F=None): +def _remap_lanczos(Fe, x, m, F, fwd=True): """Lanczos resampling from grid Fe to points x. At the edges, the Lanczos filter wraps around. Parameters ---------- - xp : module - The array module for this implementation Fe : (H, W) The function at equally spaced samples. x : (N, 2) float32 The non-uniform sample positions on the grid. m : int > 0 The Lanczos filter is 2m + 1 wide. - - Returns - ------- F : (N, ) The values at the non-uniform samples. """ - # NOTE: This irregular convolution is very similar to the gather function - # from usfft assert Fe.ndim == 2 assert x.ndim == 2 and x.shape[-1] == 2 assert m > 0 - F = xp.zeros(x.shape[:-1], dtype=Fe.dtype) if F is None else F assert F.shape == x.shape[:-1], F.dtype == Fe.dtype - n = Fe.shape[-2:] - # ell is the integer center of the kernel - ell = xp.floor(x).astype('int32') - for i0 in range(-m, m + 1): - kern0 = _lanczos(xp, ell[..., 0] + i0 - x[..., 0], m) - i0 = _nearest(xp, ell[..., 0] + i0, n[0]) - for i1 in range(-m, m + 1): - kern1 = _lanczos(xp, ell[..., 1] + i1 - x[..., 1], m) - i1 = _nearest(xp, ell[..., 1] + i1, n[1]) - # Indexing Fe here causes problems for a stack of images - F += Fe[i0, i1] * kern0 * kern1 - - return F + assert Fe.dtype == 'complex64' + assert F.dtype == 'complex64' + assert x.dtype == 'float32' + lanczos_width = 2 * m + 1 + + if fwd: + kernel = cp.RawKernel(_cu_source, "fwd_lanczos_interp2D") + else: + kernel = cp.RawKernel(_cu_source, "adj_lanczos_interp2D") + + grid = (-(-lanczos_width**2 // kernel.max_threads_per_block), 1, + min(x.shape[0], 65535)) + block = (min(kernel.max_threads_per_block, lanczos_width**2),) + kernel(grid, block, ( + Fe, + cp.array(Fe.shape, dtype='int32'), + F, + x, + len(x), + lanczos_width, + )) class Flow(Operator): @@ -82,6 +73,7 @@ def fwd(self, f, flow, filter_size=5): The width of the Lanczos filter. Automatically rounded up to an odd positive integer. """ + assert f.shape == flow.shape[:-1] # Convert from displacements to coordinates h, w = flow.shape[-3:-1] coords = -flow.copy() @@ -96,6 +88,40 @@ def fwd(self, f, flow, filter_size=5): a = max(0, (filter_size) // 2) for i in range(len(f)): - _remap_lanczos(self.xp, f[i], coords[i], a, g[i]) + _remap_lanczos(f[i], coords[i], a, g[i]) return g.reshape(shape) + + def adj(self, g, flow, filter_size=5): + """Remap individual pixels of f with Lanczos filtering. + + Parameters + ---------- + g (..., H, W) complex64 + A stack of deformed arrays. + flow (..., H, W, 2) float32 + The displacements to be applied to each pixel along the last two + dimensions. + filter_size : int + The width of the Lanczos filter. Automatically rounded up to an + odd positive integer. + """ + f = self.xp.zeros_like(g) + assert f.shape == flow.shape[:-1] + # Convert from displacements to coordinates + h, w = flow.shape[-3:-1] + coords = -flow.copy() + coords[..., 0] += self.xp.arange(h)[:, None] + coords[..., 1] += self.xp.arange(w) + + # Reshape into stack of 2D images + shape = f.shape + coords = coords.reshape(-1, h * w, 2) + f = f.reshape(-1, h, w) + g = g.reshape(-1, h * w) + + a = max(0, (filter_size) // 2) + for i in range(len(f)): + _remap_lanczos(f[i], coords[i], a, g[i], fwd=False) + + return f.reshape(shape) diff --git a/src/tike/operators/cupy/interp.cu b/src/tike/operators/cupy/interp.cu new file mode 100644 index 00000000..24acfd52 --- /dev/null +++ b/src/tike/operators/cupy/interp.cu @@ -0,0 +1,182 @@ +// Cannot use complex types because of atomicAdd() +// #include + +// n % d, but the sign always matches the divisor (d) +__device__ int +mod(int n, int d) { + return ((n % d) + d) % d; +} + +// power function for integers and exponents >= 0 +__device__ int +pow(int b, int e) { + assert(e >= 0); + int result = 1; + for (int i = 0; i < e; i++) { + result *= b; + } + return result; +} + +// Convert a 1d coordinate (d) where s is the max 1d coordinate to nd +// coordinates (nd) for a grid with diameter along all dimensions and centered +// on the origin. +__device__ void +_1d_to_nd(int* nd, int ndim, int d, int s, int diameter, const int* origin) { + assert(0 <= d && d < s); + int radius = diameter / 2; + for (int dim = 0; dim < ndim; dim++) { + s /= diameter; + nd[dim] = d / s - radius + origin[dim]; + d = d % s; + } +} + +__device__ int +nearest(int ndim, int* x, const int* limit) { + for (int dim = 0; dim < ndim; dim++) { + x[dim] = min(max(0, x[dim]), limit[dim] - 1); + } +} + +__device__ int +wrap(int ndim, int* x, const int* limit) { + for (int dim = 0; dim < ndim; dim++) { + x[dim] = mod(x[dim], limit[dim]); + } +} + +// Convert an Nd coordinate (nd) from a grid with given shape a 1d linear +// coordinate. +__device__ int +_nd_to_1d(int ndim, const int* nd, const int* shape) { + int linear = 0; + int stride = 1; + for (int dim = ndim - 1; dim >= 0; dim--) { + assert(shape[dim] > 0); + assert(0 <= nd[dim] && nd[dim] < shape[dim]); + linear += nd[dim] * stride; + stride *= shape[dim]; + } + assert(linear >= 0); + return linear; +} + +typedef float +kernel_function(int ndim, const float* center, const int* point); + +// The two lobe lanczos kernel +__device__ float +_lanczos2(float x) { + if (x == 0.0f) { + return 1.0f; + } else if (fabsf(x) <= 2.0f) { + // printf("distance: %f\n", x); + const float pix = x * 3.141592653589793238462643383279502884f; + return 2.0f * sin(pix) * sin(pix * 0.5f) / (pix * pix); + } else { + return 0.0f; + } +} + +// Return the lanczos kernel weight for the given kernel center and point. +__device__ float +lanczos_kernel(int ndim, const float* center, const int* point) { + float weight = 1.0f; + for (int dim = 0; dim < ndim; dim++) { + weight *= _lanczos2(center[dim] - (float)point[dim]); + } + return weight; +} + +typedef void +scatterOrGather(float2*, int, float2*, int, float weight); + +// Many uniform grid points are collected to one nonuniform point. This is +// linear interpolation, smoothing, etc. +__device__ void +gather(float2* grid, int gi, float2* points, int pi, float weight) { + atomicAdd(&points[pi].x, grid[gi].x * weight); + atomicAdd(&points[pi].y, grid[gi].y * weight); +} + +// One nonuniform point is spread to many uniform grid points. This is the +// adjoint operation. +__device__ void +scatter(float2* grid, int gi, float2* points, int pi, float weight) { + atomicAdd(&grid[gi].x, points[pi].x * weight); + atomicAdd(&grid[gi].y, points[pi].y * weight); +} + +// grid shape (-(-diameter^ndim // max_threads), 0, nf) +// block shape (min(diameter^ndim, max_threads), 0, 0) +__device__ void +_loop_over_kernels(int ndim, // number of dimensions + kernel_function get_weight, scatterOrGather operation, + float2* grid, // values on uniform grid + const int* gshape, // dimensions of uniform grid + float2* points, // values at nonuniform points + const float* x, // coordinates of nonuniform points + const int nx, // the number of nonuniform points + const int diameter // kernel diameter, should be odd? +) { + assert(grid != NULL); + assert(gshape != NULL); + assert(points != NULL); + assert(x != NULL); + assert(nx >= 0); + assert(diameter > 0); + const int max_dim = 3; + assert(0 < ndim && ndim <= max_dim); + + const int nk = pow(diameter, ndim); // number of grid positions in kernel + + // nonuniform position index (xi) + for (int xi = blockIdx.z; xi < nx; xi += gridDim.z) { + // closest ND grid coord to point center of kernel + int center[max_dim]; + for (int dim = 0; dim < ndim; dim++) { + center[dim] = int(floor(x[ndim * xi + dim])); + } + // linear intra-kernel index (ki) + // clang-format off + for ( + int ki = threadIdx.x + blockDim.x * blockIdx.x; + ki < nk; + ki += blockDim.x * gridDim.x + ) { + // clang-format on + // Convert linear intra-kernel index to ND grid coord (knd) + int knd[max_dim]; + _1d_to_nd(knd, ndim, ki, nk, diameter, center); + + // Weights are computed from correct distance... + const float weight = get_weight(ndim, &x[ndim * xi], knd); + + // ... but for values outside the grid we wrap around so that all of the + // values are valid. + wrap(ndim, knd, gshape); + + // Convert ND grid coord to linear grid coord + const int gi = _nd_to_1d(ndim, knd, gshape); + + operation(grid, gi, points, xi, weight); + } + } +} + +extern "C" __global__ void +fwd_lanczos_interp2D(float2* grid, const int* grid_shape, float2* points, + const float* x, int num_points, int diameter + +) { + _loop_over_kernels(2, lanczos_kernel, gather, grid, grid_shape, points, x, + num_points, diameter); +} + +extern "C" __global__ void +adj_lanczos_interp2D(float2* grid, const int* grid_shape, float2* points, + const float* x, int num_points, int diameter) { + _loop_over_kernels(2, lanczos_kernel, scatter, grid, grid_shape, points, x, + num_points, diameter); +} diff --git a/tests/operators/test_flow.py b/tests/operators/test_flow.py index b1bee20b..92dd4c4f 100644 --- a/tests/operators/test_flow.py +++ b/tests/operators/test_flow.py @@ -21,42 +21,56 @@ def setUp(self, n=16, nz=17, ntheta=8): self.n = n self.nz = nz self.ntheta = ntheta + + np.random.seed(0) + self.original = random_complex(self.ntheta, self.nz, self.n) + self.data = random_complex(*self.original.shape) + self.shift = (np.random.rand(*self.original.shape, 2) - 0.5) * 16 + print(Flow) def test_adjoint(self): """Check that the adjoint operator is correct.""" - np.random.seed(0) - shift = np.empty([self.ntheta, self.nz, self.n, 2]) - # Apparently, this test only passes for integer shifts - shift[:, :, :, :] = np.round( - np.random.random([self.ntheta, 1, 1, 2]) * 5) - original = random_complex(self.ntheta, self.nz, self.n) - data = random_complex(*original.shape) - with Flow() as op: - shift = op.asarray(shift, dtype='float32') - original = op.asarray(original, dtype='complex64') - data = op.asarray(data, dtype='complex64') + shift = op.asarray(self.shift, dtype='float32') + original = op.asarray(self.original, dtype='complex64') + data = op.asarray(self.data, dtype='complex64') d = op.fwd(original, shift) - o = op.fwd(data, -shift) - original1 = op.fwd(d, -shift) + o = op.adj(data, shift) a = inner_complex(d, data) b = inner_complex(original, o) - e = np.linalg.norm(original - original1) / np.linalg.norm(original) print() print(' = {:.6f}{:+.6f}j'.format(a.real.item(), a.imag.item())) print('< u, S*a> = {:.6f}{:+.6f}j'.format(b.real.item(), b.imag.item())) - print(f'|u - S*Su| / |u|= {e.item():.6f}') - # Test whether Adjoint fixed probe operator is correct op.xp.testing.assert_allclose(a.real, b.real, rtol=1e-5) op.xp.testing.assert_allclose(a.imag, b.imag, rtol=1e-5) + def test_normalized(self): + """Check that the adjoint operator is normalized.""" + with Flow() as op: + + shift = op.asarray(self.shift, dtype='float32') + original = op.asarray(self.original, dtype='complex64') + + d = op.fwd(op.fwd(original, shift), -shift) + + a = inner_complex(d, d) + b = inner_complex(original, original) + print() + print(' = {:.6f}{:+.6f}j'.format( + a.real.item(), a.imag.item())) + print('< u, u> = {:.6f}{:+.6f}j'.format( + b.real.item(), b.imag.item())) + + # op.xp.testing.assert_allclose(a.real, b.real, rtol=1e-5) + # op.xp.testing.assert_allclose(a.imag, b.imag, rtol=1e-5) + if __name__ == '__main__': unittest.main() From ba7c4ee304c2f526087e90d8211ad70033e61425 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Mon, 21 Sep 2020 18:33:45 -0500 Subject: [PATCH 028/109] REF: Autoselect operator in align.reconstruct() --- src/tike/align/align.py | 10 ++++++++-- src/tike/align/solvers/cgrad.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/tike/align/align.py b/src/tike/align/align.py index d6b40635..04cce7cc 100644 --- a/src/tike/align/align.py +++ b/src/tike/align/align.py @@ -43,7 +43,7 @@ def reconstruct( original, unaligned, algorithm, - num_iter=1, rtol=-1, **kwargs + num_iter=1, rtol=-1, flow=None, **kwargs ): # yapf: disable """Solve the alignment problem; returning either the original or the shift. @@ -59,12 +59,17 @@ def reconstruct( """ if algorithm in solvers.__all__: + if flow is not None and flow.shape == (*original.shape, 2): + Operator = Flow + else: + Operator = Shift # Initialize an operator. - with Shift() as operator: + with Operator() as operator: # send any array-likes to device unaligned = operator.asarray(unaligned, dtype='complex64') original = operator.asarray(original, dtype='complex64') + flow = operator.asarray(flow, dtype='float32') result = {} for key, value in kwargs.items(): if np.ndim(value) > 0: @@ -80,6 +85,7 @@ def reconstruct( original=original, unaligned=unaligned, num_iter=num_iter, + flow=flow, **kwargs, ) diff --git a/src/tike/align/solvers/cgrad.py b/src/tike/align/solvers/cgrad.py index c311d0f8..4ea5ae36 100644 --- a/src/tike/align/solvers/cgrad.py +++ b/src/tike/align/solvers/cgrad.py @@ -24,7 +24,7 @@ def cost_function(original): def grad(original): return ( - op.fwd(op.fwd(original, flow) - unaligned, -flow) + + op.adj(op.fwd(original, flow) - unaligned, flow) + (original - reg) ) From 360aa090ece98c1410e19c52eebd36bb5ec60c22 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 22 Sep 2020 13:32:08 -0500 Subject: [PATCH 029/109] admm ptycho-align-lamino with penalty updates --- src/tike/admm.py | 541 ++++++++++++++++++---------- src/tike/align/solvers/cgrad.py | 16 +- src/tike/ptycho/ptycho.py | 6 +- src/tike/ptycho/solvers/combined.py | 10 +- 4 files changed, 363 insertions(+), 210 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index b4c067ad..eb251729 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -19,6 +19,7 @@ def update_penalty(psi, h, h0, rho): rho *= 2 elif (s > 10 * r): rho *= 0.5 + logging.info(f"Update penalty parameter ρ = {rho}.") return rho @@ -37,7 +38,18 @@ def find_min_max(data): return mmin, mmax -def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): +def lamino_align( + data, + psi, + scan, + probe, + theta, + tilt, + u=None, + flow=None, + niter=1, + folder=None, +): """Solve the joint lamino-alignment problem using ADMM. Parameters @@ -53,110 +65,218 @@ def lamino_align(data, tilt, theta, u=None, flow=None, niter=8, rho=0.5): flow : (ntheta, detector, detector, 2) float32 An initial guess for the alignmnt displacement field. """ - ntheta, _, det = data.shape - u = np.zeros([det, det, det], dtype='complex64') if u is None else u - lamd = np.zeros([ntheta, det, det], dtype='float32') - flow = np.zeros([ntheta, det, det, 2], - dtype='float32') if flow is None else flow - - psi = data.copy() - h0 = psi.copy() - # Start with large winsize and decrease each ADMM iteration. - winsize = min(*data.shape[1:]) - mmin, mmax = find_min_max(data.real) - - for k in range(niter): - - logging.info("Find flow using farneback.") - result = tike.align.solvers.farneback( - op=None, - unaligned=data, - original=psi, - flow=flow, - pyr_scale=0.5, - levels=1, - winsize=winsize, - num_iter=4, - hi=mmax, - lo=mmin, - ) - flow = result['shift'] - - logging.info("Recover original/aligned projections.") - result = tike.align.reconstruct( - unaligned=data, - original=psi, - flow=flow, - num_iter=4, - algorithm='cgrad', - reg=tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - ) + lamd / rho, - rho=rho, - ) - psi = result['original'] - - logging.info('Solve the laminography problem.') - result = tike.lamino.reconstruct( - data=psi - lamd / rho, - theta=theta, - tilt=tilt, + comm = MPICommunicator() + + all_theta = comm.gather(theta) + if comm.rank == 0: + np.save(f"{folder}/alltheta", all_theta) + + with cp.cuda.Device(comm.rank): + + logging.info("Solve the ptychography problem.") + + # presult = { + # 'psi': psi, + # 'scan': scan, + # 'probe': probe, + # } + + # presult = tike.ptycho.reconstruct( + # data=data, + # algorithm='combined', + # num_iter=niter, + # cg_iter=4, + # recover_psi=True, + # recover_probe=True, + # recover_positions=False, + # model='gaussian', + # **presult, + # ) + # psi = presult['psi'] + + # if comm.rank == 0: + # dxchange.write_tiff( + # presult['psi'].real, + # f'{folder}/psi-real-{(1):03d}.tiff', + # dtype='float32', + # ) + # dxchange.write_tiff( + # presult['psi'].imag, + # f'{folder}/psi-imag-{(1):03d}.tiff', + # dtype='float32', + # ) + + # logging.info("Rotate and crop projections.") + + # _, trimmed, _ = rotate_and_crop(psi.copy()) + + # Set preliminary values for ADMM + w = 256 + flow = np.zeros( + [len(theta), w, w, 2], + dtype='float32', + ) if flow is None else flow + winsize = w + + u = np.zeros( + [w, w, w], + dtype='complex64', + ) if u is None else u + phi = np.exp(1j * tike.lamino.simulate( obj=u, - algorithm='cgrad', - num_iter=1, - ) - u = result['obj'] - - logging.info('Update lambda and rho.') - h = tike.lamino.simulate(obj=u, theta=theta, tilt=tilt) - lamd = lamd + rho * (h - psi) - rho = update_penalty(psi, h, h0, rho) - h0 = h - - np.save(f"flow-tike-{(k+1):03d}", flow) - # np.save(f"flow-tike-v-{(k+1):03d}", vflow[..., ::-1]) - - # checking intermediate results - lagr = [ - np.linalg.norm((tike.align.simulate(psi, flow) - data))**2, - np.sum(np.real(np.conj(lamd) * (h - psi))), - rho * np.linalg.norm(h - psi)**2, - ] - print( - "k: {:03d}, ρ: {:.3e}, winsize: {:03d}, flow: {:.3e}, " - " lagrangian: {:.3e}, {:.3e}, {:.3e} = {:.3e}".format( - k, - rho, - winsize, - np.linalg.norm(flow), - *lagr, - np.sum(lagr), - ), - flush=True, - ) - - # Limit winsize to larger value. 20? - if winsize > 20: - winsize -= 1 - - if (k + 1) % 10 == 0: - dxchange.write_tiff( - np.imag(u), - f'particle-i-{(k+1):03d}.tiff', - dtype='float32', + tilt=tilt, + theta=theta, + )) + phi = phi.astype('complex64') + Hu = phi.copy() + Hu0 = Hu + + λ_a = np.zeros_like(phi) + rho = 0.5 + + all_trimmed = None + if comm.rank == 0: + all_trimmed = dxchange.read_tiff( + f'{folder}/trimmed-real-{(1):03d}.tiff' + ) + 1j * dxchange.read_tiff(f'{folder}/trimmed-imag-{(1):03d}.tiff') + all_trimmed = all_trimmed.astype('complex64') + trimmed = comm.scatter(all_trimmed) + + # all_trimmed = comm.gather(trimmed) + # if comm.rank == 0: + # dxchange.write_tiff( + # all_trimmed.real, + # f'{folder}/trimmed-real-{(1):03d}.tiff', + # dtype='float32', + # ) + # dxchange.write_tiff( + # all_trimmed.imag, + # f'{folder}/trimmed-imag-{(1):03d}.tiff', + # dtype='float32', + # ) + del all_trimmed + + for k in range(niter): + + logging.info("Recover original/aligned projections.") + + aresult = tike.align.reconstruct( + unaligned=trimmed, + original=phi, + flow=flow, + num_iter=4, + algorithm='cgrad', + reg=Hu + λ_a / rho, + rho=rho, ) - dxchange.write_tiff( - np.real(u), - f'particle-r-{(k+1):03d}.tiff', - dtype='float32', + phi = aresult['original'] + + logging.info("Find flow using farneback.") + + fresult = tike.align.solvers.farneback( + op=None, + unaligned=trimmed, + original=phi, + flow=flow, + pyr_scale=0.5, + levels=1, + winsize=winsize, + num_iter=4, ) + flow = fresult['shift'] + + # Gather all to one thread + λ_a, phi, theta = [comm.gather(x) for x in (λ_a, phi, theta)] + + if comm.rank == 0: + logging.info('Solve the laminography problem.') + + result = tike.lamino.reconstruct( + data=-1j * np.log(phi - λ_a / rho), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + ) + u = result['obj'] + + # We cannot reorder phi, theta without ruining correspondence + # with data, psi, etc, but we can reorder the saved array + order = np.argsort(theta) + dxchange.write_tiff( + phi[order].real, + f'{folder}/phi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + phi[order].imag, + f'{folder}/phi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + # Separate again to multiple threads + λ_a, phi, theta = [comm.scatter(x) for x in (λ_a, phi, theta)] + u = comm.broadcast(u) + + logging.info('Update lambda and rho.') + + CψDφ = tike.align.simulate(phi, flow) - trimmed + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + φHu = Hu - phi + λ_a = λ_a + rho * φHu + rho = update_penalty(phi, Hu, Hu0, rho) + Hu0 = Hu + + lagrangian = [ + [np.linalg.norm(CψDφ.ravel())**2], + [2 * np.real(λ_a.conj() * φHu)], + [rho * np.linalg.norm(φHu.ravel())**2], + ] + lagrangian = [comm.gather(x) for x in lagrangian] + + if comm.rank == 0: + lagrangian = [np.sum(x) for x in lagrangian] + print( + f"k: {k:03d}, ρ: {rho:.3e}, winsize: {winsize:03d}, " + "Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e} {:+6.3e}".format( + np.sum(lagrangian), *lagrangian), + flush=True, + ) + dxchange.write_tiff( + Hu.real, + f'{folder}/Hu-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.imag, + f'{folder}/Hu-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + np.save(f"{folder}/flow-tike-{(k+1):03d}", flow) + + # Limit winsize to larger value. 20? + if winsize > 20: + winsize -= 1 return u -def rotate_and_crop(x, radius=128, angle=72.035): +def rotate_and_crop(x, corners=None, radius=128, angle=72.035): """Rotate x in two trailing dimensions then crop around center-of-mass. Parameters @@ -172,32 +292,38 @@ def rotate_and_crop(x, radius=128, angle=72.035): preserve_range=True, resize=False, ) - corner = np.zeros((len(x), 2), dtype=int) + corners = -np.ones((len(x), 2), dtype=int) if corners is None else corners patch = np.zeros((len(x), 2 * radius, 2 * radius), dtype='complex64') for i in range(len(x)): # Rotate by desired angle (degrees) x[i].real = skimage.transform.rotate(x[i].real, **rotate_params, cval=1) x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params, cval=0) - # Find the center of mass - phase = np.angle(x[i]) - phase[phase < 0] = 0 - M = skimage.measure.moments(phase, order=1) - center = np.array([M[1, 0] / M[0, 0], M[0, 1] / M[0, 0]]).astype('int') - - # Adjust the cropping region so it stays within the image - lo = np.fmax(0, center - radius) - hi = lo + 2 * radius - shift = np.fmin(0, x[i].shape - hi) - hi += shift - lo += shift - assert np.all(lo >= 0), lo - assert np.all(hi <= x[i].shape), (hi, x[i].shape) + if corners[i][0] < 0: + # Find the center of mass + phase = np.angle(x[i]) + phase[phase < 0] = 0 + M = skimage.measure.moments(phase, order=1) + center = np.array([M[1, 0] / M[0, 0], + M[0, 1] / M[0, 0]]).astype('int') + + # Adjust the cropping region so it stays within the image + lo = np.fmax(0, center - radius) + hi = lo + 2 * radius + shift = np.fmin(0, x[i].shape - hi) + hi += shift + lo += shift + assert np.all(lo >= 0), lo + assert np.all(hi <= x[i].shape), (hi, x[i].shape) + corners[i] = lo + else: + lo = corners[i] + hi = corners[i] + 2 * radius + # Crop image patch[i] = x[i][lo[0]:hi[0], lo[1]:hi[1]] - corner[i] = lo - return x, patch, corner + return x, patch, corners def uncrop_and_rotate(x, patch, lo, radius=128, angle=-72.035): @@ -231,32 +357,40 @@ def ptycho_lamino_align( """Solve the joint ptycho-lamino-alignment problem using ADMM.""" comm = MPICommunicator() with cp.cuda.Device(comm.rank): - # Set initial values for intermediate variables + # Set preliminary values for ADMM w = 256 + flow = np.zeros( + [len(theta), w, w, 2], + dtype='float32', + ) if flow is None else flow + winsize = w + u = np.zeros( [w, w, w], dtype='complex64', ) if u is None else u - winsize = min(*u.shape[:2]) - - # data, psi, scan, probe, theta = [ - # comm.scatter(x) for x in (data, psi, scan, probe, theta) - # ] + phi = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + phi = phi.astype('complex64') + Hu = phi.copy() + Hu0 = Hu + Dφ0 = phi - flow = np.zeros( - [len(theta), 2], #w, w, 2], - dtype='float32', - ) if flow is None else flow presult = { # ptychography result 'psi': psi, 'scan': scan, 'probe': probe, } - phi = np.exp(1j * tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) - phi = phi.astype('complex64') + + corners = None + reg_p = None λ_p = np.zeros_like(phi) + ρ_p = 0.5 λ_a = np.zeros_like(phi) - reg_p = np.zeros_like(psi) + ρ_a = 0.5 for k in range(niter): logging.info(f"Start ADMM iteration {k}.") @@ -268,15 +402,15 @@ def ptycho_lamino_align( # value of the cropping corner locations reg_p = uncrop_and_rotate( psi_rotated, - # λ_p / 0.5 - tike.align.simulate(phi, flow), - λ_p / 0.5 - Hu, + λ_p / ρ_p - Dφ, corners, ) presult = tike.ptycho.reconstruct( data=data, reg=reg_p, + rho=ρ_p, algorithm='combined', - num_iter=, + num_iter=1, cg_iter=4, recover_psi=True, recover_probe=True, @@ -287,53 +421,46 @@ def ptycho_lamino_align( psi = presult['psi'] logging.info("Rotate and crop projections.") - psi_rotated, trimmed, corners = rotate_and_crop(psi.copy()) - - # logging.info("Recover aligned projections from unaligned.") - # aresult = tike.align.reconstruct( - # unaligned=trimmed + λ_p / 0.5, - # original=phi, - # flow=flow, - # num_iter=4, - # algorithm='cgrad', - # reg=np.exp(1j * tike.lamino.simulate( - # obj=u, - # tilt=tilt, - # theta=theta, - # )) - λ_a / 0.5, - # ) - # phi = aresult['original'] - phi = trimmed - - # logging.info("Estimate alignment using Farneback.") - # aresult = tike.align.solvers.farneback( - # op=None, - # unaligned=trimmed + λ_p / 0.5, - # original=phi, - # flow=flow, - # pyr_scale=0.5, - # levels=1, - # winsize=winsize, - # num_iter=4, - # ) - - # logging.info("Estimate alignment using cross correlation.") - # aresult = tike.align.reconstruct( - # unaligned=trimmed + λ_p / 0.5, - # original=phi, - # flow=flow, - # algorithm='cross_correlation', - # upsample_factor=10, - # ) - # flow = aresult['shift'] + psi_rotated, trimmed, corners = rotate_and_crop(psi.copy(), corners) + + logging.info("Recover aligned projections from unaligned.") + aresult = tike.align.reconstruct( + unaligned=trimmed + λ_p / ρ_p, + original=phi, + flow=flow, + num_iter=4, + algorithm='cgrad', + reg=np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) - λ_a / ρ_a, + rho_p=ρ_p, + rho_a=ρ_a, + ) + phi = aresult['original'] + + logging.info("Estimate alignment using Farneback.") + aresult = tike.align.solvers.farneback( + op=None, + unaligned=trimmed + λ_p / ρ_p, + original=phi, + flow=flow, + pyr_scale=0.5, + levels=1, + winsize=winsize, + num_iter=4, + ) # Gather all to one thread - λ_a, phi, theta = [comm.gather(x) for x in (λ_a, phi, theta)] + λ_a, phi, theta, Hu_ = [ + comm.gather(x) for x in (λ_a, phi, theta, Hu) + ] if comm.rank == 0: logging.info('Solve the laminography problem.') lresult = tike.lamino.reconstruct( - data=-1j * np.log(phi + λ_a / 0.5), + data=-1j * np.log(phi + λ_a / ρ_a), theta=theta, tilt=tilt, obj=u, @@ -356,6 +483,16 @@ def ptycho_lamino_align( f'{folder}/phi-imag-{(k+1):03d}.tiff', dtype='float32', ) + dxchange.write_tiff( + Hu_[order].real, + f'{folder}/Hu-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu_[order].imag, + f'{folder}/Hu-imag-{(k+1):03d}.tiff', + dtype='float32', + ) # Separate again to multiple threads λ_a, phi, theta = [comm.scatter(x) for x in (λ_a, phi, theta)] @@ -363,39 +500,53 @@ def ptycho_lamino_align( logging.info('Update lambdas and rhos.') - Hu = np.exp(1j * - tike.lamino.simulate(obj=u, tilt=tilt, theta=theta)) + Dφ = tike.align.simulate(phi, flow) + CψDφ = trimmed - Dφ + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) φHu = phi - Hu - CψDφ = φHu #trimmed - tike.align.simulate(phi, flow) + λ_p += ρ_p * CψDφ + λ_a += ρ_a * φHu - λ_a += 0.5 * φHu - λ_p += 0.5 * CψDφ + ρ_p = update_penalty(phi, Hu, Hu0, ρ_p) + ρ_a = update_penalty(trimmed, Dφ, Dφ0, ρ_a) + Hu0 = Hu + Dφ0 = Dφ lagrangian = ( [presult['cost']], - 2 * np.real(λ_p.conj() * CψDφ) + - 0.5 * np.linalg.norm(CψDφ.ravel())**2, - 2 * np.real(λ_a.conj() * φHu) + - 0.5 * np.linalg.norm(φHu.ravel())**2, + [ + 2 * np.real(λ_p.conj() * CψDφ) + + ρ_p * np.linalg.norm(CψDφ.ravel())**2 + ], + [ + 2 * np.sum(np.real(λ_a.conj() * φHu)) + + ρ_a * np.linalg.norm(φHu.ravel())**2 + ], ) - lagrangian = [comm.gather(x) for x in lagrangian] - # Limit winsize to larger value. 20? - if winsize > 20: - winsize -= 1 - - if (k + 1) % 1 == 0 and comm.rank == 0: + if comm.rank == 0: lagrangian = [np.sum(x) for x in lagrangian] print( - 'Lagrangian = {:+6.3e} = {:+6.3e} {:+6.3e} {:+6.3e}'.format( + f"k: {k:03d}, ρ_p: {ρ_p:6.3e}, ρ_a: {ρ_a:6.3e}, " + f"winsize: {winsize:03d}, " + 'Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e} {:+6.3e}'.format( np.sum(lagrangian), *lagrangian), - flush=True) - + flush=True, + ) dxchange.write_tiff( - skimage.restoration.unwrap_phase(np.angle( - presult['psi'])).astype('float32'), - f'{folder}/object-phase-{(k+1):03d}.tiff', + presult['psi'].real, + f'{folder}/psi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + presult['psi'].imag, + f'{folder}/psi-imag-{(k+1):03d}.tiff', + dtype='float32', ) dxchange.write_tiff( u.real, @@ -409,5 +560,9 @@ def ptycho_lamino_align( ) np.save(f"{folder}/flow-tike-{(k+1):03d}", flow) + # Limit winsize to larger value. 20? + if winsize > 20: + winsize -= 1 + result = presult return result diff --git a/src/tike/align/solvers/cgrad.py b/src/tike/align/solvers/cgrad.py index 4ea5ae36..53147c40 100644 --- a/src/tike/align/solvers/cgrad.py +++ b/src/tike/align/solvers/cgrad.py @@ -11,24 +11,20 @@ def cgrad( unaligned, flow, num_iter=4, - reg=0, rho=0, + reg=0, rho_p=1, rho_a=0, **kwargs ): # yapf: disable """Recover an undistorted image from a given flow.""" def cost_function(original): - return ( - 0.5 * op.xp.linalg.norm((op.fwd(original, flow) - unaligned).ravel())**2 + - 0.5 * op.xp.linalg.norm((original - reg).ravel())**2 - ) + return (rho_p * op.xp.linalg.norm( + (op.fwd(original, flow) - unaligned).ravel(),)**2 + + rho_a * op.xp.linalg.norm((original - reg).ravel())**2) def grad(original): - return ( - op.adj(op.fwd(original, flow) - unaligned, flow) + - (original - reg) - ) + return (rho_p * op.adj(op.fwd(original, flow) - unaligned, flow) + + rho_a * (original - reg)) - cost = 0 original, cost = conjugate_gradient( op.xp, x=original, diff --git a/src/tike/ptycho/ptycho.py b/src/tike/ptycho/ptycho.py index d525e43a..15aa3cf9 100644 --- a/src/tike/ptycho/ptycho.py +++ b/src/tike/ptycho/ptycho.py @@ -187,9 +187,9 @@ def reconstruct( if np.ndim(value) > 0: kwargs[key] = pool.bcast(value) - result['probe'] = _rescale_obj_probe(operator, pool, data, - result['psi'], result['scan'], - result['probe']) + # result['probe'] = _rescale_obj_probe(operator, pool, data, + # result['psi'], result['scan'], + # result['probe']) cost = 0 for i in range(num_iter): diff --git a/src/tike/ptycho/solvers/combined.py b/src/tike/ptycho/solvers/combined.py index 1330f215..0a6fa4ae 100644 --- a/src/tike/ptycho/solvers/combined.py +++ b/src/tike/ptycho/solvers/combined.py @@ -11,7 +11,7 @@ def combined( op, pool, - data, probe, scan, psi, rho=0, reg=[0], + data, probe, scan, psi, rho=0, reg=None, recover_psi=True, recover_probe=True, recover_positions=False, cg_iter=4, **kwargs @@ -105,8 +105,9 @@ def cost_function_multi(psi, **kwargs): cost_cpu = 0 for c in cost_out: cost_cpu += op.asnumpy(c) - cost_cpu += op.asnumpy(0.5 * op.xp.linalg.norm( - (psi[0] + reg[0]).ravel())**2) + if reg is not None: + cost_cpu += op.asnumpy(rho * op.xp.linalg.norm( + (psi[0] + reg[0]).ravel())**2) return cost_cpu def grad_multi(psi): @@ -117,7 +118,8 @@ def grad_multi(psi): grad_cpu_tmp = op.asnumpy(grad_list[i]) grad_tmp = op.asarray(grad_cpu_tmp) grad_list[0] += grad_tmp - grad_list[0] += 0.5 * (psi[0] + reg[0]) + if reg is not None: + grad_list[0] += rho * (psi[0] + reg[0]) return grad_list[0] def dir_multi(dir): From 2b5cc5d7b86dc1be64f5d598f7f62f9509cb852b Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 22 Sep 2020 17:04:45 -0500 Subject: [PATCH 030/109] TST: Add normalization test to operators tests Because: - We want to know not only if the adjoint operator is actually adjoint but also if it preserves the magnitude of the original --- src/tike/operators/cupy/shift.py | 3 ++ tests/operators/test_flow.py | 72 ++++++++--------------------- tests/operators/test_lamino.py | 60 ++++++++---------------- tests/operators/test_propagation.py | 59 +++++++++-------------- tests/operators/test_shift.py | 56 ++++++++-------------- tests/operators/util.py | 51 ++++++++++++++++++++ 6 files changed, 131 insertions(+), 170 deletions(-) diff --git a/src/tike/operators/cupy/shift.py b/src/tike/operators/cupy/shift.py index 30743312..5e8b8285 100644 --- a/src/tike/operators/cupy/shift.py +++ b/src/tike/operators/cupy/shift.py @@ -34,3 +34,6 @@ def fwd(self, a, shift, overwrite=False): padded *= shift padded = self._ifft2(padded, axes=(-2, -1), overwrite=overwrite) return padded[..., pz:-pz, pn:-pn].reshape(shape) + + def adj(self, a, shift, overwrite=False): + return self.fwd(a, -shift, overwrite=overwrite) diff --git a/tests/operators/test_flow.py b/tests/operators/test_flow.py index 92dd4c4f..96dd0ada 100644 --- a/tests/operators/test_flow.py +++ b/tests/operators/test_flow.py @@ -4,72 +4,38 @@ import unittest import numpy as np - from tike.operators import Flow -from .util import random_complex, inner_complex + +from .util import random_complex, OperatorTests __author__ = "Daniel Ching, Viktor Nikitin" __copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." __docformat__ = 'restructuredtext en' -class TestFlow(unittest.TestCase): +class TestFlow(unittest.TestCase, OperatorTests): """Test the Flow operator.""" def setUp(self, n=16, nz=17, ntheta=8): """Load a dataset for reconstruction.""" - self.n = n - self.nz = nz - self.ntheta = ntheta - - np.random.seed(0) - self.original = random_complex(self.ntheta, self.nz, self.n) - self.data = random_complex(*self.original.shape) - self.shift = (np.random.rand(*self.original.shape, 2) - 0.5) * 16 - - print(Flow) - - def test_adjoint(self): - """Check that the adjoint operator is correct.""" - with Flow() as op: - - shift = op.asarray(self.shift, dtype='float32') - original = op.asarray(self.original, dtype='complex64') - data = op.asarray(self.data, dtype='complex64') - d = op.fwd(original, shift) - o = op.adj(data, shift) + self.operator = Flow() + self.operator.__enter__() + self.xp = self.operator.xp - a = inner_complex(d, data) - b = inner_complex(original, o) - print() - print(' = {:.6f}{:+.6f}j'.format(a.real.item(), - a.imag.item())) - print('< u, S*a> = {:.6f}{:+.6f}j'.format(b.real.item(), - b.imag.item())) - - op.xp.testing.assert_allclose(a.real, b.real, rtol=1e-5) - op.xp.testing.assert_allclose(a.imag, b.imag, rtol=1e-5) - - def test_normalized(self): - """Check that the adjoint operator is normalized.""" - with Flow() as op: - - shift = op.asarray(self.shift, dtype='float32') - original = op.asarray(self.original, dtype='complex64') - - d = op.fwd(op.fwd(original, shift), -shift) - - a = inner_complex(d, d) - b = inner_complex(original, original) - print() - print(' = {:.6f}{:+.6f}j'.format( - a.real.item(), a.imag.item())) - print('< u, u> = {:.6f}{:+.6f}j'.format( - b.real.item(), b.imag.item())) - - # op.xp.testing.assert_allclose(a.real, b.real, rtol=1e-5) - # op.xp.testing.assert_allclose(a.imag, b.imag, rtol=1e-5) + np.random.seed(0) + self.m = self.xp.asarray(random_complex(ntheta, nz, n), + dtype='complex64') + self.m_name = 'f' + self.d = self.xp.asarray(random_complex(*self.m.shape), + dtype='complex64') + self.d_name = 'g' + self.kwargs = { + 'flow': + self.xp.asarray((np.random.rand(*self.m.shape, 2) - 0.5) * 16, + dtype='float32'), + } + print(self.operator) if __name__ == '__main__': diff --git a/tests/operators/test_lamino.py b/tests/operators/test_lamino.py index c017dddf..e15778a2 100644 --- a/tests/operators/test_lamino.py +++ b/tests/operators/test_lamino.py @@ -4,57 +4,35 @@ import unittest import numpy as np - -from .util import random_complex, inner_complex from tike.operators import Lamino +from .util import random_complex, OperatorTests + __author__ = "Daniel Ching, Viktor Nikitin" __copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." __docformat__ = 'restructuredtext en' -class TestLamino(unittest.TestCase): +class TestLamino(unittest.TestCase, OperatorTests): """Test the Laminography operator.""" - def setUp(self, n=16, ntheta=8, tilt=np.pi/3, eps=1e-3): - """Load a dataset for reconstruction.""" - self.n = n - self.ntheta = ntheta - self.theta = np.linspace(0, 2*np.pi, ntheta) - self.tilt = tilt - self.eps = eps - print(Lamino) - - def test_adjoint(self): - """Check that the adjoint operator is correct.""" + def setUp(self, n=16, ntheta=8, tilt=np.pi / 3, eps=1e-6): + self.operator = Lamino( + n=n, + theta=np.linspace(0, 2 * np.pi, ntheta), + tilt=tilt, + eps=eps, + ) + self.operator.__enter__() + self.xp = self.operator.xp np.random.seed(0) - obj = random_complex(self.n, self.n, self.n) - data = random_complex(self.ntheta, self.n, self.n) - - with Lamino( - n=self.n, - theta=self.theta, - tilt=self.tilt, - eps=self.eps - ) as op: - - obj = op.asarray(obj.astype('complex64')) - data = op.asarray(data.astype('complex64')) - - d = op.fwd(obj) - assert d.shape == data.shape - o = op.adj(data) - assert obj.shape == o.shape - a = inner_complex(d, data) - b = inner_complex(obj, o) - print() - print(' = {:.6f}{:+.6f}j'.format( - a.real.item(), a.imag.item())) - print(' = {:.6f}{:+.6f}j'.format( - b.real.item(), b.imag.item())) - # Test whether Adjoint fixed probe operator is correct - op.xp.testing.assert_allclose(a.real, b.real, rtol=1e-2) - op.xp.testing.assert_allclose(a.imag, b.imag, rtol=1e-2) + self.m = self.xp.asarray(random_complex(n, n, n), dtype='complex64') + self.m_name = 'u' + self.d = self.xp.asarray(random_complex(ntheta, n, n), + dtype='complex64') + self.d_name = 'data' + self.kwargs = {} + print(self.operator) if __name__ == '__main__': diff --git a/tests/operators/test_propagation.py b/tests/operators/test_propagation.py index bdfdfdc3..ac1a189c 100644 --- a/tests/operators/test_propagation.py +++ b/tests/operators/test_propagation.py @@ -4,55 +4,38 @@ import unittest import numpy as np - -from .util import random_complex, inner_complex from tike.operators import Propagation +from .util import random_complex, OperatorTests + __author__ = "Daniel Ching" __copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." __docformat__ = 'restructuredtext en' -class TestPropagation(unittest.TestCase): +class TestPropagation(unittest.TestCase, OperatorTests): """Test the Propagation operator.""" - def setUp(self): + def setUp(self, nwaves=13, probe_shape=127): """Load a dataset for reconstruction.""" - self.nwaves = 13 - self.probe_shape = 127 - self.detector_shape = self.probe_shape - print(Propagation) - - def test_adjoint(self): - """Check that the adjoint operator is correct.""" + self.operator = Propagation( + nwaves=nwaves, + detector_shape=probe_shape, + probe_shape=probe_shape, + ) + self.operator.__enter__() + self.xp = self.operator.xp np.random.seed(0) - nearplane = random_complex(self.nwaves, self.probe_shape, - self.probe_shape) - farplane = random_complex(self.nwaves, self.detector_shape, - self.detector_shape) - - with Propagation( - nwaves=self.nwaves, - detector_shape=self.detector_shape, - probe_shape=self.probe_shape, - ) as op: - nearplane = op.asarray(nearplane, dtype='complex64') - farplane = op.asarray(farplane, dtype='complex64') - - f = op.fwd(nearplane=nearplane,) - assert f.shape == farplane.shape - n = op.adj(farplane=farplane,) - assert nearplane.shape == n.shape - a = inner_complex(nearplane, n) - b = inner_complex(f, farplane) - print() - print('<ψ , F*Ψ> = {:.6f}{:+.6f}j'.format(a.real.item(), - a.imag.item())) - print(' = {:.6f}{:+.6f}j'.format(b.real.item(), - b.imag.item())) - # Test whether Adjoint fixed probe operator is correct - op.xp.testing.assert_allclose(a.real, b.real, rtol=1e-5) - op.xp.testing.assert_allclose(a.imag, b.imag, rtol=1e-5) + self.m = self.xp.asarray(random_complex(nwaves, probe_shape, + probe_shape), + dtype='complex64') + self.m_name = 'nearplane' + self.d = self.xp.asarray(random_complex(nwaves, probe_shape, + probe_shape), + dtype='complex64') + self.d_name = 'farplane' + self.kwargs = {} + print(self.operator) if __name__ == '__main__': diff --git a/tests/operators/test_shift.py b/tests/operators/test_shift.py index 022f4a3c..3d3364c7 100644 --- a/tests/operators/test_shift.py +++ b/tests/operators/test_shift.py @@ -4,55 +4,35 @@ import unittest import numpy as np - from tike.operators import Shift -from .util import random_complex, inner_complex + +from .util import random_complex, OperatorTests __author__ = "Daniel Ching, Viktor Nikitin" __copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." __docformat__ = 'restructuredtext en' -class TestShift(unittest.TestCase): +class TestShift(unittest.TestCase, OperatorTests): """Test the Shift operator.""" def setUp(self, n=16, nz=17, ntheta=8): - """Load a dataset for reconstruction.""" - self.n = n - self.nz = nz - self.ntheta = ntheta - print(Shift) - - def test_adjoint(self): - """Check that the adjoint operator is correct.""" + self.operator = Shift() + self.operator.__enter__() + self.xp = self.operator.xp np.random.seed(0) - shift = np.random.random([self.ntheta, 2]) - original = random_complex(self.ntheta, self.nz, self.n) - data = random_complex(*original.shape) - - with Shift() as op: - - shift = op.asarray(shift, dtype='float32') - original = op.asarray(original, dtype='complex64') - data = op.asarray(data, dtype='complex64') - - d = op.fwd(original, shift) - o = op.fwd(data, -shift) - original1 = op.fwd(d, -shift) - - a = inner_complex(d, data) - b = inner_complex(original, o) - e = np.linalg.norm(original - original1) / np.linalg.norm(original) - print() - print(' = {:.6f}{:+.6f}j'.format(a.real.item(), - a.imag.item())) - print('< u, S*a> = {:.6f}{:+.6f}j'.format(b.real.item(), - b.imag.item())) - print(f'|u - S*Su| / |u|= {e.item():.6f}') - - # Test whether Adjoint fixed probe operator is correct - op.xp.testing.assert_allclose(a.real, b.real, rtol=1e-5) - op.xp.testing.assert_allclose(a.imag, b.imag, rtol=1e-5) + self.m = self.xp.asarray(random_complex(ntheta, nz, n), + dtype='complex64') + self.m_name = 'a' + self.d = self.xp.asarray(random_complex(*self.m.shape), + dtype='complex64') + self.d_name = 'a' + self.kwargs = { + 'shift': + self.xp.asarray((np.random.random([ntheta, 2]) - 0.5) * 7, + dtype='float32') + } + print(self.operator) if __name__ == '__main__': diff --git a/tests/operators/util.py b/tests/operators/util.py index d7f56993..e4254dd5 100644 --- a/tests/operators/util.py +++ b/tests/operators/util.py @@ -1,5 +1,9 @@ import numpy as np +__author__ = "Daniel Ching, Viktor Nikitin" +__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + def random_complex(*args): """Return a complex random array in the range (-0.5, 0.5).""" @@ -9,3 +13,50 @@ def random_complex(*args): def inner_complex(x, y): """Return the complex inner product; the order of the operands matters.""" return (x.conj() * y).sum() + + +class OperatorTests(): + """Provide operator tests for correct adjoint and normalization.""" + + def setUp(self): + self.operator = None + self.operator.__enter__() + self.xp = self.operator.xp + np.random.seed(0) + self.m = None + self.m_name = '' + self.d = None + self.d_name = '' + self.kwargs = {} + print(self.operator) + raise NotImplementedError() + + def tearDown(self): + self.operator.__exit__(None, None, None) + + def test_adjoint(self): + """Check that the adjoint operator is correct.""" + d = self.operator.fwd(**{self.m_name: self.m}, **self.kwargs) + assert d.shape == self.d.shape + m = self.operator.adj(**{self.d_name: self.d}, **self.kwargs) + assert m.shape == self.m.shape + a = inner_complex(d, self.d) + b = inner_complex(self.m, m) + print() + print(' = {:.6f}{:+.6f}j'.format(a.real.item(), a.imag.item())) + print('< d, F*d> = {:.6f}{:+.6f}j'.format(b.real.item(), b.imag.item())) + self.xp.testing.assert_allclose(a.real, b.real, rtol=1e-5) + self.xp.testing.assert_allclose(a.imag, b.imag, rtol=1e-5) + + def test_normalized(self): + """Check that the adjoint operator is normalized.""" + d = self.operator.fwd(**{self.m_name: self.m}, **self.kwargs) + m = self.operator.adj(**{self.d_name: d}, **self.kwargs) + a = inner_complex(m, m) + b = inner_complex(self.m, self.m) + print() + print(' = {:.6f}{:+.6f}j'.format(a.real.item(), + a.imag.item())) + print('< m, m> = {:.6f}{:+.6f}j'.format(b.real.item(), + b.imag.item())) + # self.xp.testing.assert_allclose(a.real, b.real, rtol=1e-5) From afb75d42d9a349325d43dc1f1d2ece8111a69cc6 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 23 Sep 2020 13:14:55 -0500 Subject: [PATCH 031/109] Required regularization arguments. --- src/tike/align/solvers/cgrad.py | 19 +++++++++++++------ src/tike/ptycho/ptycho.py | 6 +++--- src/tike/ptycho/solvers/combined.py | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/tike/align/solvers/cgrad.py b/src/tike/align/solvers/cgrad.py index 53147c40..a567b7ba 100644 --- a/src/tike/align/solvers/cgrad.py +++ b/src/tike/align/solvers/cgrad.py @@ -10,20 +10,27 @@ def cgrad( original, unaligned, flow, + reg, rho_p, rho_a, num_iter=4, - reg=0, rho_p=1, rho_a=0, **kwargs ): # yapf: disable """Recover an undistorted image from a given flow.""" def cost_function(original): - return (rho_p * op.xp.linalg.norm( - (op.fwd(original, flow) - unaligned).ravel(),)**2 + - rho_a * op.xp.linalg.norm((original - reg).ravel())**2) + return ( + rho_p * op.xp.linalg.norm(( + unaligned - op.fwd(original, flow)).ravel() + )**2 + + rho_a * op.xp.linalg.norm(( + original - reg).ravel() + )**2 + ) def grad(original): - return (rho_p * op.adj(op.fwd(original, flow) - unaligned, flow) + - rho_a * (original - reg)) + return ( + rho_p * op.adj(op.fwd(original, flow) - unaligned, flow) + + rho_a * (original - reg) + ) original, cost = conjugate_gradient( op.xp, diff --git a/src/tike/ptycho/ptycho.py b/src/tike/ptycho/ptycho.py index 15aa3cf9..d525e43a 100644 --- a/src/tike/ptycho/ptycho.py +++ b/src/tike/ptycho/ptycho.py @@ -187,9 +187,9 @@ def reconstruct( if np.ndim(value) > 0: kwargs[key] = pool.bcast(value) - # result['probe'] = _rescale_obj_probe(operator, pool, data, - # result['psi'], result['scan'], - # result['probe']) + result['probe'] = _rescale_obj_probe(operator, pool, data, + result['psi'], result['scan'], + result['probe']) cost = 0 for i in range(num_iter): diff --git a/src/tike/ptycho/solvers/combined.py b/src/tike/ptycho/solvers/combined.py index 0a6fa4ae..d8ff0ad8 100644 --- a/src/tike/ptycho/solvers/combined.py +++ b/src/tike/ptycho/solvers/combined.py @@ -11,7 +11,7 @@ def combined( op, pool, - data, probe, scan, psi, rho=0, reg=None, + data, probe, scan, psi, rho, reg, recover_psi=True, recover_probe=True, recover_positions=False, cg_iter=4, **kwargs From db3903900f25c67a75d5b0cf226a8332a12ee865 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 23 Sep 2020 15:33:54 -0500 Subject: [PATCH 032/109] NEW: Allow setting lamino accuracy from reconstruct --- src/tike/lamino/lamino.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tike/lamino/lamino.py b/src/tike/lamino/lamino.py index d1ea1654..0d77a16d 100644 --- a/src/tike/lamino/lamino.py +++ b/src/tike/lamino/lamino.py @@ -88,7 +88,7 @@ def reconstruct( theta, tilt, algorithm, - obj=None, num_iter=1, rtol=-1, **kwargs + obj=None, num_iter=1, rtol=-1, eps=1e-3, **kwargs ): # yapf: disable """Solve the Laminography problem using the given `algorithm`. @@ -109,7 +109,7 @@ def reconstruct( n=obj.shape[-1], theta=theta, tilt=tilt, - eps=1e-3, + eps=eps, **kwargs, ) as operator: # send any array-likes to device From 034fa2ed28882d7e6283c7470da933888073303c Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 23 Sep 2020 15:40:00 -0500 Subject: [PATCH 033/109] NEW: Add gaussian kernel to interp.cu --- src/tike/operators/cupy/interp.cu | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/tike/operators/cupy/interp.cu b/src/tike/operators/cupy/interp.cu index 24acfd52..fff38a67 100644 --- a/src/tike/operators/cupy/interp.cu +++ b/src/tike/operators/cupy/interp.cu @@ -65,15 +65,14 @@ _nd_to_1d(int ndim, const int* nd, const int* shape) { typedef float kernel_function(int ndim, const float* center, const int* point); -// The two lobe lanczos kernel __device__ float -_lanczos2(float x) { +_lanczos(float x, float nlobes) { if (x == 0.0f) { return 1.0f; - } else if (fabsf(x) <= 2.0f) { + } else if (fabsf(x) <= nlobes) { // printf("distance: %f\n", x); const float pix = x * 3.141592653589793238462643383279502884f; - return 2.0f * sin(pix) * sin(pix * 0.5f) / (pix * pix); + return nlobes * sin(pix) * sin(pix / nlobes) / (pix * pix); } else { return 0.0f; } @@ -84,11 +83,24 @@ __device__ float lanczos_kernel(int ndim, const float* center, const int* point) { float weight = 1.0f; for (int dim = 0; dim < ndim; dim++) { - weight *= _lanczos2(center[dim] - (float)point[dim]); + weight *= _lanczos(center[dim] - (float)point[dim], 2.0f); } return weight; } +// Return the gaussian kernel weight for the given kernel center and point. +__device__ float +gaussian_kernel(int ndim, const float* center, const int* point) { + float weight = 0.0f; + const float two_sigma2 = 2.0f; // two times sigma^2 + const float pi = 3.141592653589793238462643383279502884f; + for (int dim = 0; dim < ndim; dim++) { + const float distance = (center[dim] - (float)point[dim]); + weight += distance * distance; + } + return expf(-weight / two_sigma2) / (pi * two_sigma2); +} + typedef void scatterOrGather(float2*, int, float2*, int, float weight); From 37674b5a909344cfb45551f940e9dff0c298fb26 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 24 Sep 2020 19:53:34 -0500 Subject: [PATCH 034/109] NEW: Alignment operator composed of rotate and pad --- src/tike/operators/cupy/__init__.py | 6 +++ src/tike/operators/cupy/alignment.py | 48 ++++++++++++++++++++ src/tike/operators/cupy/pad.py | 40 +++++++++++++++++ src/tike/operators/cupy/rotate.py | 65 ++++++++++++++++++++++++++++ tests/operators/test_alignment.py | 45 +++++++++++++++++++ tests/operators/test_pad.py | 44 +++++++++++++++++++ tests/operators/test_rotate.py | 53 +++++++++++++++++++++++ 7 files changed, 301 insertions(+) create mode 100644 src/tike/operators/cupy/alignment.py create mode 100644 src/tike/operators/cupy/pad.py create mode 100644 src/tike/operators/cupy/rotate.py create mode 100644 tests/operators/test_alignment.py create mode 100644 tests/operators/test_pad.py create mode 100644 tests/operators/test_rotate.py diff --git a/src/tike/operators/cupy/__init__.py b/src/tike/operators/cupy/__init__.py index 8720d6a8..ef85dbdc 100644 --- a/src/tike/operators/cupy/__init__.py +++ b/src/tike/operators/cupy/__init__.py @@ -5,21 +5,27 @@ launches and memory management may by accessed from Python. """ +from .alignment import * from .convolution import * from .flow import * from .lamino import * from .operator import * +from .pad import * from .propagation import * from .ptycho import * from .shift import * +from .rotate import * __all__ = ( + 'Alignment', 'Convolution', 'Flow', 'Lamino', 'Operator', + 'Pad', 'Propagation', 'Ptycho', + 'Rotate', 'Shift', # 'Tomo', ) diff --git a/src/tike/operators/cupy/alignment.py b/src/tike/operators/cupy/alignment.py new file mode 100644 index 00000000..20fcf064 --- /dev/null +++ b/src/tike/operators/cupy/alignment.py @@ -0,0 +1,48 @@ +"""Defines an alignment operator.""" + +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." + +import numpy as np + +from .operator import Operator +from .rotate import Rotate +from .pad import Pad + + +class Alignment(Operator): + """An alignment operator composed of pad and rotate operations.""" + + def __init__(self): + """Please see help(Alignment) for more info.""" + self.pad = Pad() + self.rotate = Rotate() + + def __enter__(self): + self.pad.__enter__() + self.rotate.__enter__() + return self + + def __exit__(self, type, value, traceback): + self.pad.__exit__(type, value, traceback) + self.rotate.__exit__(type, value, traceback) + + def fwd(self, unpadded, corner, padded_shape, angle, **kwargs): + return self.rotate.fwd( + unrotated=self.pad.fwd( + unpadded=unpadded, + corner=corner, + padded_shape=padded_shape, + ), + angle=angle, + ) + + def adj(self, rotated, corner, unpadded_shape, angle, **kwargs): + return self.pad.adj( + padded=self.rotate.adj( + rotated=rotated, + angle=angle, + ), + corner=corner, + unpadded_shape=unpadded_shape, + ) diff --git a/src/tike/operators/cupy/pad.py b/src/tike/operators/cupy/pad.py new file mode 100644 index 00000000..fa943254 --- /dev/null +++ b/src/tike/operators/cupy/pad.py @@ -0,0 +1,40 @@ +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + +import numpy as np + +from .flow import _remap_lanczos +from .operator import Operator + +class Pad(Operator): + """Pad a stack of 2D images to the same shape but with unique pad_widths. + """ + + def fwd(self, unpadded, corner, padded_shape, **kwargs): + assert np.all(np.asarray(unpadded.shape) <= padded_shape) + assert self.xp.all(corner >= 0) + # assert self.xp.all(corner + unpadded.shape[1:] <= padded_shape[1:]) + padded = self.xp.zeros(dtype=unpadded.dtype, shape=padded_shape) + for i in range(padded.shape[0]): + # yapf: disable + padded[ + i, + corner[i, 0]:corner[i, 0] + unpadded.shape[1], + corner[i, 1]:corner[i, 1] + unpadded.shape[2]] = unpadded[i] + # yapf: enable + return padded + + def adj(self, padded, corner, unpadded_shape, **kwargs): + assert np.all(np.asarray(unpadded_shape) <= padded.shape) + assert self.xp.all(corner >= 0) + # assert self.xp.all(corner + unpadded_shape[1:] <= padded.shape[1:]) + unpadded = self.xp.empty(dtype=padded.dtype, shape=unpadded_shape) + for i in range(unpadded.shape[0]): + # yapf: disable + unpadded[i] = padded[ + i, + corner[i, 0]:corner[i, 0] + unpadded.shape[1], + corner[i, 1]:corner[i, 1] + unpadded.shape[2]] + # yapf: enable + return unpadded diff --git a/src/tike/operators/cupy/rotate.py b/src/tike/operators/cupy/rotate.py new file mode 100644 index 00000000..61a9ddc1 --- /dev/null +++ b/src/tike/operators/cupy/rotate.py @@ -0,0 +1,65 @@ +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + +import numpy as np + +from .flow import _remap_lanczos +from .operator import Operator + + +class Rotate(Operator): + """Rotate a stack of 2D images along last two dimensions.""" + + def _make_grid(self, unrotated, angle): + """Return the points on the rotated grid.""" + cos, sin = np.cos(angle), np.sin(angle) + shifti = (unrotated.shape[-2] - 1) / 2.0 + shiftj = (unrotated.shape[-1] - 1) / 2.0 + + i, j = self.xp.mgrid[0:unrotated.shape[-2], + 0:unrotated.shape[-1]].astype('float32') + + i -= shifti + j -= shiftj + + i1 = (+cos * i + sin * j) + shifti + j1 = (-sin * i + cos * j) + shiftj + + return self.xp.stack([i1.ravel(), j1.ravel()], axis=-1) + + def fwd(self, unrotated, angle): + f = unrotated + g = self.xp.zeros_like(f) + + # Compute rotated coordinates + coords = self._make_grid(f, angle) + + # Reshape into stack of 2D images + shape = f.shape + h, w = shape[-2:] + f = f.reshape(-1, h, w) + g = g.reshape(-1, h * w) + + for i in range(len(f)): + _remap_lanczos(f[i], coords, 2, g[i], fwd=True) + + return g.reshape(shape) + + def adj(self, rotated, angle): + g = rotated + f = self.xp.zeros_like(g) + + # Compute rotated coordinates + coords = self._make_grid(f, angle) + + # Reshape into stack of 2D images + shape = f.shape + h, w = shape[-2:] + f = f.reshape(-1, h, w) + g = g.reshape(-1, h * w) + + for i in range(len(f)): + _remap_lanczos(f[i], coords, 2, g[i], fwd=False) + + return f.reshape(shape) diff --git a/tests/operators/test_alignment.py b/tests/operators/test_alignment.py new file mode 100644 index 00000000..169219db --- /dev/null +++ b/tests/operators/test_alignment.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import unittest + +import numpy as np +from tike.operators import Alignment + +from .util import random_complex, OperatorTests + +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + + +class TestAlignment(unittest.TestCase, OperatorTests): + """Test the Alignment operator.""" + + def setUp(self, shape=(7, 5, 5)): + """Load a dataset for reconstruction.""" + + self.operator = Alignment() + self.operator.__enter__() + self.xp = self.operator.xp + + padded_shape = shape + np.asarray((0, 41, 32)) + corner = self.xp.asarray(np.random.randint(0, 32, size=(shape[0], 2))) + + np.random.seed(0) + self.m = self.xp.asarray(random_complex(*shape), dtype='complex64') + self.m_name = 'unpadded' + self.d = self.xp.asarray(random_complex(*padded_shape), + dtype='complex64') + self.d_name = 'rotated' + self.kwargs = { + 'corner': corner, + 'padded_shape': padded_shape, + 'unpadded_shape': shape, + 'angle': np.random.rand() * 2 * np.pi, + } + print(self.operator) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/operators/test_pad.py b/tests/operators/test_pad.py new file mode 100644 index 00000000..ba700cb0 --- /dev/null +++ b/tests/operators/test_pad.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import unittest + +import numpy as np +from tike.operators import Pad + +from .util import random_complex, OperatorTests + +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + + +class TestPad(unittest.TestCase, OperatorTests): + """Test the Pad operator.""" + + def setUp(self, shape=(7, 5, 5)): + """Load a dataset for reconstruction.""" + + self.operator = Pad() + self.operator.__enter__() + self.xp = self.operator.xp + + padded_shape = shape + np.asarray((0, 41, 32)) + corner = self.xp.asarray(np.random.randint(0, 32, size=(shape[0], 2))) + + np.random.seed(0) + self.m = self.xp.asarray(random_complex(*shape), dtype='complex64') + self.m_name = 'unpadded' + self.d = self.xp.asarray(random_complex(*padded_shape), + dtype='complex64') + self.d_name = 'padded' + self.kwargs = { + 'corner': corner, + 'padded_shape': padded_shape, + 'unpadded_shape': shape, + } + print(self.operator) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/operators/test_rotate.py b/tests/operators/test_rotate.py new file mode 100644 index 00000000..05183ff8 --- /dev/null +++ b/tests/operators/test_rotate.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import unittest + +import numpy as np +from tike.operators import Rotate + +from .util import random_complex, OperatorTests + +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2020, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + + +class TestRotate(unittest.TestCase, OperatorTests): + """Test the Rotate operator.""" + + def setUp(self, shape=(7, 25, 53)): + """Load a dataset for reconstruction.""" + + self.operator = Rotate() + self.operator.__enter__() + self.xp = self.operator.xp + + np.random.seed(0) + self.m = self.xp.asarray(random_complex(*shape), dtype='complex64') + self.m_name = 'unrotated' + self.d = self.xp.asarray(random_complex(*shape), dtype='complex64') + self.d_name = 'rotated' + self.kwargs = { + 'angle': np.random.rand() * 2 * np.pi, + } + print(self.operator) + + def debug_show(self): + import libimage + import matplotlib.pyplot as plt + x = self.xp.asarray(libimage.load('coins', 256), dtype='complex64') + y = self.operator.fwd(x[None], 4 * np.pi) + + print(x.shape, y.shape) + + plt.figure() + plt.imshow(x.real.get()) + + plt.figure() + plt.imshow(y[0].real.get()) + plt.show() + + +if __name__ == '__main__': + unittest.main() From d0c3c7726d53f269b3811749e25463acb940c8d6 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 24 Sep 2020 19:20:57 -0500 Subject: [PATCH 035/109] NEW: Only recale ptycho when flux is off by more than 1 percent --- src/tike/ptycho/ptycho.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tike/ptycho/ptycho.py b/src/tike/ptycho/ptycho.py index d525e43a..bdabbe04 100644 --- a/src/tike/ptycho/ptycho.py +++ b/src/tike/ptycho/ptycho.py @@ -231,9 +231,9 @@ def _rescale_obj_probe(operator, pool, data, psi, scan, probe): rescale = (np.linalg.norm(np.ravel(np.sqrt(data))) / np.linalg.norm(np.ravel(np.sqrt(intensity)))) - logger.info("object and probe rescaled by %f", rescale) - - probe *= rescale + if abs(1 - rescale) > 0.01: + logger.info("object and probe rescaled by %f", rescale) + probe *= rescale probe = pool.bcast(probe) del scan From 963d9e06bb9d8ada8019c0e8954a09136d30c12c Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 24 Sep 2020 19:21:19 -0500 Subject: [PATCH 036/109] DEV: Before pad rotate as official operator --- src/tike/admm.py | 395 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 348 insertions(+), 47 deletions(-) diff --git a/src/tike/admm.py b/src/tike/admm.py index eb251729..74f692fc 100644 --- a/src/tike/admm.py +++ b/src/tike/admm.py @@ -12,13 +12,18 @@ import tike.ptycho -def update_penalty(psi, h, h0, rho): +def update_penalty(comm, psi, h, h0, rho): r = np.linalg.norm(psi - h)**2 s = np.linalg.norm(rho * (h - h0))**2 - if (r > 10 * s): - rho *= 2 - elif (s > 10 * r): - rho *= 0.5 + r, s = [comm.gather(x) for x in ([r], [s])] + if comm.rank == 0: + r = np.sum(r) + s = np.sum(s) + if (r > 10 * s): + rho *= 2 + elif (s > 10 * r): + rho *= 0.5 + rho = comm.broadcast(rho) logging.info(f"Update penalty parameter ρ = {rho}.") return rho @@ -37,7 +42,7 @@ def find_min_max(data): return mmin, mmax - +# lamino + align converges (ish) according to Viktor, Doga when looking at lagrangian def lamino_align( data, psi, @@ -276,28 +281,125 @@ def lamino_align( return u +# lamino by itself works converges +def lamino( + data, + psi, + scan, + probe, + theta, + tilt, + u=None, + flow=None, + niter=1, + folder=None, +): + """Solve the joint lamino-alignment problem using ADMM. + + Parameters + ---------- + data : (ntheta, detector, detector) float32 + tilt : radians float32 + The laminography tilt angle in radians. + theta : float32 + The rotation angle of each data frame in radians. + u : (detector, detector, detector) complex64 + An initial guess for the object + lamd : (ntheta, detector, detector) float32 + flow : (ntheta, detector, detector, 2) float32 + An initial guess for the alignmnt displacement field. + """ + comm = MPICommunicator() + + all_theta = comm.gather(theta) + if comm.rank == 0: + np.save(f"{folder}/alltheta", all_theta) + + with cp.cuda.Device(comm.rank): + + if comm.rank == 0: + all_trimmed = dxchange.read_tiff( + f'{folder}/trimmed-real-{(1):03d}.tiff' + ) + 1j * dxchange.read_tiff(f'{folder}/trimmed-imag-{(1):03d}.tiff') + phi = all_trimmed.astype('complex64') + + for k in range(niter): + + if comm.rank == 0: + logging.info('Solve the laminography problem.') + + lresult = tike.lamino.reconstruct( + data=-1j * np.log(phi), + theta=all_theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + ) + u = lresult['obj'] + + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=all_theta, + )) + + print( + f"k: {k:03d}, " + "lamino: {:+6.3e}".format(lresult['cost']), + flush=True, + ) + dxchange.write_tiff( + Hu.real, + f'{folder}/Hu-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.imag, + f'{folder}/Hu-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + return u + + def rotate_and_crop(x, corners=None, radius=128, angle=72.035): """Rotate x in two trailing dimensions then crop around center-of-mass. Parameters ---------- x : (M, N, O) complex64 + The image to be cropped. radius : int + How much to crop around the center-of-mass along each dimension. angle : float Rotation angle in degrees. + corners : (len(x), 2) float32 + Crop at these positions instead of the center-of-mass. """ rotate_params = dict( angle=angle, clip=False, preserve_range=True, resize=False, + mode='edge', ) corners = -np.ones((len(x), 2), dtype=int) if corners is None else corners patch = np.zeros((len(x), 2 * radius, 2 * radius), dtype='complex64') for i in range(len(x)): # Rotate by desired angle (degrees) - x[i].real = skimage.transform.rotate(x[i].real, **rotate_params, cval=1) - x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params, cval=0) + x[i].real = skimage.transform.rotate(x[i].real, **rotate_params) + x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params) if corners[i][0] < 0: # Find the center of mass @@ -327,18 +429,20 @@ def rotate_and_crop(x, corners=None, radius=128, angle=72.035): def uncrop_and_rotate(x, patch, lo, radius=128, angle=-72.035): + lo = np.zeros((len(x), 2), dtype=int) if lo is None else lo rotate_params = dict( angle=angle, clip=False, preserve_range=True, resize=False, + mode='edge', ) for i in range(len(x)): x[i][lo[i][0]:lo[i][0] + 2 * radius, lo[i][1]:lo[i][1] + 2 * radius] = patch[i] # Rotate by desired angle (degrees) - x[i].real = skimage.transform.rotate(x[i].real, **rotate_params, cval=1) - x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params, cval=0) + x[i].real = skimage.transform.rotate(x[i].real, **rotate_params) + x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params) return x @@ -353,31 +457,34 @@ def ptycho_lamino_align( flow=None, niter=1, folder=None, + fixed_crop=False, ): """Solve the joint ptycho-lamino-alignment problem using ADMM.""" comm = MPICommunicator() with cp.cuda.Device(comm.rank): # Set preliminary values for ADMM - w = 256 + w = 256 + 32 flow = np.zeros( [len(theta), w, w, 2], dtype='float32', ) if flow is None else flow winsize = w + corners = None u = np.zeros( [w, w, w], dtype='complex64', ) if u is None else u + phi = np.exp(1j * tike.lamino.simulate( obj=u, tilt=tilt, theta=theta, - )) - phi = phi.astype('complex64') + )).astype('complex64') + Hu = phi.copy() - Hu0 = Hu - Dφ0 = phi + Hu0 = phi.copy() + Dφ0 = phi.copy() presult = { # ptychography result 'psi': psi, @@ -385,8 +492,6 @@ def ptycho_lamino_align( 'probe': probe, } - corners = None - reg_p = None λ_p = np.zeros_like(phi) ρ_p = 0.5 λ_a = np.zeros_like(phi) @@ -398,13 +503,14 @@ def ptycho_lamino_align( logging.info("Solve the ptychography problem.") if k > 0: - # Skip regularization on zeroth iteration because we don't know - # value of the cropping corner locations reg_p = uncrop_and_rotate( - psi_rotated, - λ_p / ρ_p - Dφ, - corners, + x=psi_rotated, + patch=λ_p / ρ_p - Dφ, + lo=corners, + radius=w // 2, ) + else: + reg_p = None presult = tike.ptycho.reconstruct( data=data, reg=reg_p, @@ -421,7 +527,11 @@ def ptycho_lamino_align( psi = presult['psi'] logging.info("Rotate and crop projections.") - psi_rotated, trimmed, corners = rotate_and_crop(psi.copy(), corners) + psi_rotated, trimmed, corners = rotate_and_crop( + x=psi.copy(), + corners=corners if fixed_crop else None, + radius=w // 2, + ) logging.info("Recover aligned projections from unaligned.") aresult = tike.align.reconstruct( @@ -430,18 +540,14 @@ def ptycho_lamino_align( flow=flow, num_iter=4, algorithm='cgrad', - reg=np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) - λ_a / ρ_a, + reg=Hu - λ_a / ρ_a, rho_p=ρ_p, rho_a=ρ_a, ) phi = aresult['original'] logging.info("Estimate alignment using Farneback.") - aresult = tike.align.solvers.farneback( + fresult = tike.align.solvers.farneback( op=None, unaligned=trimmed + λ_p / ρ_p, original=phi, @@ -451,11 +557,10 @@ def ptycho_lamino_align( winsize=winsize, num_iter=4, ) + flow = fresult['shift'] # Gather all to one thread - λ_a, phi, theta, Hu_ = [ - comm.gather(x) for x in (λ_a, phi, theta, Hu) - ] + λ_a, phi, theta = [comm.gather(x) for x in (λ_a, phi, theta)] if comm.rank == 0: logging.info('Solve the laminography problem.') @@ -483,16 +588,6 @@ def ptycho_lamino_align( f'{folder}/phi-imag-{(k+1):03d}.tiff', dtype='float32', ) - dxchange.write_tiff( - Hu_[order].real, - f'{folder}/Hu-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu_[order].imag, - f'{folder}/Hu-imag-{(k+1):03d}.tiff', - dtype='float32', - ) # Separate again to multiple threads λ_a, phi, theta = [comm.scatter(x) for x in (λ_a, phi, theta)] @@ -511,8 +606,8 @@ def ptycho_lamino_align( λ_p += ρ_p * CψDφ λ_a += ρ_a * φHu - ρ_p = update_penalty(phi, Hu, Hu0, ρ_p) - ρ_a = update_penalty(trimmed, Dφ, Dφ0, ρ_a) + ρ_p = update_penalty(comm, trimmed, Dφ, Dφ0, ρ_p) + ρ_a = update_penalty(comm, phi, Hu, Hu0, ρ_a) Hu0 = Hu Dφ0 = Dφ @@ -523,28 +618,31 @@ def ptycho_lamino_align( ρ_p * np.linalg.norm(CψDφ.ravel())**2 ], [ - 2 * np.sum(np.real(λ_a.conj() * φHu)) + + 2 * np.real(λ_a.conj() * φHu) + ρ_a * np.linalg.norm(φHu.ravel())**2 ], ) lagrangian = [comm.gather(x) for x in lagrangian] + acost = comm.gather([aresult['cost']]) if comm.rank == 0: lagrangian = [np.sum(x) for x in lagrangian] print( f"k: {k:03d}, ρ_p: {ρ_p:6.3e}, ρ_a: {ρ_a:6.3e}, " f"winsize: {winsize:03d}, " + f"alignment: {np.sum(acost):+6.3e} " + f"laminography: {lresult['cost']:+6.3e} " 'Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e} {:+6.3e}'.format( np.sum(lagrangian), *lagrangian), flush=True, ) dxchange.write_tiff( - presult['psi'].real, + trimmed.real, f'{folder}/psi-real-{(k+1):03d}.tiff', dtype='float32', ) dxchange.write_tiff( - presult['psi'].imag, + trimmed.imag, f'{folder}/psi-imag-{(k+1):03d}.tiff', dtype='float32', ) @@ -558,6 +656,16 @@ def ptycho_lamino_align( f'{folder}/particle-imag-{(k+1):03d}.tiff', dtype='float32', ) + dxchange.write_tiff( + Hu.real, + f'{folder}/Hu-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.imag, + f'{folder}/Hu-imag-{(k+1):03d}.tiff', + dtype='float32', + ) np.save(f"{folder}/flow-tike-{(k+1):03d}", flow) # Limit winsize to larger value. 20? @@ -566,3 +674,196 @@ def ptycho_lamino_align( result = presult return result + +# Does not converge!? +def ptycho_lamino( + data, + psi, + scan, + probe, + theta, + tilt, + u=None, + flow=None, + niter=1, + folder=None, + fixed_crop=True, +): + """Solve the joint ptycho-lamino-alignment problem using ADMM.""" + comm = MPICommunicator() + with cp.cuda.Device(comm.rank): + # Set preliminary values for ADMM + w = 256 + 32 + corners = None + + u = np.zeros( + [w, w, w], + dtype='complex64', + ) if u is None else u + + Hu0 = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )).astype('complex64') + TPHu0 = uncrop_and_rotate( + x=np.zeros_like(psi) + 1.0, + patch=Hu0, + lo=corners, + radius=w // 2, + ) + + presult = { # ptychography result + 'psi': psi, + 'scan': scan, + 'probe': probe, + } + + λ_p = np.zeros_like(psi) + ρ_p = 0.5 + + for k in range(niter): + logging.info(f"Start ADMM iteration {k}.") + + logging.info("Solve the ptychography problem.") + + if k > 0: + reg_p = λ_p / ρ_p - TPHu + else: + reg_p = None + presult = tike.ptycho.reconstruct( + data=data, + reg=reg_p, + rho=ρ_p, + algorithm='combined', + num_iter=1, + cg_iter=4, + recover_psi=True, + recover_probe=True, + recover_positions=False, + model='gaussian', + **presult, + ) + psi = presult['psi'] + + logging.info("Rotate and crop projections.") + _, trimmed, corners = rotate_and_crop( + x=psi + λ_p / ρ_p, + corners=corners if fixed_crop else None, + radius=w // 2, + ) + + # Gather all to one thread + trimmed, theta = [comm.gather(x) for x in (trimmed, theta)] + + if comm.rank == 0: + logging.info('Solve the laminography problem.') + lresult = tike.lamino.reconstruct( + data=-1j * np.log(trimmed), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + cg_iter=4, + ) + u = lresult['obj'] + + # We cannot reorder phi, theta without ruining correspondence + # with data, psi, etc, but we can reorder the saved array + order = np.argsort(theta) + dxchange.write_tiff( + trimmed[order].real, + f'{folder}/phi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + trimmed[order].imag, + f'{folder}/phi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + # Separate again to multiple threads + trimmed, theta = [comm.scatter(x) for x in (trimmed, theta)] + u = comm.broadcast(u) + + logging.info('Update lambdas and rhos.') + + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + TPHu = uncrop_and_rotate( + x=np.zeros_like(psi) + 1.0, + patch=Hu, + lo=corners, + radius=w // 2, + ) + ψTPHu = psi - TPHu + λ_p += ρ_p * ψTPHu + + ρ_p = update_penalty(comm, psi, TPHu, TPHu0, ρ_p) + TPHu0 = TPHu + + lagrangian = ( + [presult['cost']], + [ + 2 * np.real(λ_p.conj() * ψTPHu) + + ρ_p * np.linalg.norm(ψTPHu.ravel())**2 + ], + ) + lagrangian = [comm.gather(x) for x in lagrangian] + + if comm.rank == 0: + lagrangian = [np.sum(x) for x in lagrangian] + print( + f"k: {k:03d}, ρ_p: {ρ_p:6.3e}, " + f"laminography: {lresult['cost']:+6.3e} " + 'Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e}'.format( + np.sum(lagrangian), *lagrangian), + flush=True, + ) + dxchange.write_tiff( + psi.real, + f'{folder}/psi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + psi.imag, + f'{folder}/psi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + TPHu.real, + f'{folder}/TPHu-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + TPHu.imag, + f'{folder}/TPHu-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + (λ_p / ρ_p).imag, + f'{folder}/lamb-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + (λ_p / ρ_p).real, + f'{folder}/lamb-real-{(k+1):03d}.tiff', + dtype='float32', + ) + + result = presult + return result From 8fe33eb6a05a0fe33649a9b49dac7fe84168b7c4 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 29 Sep 2020 16:28:31 -0500 Subject: [PATCH 037/109] MAI: Add tiff files to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 10fa2fbb..6e581055 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,5 @@ dmypy.json core .DS_Store .nfs* + +*.tiff From 2f7c26ed222f2e8d6bfce2c222efad49f4f28f6c Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 29 Sep 2020 17:00:38 -0500 Subject: [PATCH 038/109] REF: Update Pad operator to pad symmetrically by default --- src/tike/operators/cupy/pad.py | 65 ++++++++++++++++++++++------------ tests/operators/test_pad.py | 25 +++++++++++++ 2 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/tike/operators/cupy/pad.py b/src/tike/operators/cupy/pad.py index fa943254..71b5488f 100644 --- a/src/tike/operators/cupy/pad.py +++ b/src/tike/operators/cupy/pad.py @@ -7,34 +7,53 @@ from .flow import _remap_lanczos from .operator import Operator + class Pad(Operator): """Pad a stack of 2D images to the same shape but with unique pad_widths. + + By default, no padding is applied and/or the padding is applied + symmetrically. + + Parameters + ---------- + corner: (N, 2) + The min corner of the images in the padded array. """ - def fwd(self, unpadded, corner, padded_shape, **kwargs): - assert np.all(np.asarray(unpadded.shape) <= padded_shape) - assert self.xp.all(corner >= 0) - # assert self.xp.all(corner + unpadded.shape[1:] <= padded_shape[1:]) - padded = self.xp.zeros(dtype=unpadded.dtype, shape=padded_shape) + def fwd(self, unpadded, corner=None, padded_shape=None, **kwargs): + if padded_shape is None: + padded_shape = unpadded.shape + if corner is None: + corner = self.xp.tile( + (((padded_shape[-2] - unpadded.shape[-2]) // 2, + (padded_shape[-1] - unpadded.shape[-1]) // 2)), + (padded_shape[0], 1), + ) + + padded = self.xp.zeros(shape=padded_shape, dtype=unpadded.dtype) for i in range(padded.shape[0]): - # yapf: disable - padded[ - i, - corner[i, 0]:corner[i, 0] + unpadded.shape[1], - corner[i, 1]:corner[i, 1] + unpadded.shape[2]] = unpadded[i] - # yapf: enable + lo0, hi0 = corner[i, 0], corner[i, 0] + unpadded.shape[-2] + lo1, hi1 = corner[i, 1], corner[i, 1] + unpadded.shape[-1] + assert lo0 >= 0 and lo1 >= 0 + assert hi0 <= padded.shape[-2] and hi1 <= padded.shape[-1] + padded[i][lo0:hi0, lo1:hi1] = unpadded[i] return padded - def adj(self, padded, corner, unpadded_shape, **kwargs): - assert np.all(np.asarray(unpadded_shape) <= padded.shape) - assert self.xp.all(corner >= 0) - # assert self.xp.all(corner + unpadded_shape[1:] <= padded.shape[1:]) - unpadded = self.xp.empty(dtype=padded.dtype, shape=unpadded_shape) - for i in range(unpadded.shape[0]): - # yapf: disable - unpadded[i] = padded[ - i, - corner[i, 0]:corner[i, 0] + unpadded.shape[1], - corner[i, 1]:corner[i, 1] + unpadded.shape[2]] - # yapf: enable + def adj(self, padded, corner=None, unpadded_shape=None, **kwargs): + if unpadded_shape is None: + unpadded_shape = padded.shape + if corner is None: + corner = self.xp.tile( + (((padded.shape[-2] - unpadded_shape[-2]) // 2, + (padded.shape[-1] - unpadded_shape[-1]) // 2)), + (padded.shape[0], 1), + ) + + unpadded = self.xp.empty(shape=unpadded_shape, dtype=padded.dtype) + for i in range(padded.shape[0]): + lo0, hi0 = corner[i, 0], corner[i, 0] + unpadded.shape[-2] + lo1, hi1 = corner[i, 1], corner[i, 1] + unpadded.shape[-1] + assert lo0 >= 0 and lo1 >= 0 + assert hi0 <= padded.shape[-2] and hi1 <= padded.shape[-1] + unpadded[i] = padded[i][lo0:hi0, lo1:hi1] return unpadded diff --git a/tests/operators/test_pad.py b/tests/operators/test_pad.py index ba700cb0..f1b7acef 100644 --- a/tests/operators/test_pad.py +++ b/tests/operators/test_pad.py @@ -40,5 +40,30 @@ def setUp(self, shape=(7, 5, 5)): print(self.operator) +class TestPadDefaults(unittest.TestCase, OperatorTests): + """Test the Pad operator.""" + + def setUp(self, shape=(7, 5, 5)): + """Load a dataset for reconstruction.""" + + self.operator = Pad() + self.operator.__enter__() + self.xp = self.operator.xp + + padded_shape = shape + + np.random.seed(0) + self.m = self.xp.asarray(random_complex(*shape), dtype='complex64') + self.m_name = 'unpadded' + self.d = self.xp.asarray(random_complex(*padded_shape), + dtype='complex64') + self.d_name = 'padded' + self.kwargs = { + 'corner': None, + 'padded_shape': None, + 'unpadded_shape': None, + } + print(self.operator) + if __name__ == '__main__': unittest.main() From f87bc405c2f6115a94fdd2f5fbaa6e7d30b8ea83 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 1 Oct 2020 20:53:52 -0500 Subject: [PATCH 039/109] API: Remove padding from Shift operator --- src/tike/operators/cupy/shift.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/tike/operators/cupy/shift.py b/src/tike/operators/cupy/shift.py index 5e8b8285..b1dbb17a 100644 --- a/src/tike/operators/cupy/shift.py +++ b/src/tike/operators/cupy/shift.py @@ -20,20 +20,17 @@ def fwd(self, a, shift, overwrite=False): """ shape = a.shape - a = a.reshape(-1, *shape[-2:]) - pz, pn = a.shape[-2] // 2, a.shape[-1] // 2 - padded = self.xp.pad(a, ((0, 0), (pz, pz), (pn, pn))) - [x, y] = self.xp.meshgrid( - self.xp.fft.fftfreq(2 * pn + a.shape[-1]).astype('float32'), - self.xp.fft.fftfreq(2 * pz + a.shape[-2]).astype('float32'), + padded = a.reshape(-1, *shape[-2:]) + padded = self._fft2(padded, axes=(-2, -1), overwrite=overwrite) + x, y = self.xp.meshgrid( + self.xp.fft.fftfreq(padded.shape[-1]).astype('float32'), + self.xp.fft.fftfreq(padded.shape[-2]).astype('float32'), ) - shift = self.xp.exp( + padded *= self.xp.exp( -2j * self.xp.pi * (x * shift[..., 1, None, None] + y * shift[..., 0, None, None])) - padded = self._fft2(padded, axes=(-2, -1), overwrite=overwrite) - padded *= shift - padded = self._ifft2(padded, axes=(-2, -1), overwrite=overwrite) - return padded[..., pz:-pz, pn:-pn].reshape(shape) + padded = self._ifft2(padded, axes=(-2, -1), overwrite=True) + return padded.reshape(*shape) def adj(self, a, shift, overwrite=False): return self.fwd(a, -shift, overwrite=overwrite) From 707fb8a3f12c7b15a1f7077704fd8d38bab99da1 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 1 Oct 2020 20:55:04 -0500 Subject: [PATCH 040/109] NEW: Make some operators identity when params are None --- src/tike/operators/cupy/rotate.py | 4 ++++ src/tike/operators/cupy/shift.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/tike/operators/cupy/rotate.py b/src/tike/operators/cupy/rotate.py index 61a9ddc1..2ae42a96 100644 --- a/src/tike/operators/cupy/rotate.py +++ b/src/tike/operators/cupy/rotate.py @@ -29,6 +29,8 @@ def _make_grid(self, unrotated, angle): return self.xp.stack([i1.ravel(), j1.ravel()], axis=-1) def fwd(self, unrotated, angle): + if angle is None: + return unrotated f = unrotated g = self.xp.zeros_like(f) @@ -47,6 +49,8 @@ def fwd(self, unrotated, angle): return g.reshape(shape) def adj(self, rotated, angle): + if angle is None: + return rotated g = rotated f = self.xp.zeros_like(g) diff --git a/src/tike/operators/cupy/shift.py b/src/tike/operators/cupy/shift.py index b1dbb17a..2c60ece4 100644 --- a/src/tike/operators/cupy/shift.py +++ b/src/tike/operators/cupy/shift.py @@ -19,6 +19,8 @@ def fwd(self, a, shift, overwrite=False): The the shifts to be applied along the last two axes. """ + if shift is None: + return a shape = a.shape padded = a.reshape(-1, *shape[-2:]) padded = self._fft2(padded, axes=(-2, -1), overwrite=overwrite) @@ -33,4 +35,6 @@ def fwd(self, a, shift, overwrite=False): return padded.reshape(*shape) def adj(self, a, shift, overwrite=False): + if shift is None: + return a return self.fwd(a, -shift, overwrite=overwrite) From a63e63f550c319289cdaea382475586921eef57c Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 1 Oct 2020 20:58:58 -0500 Subject: [PATCH 041/109] BUG: Push lamino params to GPU --- src/tike/operators/cupy/lamino.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tike/operators/cupy/lamino.py b/src/tike/operators/cupy/lamino.py index 59a474e9..66846dae 100644 --- a/src/tike/operators/cupy/lamino.py +++ b/src/tike/operators/cupy/lamino.py @@ -45,9 +45,9 @@ def __init__(self, n, theta, tilt, eps=1e-3, """Please see help(Lamino) for more info.""" self.n = n self.ntheta = len(theta) - self.tilt = tilt + self.tilt = self.xp.asarray(tilt) self.eps = eps - self.xi = self._make_grids(theta) + self.xi = self._make_grids(self.xp.asarray(theta)) def __enter__(self): """Return self at start of a with-block.""" From 35d7c3abb713783e60087115b5d6d521ebf439e4 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 1 Oct 2020 20:59:39 -0500 Subject: [PATCH 042/109] PATCH: Make some operators identity --- src/tike/operators/cupy/flow.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index 0ae5183d..06a4705d 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -73,6 +73,8 @@ def fwd(self, f, flow, filter_size=5): The width of the Lanczos filter. Automatically rounded up to an odd positive integer. """ + if flow is None: + return f assert f.shape == flow.shape[:-1] # Convert from displacements to coordinates h, w = flow.shape[-3:-1] @@ -106,6 +108,8 @@ def adj(self, g, flow, filter_size=5): The width of the Lanczos filter. Automatically rounded up to an odd positive integer. """ + if flow is None: + return g f = self.xp.zeros_like(g) assert f.shape == flow.shape[:-1] # Convert from displacements to coordinates From 2c6d3d4b0af2c6fe2d202659a5eeffff96c4a4ca Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 1 Oct 2020 21:00:57 -0500 Subject: [PATCH 043/109] API: Have farneback solver return 'flow' --- src/tike/align/solvers/farneback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tike/align/solvers/farneback.py b/src/tike/align/solvers/farneback.py index d8d01073..fbc12c2a 100644 --- a/src/tike/align/solvers/farneback.py +++ b/src/tike/align/solvers/farneback.py @@ -103,4 +103,4 @@ def farneback( flags=4, ) flow[i] = 0.5 * (aflow + pflow) - return {'shift': flow[..., ::-1], 'cost': -1} + return {'flow': flow[..., ::-1], 'cost': -1} From 5932176c408d2243bb0150b95d59dcef6c6b35fa Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 1 Oct 2020 21:01:13 -0500 Subject: [PATCH 044/109] STY: Whitespace --- src/tike/align/solvers/farneback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tike/align/solvers/farneback.py b/src/tike/align/solvers/farneback.py index fbc12c2a..7414c964 100644 --- a/src/tike/align/solvers/farneback.py +++ b/src/tike/align/solvers/farneback.py @@ -6,11 +6,11 @@ def _rescale_8bit(a, b, hi=None, lo=None): """Return a, b rescaled into the same 8-bit range. - + The images are rescaled into the range [lo, hi] if provided; otherwise, the range is decided by clipping the histogram of all bins that are less than 0.5 percent of the fullest bin. - + """ if hi is None or lo is None: @@ -79,8 +79,8 @@ def farneback( *_rescale_8bit( np.real(original[i]), np.real(unaligned[i]), - hi = hi[i] if hi is not None else None, - lo = lo[i] if lo is not None else None, + hi=hi[i] if hi is not None else None, + lo=lo[i] if lo is not None else None, ), flow=flow[i], pyr_scale=pyr_scale, From 5fdadbbe4036ae2f11e9407b75ea46610cb71d47 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 1 Oct 2020 21:30:18 -0500 Subject: [PATCH 045/109] API: Use Alignment operator in alignment module --- src/tike/align/align.py | 47 +++++++--------------------- src/tike/align/solvers/cgrad.py | 32 ++++++++++++------- src/tike/operators/cupy/alignment.py | 34 +++++++++++++------- 3 files changed, 55 insertions(+), 58 deletions(-) diff --git a/src/tike/align/align.py b/src/tike/align/align.py index 04cce7cc..abfeb35d 100644 --- a/src/tike/align/align.py +++ b/src/tike/align/align.py @@ -9,7 +9,7 @@ import logging import numpy as np -from tike.operators import Shift, Flow +from tike.operators import Alignment from tike.align import solvers logger = logging.getLogger(__name__) @@ -17,23 +17,16 @@ def simulate( original, - shift, **kwargs ): # yapf: disable """Return original shifted by shift.""" - assert original.ndim > 2 - if shift.shape == (*original.shape, 2): - Operator = Flow - elif shift.shape == (*original.shape[:-2], 2): - Operator = Shift - else: - raise ValueError( - 'There must be one shift per image or one shift per pixel.') - - with Operator() as operator: + with Alignment() as operator: + for key, value in kwargs.items(): + if not isinstance(value, tuple) and np.ndim(value) > 0: + kwargs[key] = operator.asarray(value) unaligned = operator.fwd( operator.asarray(original, dtype='complex64'), - operator.asarray(shift, dtype='float32'), + **kwargs, ) assert unaligned.dtype == 'complex64', unaligned.dtype return operator.asnumpy(unaligned) @@ -43,7 +36,7 @@ def reconstruct( original, unaligned, algorithm, - num_iter=1, rtol=-1, flow=None, **kwargs + num_iter=1, rtol=-1, **kwargs ): # yapf: disable """Solve the alignment problem; returning either the original or the shift. @@ -51,44 +44,26 @@ def reconstruct( ---------- unaligned, original: (..., H, W) complex64 The images to be aligned. - shift : (..., 2), (..., H, W, 2) float32 - The displacements of pixels from original to unaligned. rtol : float Terminate early if the relative decrease of the cost function is less than this amount. """ if algorithm in solvers.__all__: - if flow is not None and flow.shape == (*original.shape, 2): - Operator = Flow - else: - Operator = Shift - - # Initialize an operator. - with Operator() as operator: - # send any array-likes to device - unaligned = operator.asarray(unaligned, dtype='complex64') - original = operator.asarray(original, dtype='complex64') - flow = operator.asarray(flow, dtype='float32') - result = {} + with Alignment() as operator: for key, value in kwargs.items(): - if np.ndim(value) > 0: + if not isinstance(value, tuple) and np.ndim(value) > 0: kwargs[key] = operator.asarray(value) - logger.info("{} on {:,d} - {:,d} by {:,d} images for {:,d} " "iterations.".format(algorithm, *unaligned.shape, num_iter)) - - kwargs.update(result) result = getattr(solvers, algorithm)( operator, - original=original, - unaligned=unaligned, + original=operator.asarray(original, dtype='complex64'), + unaligned=operator.asarray(unaligned, dtype='complex64'), num_iter=num_iter, - flow=flow, **kwargs, ) - return {k: operator.asnumpy(v) for k, v in result.items()} else: raise ValueError( diff --git a/src/tike/align/solvers/cgrad.py b/src/tike/align/solvers/cgrad.py index a567b7ba..d5db65ae 100644 --- a/src/tike/align/solvers/cgrad.py +++ b/src/tike/align/solvers/cgrad.py @@ -9,28 +9,38 @@ def cgrad( op, original, unaligned, - flow, reg, rho_p, rho_a, num_iter=4, + cost=None, **kwargs ): # yapf: disable """Recover an undistorted image from a given flow.""" def cost_function(original): + # yapf: disable return ( - rho_p * op.xp.linalg.norm(( - unaligned - op.fwd(original, flow)).ravel() - )**2 + - rho_a * op.xp.linalg.norm(( - original - reg).ravel() - )**2 + rho_p * op.xp.linalg.norm(op.xp.ravel( + unaligned - op.fwd( + original, + padded_shape=unaligned.shape, + **kwargs, + )))**2 + + rho_a * op.xp.linalg.norm(op.xp.ravel( + original - reg + ))**2 ) + # yapf: enable def grad(original): - return ( - rho_p * op.adj(op.fwd(original, flow) - unaligned, flow) + - rho_a * (original - reg) - ) + return (rho_p * op.adj( + op.fwd( + original, + padded_shape=unaligned.shape, + **kwargs, + ) - unaligned, + unpadded_shape=original.shape, + **kwargs, + ) + rho_a * (original - reg)) original, cost = conjugate_gradient( op.xp, diff --git a/src/tike/operators/cupy/alignment.py b/src/tike/operators/cupy/alignment.py index 20fcf064..fa22460e 100644 --- a/src/tike/operators/cupy/alignment.py +++ b/src/tike/operators/cupy/alignment.py @@ -5,9 +5,11 @@ import numpy as np +from .flow import Flow from .operator import Operator -from .rotate import Rotate from .pad import Pad +from .rotate import Rotate +from .shift import Shift class Alignment(Operator): @@ -15,34 +17,44 @@ class Alignment(Operator): def __init__(self): """Please see help(Alignment) for more info.""" + self.flow = Flow() self.pad = Pad() self.rotate = Rotate() + self.shift = Shift() def __enter__(self): + self.flow.__enter__() self.pad.__enter__() self.rotate.__enter__() + self.shift.__enter__() return self def __exit__(self, type, value, traceback): + self.flow.__exit__(type, value, traceback) self.pad.__exit__(type, value, traceback) self.rotate.__exit__(type, value, traceback) + self.shift.__exit__(type, value, traceback) - def fwd(self, unpadded, corner, padded_shape, angle, **kwargs): + def fwd(self, unpadded, flow, padded_shape, angle, unpadded_shape=None): return self.rotate.fwd( - unrotated=self.pad.fwd( - unpadded=unpadded, - corner=corner, - padded_shape=padded_shape, + unrotated=self.flow.fwd( + f=self.pad.fwd( + unpadded=unpadded, + padded_shape=padded_shape, + ), + flow=flow, ), angle=angle, ) - def adj(self, rotated, corner, unpadded_shape, angle, **kwargs): + def adj(self, rotated, flow, unpadded_shape, angle, padded_shape=None): return self.pad.adj( - padded=self.rotate.adj( - rotated=rotated, - angle=angle, + padded=self.flow.adj( + g=self.rotate.adj( + rotated=rotated, + angle=angle, + ), + flow=flow, ), - corner=corner, unpadded_shape=unpadded_shape, ) From 2eae5dd608acca2fead078390f8afd8a18d49bbb Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 7 Oct 2020 16:36:46 -0500 Subject: [PATCH 046/109] DEV: Add helpful info to assert statement --- src/tike/operators/cupy/flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index 06a4705d..51899bad 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -75,7 +75,7 @@ def fwd(self, f, flow, filter_size=5): """ if flow is None: return f - assert f.shape == flow.shape[:-1] + assert f.shape == flow.shape[:-1], (f.shape, flow.shape) # Convert from displacements to coordinates h, w = flow.shape[-3:-1] coords = -flow.copy() @@ -111,7 +111,7 @@ def adj(self, g, flow, filter_size=5): if flow is None: return g f = self.xp.zeros_like(g) - assert f.shape == flow.shape[:-1] + assert f.shape == flow.shape[:-1], (f.shape, flow.shape) # Convert from displacements to coordinates h, w = flow.shape[-3:-1] coords = -flow.copy() From 3d9259a7dfec7ea70736ea583b150ec4ce64c696 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 7 Oct 2020 16:38:52 -0500 Subject: [PATCH 047/109] API: Replace kwargs with catch for num_iter --- src/tike/align/solvers/cross_correlation.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/tike/align/solvers/cross_correlation.py b/src/tike/align/solvers/cross_correlation.py index 11f44e9f..9b7f6def 100644 --- a/src/tike/align/solvers/cross_correlation.py +++ b/src/tike/align/solvers/cross_correlation.py @@ -29,8 +29,14 @@ import numpy as np -def cross_correlation(op, original, unaligned, upsample_factor=1, space="real", - **kwargs): # yapf: disable +def cross_correlation( + op, + original, + unaligned, + upsample_factor=1, + space="real", + num_iter=None, +): """Efficient subpixel image translation alignment by cross-correlation. This code gives the same precision as the FFT upsampled cross-correlation From 7e2ff9e5420dc3f7723c77781195fd218e7850ca Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 7 Oct 2020 16:59:32 -0500 Subject: [PATCH 048/109] API: Switch from 'wrap' to 'constant value' interpolation Because: - It provides better aligment when rotating the object doesn't a duplicate object to appear on the edge --- src/tike/operators/cupy/alignment.py | 26 +++++++++++- src/tike/operators/cupy/flow.py | 11 ++--- src/tike/operators/cupy/interp.cu | 62 ++++++++++++++++++---------- src/tike/operators/cupy/pad.py | 5 ++- src/tike/operators/cupy/rotate.py | 8 ++-- tests/operators/test_alignment.py | 6 ++- 6 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/tike/operators/cupy/alignment.py b/src/tike/operators/cupy/alignment.py index fa22460e..6bef6a17 100644 --- a/src/tike/operators/cupy/alignment.py +++ b/src/tike/operators/cupy/alignment.py @@ -35,26 +35,48 @@ def __exit__(self, type, value, traceback): self.rotate.__exit__(type, value, traceback) self.shift.__exit__(type, value, traceback) - def fwd(self, unpadded, flow, padded_shape, angle, unpadded_shape=None): + def fwd( + self, + unpadded, + flow, + padded_shape, + angle, + unpadded_shape=None, + cval=0.0, + ): return self.rotate.fwd( unrotated=self.flow.fwd( f=self.pad.fwd( unpadded=unpadded, padded_shape=padded_shape, + cval=cval, ), flow=flow, + cval=cval, ), angle=angle, + cval=cval, ) - def adj(self, rotated, flow, unpadded_shape, angle, padded_shape=None): + def adj( + self, + rotated, + flow, + unpadded_shape, + angle, + padded_shape=None, + cval=0.0, + ): return self.pad.adj( padded=self.flow.adj( g=self.rotate.adj( rotated=rotated, angle=angle, + cval=cval, ), flow=flow, + cval=cval, ), unpadded_shape=unpadded_shape, + cval=cval, ) diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index 51899bad..d73eb2ff 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -9,7 +9,7 @@ _cu_source = files('tike.operators.cupy').joinpath('interp.cu').read_text() -def _remap_lanczos(Fe, x, m, F, fwd=True): +def _remap_lanczos(Fe, x, m, F, fwd=True, cval=0.0): """Lanczos resampling from grid Fe to points x. At the edges, the Lanczos filter wraps around. @@ -49,6 +49,7 @@ def _remap_lanczos(Fe, x, m, F, fwd=True): x, len(x), lanczos_width, + cp.complex64(cval), )) @@ -59,7 +60,7 @@ class Flow(Operator): images. """ - def fwd(self, f, flow, filter_size=5): + def fwd(self, f, flow, filter_size=5, cval=0.0): """Remap individual pixels of f with Lanczos filtering. Parameters @@ -90,11 +91,11 @@ def fwd(self, f, flow, filter_size=5): a = max(0, (filter_size) // 2) for i in range(len(f)): - _remap_lanczos(f[i], coords[i], a, g[i]) + _remap_lanczos(f[i], coords[i], a, g[i], cval=cval) return g.reshape(shape) - def adj(self, g, flow, filter_size=5): + def adj(self, g, flow, filter_size=5, cval=0.0): """Remap individual pixels of f with Lanczos filtering. Parameters @@ -126,6 +127,6 @@ def adj(self, g, flow, filter_size=5): a = max(0, (filter_size) // 2) for i in range(len(f)): - _remap_lanczos(f[i], coords[i], a, g[i], fwd=False) + _remap_lanczos(f[i], coords[i], a, g[i], fwd=False, cval=cval) return f.reshape(shape) diff --git a/src/tike/operators/cupy/interp.cu b/src/tike/operators/cupy/interp.cu index fff38a67..92116b22 100644 --- a/src/tike/operators/cupy/interp.cu +++ b/src/tike/operators/cupy/interp.cu @@ -32,20 +32,30 @@ _1d_to_nd(int* nd, int ndim, int d, int s, int diameter, const int* origin) { } } -__device__ int +__device__ void nearest(int ndim, int* x, const int* limit) { for (int dim = 0; dim < ndim; dim++) { x[dim] = min(max(0, x[dim]), limit[dim] - 1); } } -__device__ int +__device__ void wrap(int ndim, int* x, const int* limit) { for (int dim = 0; dim < ndim; dim++) { x[dim] = mod(x[dim], limit[dim]); } } +__device__ bool +inside_bounds(int ndim, int* x, const int* limit) { + for (int dim = 0; dim < ndim; dim++) { + if (x[dim] < 0 || x[dim] >= limit[dim]){ + return false; + } + } + return true; +} + // Convert an Nd coordinate (nd) from a grid with given shape a 1d linear // coordinate. __device__ int @@ -102,22 +112,29 @@ gaussian_kernel(int ndim, const float* center, const int* point) { } typedef void -scatterOrGather(float2*, int, float2*, int, float weight); +scatterOrGather(float2*, int, float2*, int, float, float2); // Many uniform grid points are collected to one nonuniform point. This is // linear interpolation, smoothing, etc. __device__ void -gather(float2* grid, int gi, float2* points, int pi, float weight) { - atomicAdd(&points[pi].x, grid[gi].x * weight); - atomicAdd(&points[pi].y, grid[gi].y * weight); +gather(float2* grid, int gi, float2* points, int pi, float weight, float2 cval) { + if (gi >= 0){ + atomicAdd(&points[pi].x, grid[gi].x * weight); + atomicAdd(&points[pi].y, grid[gi].y * weight); + } else { + atomicAdd(&points[pi].x, cval.x * weight); + atomicAdd(&points[pi].y, cval.y * weight); + } } // One nonuniform point is spread to many uniform grid points. This is the // adjoint operation. __device__ void -scatter(float2* grid, int gi, float2* points, int pi, float weight) { - atomicAdd(&grid[gi].x, points[pi].x * weight); - atomicAdd(&grid[gi].y, points[pi].y * weight); +scatter(float2* grid, int gi, float2* points, int pi, float weight, float2 cval) { + if (gi >= 0){ + atomicAdd(&grid[gi].x, points[pi].x * weight); + atomicAdd(&grid[gi].y, points[pi].y * weight); + } } // grid shape (-(-diameter^ndim // max_threads), 0, nf) @@ -130,7 +147,8 @@ _loop_over_kernels(int ndim, // number of dimensions float2* points, // values at nonuniform points const float* x, // coordinates of nonuniform points const int nx, // the number of nonuniform points - const int diameter // kernel diameter, should be odd? + const int diameter, // kernel diameter, should be odd? + const float2 cval // value to use for off-grid points ) { assert(grid != NULL); assert(gshape != NULL); @@ -165,30 +183,30 @@ _loop_over_kernels(int ndim, // number of dimensions // Weights are computed from correct distance... const float weight = get_weight(ndim, &x[ndim * xi], knd); - // ... but for values outside the grid we wrap around so that all of the - // values are valid. - wrap(ndim, knd, gshape); - - // Convert ND grid coord to linear grid coord - const int gi = _nd_to_1d(ndim, knd, gshape); - - operation(grid, gi, points, xi, weight); + if (inside_bounds(ndim, knd, gshape)){ + // For values inside the grid we set weight + // Convert ND grid coord to linear grid coord + int gi = _nd_to_1d(ndim, knd, gshape); + operation(grid, gi, points, xi, weight, cval); + } else{ + operation(grid, -1, points, xi, weight, cval); + } } } } extern "C" __global__ void fwd_lanczos_interp2D(float2* grid, const int* grid_shape, float2* points, - const float* x, int num_points, int diameter + const float* x, int num_points, int diameter, float2 cval ) { _loop_over_kernels(2, lanczos_kernel, gather, grid, grid_shape, points, x, - num_points, diameter); + num_points, diameter, cval); } extern "C" __global__ void adj_lanczos_interp2D(float2* grid, const int* grid_shape, float2* points, - const float* x, int num_points, int diameter) { + const float* x, int num_points, int diameter, float2 cval) { _loop_over_kernels(2, lanczos_kernel, scatter, grid, grid_shape, points, x, - num_points, diameter); + num_points, diameter, cval); } diff --git a/src/tike/operators/cupy/pad.py b/src/tike/operators/cupy/pad.py index 71b5488f..e7c64f2a 100644 --- a/src/tike/operators/cupy/pad.py +++ b/src/tike/operators/cupy/pad.py @@ -20,7 +20,7 @@ class Pad(Operator): The min corner of the images in the padded array. """ - def fwd(self, unpadded, corner=None, padded_shape=None, **kwargs): + def fwd(self, unpadded, corner=None, padded_shape=None, cval=0.0, **kwargs): if padded_shape is None: padded_shape = unpadded.shape if corner is None: @@ -30,7 +30,8 @@ def fwd(self, unpadded, corner=None, padded_shape=None, **kwargs): (padded_shape[0], 1), ) - padded = self.xp.zeros(shape=padded_shape, dtype=unpadded.dtype) + padded = self.xp.empty(shape=padded_shape, dtype=unpadded.dtype) + padded[:] = cval for i in range(padded.shape[0]): lo0, hi0 = corner[i, 0], corner[i, 0] + unpadded.shape[-2] lo1, hi1 = corner[i, 1], corner[i, 1] + unpadded.shape[-1] diff --git a/src/tike/operators/cupy/rotate.py b/src/tike/operators/cupy/rotate.py index 2ae42a96..fcc14abc 100644 --- a/src/tike/operators/cupy/rotate.py +++ b/src/tike/operators/cupy/rotate.py @@ -28,7 +28,7 @@ def _make_grid(self, unrotated, angle): return self.xp.stack([i1.ravel(), j1.ravel()], axis=-1) - def fwd(self, unrotated, angle): + def fwd(self, unrotated, angle, cval=0.0): if angle is None: return unrotated f = unrotated @@ -44,11 +44,11 @@ def fwd(self, unrotated, angle): g = g.reshape(-1, h * w) for i in range(len(f)): - _remap_lanczos(f[i], coords, 2, g[i], fwd=True) + _remap_lanczos(f[i], coords, 2, g[i], fwd=True, cval=0.0) return g.reshape(shape) - def adj(self, rotated, angle): + def adj(self, rotated, angle, cval=0.0): if angle is None: return rotated g = rotated @@ -64,6 +64,6 @@ def adj(self, rotated, angle): g = g.reshape(-1, h * w) for i in range(len(f)): - _remap_lanczos(f[i], coords, 2, g[i], fwd=False) + _remap_lanczos(f[i], coords, 2, g[i], fwd=False, cval=cval) return f.reshape(shape) diff --git a/tests/operators/test_alignment.py b/tests/operators/test_alignment.py index 169219db..261434ec 100644 --- a/tests/operators/test_alignment.py +++ b/tests/operators/test_alignment.py @@ -24,7 +24,8 @@ def setUp(self, shape=(7, 5, 5)): self.xp = self.operator.xp padded_shape = shape + np.asarray((0, 41, 32)) - corner = self.xp.asarray(np.random.randint(0, 32, size=(shape[0], 2))) + flow = (self.xp.random.rand(*padded_shape, 2, dtype='float32') - + 0.5) * 9 np.random.seed(0) self.m = self.xp.asarray(random_complex(*shape), dtype='complex64') @@ -33,10 +34,11 @@ def setUp(self, shape=(7, 5, 5)): dtype='complex64') self.d_name = 'rotated' self.kwargs = { - 'corner': corner, + 'flow': flow, 'padded_shape': padded_shape, 'unpadded_shape': shape, 'angle': np.random.rand() * 2 * np.pi, + 'cval': 0, } print(self.operator) From 89dfb737e0d019e466102d8cf0d40d1fc65039a3 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 8 Oct 2020 10:29:22 -0500 Subject: [PATCH 049/109] BUG: cval was always set to zero in rotate --- src/tike/operators/cupy/rotate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tike/operators/cupy/rotate.py b/src/tike/operators/cupy/rotate.py index fcc14abc..a31c76c6 100644 --- a/src/tike/operators/cupy/rotate.py +++ b/src/tike/operators/cupy/rotate.py @@ -44,7 +44,7 @@ def fwd(self, unrotated, angle, cval=0.0): g = g.reshape(-1, h * w) for i in range(len(f)): - _remap_lanczos(f[i], coords, 2, g[i], fwd=True, cval=0.0) + _remap_lanczos(f[i], coords, 2, g[i], fwd=True, cval=cval) return g.reshape(shape) From 4de3ce2fce9381a04e6ea1aed6f5cf7185cba8fd Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 8 Oct 2020 11:33:37 -0500 Subject: [PATCH 050/109] DEV: Avoid atomicAdd() in interpolation kernel Because: - Maybe it runs faster to avoid atomic operations during gather Not yet tested to see whether there are performance benefits. The trade off is less parallelism because each element of the interpolation kernel is now processed sequentially. --- src/tike/operators/cupy/flow.py | 5 ++--- src/tike/operators/cupy/interp.cu | 26 ++++++++++++-------------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index d73eb2ff..34be0e85 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -39,9 +39,8 @@ def _remap_lanczos(Fe, x, m, F, fwd=True, cval=0.0): else: kernel = cp.RawKernel(_cu_source, "adj_lanczos_interp2D") - grid = (-(-lanczos_width**2 // kernel.max_threads_per_block), 1, - min(x.shape[0], 65535)) - block = (min(kernel.max_threads_per_block, lanczos_width**2),) + grid = (-(-x.shape[0] // kernel.max_threads_per_block), 0, 0) + block = (min(x.shape[0], kernel.max_threads_per_block), 0, 0) kernel(grid, block, ( Fe, cp.array(Fe.shape, dtype='int32'), diff --git a/src/tike/operators/cupy/interp.cu b/src/tike/operators/cupy/interp.cu index 92116b22..cc60b3ca 100644 --- a/src/tike/operators/cupy/interp.cu +++ b/src/tike/operators/cupy/interp.cu @@ -119,11 +119,11 @@ scatterOrGather(float2*, int, float2*, int, float, float2); __device__ void gather(float2* grid, int gi, float2* points, int pi, float weight, float2 cval) { if (gi >= 0){ - atomicAdd(&points[pi].x, grid[gi].x * weight); - atomicAdd(&points[pi].y, grid[gi].y * weight); + points[pi].x += grid[gi].x * weight; + points[pi].y += grid[gi].y * weight; } else { - atomicAdd(&points[pi].x, cval.x * weight); - atomicAdd(&points[pi].y, cval.y * weight); + points[pi].x += cval.x * weight; + points[pi].y += cval.y * weight; } } @@ -137,8 +137,8 @@ scatter(float2* grid, int gi, float2* points, int pi, float weight, float2 cval) } } -// grid shape (-(-diameter^ndim // max_threads), 0, nf) -// block shape (min(diameter^ndim, max_threads), 0, 0) +// grid shape (-(-nx // max_threads), 0, 0) +// block shape (min(nx, max_threads), 0, 0) __device__ void _loop_over_kernels(int ndim, // number of dimensions kernel_function get_weight, scatterOrGather operation, @@ -162,20 +162,18 @@ _loop_over_kernels(int ndim, // number of dimensions const int nk = pow(diameter, ndim); // number of grid positions in kernel // nonuniform position index (xi) - for (int xi = blockIdx.z; xi < nx; xi += gridDim.z) { + for ( + int xi = threadIdx.x + blockDim.x * blockIdx.x; + xi < nx; + xi += blockDim.x * gridDim.x + ) { // closest ND grid coord to point center of kernel int center[max_dim]; for (int dim = 0; dim < ndim; dim++) { center[dim] = int(floor(x[ndim * xi + dim])); } // linear intra-kernel index (ki) - // clang-format off - for ( - int ki = threadIdx.x + blockDim.x * blockIdx.x; - ki < nk; - ki += blockDim.x * gridDim.x - ) { - // clang-format on + for (int ki = 0; ki < nk; ki++) { // Convert linear intra-kernel index to ND grid coord (knd) int knd[max_dim]; _1d_to_nd(knd, ndim, ki, nk, diameter, center); From 86dc20441e2b0763b5f1c7718bebcbeb2f5b8d96 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 9 Oct 2020 18:03:18 -0500 Subject: [PATCH 051/109] REF: Split admm into module --- src/tike/admm.py | 869 -------------------------------------- src/tike/admm/__init__.py | 3 + src/tike/admm/admm.py | 132 ++++++ src/tike/admm/al.py | 234 ++++++++++ src/tike/admm/pal.py | 289 +++++++++++++ src/tike/admm/pl.py | 144 +++++++ 6 files changed, 802 insertions(+), 869 deletions(-) delete mode 100644 src/tike/admm.py create mode 100644 src/tike/admm/__init__.py create mode 100644 src/tike/admm/admm.py create mode 100644 src/tike/admm/al.py create mode 100644 src/tike/admm/pal.py create mode 100644 src/tike/admm/pl.py diff --git a/src/tike/admm.py b/src/tike/admm.py deleted file mode 100644 index 74f692fc..00000000 --- a/src/tike/admm.py +++ /dev/null @@ -1,869 +0,0 @@ -import logging -import multiprocessing - -import cupy as cp -import dxchange -import numpy as np -import skimage.transform - -import tike.align -from tike.communicator import MPICommunicator -import tike.lamino -import tike.ptycho - - -def update_penalty(comm, psi, h, h0, rho): - r = np.linalg.norm(psi - h)**2 - s = np.linalg.norm(rho * (h - h0))**2 - r, s = [comm.gather(x) for x in ([r], [s])] - if comm.rank == 0: - r = np.sum(r) - s = np.sum(s) - if (r > 10 * s): - rho *= 2 - elif (s > 10 * r): - rho *= 0.5 - rho = comm.broadcast(rho) - logging.info(f"Update penalty parameter ρ = {rho}.") - return rho - - -def find_min_max(data): - mmin = np.zeros(data.shape[0], dtype='float32') - mmax = np.zeros(data.shape[0], dtype='float32') - - for k in range(data.shape[0]): - h, e = np.histogram(data[k][:], 1000) - stend = np.where(h > np.max(h) * 0.005) - st = stend[0][0] - end = stend[0][-1] - mmin[k] = e[st] - mmax[k] = e[end + 1] - - return mmin, mmax - -# lamino + align converges (ish) according to Viktor, Doga when looking at lagrangian -def lamino_align( - data, - psi, - scan, - probe, - theta, - tilt, - u=None, - flow=None, - niter=1, - folder=None, -): - """Solve the joint lamino-alignment problem using ADMM. - - Parameters - ---------- - data : (ntheta, detector, detector) float32 - tilt : radians float32 - The laminography tilt angle in radians. - theta : float32 - The rotation angle of each data frame in radians. - u : (detector, detector, detector) complex64 - An initial guess for the object - lamd : (ntheta, detector, detector) float32 - flow : (ntheta, detector, detector, 2) float32 - An initial guess for the alignmnt displacement field. - """ - comm = MPICommunicator() - - all_theta = comm.gather(theta) - if comm.rank == 0: - np.save(f"{folder}/alltheta", all_theta) - - with cp.cuda.Device(comm.rank): - - logging.info("Solve the ptychography problem.") - - # presult = { - # 'psi': psi, - # 'scan': scan, - # 'probe': probe, - # } - - # presult = tike.ptycho.reconstruct( - # data=data, - # algorithm='combined', - # num_iter=niter, - # cg_iter=4, - # recover_psi=True, - # recover_probe=True, - # recover_positions=False, - # model='gaussian', - # **presult, - # ) - # psi = presult['psi'] - - # if comm.rank == 0: - # dxchange.write_tiff( - # presult['psi'].real, - # f'{folder}/psi-real-{(1):03d}.tiff', - # dtype='float32', - # ) - # dxchange.write_tiff( - # presult['psi'].imag, - # f'{folder}/psi-imag-{(1):03d}.tiff', - # dtype='float32', - # ) - - # logging.info("Rotate and crop projections.") - - # _, trimmed, _ = rotate_and_crop(psi.copy()) - - # Set preliminary values for ADMM - w = 256 - flow = np.zeros( - [len(theta), w, w, 2], - dtype='float32', - ) if flow is None else flow - winsize = w - - u = np.zeros( - [w, w, w], - dtype='complex64', - ) if u is None else u - phi = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) - phi = phi.astype('complex64') - Hu = phi.copy() - Hu0 = Hu - - λ_a = np.zeros_like(phi) - rho = 0.5 - - all_trimmed = None - if comm.rank == 0: - all_trimmed = dxchange.read_tiff( - f'{folder}/trimmed-real-{(1):03d}.tiff' - ) + 1j * dxchange.read_tiff(f'{folder}/trimmed-imag-{(1):03d}.tiff') - all_trimmed = all_trimmed.astype('complex64') - trimmed = comm.scatter(all_trimmed) - - # all_trimmed = comm.gather(trimmed) - # if comm.rank == 0: - # dxchange.write_tiff( - # all_trimmed.real, - # f'{folder}/trimmed-real-{(1):03d}.tiff', - # dtype='float32', - # ) - # dxchange.write_tiff( - # all_trimmed.imag, - # f'{folder}/trimmed-imag-{(1):03d}.tiff', - # dtype='float32', - # ) - del all_trimmed - - for k in range(niter): - - logging.info("Recover original/aligned projections.") - - aresult = tike.align.reconstruct( - unaligned=trimmed, - original=phi, - flow=flow, - num_iter=4, - algorithm='cgrad', - reg=Hu + λ_a / rho, - rho=rho, - ) - phi = aresult['original'] - - logging.info("Find flow using farneback.") - - fresult = tike.align.solvers.farneback( - op=None, - unaligned=trimmed, - original=phi, - flow=flow, - pyr_scale=0.5, - levels=1, - winsize=winsize, - num_iter=4, - ) - flow = fresult['shift'] - - # Gather all to one thread - λ_a, phi, theta = [comm.gather(x) for x in (λ_a, phi, theta)] - - if comm.rank == 0: - logging.info('Solve the laminography problem.') - - result = tike.lamino.reconstruct( - data=-1j * np.log(phi - λ_a / rho), - theta=theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - ) - u = result['obj'] - - # We cannot reorder phi, theta without ruining correspondence - # with data, psi, etc, but we can reorder the saved array - order = np.argsort(theta) - dxchange.write_tiff( - phi[order].real, - f'{folder}/phi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - phi[order].imag, - f'{folder}/phi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - - # Separate again to multiple threads - λ_a, phi, theta = [comm.scatter(x) for x in (λ_a, phi, theta)] - u = comm.broadcast(u) - - logging.info('Update lambda and rho.') - - CψDφ = tike.align.simulate(phi, flow) - trimmed - Hu = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) - φHu = Hu - phi - λ_a = λ_a + rho * φHu - rho = update_penalty(phi, Hu, Hu0, rho) - Hu0 = Hu - - lagrangian = [ - [np.linalg.norm(CψDφ.ravel())**2], - [2 * np.real(λ_a.conj() * φHu)], - [rho * np.linalg.norm(φHu.ravel())**2], - ] - lagrangian = [comm.gather(x) for x in lagrangian] - - if comm.rank == 0: - lagrangian = [np.sum(x) for x in lagrangian] - print( - f"k: {k:03d}, ρ: {rho:.3e}, winsize: {winsize:03d}, " - "Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e} {:+6.3e}".format( - np.sum(lagrangian), *lagrangian), - flush=True, - ) - dxchange.write_tiff( - Hu.real, - f'{folder}/Hu-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.imag, - f'{folder}/Hu-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.real, - f'{folder}/particle-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.imag, - f'{folder}/particle-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - np.save(f"{folder}/flow-tike-{(k+1):03d}", flow) - - # Limit winsize to larger value. 20? - if winsize > 20: - winsize -= 1 - - return u - - -# lamino by itself works converges -def lamino( - data, - psi, - scan, - probe, - theta, - tilt, - u=None, - flow=None, - niter=1, - folder=None, -): - """Solve the joint lamino-alignment problem using ADMM. - - Parameters - ---------- - data : (ntheta, detector, detector) float32 - tilt : radians float32 - The laminography tilt angle in radians. - theta : float32 - The rotation angle of each data frame in radians. - u : (detector, detector, detector) complex64 - An initial guess for the object - lamd : (ntheta, detector, detector) float32 - flow : (ntheta, detector, detector, 2) float32 - An initial guess for the alignmnt displacement field. - """ - comm = MPICommunicator() - - all_theta = comm.gather(theta) - if comm.rank == 0: - np.save(f"{folder}/alltheta", all_theta) - - with cp.cuda.Device(comm.rank): - - if comm.rank == 0: - all_trimmed = dxchange.read_tiff( - f'{folder}/trimmed-real-{(1):03d}.tiff' - ) + 1j * dxchange.read_tiff(f'{folder}/trimmed-imag-{(1):03d}.tiff') - phi = all_trimmed.astype('complex64') - - for k in range(niter): - - if comm.rank == 0: - logging.info('Solve the laminography problem.') - - lresult = tike.lamino.reconstruct( - data=-1j * np.log(phi), - theta=all_theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - ) - u = lresult['obj'] - - Hu = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=all_theta, - )) - - print( - f"k: {k:03d}, " - "lamino: {:+6.3e}".format(lresult['cost']), - flush=True, - ) - dxchange.write_tiff( - Hu.real, - f'{folder}/Hu-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.imag, - f'{folder}/Hu-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.real, - f'{folder}/particle-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.imag, - f'{folder}/particle-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - - return u - - -def rotate_and_crop(x, corners=None, radius=128, angle=72.035): - """Rotate x in two trailing dimensions then crop around center-of-mass. - - Parameters - ---------- - x : (M, N, O) complex64 - The image to be cropped. - radius : int - How much to crop around the center-of-mass along each dimension. - angle : float - Rotation angle in degrees. - corners : (len(x), 2) float32 - Crop at these positions instead of the center-of-mass. - """ - rotate_params = dict( - angle=angle, - clip=False, - preserve_range=True, - resize=False, - mode='edge', - ) - corners = -np.ones((len(x), 2), dtype=int) if corners is None else corners - patch = np.zeros((len(x), 2 * radius, 2 * radius), dtype='complex64') - for i in range(len(x)): - # Rotate by desired angle (degrees) - x[i].real = skimage.transform.rotate(x[i].real, **rotate_params) - x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params) - - if corners[i][0] < 0: - # Find the center of mass - phase = np.angle(x[i]) - phase[phase < 0] = 0 - M = skimage.measure.moments(phase, order=1) - center = np.array([M[1, 0] / M[0, 0], - M[0, 1] / M[0, 0]]).astype('int') - - # Adjust the cropping region so it stays within the image - lo = np.fmax(0, center - radius) - hi = lo + 2 * radius - shift = np.fmin(0, x[i].shape - hi) - hi += shift - lo += shift - assert np.all(lo >= 0), lo - assert np.all(hi <= x[i].shape), (hi, x[i].shape) - corners[i] = lo - else: - lo = corners[i] - hi = corners[i] + 2 * radius - - # Crop image - patch[i] = x[i][lo[0]:hi[0], lo[1]:hi[1]] - - return x, patch, corners - - -def uncrop_and_rotate(x, patch, lo, radius=128, angle=-72.035): - lo = np.zeros((len(x), 2), dtype=int) if lo is None else lo - rotate_params = dict( - angle=angle, - clip=False, - preserve_range=True, - resize=False, - mode='edge', - ) - for i in range(len(x)): - x[i][lo[i][0]:lo[i][0] + 2 * radius, - lo[i][1]:lo[i][1] + 2 * radius] = patch[i] - # Rotate by desired angle (degrees) - x[i].real = skimage.transform.rotate(x[i].real, **rotate_params) - x[i].imag = skimage.transform.rotate(x[i].imag, **rotate_params) - return x - - -def ptycho_lamino_align( - data, - psi, - scan, - probe, - theta, - tilt, - u=None, - flow=None, - niter=1, - folder=None, - fixed_crop=False, -): - """Solve the joint ptycho-lamino-alignment problem using ADMM.""" - comm = MPICommunicator() - with cp.cuda.Device(comm.rank): - # Set preliminary values for ADMM - w = 256 + 32 - flow = np.zeros( - [len(theta), w, w, 2], - dtype='float32', - ) if flow is None else flow - winsize = w - corners = None - - u = np.zeros( - [w, w, w], - dtype='complex64', - ) if u is None else u - - phi = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )).astype('complex64') - - Hu = phi.copy() - Hu0 = phi.copy() - Dφ0 = phi.copy() - - presult = { # ptychography result - 'psi': psi, - 'scan': scan, - 'probe': probe, - } - - λ_p = np.zeros_like(phi) - ρ_p = 0.5 - λ_a = np.zeros_like(phi) - ρ_a = 0.5 - - for k in range(niter): - logging.info(f"Start ADMM iteration {k}.") - - logging.info("Solve the ptychography problem.") - - if k > 0: - reg_p = uncrop_and_rotate( - x=psi_rotated, - patch=λ_p / ρ_p - Dφ, - lo=corners, - radius=w // 2, - ) - else: - reg_p = None - presult = tike.ptycho.reconstruct( - data=data, - reg=reg_p, - rho=ρ_p, - algorithm='combined', - num_iter=1, - cg_iter=4, - recover_psi=True, - recover_probe=True, - recover_positions=False, - model='gaussian', - **presult, - ) - psi = presult['psi'] - - logging.info("Rotate and crop projections.") - psi_rotated, trimmed, corners = rotate_and_crop( - x=psi.copy(), - corners=corners if fixed_crop else None, - radius=w // 2, - ) - - logging.info("Recover aligned projections from unaligned.") - aresult = tike.align.reconstruct( - unaligned=trimmed + λ_p / ρ_p, - original=phi, - flow=flow, - num_iter=4, - algorithm='cgrad', - reg=Hu - λ_a / ρ_a, - rho_p=ρ_p, - rho_a=ρ_a, - ) - phi = aresult['original'] - - logging.info("Estimate alignment using Farneback.") - fresult = tike.align.solvers.farneback( - op=None, - unaligned=trimmed + λ_p / ρ_p, - original=phi, - flow=flow, - pyr_scale=0.5, - levels=1, - winsize=winsize, - num_iter=4, - ) - flow = fresult['shift'] - - # Gather all to one thread - λ_a, phi, theta = [comm.gather(x) for x in (λ_a, phi, theta)] - - if comm.rank == 0: - logging.info('Solve the laminography problem.') - lresult = tike.lamino.reconstruct( - data=-1j * np.log(phi + λ_a / ρ_a), - theta=theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - cg_iter=4, - ) - u = lresult['obj'] - - # We cannot reorder phi, theta without ruining correspondence - # with data, psi, etc, but we can reorder the saved array - order = np.argsort(theta) - dxchange.write_tiff( - phi[order].real, - f'{folder}/phi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - phi[order].imag, - f'{folder}/phi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - - # Separate again to multiple threads - λ_a, phi, theta = [comm.scatter(x) for x in (λ_a, phi, theta)] - u = comm.broadcast(u) - - logging.info('Update lambdas and rhos.') - - Dφ = tike.align.simulate(phi, flow) - CψDφ = trimmed - Dφ - Hu = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) - φHu = phi - Hu - λ_p += ρ_p * CψDφ - λ_a += ρ_a * φHu - - ρ_p = update_penalty(comm, trimmed, Dφ, Dφ0, ρ_p) - ρ_a = update_penalty(comm, phi, Hu, Hu0, ρ_a) - Hu0 = Hu - Dφ0 = Dφ - - lagrangian = ( - [presult['cost']], - [ - 2 * np.real(λ_p.conj() * CψDφ) + - ρ_p * np.linalg.norm(CψDφ.ravel())**2 - ], - [ - 2 * np.real(λ_a.conj() * φHu) + - ρ_a * np.linalg.norm(φHu.ravel())**2 - ], - ) - lagrangian = [comm.gather(x) for x in lagrangian] - acost = comm.gather([aresult['cost']]) - - if comm.rank == 0: - lagrangian = [np.sum(x) for x in lagrangian] - print( - f"k: {k:03d}, ρ_p: {ρ_p:6.3e}, ρ_a: {ρ_a:6.3e}, " - f"winsize: {winsize:03d}, " - f"alignment: {np.sum(acost):+6.3e} " - f"laminography: {lresult['cost']:+6.3e} " - 'Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e} {:+6.3e}'.format( - np.sum(lagrangian), *lagrangian), - flush=True, - ) - dxchange.write_tiff( - trimmed.real, - f'{folder}/psi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - trimmed.imag, - f'{folder}/psi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.real, - f'{folder}/particle-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.imag, - f'{folder}/particle-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.real, - f'{folder}/Hu-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.imag, - f'{folder}/Hu-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - np.save(f"{folder}/flow-tike-{(k+1):03d}", flow) - - # Limit winsize to larger value. 20? - if winsize > 20: - winsize -= 1 - - result = presult - return result - -# Does not converge!? -def ptycho_lamino( - data, - psi, - scan, - probe, - theta, - tilt, - u=None, - flow=None, - niter=1, - folder=None, - fixed_crop=True, -): - """Solve the joint ptycho-lamino-alignment problem using ADMM.""" - comm = MPICommunicator() - with cp.cuda.Device(comm.rank): - # Set preliminary values for ADMM - w = 256 + 32 - corners = None - - u = np.zeros( - [w, w, w], - dtype='complex64', - ) if u is None else u - - Hu0 = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )).astype('complex64') - TPHu0 = uncrop_and_rotate( - x=np.zeros_like(psi) + 1.0, - patch=Hu0, - lo=corners, - radius=w // 2, - ) - - presult = { # ptychography result - 'psi': psi, - 'scan': scan, - 'probe': probe, - } - - λ_p = np.zeros_like(psi) - ρ_p = 0.5 - - for k in range(niter): - logging.info(f"Start ADMM iteration {k}.") - - logging.info("Solve the ptychography problem.") - - if k > 0: - reg_p = λ_p / ρ_p - TPHu - else: - reg_p = None - presult = tike.ptycho.reconstruct( - data=data, - reg=reg_p, - rho=ρ_p, - algorithm='combined', - num_iter=1, - cg_iter=4, - recover_psi=True, - recover_probe=True, - recover_positions=False, - model='gaussian', - **presult, - ) - psi = presult['psi'] - - logging.info("Rotate and crop projections.") - _, trimmed, corners = rotate_and_crop( - x=psi + λ_p / ρ_p, - corners=corners if fixed_crop else None, - radius=w // 2, - ) - - # Gather all to one thread - trimmed, theta = [comm.gather(x) for x in (trimmed, theta)] - - if comm.rank == 0: - logging.info('Solve the laminography problem.') - lresult = tike.lamino.reconstruct( - data=-1j * np.log(trimmed), - theta=theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - cg_iter=4, - ) - u = lresult['obj'] - - # We cannot reorder phi, theta without ruining correspondence - # with data, psi, etc, but we can reorder the saved array - order = np.argsort(theta) - dxchange.write_tiff( - trimmed[order].real, - f'{folder}/phi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - trimmed[order].imag, - f'{folder}/phi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - - # Separate again to multiple threads - trimmed, theta = [comm.scatter(x) for x in (trimmed, theta)] - u = comm.broadcast(u) - - logging.info('Update lambdas and rhos.') - - Hu = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) - TPHu = uncrop_and_rotate( - x=np.zeros_like(psi) + 1.0, - patch=Hu, - lo=corners, - radius=w // 2, - ) - ψTPHu = psi - TPHu - λ_p += ρ_p * ψTPHu - - ρ_p = update_penalty(comm, psi, TPHu, TPHu0, ρ_p) - TPHu0 = TPHu - - lagrangian = ( - [presult['cost']], - [ - 2 * np.real(λ_p.conj() * ψTPHu) + - ρ_p * np.linalg.norm(ψTPHu.ravel())**2 - ], - ) - lagrangian = [comm.gather(x) for x in lagrangian] - - if comm.rank == 0: - lagrangian = [np.sum(x) for x in lagrangian] - print( - f"k: {k:03d}, ρ_p: {ρ_p:6.3e}, " - f"laminography: {lresult['cost']:+6.3e} " - 'Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e}'.format( - np.sum(lagrangian), *lagrangian), - flush=True, - ) - dxchange.write_tiff( - psi.real, - f'{folder}/psi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - psi.imag, - f'{folder}/psi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.real, - f'{folder}/particle-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.imag, - f'{folder}/particle-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - TPHu.real, - f'{folder}/TPHu-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - TPHu.imag, - f'{folder}/TPHu-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - (λ_p / ρ_p).imag, - f'{folder}/lamb-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - (λ_p / ρ_p).real, - f'{folder}/lamb-real-{(k+1):03d}.tiff', - dtype='float32', - ) - - result = presult - return result diff --git a/src/tike/admm/__init__.py b/src/tike/admm/__init__.py new file mode 100644 index 00000000..75423b6c --- /dev/null +++ b/src/tike/admm/__init__.py @@ -0,0 +1,3 @@ +from .admm import * +from .al import * +from .pal import * diff --git a/src/tike/admm/admm.py b/src/tike/admm/admm.py new file mode 100644 index 00000000..2465aae4 --- /dev/null +++ b/src/tike/admm/admm.py @@ -0,0 +1,132 @@ +import logging + +import numpy as np + +import tike.align +import tike.lamino +import tike.ptycho + + +def update_penalty(comm, psi, h, h0, rho, diff=10): + r = np.linalg.norm(psi - h)**2 + s = np.linalg.norm(rho * (h - h0))**2 + r, s = [comm.gather(x) for x in ([r], [s])] + if comm.rank == 0: + r = np.sum(r) + s = np.sum(s) + if (r > diff * s): + rho *= 2 + elif (s > diff * r): + rho *= 0.5 + rho = comm.broadcast(rho) + logging.info(f"Update penalty parameter ρ = {rho}.") + return rho + + +def find_min_max(data): + mmin = np.zeros(data.shape[0], dtype='float32') + mmax = np.zeros(data.shape[0], dtype='float32') + + for k in range(data.shape[0]): + h, e = np.histogram(data[k][:], 1000) + stend = np.where(h > np.max(h) * 0.005) + st = stend[0][0] + end = stend[0][-1] + mmin[k] = e[st] + mmax[k] = e[end + 1] + + return mmin, mmax + + +def simulate( + u, + scan, + probe, + flow, + angle, + tilt, + theta, + padded_shape, + detector_shape, +): + phi = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + psi = tike.align.simulate( + original=phi, + flow=flow, + padded_shape=padded_shape, + angle=angle, + cval=1.0, + ) + data = tike.ptycho.simulate( + psi=psi, + probe=probe, + detector_shape=detector_shape, + scan=scan, + ) + return data, psi, phi + + +def print_log_line(**kwargs): + """Print keyword arguments and values on a single comma-separated line. + + The format of the line is as follows: + + ``` + foo: 003, bar: +1.234e+02, hello: world\n + ``` + + Parameters + ---------- + line: dictionary + The key value pairs to be printed. + + """ + line = [] + for k, v in kwargs.items(): + # Use special formatting for float and integers + if isinstance(v, (float, np.floating)): + line.append(f'"{k}": {v:6.3e}') + elif isinstance(v, (int, np.integer)): + line.append(f'"{k}": {v:3d}') + else: + line.append(f'"{k}": {v}') + # Combine all the strings and strip the last comma + print("{", ", ".join(line), "}", flush=True) + + +def optical_flow_tvl1(unaligned, original, num_iter=16): + """Wrap scikit-image optical_flow_tvl1 for complex values""" + from skimage.registration import optical_flow_tvl1 + iflow = [ + optical_flow_tvl1( + original[i].imag, + unaligned[i].imag, + num_iter=num_iter, + ) for i in range(len(original)) + ] + rflow = [ + optical_flow_tvl1( + original[i].real, + unaligned[i].real, + num_iter=num_iter, + ) for i in range(len(original)) + ] + flow = np.array(rflow, dtype='float32') + np.array(iflow, dtype='float32') + flow = np.moveaxis(flow, 1, -1) / 2.0 + return flow + + +def center_of_mass(x): + """Find the center of mass""" + import skimage.measure + center = np.empty((len(x), 2), dtype='float32') + for i in range(len(x)): + phase = np.angle(x[i]) + phase[phase < 0] = 0 + M = skimage.measure.moments(phase, order=1) + center[i] = M[1, 0] / M[0, 0], M[0, 1] / M[0, 0] + return center diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py new file mode 100644 index 00000000..33839311 --- /dev/null +++ b/src/tike/admm/al.py @@ -0,0 +1,234 @@ +import logging + + +import numpy as np +import cupy as cp +import dxchange + +import tike.align +from tike.communicator import MPICommunicator +import tike.lamino + +from .admm import * + +def lamino_align( + psi, + theta, + tilt, + u=None, + flow=None, + niter=1, + folder=None, + angle=0, + w=256 + 64 + 16 + 8, + cg_iter=4, + shift=None, + interval=8, + winsize=None, + align_method='', +): + """Solve the joint ptycho-lamino-alignment problem using ADMM.""" + u = np.zeros((w, w, w), dtype='complex64') + Hu = np.ones((len(theta), w, w), dtype='complex64') + phi = Hu + flow = np.zeros([*psi.shape, 2], dtype='float32') + winsize = min(*psi.shape[-2:]) if winsize is None else winsize + λ_l = np.zeros([len(theta), w, w], dtype='complex64') + ρ_p = 1 + ρ_l = 0.5 + comm = MPICommunicator() + with cp.cuda.Device(comm.rank if comm.size > 1 else None): + + hi, lo = find_min_max(np.angle(psi)) + + for k in range(niter): + logging.info(f"Start ADMM iteration {k}.") + + rotated = tike.align.simulate( + psi, + angle=-angle, + flow=None, + padded_shape=None, + cval=1.0, + ) + if (k + 1) == 8: + dxchange.write_tiff( + rotated.real, + f'{folder}/{comm.rank}-rotated-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + rotated.imag, + f'{folder}/{comm.rank}-rotated-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + logging.info("Recover aligned projections from unaligned.") + aresult = tike.align.reconstruct( + unaligned=psi, + original=phi, + flow=flow, + angle=angle, + num_iter=cg_iter, + algorithm='cgrad', + reg=Hu - λ_l / ρ_l, + rho_p=ρ_p, + rho_a=ρ_l, + cval=1.0, + ) + phi = aresult['original'] + + padded = tike.align.simulate( + phi, + angle=None, + flow=None, + padded_shape=psi.shape, + cval=1.0, + ) + if comm.rank == 0 and (k + 1) % interval == 0: + dxchange.write_tiff( + rotated.real, + f'{folder}/rotated-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + rotated.imag, + f'{folder}/rotated-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + padded.real, + f'{folder}/padded-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + padded.imag, + f'{folder}/padded-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + if align_method.lower() == 'flow': + winsize = max(winsize - 1, 24) + logging.info("Estimate alignment using Farneback.") + fresult = tike.align.solvers.farneback( + op=None, + unaligned=rotated, + original=padded, + flow=flow, + pyr_scale=0.5, + levels=4, + winsize=winsize, + num_iter=32, + hi=hi, lo=lo, + ) + flow = fresult['flow'] + elif align_method.lower() == 'tvl1': + logging.info("Estimate alignment using TV-L1.") + flow = optical_flow_tvl1( + unaligned=rotated, + original=padded, + num_iter=8, + ) + else: + logging.info("Estimate rigid alignment with cross correlation.") + sresult = tike.align.reconstruct( + algorithm='cross_correlation', + unaligned=rotated, + original=padded, + upsample_factor=100, + ) + flow[:] = sresult['shift'][:, None, None, :] + + # Gather all to one thread + λ_l, phi, theta = [comm.gather(x) for x in (λ_l, phi, theta)] + + if comm.rank == 0: + logging.info('Solve the laminography problem.') + lresult = tike.lamino.reconstruct( + data=-1j * np.log(phi + λ_l / ρ_l), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + cg_iter=cg_iter, + ) + u = lresult['obj'] + + # We cannot reorder phi, theta without ruining correspondence + # with data, psi, etc, but we can reorder the saved array + if (k + 1) % interval == 0: + order = np.argsort(theta) + dxchange.write_tiff( + phi[order].real, + f'{folder}/phi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + phi[order].imag, + f'{folder}/phi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + # Separate again to multiple threads + λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] + u = comm.broadcast(u) + + logging.info('Update lambdas and rhos.') + + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + φHu = phi - Hu + λ_l += ρ_l * φHu + + if k > 0: + ρ_l = update_penalty(comm, phi, Hu, Hu0, ρ_l) + Hu0 = Hu + + lagrangian = ( + [ + 2 * np.real(λ_l.conj() * φHu) + + ρ_l * np.linalg.norm(φHu.ravel())**2 + ], + ) + lagrangian = [comm.gather(x) for x in lagrangian] + acost = comm.gather([aresult['cost']]) + + if comm.rank == 0: + lagrangian = [np.sum(x) for x in lagrangian] + print_log_line( + k=k, + ρ_p=ρ_p, + ρ_l=ρ_l, + winsize=winsize, + alignment=np.sum(acost), + laminography=float(lresult['cost']), + φHu=lagrangian[0], + ) + if comm.rank == 0 and (k + 1) % interval == 0: + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.real, + f'{folder}/Hu-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.imag, + f'{folder}/Hu-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + np.save(f"{folder}/flow-{(k+1):03d}", flow) + + return u diff --git a/src/tike/admm/pal.py b/src/tike/admm/pal.py new file mode 100644 index 00000000..9baede22 --- /dev/null +++ b/src/tike/admm/pal.py @@ -0,0 +1,289 @@ +import logging + + +import numpy as np +import cupy as cp +import dxchange + +import tike.align +from tike.communicator import MPICommunicator +import tike.ptycho +import tike.lamino + +from .admm import * + +def ptycho_lamino_align( + data, + psi, + scan, + probe, + theta, + tilt, + u=None, + flow=None, + niter=1, + folder=None, + fixed_crop=False, + angle=0, + w=256 + 64 + 16 + 8, + cg_iter=4, + shift=None, + interval=8, + winsize=None, + align_method='', +): + """Solve the joint ptycho-lamino-alignment problem using ADMM.""" + u = np.zeros((w, w, w), dtype='complex64') + Hu = np.ones((len(theta), w, w), dtype='complex64') + phi = Hu + TDPφ = np.ones(psi.shape, dtype='complex64') + flow = np.zeros([*psi.shape, 2], dtype='float32') + winsize = min(*psi.shape[-2:]) if winsize is None else winsize + presult = { # ptychography result + 'psi': np.ones(psi.shape, dtype='complex64'), + 'scan': scan, + 'probe': probe, + } + λ_p = np.zeros_like(psi) + ρ_p = 0.5 + λ_l = np.zeros([len(theta), w, w], dtype='complex64') + ρ_l = 0.5 + comm = MPICommunicator() + with cp.cuda.Device(comm.rank if comm.size > 1 else None): + + for k in range(niter): + logging.info(f"Start ADMM iteration {k}.") + + logging.info("Solve the ptychography problem.") + presult = tike.ptycho.reconstruct( + data=data, + reg=λ_p / ρ_p - TDPφ, + rho=ρ_p, + algorithm='combined', + num_iter=1, + cg_iter=cg_iter, + recover_psi=True, + recover_probe=True, + recover_positions=False, + model='gaussian', + **presult, + ) + psi = presult['psi'] + + rotated = tike.align.simulate( + psi + λ_p / ρ_p, + angle=-angle, + flow=None, + padded_shape=None, + cval=1.0, + ) + if (k + 1) == 8: + dxchange.write_tiff( + rotated.real, + f'{folder}/{comm.rank}-rotated-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + rotated.imag, + f'{folder}/{comm.rank}-rotated-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + logging.info("Recover aligned projections from unaligned.") + aresult = tike.align.reconstruct( + unaligned=psi + λ_p / ρ_p, + original=phi, + flow=flow, + angle=angle, + num_iter=cg_iter, + algorithm='cgrad', + reg=Hu - λ_l / ρ_l, + rho_p=ρ_p, + rho_a=ρ_l, + cval=1.0, + ) + phi = aresult['original'] + + padded = tike.align.simulate( + phi, + angle=None, + flow=None, + padded_shape=psi.shape, + cval=1.0, + ) + if comm.rank == 0 and (k + 1) % interval == 0: + dxchange.write_tiff( + rotated.real, + f'{folder}/rotated-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + rotated.imag, + f'{folder}/rotated-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + padded.real, + f'{folder}/padded-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + padded.imag, + f'{folder}/padded-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + if align_method.lower() == 'flow': + winsize = max(winsize - 1, 24) + logging.info("Estimate alignment using Farneback.") + fresult = tike.align.solvers.farneback( + op=None, + unaligned=rotated, + original=padded, + flow=flow, + pyr_scale=0.5, + levels=4, + winsize=winsize, + num_iter=32, + ) + flow = fresult['flow'] + elif align_method.lower() == 'tvl1': + logging.info("Estimate alignment using TV-L1.") + flow = optical_flow_tvl1( + unaligned=rotated, + original=padded, + num_iter=8, + ) + else: + logging.info("Estimate rigid alignment with cross correlation.") + sresult = tike.align.reconstruct( + algorithm='cross_correlation', + unaligned=rotated, + original=padded, + upsample_factor=100, + ) + flow[:] = sresult['shift'][:, None, None, :] + + # Gather all to one thread + λ_l, phi, theta = [comm.gather(x) for x in (λ_l, phi, theta)] + + if comm.rank == 0: + logging.info('Solve the laminography problem.') + lresult = tike.lamino.reconstruct( + data=-1j * np.log(phi + λ_l / ρ_l), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + cg_iter=cg_iter, + ) + u = lresult['obj'] + + # We cannot reorder phi, theta without ruining correspondence + # with data, psi, etc, but we can reorder the saved array + if (k + 1) % interval == 0: + order = np.argsort(theta) + dxchange.write_tiff( + phi[order].real, + f'{folder}/phi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + phi[order].imag, + f'{folder}/phi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + + # Separate again to multiple threads + λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] + u = comm.broadcast(u) + + logging.info('Update lambdas and rhos.') + + TDPφ = tike.align.simulate( + phi, + angle=angle, + flow=flow, + padded_shape=psi.shape, + cval=1.0, + ) + ψTDPφ = psi - TDPφ + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + φHu = phi - Hu + λ_p += ρ_p * ψTDPφ + λ_l += ρ_l * φHu + + if k > 0: + ρ_p = update_penalty(comm, psi, TDPφ, TDPφ0, ρ_p) + ρ_l = update_penalty(comm, phi, Hu, Hu0, ρ_l) + Hu0 = Hu + TDPφ0 = TDPφ + + lagrangian = ( + [presult['cost']], + [ + 2 * np.real(λ_p.conj() * ψTDPφ) + + ρ_p * np.linalg.norm(ψTDPφ.ravel())**2 + ], + [ + 2 * np.real(λ_l.conj() * φHu) + + ρ_l * np.linalg.norm(φHu.ravel())**2 + ], + ) + lagrangian = [comm.gather(x) for x in lagrangian] + acost = comm.gather([aresult['cost']]) + + if comm.rank == 0: + lagrangian = [np.sum(x) for x in lagrangian] + print_log_line( + k=k, + ρ_p=ρ_p, + ρ_l=ρ_l, + # shift=np.linalg.norm(shift[:, None, None, :] - flow), + winsize=winsize, + alignment=np.sum(acost), + laminography=float(lresult['cost']), + Lagrangian=np.sum(lagrangian), + dGψ=lagrangian[0], + ψDφ=lagrangian[1], + φHu=lagrangian[2], + ) + if comm.rank == 0 and (k + 1) % interval == 0: + dxchange.write_tiff( + psi.real, + f'{folder}/psi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + psi.imag, + f'{folder}/psi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.real, + f'{folder}/Hu-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.imag, + f'{folder}/Hu-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + np.save(f"{folder}/flow-{(k+1):03d}", flow) + + return u diff --git a/src/tike/admm/pl.py b/src/tike/admm/pl.py new file mode 100644 index 00000000..69d8a298 --- /dev/null +++ b/src/tike/admm/pl.py @@ -0,0 +1,144 @@ +# Does not converge!? +def ptycho_lamino( + data, + psi, + scan, + probe, + theta, + tilt, + u=None, + flow=None, + niter=1, + folder=None, + fixed_crop=True, + angle=0, #-72.035 / 180 * np.pi + w=256 + 64, +): + """Solve the joint ptycho-lamino problem using ADMM.""" + u = np.zeros((w, w, w), dtype='complex64') + Hu = np.ones((len(theta), w, w), dtype='complex64') + presult = { # ptychography result + 'psi': np.ones(psi.shape, dtype='complex64'), + 'scan': scan, + 'probe': probe, + } + λ_p = np.zeros_like(psi) + ρ_p = 0.5 + comm = MPICommunicator() + with cp.cuda.Device(comm.rank): + for k in range(niter): + logging.info(f"Start ADMM iteration {k}.") + + logging.info("Solve the ptychography problem.") + + logging.info("Solve the ptychography problem.") + presult = tike.ptycho.reconstruct( + data=data, + reg=λ_p / ρ_p - Hu, + rho=ρ_p, + algorithm='combined', + num_iter=1, + cg_iter=4, + recover_psi=True, + recover_probe=True, + recover_positions=False, + model='gaussian', + **presult, + ) + psi = presult['psi'] + + # Gather all to one thread + psi, theta, λ_p = [comm.gather(x) for x in (psi, theta, λ_p)] + + if comm.rank == 0: + logging.info('Solve the laminography problem.') + lresult = tike.lamino.reconstruct( + data=-1j * np.log(psi + λ_p / ρ_p), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + cg_iter=4, + ) + u = lresult['obj'] + + # Separate again to multiple threads + psi, theta, λ_p = [comm.scatter(x) for x in (psi, theta, λ_p)] + u = comm.broadcast(u) + + logging.info('Update lambdas and rhos.') + + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + ψHu = psi - Hu + λ_p += ρ_p * ψHu + + if k > 0: + ρ_p = update_penalty(comm, psi, Hu, Hu0, ρ_p) + Hu0 = Hu + + lagrangian = ( + [presult['cost']], + [ + 2 * np.real(λ_p.conj() * ψHu) + + ρ_p * np.linalg.norm(ψHu.ravel())**2 + ], + ) + lagrangian = [comm.gather(x) for x in lagrangian] + + if comm.rank == 0: + lagrangian = [np.sum(x) for x in lagrangian] + print( + f"k: {k:03d}, ρ_p: {ρ_p:6.3e}, " + f"laminography: {lresult['cost']:+6.3e} " + 'Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e}'.format( + np.sum(lagrangian), *lagrangian), + flush=True, + ) + dxchange.write_tiff( + psi.real, + f'{folder}/psi-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + psi.imag, + f'{folder}/psi-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.real, + f'{folder}/TPHu-real-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.imag, + f'{folder}/TPHu-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + (λ_p / ρ_p).imag, + f'{folder}/lamb-imag-{(k+1):03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + (λ_p / ρ_p).real, + f'{folder}/lamb-real-{(k+1):03d}.tiff', + dtype='float32', + ) + + result = presult + return result From 123db2a4cd5cff5663c6f20cbfef9d6ab1b7c7d7 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 4 Feb 2021 11:06:35 -0600 Subject: [PATCH 052/109] BUG: Use float32 for Farneback API --- src/tike/admm/al.py | 4 ++-- src/tike/align/solvers/farneback.py | 9 ++++++++- tests/test_align.py | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index 33839311..a5b8ce34 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -112,8 +112,8 @@ def lamino_align( logging.info("Estimate alignment using Farneback.") fresult = tike.align.solvers.farneback( op=None, - unaligned=rotated, - original=padded, + unaligned=np.angle(rotated), + original=np.angle(padded), flow=flow, pyr_scale=0.5, levels=4, diff --git a/src/tike/align/solvers/farneback.py b/src/tike/align/solvers/farneback.py index 454c29d5..24b9cda3 100644 --- a/src/tike/align/solvers/farneback.py +++ b/src/tike/align/solvers/farneback.py @@ -66,6 +66,8 @@ def farneback( Expansion" 2003. """ shape = original.shape + assert original.dtype == 'float32', original.dtype + assert unaligned.dtype == 'float32', unaligned.dtype if flow is None: flow = np.zeros((*shape, 2), dtype='float32') @@ -76,7 +78,12 @@ def farneback( # Farneback implementation. for i in range(len(original)): flow[i] = calcOpticalFlowFarneback( - *_rescale_8bit(np.abs(original[i]), np.abs(unaligned[i])), + *_rescale_8bit( + original[i], + unaligned[i], + hi=hi[i] if hi is not None else None, + lo=lo[i] if lo is not None else None, + ), flow=flow[i], pyr_scale=pyr_scale, levels=levels, diff --git a/tests/test_align.py b/tests/test_align.py index 00c14d27..08536dde 100644 --- a/tests/test_align.py +++ b/tests/test_align.py @@ -91,8 +91,8 @@ def test_align_farneback(self): """Check that align.solvers.farneback works.""" result = tike.align.solvers.farneback( op=None, - unaligned=self.data, - original=self.original, + unaligned=np.angle(self.data), + original=np.angle(self.original), ) shift = result['flow'] assert shift.dtype == 'float32', shift.dtype From 1f148883d6c7837df204848fb2d34c90f2a86461 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 4 Feb 2021 13:28:26 -0600 Subject: [PATCH 053/109] NEW: Add Shift operator to Alignment composition --- src/tike/operators/cupy/alignment.py | 28 +++++++++++++++++---------- tests/data/algin_setup.pickle.lzma | Bin 144524 -> 204664 bytes tests/operators/test_alignment.py | 2 ++ tests/test_align.py | 20 ++++++++++++------- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/tike/operators/cupy/alignment.py b/src/tike/operators/cupy/alignment.py index f2e46403..cf4dbf5f 100644 --- a/src/tike/operators/cupy/alignment.py +++ b/src/tike/operators/cupy/alignment.py @@ -44,6 +44,7 @@ def __exit__(self, type, value, traceback): def fwd( self, unpadded, + shift, flow, padded_shape, angle, @@ -52,10 +53,13 @@ def fwd( ): return self.rotate.fwd( unrotated=self.flow.fwd( - f=self.pad.fwd( - unpadded=unpadded, - padded_shape=padded_shape, - cval=cval, + f=self.shift.fwd( + a=self.pad.fwd( + unpadded=unpadded, + padded_shape=padded_shape, + cval=cval, + ), + shift=shift, ), flow=flow, cval=cval, @@ -68,20 +72,24 @@ def adj( self, rotated, flow, + shift, unpadded_shape, angle, padded_shape=None, cval=0.0, ): return self.pad.adj( - padded=self.flow.adj( - g=self.rotate.adj( - rotated=rotated, - angle=angle, + padded=self.shift.adj( + a=self.flow.adj( + g=self.rotate.adj( + rotated=rotated, + angle=angle, + cval=cval, + ), + flow=flow, cval=cval, ), - flow=flow, - cval=cval, + shift=shift, ), unpadded_shape=unpadded_shape, cval=cval, diff --git a/tests/data/algin_setup.pickle.lzma b/tests/data/algin_setup.pickle.lzma index 85a474fdafa687d17086c2ce7b79810ab5c397ca..fa538077890dbe3edbe54e95f34886997b992a37 100644 GIT binary patch literal 204664 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 Ut&}`J07j|P@B7sVH3;-=P|H5`s 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 K000001X)^F8!EH_ literal 144524 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

|mZTn$3!+PMS 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 O#Ao{g000001X)@Sq5oL` diff --git a/tests/operators/test_alignment.py b/tests/operators/test_alignment.py index 4304e14e..bb08778a 100644 --- a/tests/operators/test_alignment.py +++ b/tests/operators/test_alignment.py @@ -26,6 +26,7 @@ def setUp(self, shape=(7, 5, 5)): padded_shape = shape + np.asarray((0, 41, 32)) flow = (self.xp.random.rand(*padded_shape, 2, dtype='float32') - 0.5) * 9 + shift = self.xp.random.rand(*shape[:-2], 2) - 0.5 np.random.seed(0) self.m = self.xp.asarray(random_complex(*shape), dtype='complex64') @@ -35,6 +36,7 @@ def setUp(self, shape=(7, 5, 5)): self.d_name = 'rotated' self.kwargs = { 'flow': flow, + 'shift': shift, 'padded_shape': padded_shape, 'unpadded_shape': shape, 'angle': np.random.rand() * 2 * np.pi, diff --git a/tests/test_align.py b/tests/test_align.py index 08536dde..6a348bf9 100644 --- a/tests/test_align.py +++ b/tests/test_align.py @@ -22,11 +22,9 @@ def create_dataset(self, dataset_file): Only called with setUp detects that `dataset_file` has been deleted. """ - import matplotlib.pyplot as plt - amplitude = plt.imread( - os.path.join(testdir, "data/Cryptomeria_japonica-0128.png")) - phase = plt.imread( - os.path.join(testdir, "data/Bombus_terrestris-0128.png")) + import libimage + amplitude = libimage.load("cryptomeria", 128) + phase = libimage.load("bombus", 128) original = amplitude * np.exp(1j * phase * np.pi) self.original = np.expand_dims(original, axis=0).astype('complex64') @@ -34,9 +32,12 @@ def create_dataset(self, dataset_file): self.flow = np.empty((*self.original.shape, 2), dtype='float32') self.flow[..., :] = 5 * (np.random.rand(2) - 0.5) + self.shift = 2 * (np.random.rand(*self.original.shape[:-2], 2) - 0.5) + self.data = tike.align.simulate( original=self.original, flow=self.flow, + shift=self.shift, padded_shape=None, angle=None, ) @@ -45,6 +46,7 @@ def create_dataset(self, dataset_file): self.data, self.original, self.flow, + self.shift, ] with lzma.open(dataset_file, 'wb') as file: @@ -60,6 +62,7 @@ def setUp(self): self.data, self.original, self.flow, + self.shift, ] = pickle.load(file) def test_consistent_simulate(self): @@ -67,6 +70,7 @@ def test_consistent_simulate(self): data = tike.align.simulate( original=self.original, flow=self.flow, + shift=self.shift, padded_shape=None, angle=None, ) @@ -85,7 +89,9 @@ def test_align_cross_correlation(self): shift = result['shift'] assert shift.dtype == 'float32', shift.dtype # np.testing.assert_array_equal(shift.shape, self.shift.shape) - np.testing.assert_allclose(shift, self.flow[:, 0, 0], atol=1e-1) + np.testing.assert_allclose(shift, + self.flow[:, 0, 0] + self.shift, + atol=1e-1) def test_align_farneback(self): """Check that align.solvers.farneback works.""" @@ -99,7 +105,7 @@ def test_align_farneback(self): np.testing.assert_array_equal(shift.shape, (*self.original.shape, 2)) h, w = shift.shape[1:3] np.testing.assert_allclose(shift[:, h // 2, w // 2, :], - self.flow[:, 0, 0], + self.flow[:, 0, 0] + self.shift, atol=1e-1) From e643da2f8d86ecc9a6d53104b6bda9c286c08be4 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 5 Feb 2021 19:26:32 -0600 Subject: [PATCH 054/109] API: Make laminography theta a parameter instead of constant Because: - This is required in order to divide the operator amongst multiple devices. --- src/tike/lamino/lamino.py | 11 +++++-- src/tike/lamino/solvers/cgrad.py | 10 +++---- src/tike/operators/cupy/lamino.py | 48 +++++++++++++++++++------------ tests/operators/test_lamino.py | 5 ++-- tests/test_lamino.py | 1 + 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/tike/lamino/lamino.py b/src/tike/lamino/lamino.py index 8e685978..91791f69 100644 --- a/src/tike/lamino/lamino.py +++ b/src/tike/lamino/lamino.py @@ -75,11 +75,13 @@ def simulate( assert theta.ndim == 1 with Lamino( n=obj.shape[-1], - theta=theta, tilt=tilt, **kwargs, ) as operator: - data = operator.fwd(u=operator.asarray(obj, dtype='complex64')) + data = operator.fwd( + u=operator.asarray(obj, dtype='complex64'), + theta=operator.asarray(theta, dtype='float32'), + ) assert data.dtype == 'complex64', data.dtype return operator.asnumpy(data) @@ -110,7 +112,6 @@ def reconstruct( # Initialize an operator. with Lamino( n=obj.shape[-1], - theta=theta, tilt=tilt, eps=eps, **kwargs, @@ -119,6 +120,9 @@ def reconstruct( data = np.array_split(data.astype('complex64'), comm.pool.num_workers) data = comm.pool.scatter(data) + theta = np.array_split(theta.astype('float32'), + comm.pool.num_workers) + theta = comm.pool.scatter(theta) result = { 'obj': comm.pool.bcast(obj.astype('complex64')), } @@ -136,6 +140,7 @@ def reconstruct( operator, comm, data=data, + theta=theta, **kwargs, ) # Check for early termination diff --git a/src/tike/lamino/solvers/cgrad.py b/src/tike/lamino/solvers/cgrad.py index bdfcc673..ffc59569 100644 --- a/src/tike/lamino/solvers/cgrad.py +++ b/src/tike/lamino/solvers/cgrad.py @@ -8,29 +8,29 @@ def cgrad( op, comm, - data, obj, + data, theta, obj, cg_iter=4, **kwargs ): # yapf: disable """Solve the Laminogarphy problem using the conjugate gradients method.""" - obj, cost = update_obj(op, comm, data, obj, num_iter=cg_iter) + obj, cost = update_obj(op, comm, data, theta, obj, num_iter=cg_iter) return {'obj': obj, 'cost': cost} -def update_obj(op, comm, data, obj, num_iter=1): +def update_obj(op, comm, data, theta, obj, num_iter=1): """Solver the object recovery problem.""" def cost_function(obj): - cost_out = comm.pool.map(op.cost, data, obj) + cost_out = comm.pool.map(op.cost, data, theta, obj) if comm.use_mpi: return comm.Allreduce_reduce(cost_out, 'cpu') else: return comm.reduce(cost_out, 'cpu') def grad(obj): - grad_list = comm.pool.map(op.grad, data, obj) + grad_list = comm.pool.map(op.grad, data, theta, obj) if comm.use_mpi: return comm.Allreduce_reduce(grad_list, 'gpu') else: diff --git a/src/tike/operators/cupy/lamino.py b/src/tike/operators/cupy/lamino.py index 37c9f472..5524bd1d 100644 --- a/src/tike/operators/cupy/lamino.py +++ b/src/tike/operators/cupy/lamino.py @@ -24,8 +24,6 @@ class Lamino(CachedFFT, Operator): ---------- n : int The pixel width of the cubic reconstructed grid. - theta : array-like float32 - The projection angles; rotation around the vertical axis of the object. tilt : float32 The tilt angle; the angle between the rotation axis of the object and the light source. π / 2 for conventional tomography. 0 for a beam path @@ -38,16 +36,16 @@ class Lamino(CachedFFT, Operator): corresponding to the rotation axis. data : (ntheta, n, n) complex64 The complex projection data of the object. + theta : array-like float32 + The projection angles; rotation around the vertical axis of the object. """ - def __init__(self, n, theta, tilt, eps=1e-3, + def __init__(self, n, tilt, eps=1e-3, **kwargs): # noqa: D102 yapf: disable """Please see help(Lamino) for more info.""" self.n = n - self.ntheta = len(theta) - self.tilt = self.xp.asarray(tilt) + self.tilt = tilt self.eps = eps - self.xi = self._make_grids(self.xp.asarray(theta)) def __enter__(self): """Return self at start of a with-block.""" @@ -58,9 +56,11 @@ def __enter__(self): self.gather_kernel = cp.RawKernel(_cu_source, "gather") return self - def fwd(self, u, **kwargs): + def fwd(self, u, theta, **kwargs): """Perform the forward Laminography transform.""" + xi = self._make_grids(theta) + def gather(xp, Fe, x, n, m, mu): return self.gather(Fe, x, n, m, mu) @@ -68,8 +68,8 @@ def fftn(*args, **kwargs): return self._fftn(*args, overwrite=True, **kwargs) # USFFT from equally-spaced grid to unequally-spaced grid - F = eq2us(u, self.xi, self.n, self.eps, self.xp, gather, - fftn).reshape([self.ntheta, self.n, self.n]) + F = eq2us(u, xi, self.n, self.eps, self.xp, gather, + fftn).reshape([theta.shape[-1], self.n, self.n]) # Inverse 2D FFT data = checkerboard( @@ -88,9 +88,11 @@ def fftn(*args, **kwargs): ) return data - def adj(self, data, overwrite=False, **kwargs): + def adj(self, data, theta, overwrite=False, **kwargs): """Perform the adjoint Laminography transform.""" + xi = self._make_grids(theta) + def scatter(xp, f, x, n, m, mu): return self.scatter(f, x, n, m, mu) @@ -114,7 +116,7 @@ def fftn(*args, **kwargs): ).ravel() # Inverse (x->-x) USFFT from unequally-spaced grid to equally-spaced # grid - u = us2eq(F, -self.xi, self.n, self.eps, self.xp, scatter, fftn) + u = us2eq(F, -xi, self.n, self.eps, self.xp, scatter, fftn) u /= self.n**2 return u @@ -152,13 +154,22 @@ def gather(self, Fe, x, n, m, mu): )) return F - def cost(self, data, obj): + def cost(self, data, theta, obj): "Cost function for the least-squres laminography problem" - return self.xp.linalg.norm((self.fwd(obj) - data).ravel())**2 + return self.xp.linalg.norm((self.fwd( + u=obj, + theta=theta, + ) - data).ravel())**2 - def grad(self, data, obj): + def grad(self, data, theta, obj): "Gradient for the least-squares laminography problem" - return self.adj(data=self.fwd(obj) - data) / (self.ntheta * self.n**3) + return self.adj( + data=self.fwd( + u=obj, + theta=theta, + ) - data, + theta=theta, + ) / (data.shape[-3] * self.n**3) def _make_grids(self, theta): """Return (ntheta*n*n, 3) unequally-spaced frequencies for the USFFT.""" @@ -166,9 +177,10 @@ def _make_grids(self, theta): -self.n // 2:self.n // 2] / self.n ku = ku.ravel().astype('float32') kv = kv.ravel().astype('float32') - xi = self.xp.zeros([self.ntheta, self.n * self.n, 3], dtype='float32') + xi = self.xp.zeros([theta.shape[-1], self.n * self.n, 3], + dtype='float32') ctilt, stilt = self.xp.cos(self.tilt), self.xp.sin(self.tilt) - for itheta in range(self.ntheta): + for itheta in range(theta.shape[-1]): ctheta = self.xp.cos(theta[itheta]) stheta = self.xp.sin(theta[itheta]) xi[itheta, :, 2] = ku * ctheta + kv * stheta * ctilt @@ -178,4 +190,4 @@ def _make_grids(self, theta): xi[xi >= 0.5] = 0.5 - 1e-5 xi[xi < -0.5] = -0.5 + 1e-5 - return xi.reshape(self.ntheta * self.n * self.n, 3) + return xi.reshape(theta.shape[-1] * self.n * self.n, 3) diff --git a/tests/operators/test_lamino.py b/tests/operators/test_lamino.py index 7cc45041..6da2b37b 100644 --- a/tests/operators/test_lamino.py +++ b/tests/operators/test_lamino.py @@ -19,7 +19,6 @@ class TestLamino(unittest.TestCase, OperatorTests): def setUp(self, n=16, ntheta=8, tilt=np.pi / 3, eps=1e-6): self.operator = Lamino( n=n, - theta=np.linspace(0, 2 * np.pi, ntheta), tilt=tilt, eps=eps, ) @@ -31,7 +30,9 @@ def setUp(self, n=16, ntheta=8, tilt=np.pi / 3, eps=1e-6): self.d = self.xp.asarray(random_complex(ntheta, n, n), dtype='complex64') self.d_name = 'data' - self.kwargs = {} + self.kwargs = { + 'theta': self.xp.linspace(0, 2 * np.pi, ntheta) + } print(self.operator) @unittest.skip('FIXME: This operator is not scaled.') diff --git a/tests/test_lamino.py b/tests/test_lamino.py index 52cf2f9f..a0bb08f2 100644 --- a/tests/test_lamino.py +++ b/tests/test_lamino.py @@ -132,6 +132,7 @@ def template_consistent_algorithm(self, algorithm): tilt=self.tilt, algorithm=algorithm, num_iter=1, + num_gpu=2, ) recon_file = os.path.join(testdir, From e123336225226d5584ab168877ebcf9c8459cf93 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Mon, 8 Feb 2021 13:32:14 -0600 Subject: [PATCH 055/109] NEW: Add regularizer to cross_correlation --- src/tike/align/solvers/cross_correlation.py | 38 ++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/tike/align/solvers/cross_correlation.py b/src/tike/align/solvers/cross_correlation.py index 9b7f6def..87b029e7 100644 --- a/src/tike/align/solvers/cross_correlation.py +++ b/src/tike/align/solvers/cross_correlation.py @@ -36,6 +36,7 @@ def cross_correlation( upsample_factor=1, space="real", num_iter=None, + reg_weight=1e-6, ): """Efficient subpixel image translation alignment by cross-correlation. @@ -75,7 +76,16 @@ def cross_correlation( shape = src_freq.shape image_product = src_freq * target_freq.conj() cross_correlation = op.xp.fft.ifft2(image_product) - A = np.abs(cross_correlation) + + # Add a small regularization term so that smaller shifts are preferred when + # the cross_correlation is the same for multiple shifts. + if reg_weight > 0: + w = _area_overlap(op, cross_correlation) + w = op.xp.fft.fftshift(w) * reg_weight + else: + w = 0 + + A = np.abs(cross_correlation) + w maxima = A.reshape(A.shape[0], -1).argmax(1) maxima = np.column_stack(np.unravel_index(maxima, A[0, :, :].shape)) shifts = op.xp.array(maxima, dtype='float32') @@ -125,3 +135,29 @@ def _upsampled_dft(op, data, ups, upsample_factor, axis_offsets): op.xp.fft.fftfreq(shape[1], upsample_factor)) kernel = np.exp(im2pi * kernel) return np.einsum('ijk,ipk->ijp', kernel, data) + + +def _triangle(op, N): + """Return N samples from the triangle function.""" + x = op.xp.linspace(0, 1, N, endpoint=False) + 0.5 / N + return 1 - abs(x - 0.5) + + +def _area_overlap(op, A): + """Return overlapping area of A with itself. + + Create overlap arrays for higher dimensions using matrix multiplication. + + >>> _area_overlap(np.empty(4)) + array([0.625, 0.875, 0.875, 0.625]) + >>> _area_overlap(np.empty((3, 5))) + array([[0.4 , 0.53333333, 0.66666667, 0.53333333, 0.4 ], + [0.6 , 0.8 , 1. , 0.8 , 0.6 ], + [0.4 , 0.53333333, 0.66666667, 0.53333333, 0.4 ]]) + """ + for dim, shape in enumerate(A.shape[-2:]): + if dim == 0: + w = _triangle(op, shape) + else: + w = w[..., np.newaxis] @ _triangle(op, shape)[np.newaxis, ...] + return w From cbfe16ffc6edb4aade5fc73f9c89b972f2d06a31 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 9 Feb 2021 19:55:19 -0600 Subject: [PATCH 056/109] REF: Modularize admm subproblems into separate functions --- src/tike/admm/alignment.py | 155 +++++++++++++++++++++++++ src/tike/admm/lamino.py | 124 ++++++++++++++++++++ src/tike/admm/pl.py | 229 +++++++++++++++++++------------------ src/tike/admm/ptycho.py | 45 ++++++++ 4 files changed, 441 insertions(+), 112 deletions(-) create mode 100644 src/tike/admm/alignment.py create mode 100644 src/tike/admm/lamino.py create mode 100644 src/tike/admm/ptycho.py diff --git a/src/tike/admm/alignment.py b/src/tike/admm/alignment.py new file mode 100644 index 00000000..f44875b6 --- /dev/null +++ b/src/tike/admm/alignment.py @@ -0,0 +1,155 @@ +import logging + +import tike.align + +logger = logging.getLogger(__name__) + + +def subproblem( + # constants + comm, + psi, + angle, + Hu, + λ_l, + ρ_l, + # updated + phi, + λ_p, + ρ_p, + flow, + shift, + Aφ0=None, + # parameters + align_method=False, + cg_iter=1, + folder=None, + save_result=False, +): + """ + Parameters + ---------- + psi + ptychography result. psi = A(phi) + angle + alignment rotation angle + Hu + Forward model of tomography phi = Hu + """ + + logging.info("Solve alignment subproblem.") + + save_result = False if folder is None else save_result + + aresult = tike.align.reconstruct( + unaligned=psi + λ_p / ρ_p, + original=phi, + flow=flow, + shift=shift, + angle=angle, + num_iter=cg_iter, + algorithm='cgrad', + reg=Hu - λ_l / ρ_l, + rho_p=ρ_p, + rho_a=ρ_l, + cval=1.0, + ) + phi = aresult['original'] + + if align_method: + + hi, lo = find_min_max(np.angle(psi + λ_p / ρ_p)) + + rotated = tike.align.simulate( + psi + λ_p / ρ_p, + angle=-angle, + flow=None, + shift=None, + padded_shape=None, + cval=1.0, + ) + padded = tike.align.simulate( + phi, + angle=None, + flow=None, + shift=None, + padded_shape=psi.shape, + cval=1.0, + ) + + if comm.rank == 0 and save_result: + dxchange.write_tiff( + np.angle(rotated), + f'{folder}/rotated-angle-{save_result:03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + np.angle(padded), + f'{folder}/padded-angle-{save_result:03d}.tiff', + dtype='float32', + ) + + if align_method.lower() == 'flow': + winsize = max(winsize - 1, 128) + logging.info("Estimate alignment using Farneback.") + fresult = tike.align.solvers.farneback( + op=None, + unaligned=np.angle(rotated), + original=np.angle(padded), + flow=flow, + pyr_scale=0.5, + levels=4, + winsize=winsize, + num_iter=32, + hi=hi, + lo=lo, + ) + flow = fresult['flow'] + elif align_method.lower() == 'tvl1': + logging.info("Estimate alignment using TV-L1.") + flow = optical_flow_tvl1( + unaligned=rotated, + original=padded, + num_iter=cg_iter, + ) + elif align_method.lower() == 'xcor': + logging.info("Estimate rigid alignment with cross correlation.") + sresult = tike.align.reconstruct( + algorithm='cross_correlation', + unaligned=rotated, + original=padded, + upsample_factor=100, + ) + # Limit shift change per iteration + if shift is None: + shift = np.clip(sresult['shift'], -16, 16) + else: + shift += np.clip(sresult['shift'] - shift, -16, 16) + + Aφ = tike.align.simulate( + phi, + angle=angle, + flow=flow, + shift=shift, + padded_shape=psi.shape, + cval=1.0, + ) + ψAφ = psi - Aφ + + logger.info("Update alignment lambdas and rhos") + + λ_p += ρ_p * ψAφ + + if Aφ0 is not None: + ρ_p = update_penalty(comm, psi, Aφ, Aφ0, ρ_p) + + Aφ0 = Aφ + + return ( + phi, + λ_p, + ρ_p, + flow, + shift, + Aφ0, + ) diff --git a/src/tike/admm/lamino.py b/src/tike/admm/lamino.py new file mode 100644 index 00000000..95131a77 --- /dev/null +++ b/src/tike/admm/lamino.py @@ -0,0 +1,124 @@ +import logging + +import dxchange +import numpy as np + +import tike.lamino +from .admm import update_penalty + +logger = logging.getLogger(__name__) + + +def subproblem( + # constants + comm, + phi, + theta, + tilt, + # updated + u, + λ_l, + ρ_l, + Hu0=None, + # parameters + cg_iter=1, + folder=None, + save_result=False, +): + """Solver the laminography subproblem. + + Parameters + ---------- + phi + Exponentiated projections through the object, u. + u + Refractive indices of the object. + theta + Rotation angle of each projection, phi + tilt + The off-rotation axis angle of laminography + + """ + logger.info('Solve the laminography problem.') + + save_result = False if folder is None else save_result + + # Gather all to one process + λ_l, phi, theta = [comm.gather(x) for x in (λ_l, phi, theta)] + + if comm.rank == 0: + lresult = tike.lamino.reconstruct( + data=-1j * np.log(phi + λ_l / ρ_l), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + cg_iter=cg_iter, + num_gpu=comm.size, + ) + u = lresult['obj'] + + # We cannot reorder phi, theta without ruining correspondence + # with data, psi, etc, but we can reorder the saved array + if save_result: + order = np.argsort(theta) + dxchange.write_tiff( + phi[order].real, + f'{folder}/phi-real-{save_result:03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + phi[order].imag, + f'{folder}/phi-imag-{save_result:03d}.tiff', + dtype='float32', + ) + + # Separate again to multiple processes + λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] + u = comm.broadcast(u) + + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + φHu = phi - Hu + + logger.info('Update laminography lambdas and rhos.') + + λ_l += ρ_l * φHu + + if Hu0 is not None: + ρ_l = update_penalty(comm, phi, Hu, Hu0, ρ_l) + + Hu0 = Hu + + if comm.rank == 0 and save_result: + dxchange.write_tiff( + u.real, + f'{folder}/particle-real-{save_result:03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + u.imag, + f'{folder}/particle-imag-{save_result:03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.real, + f'{folder}/Hu-real-{save_result:03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + Hu.imag, + f'{folder}/Hu-imag-{save_result:03d}.tiff', + dtype='float32', + ) + + return ( + u, + λ_l, + ρ_l, + Hu0, + ) diff --git a/src/tike/admm/pl.py b/src/tike/admm/pl.py index 69d8a298..e2af0734 100644 --- a/src/tike/admm/pl.py +++ b/src/tike/admm/pl.py @@ -1,4 +1,18 @@ -# Does not converge!? +import logging + +import numpy as np +import cupy as cp + +import tike.admm.alignment +import tike.admm.lamino +import tike.admm.ptycho +import tike.communicator + +from .admm import print_log_line + +logger = logging.getLogger(__name__) + + def ptycho_lamino( data, psi, @@ -6,139 +20,130 @@ def ptycho_lamino( probe, theta, tilt, - u=None, - flow=None, + angle, + w, + flow=False, + shift=False, niter=1, + interval=8, folder=None, - fixed_crop=True, - angle=0, #-72.035 / 180 * np.pi - w=256 + 64, + cg_iter=4, + align_method=False, ): """Solve the joint ptycho-lamino problem using ADMM.""" + presult = { + 'psi': psi, + 'scan': scan, + 'probe': probe, + } + u = np.zeros((w, w, w), dtype='complex64') Hu = np.ones((len(theta), w, w), dtype='complex64') - presult = { # ptychography result - 'psi': np.ones(psi.shape, dtype='complex64'), - 'scan': scan, - 'probe': probe, - } + phi = Hu + Aφ = np.ones(psi.shape, dtype='complex64') + λ_p = np.zeros_like(psi) ρ_p = 0.5 - comm = MPICommunicator() - with cp.cuda.Device(comm.rank): - for k in range(niter): - logging.info(f"Start ADMM iteration {k}.") - - logging.info("Solve the ptychography problem.") - - logging.info("Solve the ptychography problem.") - presult = tike.ptycho.reconstruct( - data=data, - reg=λ_p / ρ_p - Hu, - rho=ρ_p, - algorithm='combined', - num_iter=1, - cg_iter=4, - recover_psi=True, - recover_probe=True, - recover_positions=False, - model='gaussian', - **presult, - ) - psi = presult['psi'] - # Gather all to one thread - psi, theta, λ_p = [comm.gather(x) for x in (psi, theta, λ_p)] + λ_l = np.zeros([len(theta), w, w], dtype='complex64') + ρ_l = 0.5 - if comm.rank == 0: - logging.info('Solve the laminography problem.') - lresult = tike.lamino.reconstruct( - data=-1j * np.log(psi + λ_p / ρ_p), - theta=theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - cg_iter=4, - ) - u = lresult['obj'] + comm = tike.communicator.MPICommunicator() - # Separate again to multiple threads - psi, theta, λ_p = [comm.scatter(x) for x in (psi, theta, λ_p)] - u = comm.broadcast(u) + with cp.cuda.Device(comm.rank if comm.size > 1 else None): + for k in range(1, niter + 1): + logger.info(f"Start ADMM iteration {k}.") + save_result = k if k % interval == 0 else False - logging.info('Update lambdas and rhos.') + presult = tike.admm.ptycho.subproblem( + # constants + data, + λ_p=λ_p, + ρ_p=ρ_p, + Aφ=Aφ, + # updated + presult=presult, + # parameters + cg_iter=1, + folder=folder, + save_result=save_result, + ) - Hu = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) - ψHu = psi - Hu - λ_p += ρ_p * ψHu + ( + phi, + λ_p, + ρ_p, + flow, + shift, + Aφ, + ) = tike.admm.alignment.subproblem( + # constants + comm, + presult['psi'], + angle, + Hu, + λ_l, + ρ_l, + # updated + phi, + λ_p, + ρ_p, + flow=None, + shift=None, + Aφ0=Aφ, + # parameters + align_method='', + cg_iter=1, + folder=folder, + save_result=save_result, + ) - if k > 0: - ρ_p = update_penalty(comm, psi, Hu, Hu0, ρ_p) - Hu0 = Hu + ( + u, + λ_l, + ρ_l, + Hu, + ) = tike.admm.lamino.subproblem( + # constants + comm, + phi, + theta, + tilt, + # updated + u, + λ_l, + ρ_l, + Hu0=Hu, + # parameters + cg_iter=1, + folder=folder, + save_result=save_result, + ) + # Record metrics for each subproblem + ψAφ = presult['psi'] - Aφ + φHu = phi - Hu lagrangian = ( [presult['cost']], [ - 2 * np.real(λ_p.conj() * ψHu) + - ρ_p * np.linalg.norm(ψHu.ravel())**2 + 2 * np.real(λ_p.conj() * ψAφ) + + ρ_p * np.linalg.norm(ψAφ.ravel())**2 + ], + [ + 2 * np.real(λ_l.conj() * φHu) + + ρ_l * np.linalg.norm(φHu.ravel())**2 ], ) lagrangian = [comm.gather(x) for x in lagrangian] if comm.rank == 0: lagrangian = [np.sum(x) for x in lagrangian] - print( - f"k: {k:03d}, ρ_p: {ρ_p:6.3e}, " - f"laminography: {lresult['cost']:+6.3e} " - 'Lagrangian: {:+6.3e} = {:+6.3e} {:+6.3e}'.format( - np.sum(lagrangian), *lagrangian), - flush=True, - ) - dxchange.write_tiff( - psi.real, - f'{folder}/psi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - psi.imag, - f'{folder}/psi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.real, - f'{folder}/particle-real-{(k+1):03d}.tiff', - dtype='float32', + print_log_line( + k=k, + ρ_p=ρ_p, + ρ_l=ρ_l, + Lagrangian=np.sum(lagrangian), + dGψ=lagrangian[0], + ψAφ=lagrangian[1], + φHu=lagrangian[2], ) - dxchange.write_tiff( - u.imag, - f'{folder}/particle-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.real, - f'{folder}/TPHu-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.imag, - f'{folder}/TPHu-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - (λ_p / ρ_p).imag, - f'{folder}/lamb-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - (λ_p / ρ_p).real, - f'{folder}/lamb-real-{(k+1):03d}.tiff', - dtype='float32', - ) - - result = presult - return result diff --git a/src/tike/admm/ptycho.py b/src/tike/admm/ptycho.py new file mode 100644 index 00000000..a7bf3d89 --- /dev/null +++ b/src/tike/admm/ptycho.py @@ -0,0 +1,45 @@ +import logging + +import tike.ptycho + +logger = logging.getLogger(__name__) + + +def subproblem( + # constants + data, + λ_p, + ρ_p, + Aφ, + # updated + presult, + # parameters + cg_iter=1, + folder=None, + save_result=False, +): + """Solve the ptychography subsproblem. + + Parameters + ---------- + + """ + logger.info("Solve the ptychography problem.") + + presult = tike.ptycho.reconstruct( + data=data, + reg=λ_p / ρ_p - Aφ, + rho=ρ_p, + algorithm='cgrad', + num_iter=1, + cg_iter=cg_iter, + recover_psi=True, + recover_probe=True, + recover_positions=False, + model='gaussian', + **presult, + ) + + logger.info("No update for ptychography lambdas and rhos") + + return presult From 138f81cfc636d362a1ab2653dd55f81ba5f0eaf2 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 10 Feb 2021 18:02:19 -0600 Subject: [PATCH 057/109] BUG: Missing import in tike.admm.alignment --- src/tike/admm/alignment.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tike/admm/alignment.py b/src/tike/admm/alignment.py index f44875b6..0ab29f3f 100644 --- a/src/tike/admm/alignment.py +++ b/src/tike/admm/alignment.py @@ -1,5 +1,6 @@ import logging +from .admm import update_penalty import tike.align logger = logging.getLogger(__name__) From c478b3c10eb57e94bb9a7e4b4d9644396a84d9da Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 10 Feb 2021 18:02:54 -0600 Subject: [PATCH 058/109] REF: Save result before reconstruction admm.lamino --- src/tike/admm/lamino.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/tike/admm/lamino.py b/src/tike/admm/lamino.py index 95131a77..1af25892 100644 --- a/src/tike/admm/lamino.py +++ b/src/tike/admm/lamino.py @@ -47,21 +47,9 @@ def subproblem( λ_l, phi, theta = [comm.gather(x) for x in (λ_l, phi, theta)] if comm.rank == 0: - lresult = tike.lamino.reconstruct( - data=-1j * np.log(phi + λ_l / ρ_l), - theta=theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - cg_iter=cg_iter, - num_gpu=comm.size, - ) - u = lresult['obj'] - - # We cannot reorder phi, theta without ruining correspondence - # with data, psi, etc, but we can reorder the saved array if save_result: + # We cannot reorder phi, theta without ruining correspondence + # with data, psi, etc, but we can reorder the saved array order = np.argsort(theta) dxchange.write_tiff( phi[order].real, @@ -74,6 +62,18 @@ def subproblem( dtype='float32', ) + lresult = tike.lamino.reconstruct( + data=-1j * np.log(phi + λ_l / ρ_l), + theta=theta, + tilt=tilt, + obj=u, + algorithm='cgrad', + num_iter=1, + cg_iter=cg_iter, + num_gpu=comm.size, + ) + u = lresult['obj'] + # Separate again to multiple processes λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] u = comm.broadcast(u) From 09fd5e6a4f08cd584c0795d31de304cea09ba71d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 10 Feb 2021 18:06:03 -0600 Subject: [PATCH 059/109] NEW: Add inverse operator to alignment module --- src/tike/align/align.py | 17 ++++++++++++++++ src/tike/operators/cupy/alignment.py | 30 ++++++++++++++++++++++++++++ src/tike/operators/cupy/shift.py | 4 ++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/tike/align/align.py b/src/tike/align/align.py index abfeb35d..1712486e 100644 --- a/src/tike/align/align.py +++ b/src/tike/align/align.py @@ -4,6 +4,7 @@ __all__ = [ "reconstruct", "simulate", + "invert", ] import logging @@ -31,6 +32,22 @@ def simulate( assert unaligned.dtype == 'complex64', unaligned.dtype return operator.asnumpy(unaligned) +def invert( + original, + **kwargs +): # yapf: disable + """Return original shifted by shift.""" + with Alignment() as operator: + for key, value in kwargs.items(): + if not isinstance(value, tuple) and np.ndim(value) > 0: + kwargs[key] = operator.asarray(value) + unaligned = operator.inv( + operator.asarray(original, dtype='complex64'), + **kwargs, + ) + assert unaligned.dtype == 'complex64', unaligned.dtype + return operator.asnumpy(unaligned) + def reconstruct( original, diff --git a/src/tike/operators/cupy/alignment.py b/src/tike/operators/cupy/alignment.py index cf4dbf5f..430e779d 100644 --- a/src/tike/operators/cupy/alignment.py +++ b/src/tike/operators/cupy/alignment.py @@ -60,6 +60,7 @@ def fwd( cval=cval, ), shift=shift, + cval=cval, ), flow=flow, cval=cval, @@ -90,6 +91,35 @@ def adj( cval=cval, ), shift=shift, + cval=cval, + ), + unpadded_shape=unpadded_shape, + cval=cval, + ) + + def inv( + self, + rotated, + flow, + shift, + unpadded_shape, + angle, + padded_shape=None, + cval=0.0, + ): + return self.pad.adj( + padded=self.shift.adj( + a=self.flow.fwd( + g=self.rotate.fwd( + rotated=rotated, + angle=-angle, + cval=cval, + ), + flow=-flow, + cval=cval, + ), + shift=shift, + cval=cval, ), unpadded_shape=unpadded_shape, cval=cval, diff --git a/src/tike/operators/cupy/shift.py b/src/tike/operators/cupy/shift.py index 2c60ece4..7fbb26ef 100644 --- a/src/tike/operators/cupy/shift.py +++ b/src/tike/operators/cupy/shift.py @@ -8,7 +8,7 @@ class Shift(CachedFFT, Operator): """Shift last two dimensions of an array using Fourier method.""" - def fwd(self, a, shift, overwrite=False): + def fwd(self, a, shift, overwrite=False, cval=None): """Apply shifts along last two dimensions of a. Parameters @@ -34,7 +34,7 @@ def fwd(self, a, shift, overwrite=False): padded = self._ifft2(padded, axes=(-2, -1), overwrite=True) return padded.reshape(*shape) - def adj(self, a, shift, overwrite=False): + def adj(self, a, shift, overwrite=False, cval=None): if shift is None: return a return self.fwd(a, -shift, overwrite=overwrite) From 02e5d47f552231088e10d657077fe8c203e15857 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 10 Feb 2021 18:06:25 -0600 Subject: [PATCH 060/109] NEW: Save ptycho result in admm.ptycho --- src/tike/admm/ptycho.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/tike/admm/ptycho.py b/src/tike/admm/ptycho.py index a7bf3d89..d40706bd 100644 --- a/src/tike/admm/ptycho.py +++ b/src/tike/admm/ptycho.py @@ -1,11 +1,15 @@ import logging +import dxchange +import numpy as np + import tike.ptycho logger = logging.getLogger(__name__) def subproblem( + comm, # constants data, λ_p, @@ -42,4 +46,16 @@ def subproblem( logger.info("No update for ptychography lambdas and rhos") + if comm.rank == 0 and save_result: + dxchange.write_tiff( + np.abs(presult['psi']), + f'{folder}/psi-abs-{save_result:03d}.tiff', + dtype='float32', + ) + dxchange.write_tiff( + np.angle(presult['psi']), + f'{folder}/psi-angle-{save_result:03d}.tiff', + dtype='float32', + ) + return presult From cab952f07766f9061f43129cac857002f1d59b67 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 11 Feb 2021 13:39:34 -0600 Subject: [PATCH 061/109] NEW: Add rescale optional parameter to ptycho --- src/tike/ptycho/ptycho.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/tike/ptycho/ptycho.py b/src/tike/ptycho/ptycho.py index c43b7dc0..ac95d6b7 100644 --- a/src/tike/ptycho/ptycho.py +++ b/src/tike/ptycho/ptycho.py @@ -194,6 +194,7 @@ def reconstruct( model='gaussian', use_mpi=False, cost=None, times=None, batch_size=None, subset_is_random=None, eigen_probe=None, eigen_weights=None, + rescale=True, **kwargs ): # yapf: disable """Solve the ptychography problem using the given `algorithm`. @@ -270,15 +271,16 @@ def reconstruct( if np.ndim(value) > 0: kwargs[key] = comm.pool.bcast(value) - result['probe'] = comm.pool.bcast( - _rescale_obj_probe( - operator, - comm, - data[0][0], - result['psi'][0], - scan[0][0], - result['probe'][0], - )) + if rescale: + result['probe'] = comm.pool.bcast( + _rescale_obj_probe( + operator, + comm, + data[0][0], + result['psi'][0], + scan[0][0], + result['probe'][0], + )) costs = [] times = [] From 494131f8ffa9490bf8b7922370da852fba78db1b Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 11 Feb 2021 13:40:31 -0600 Subject: [PATCH 062/109] BUG: inverse alignment operator --- src/tike/operators/cupy/alignment.py | 8 ++++---- src/tike/operators/cupy/flow.py | 8 ++++++++ src/tike/operators/cupy/pad.py | 2 ++ src/tike/operators/cupy/rotate.py | 7 +++++++ src/tike/operators/cupy/shift.py | 4 +++- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/tike/operators/cupy/alignment.py b/src/tike/operators/cupy/alignment.py index 430e779d..f843c1a1 100644 --- a/src/tike/operators/cupy/alignment.py +++ b/src/tike/operators/cupy/alignment.py @@ -110,12 +110,12 @@ def inv( return self.pad.adj( padded=self.shift.adj( a=self.flow.fwd( - g=self.rotate.fwd( - rotated=rotated, - angle=-angle, + f=self.rotate.fwd( + unrotated=rotated, + angle=angle if angle is None else -angle, cval=cval, ), - flow=-flow, + flow=flow if flow is None else -flow, cval=cval, ), shift=shift, diff --git a/src/tike/operators/cupy/flow.py b/src/tike/operators/cupy/flow.py index 52d36d8b..ed3d8eee 100644 --- a/src/tike/operators/cupy/flow.py +++ b/src/tike/operators/cupy/flow.py @@ -133,3 +133,11 @@ def adj(self, g, flow, filter_size=5, cval=0.0): _remap_lanczos(f[i], coords[i], a, g[i], fwd=False, cval=cval) return f.reshape(shape) + + def inv(self, g, flow, filter_size=5, cval=0.0): + return self.fwd( + g, + flow if flow is None else -flow, + filter_size, + cval, + ) diff --git a/src/tike/operators/cupy/pad.py b/src/tike/operators/cupy/pad.py index 4d71b80c..defcaa7e 100644 --- a/src/tike/operators/cupy/pad.py +++ b/src/tike/operators/cupy/pad.py @@ -79,3 +79,5 @@ def adj(self, padded, corner=None, unpadded_shape=None, **kwargs): assert hi0 <= padded.shape[-2] and hi1 <= padded.shape[-1] unpadded[i] = padded[i][lo0:hi0, lo1:hi1] return unpadded + + inv = adj diff --git a/src/tike/operators/cupy/rotate.py b/src/tike/operators/cupy/rotate.py index d8229e81..e214657c 100644 --- a/src/tike/operators/cupy/rotate.py +++ b/src/tike/operators/cupy/rotate.py @@ -76,3 +76,10 @@ def adj(self, rotated, angle, cval=0.0): _remap_lanczos(f[i], coords, 2, g[i], fwd=False, cval=cval) return f.reshape(shape) + + def inv(self, rotated, angle, cval=0.0): + return self.fwd( + rotated, + angle if angle is None else -angle, + cval, + ) diff --git a/src/tike/operators/cupy/shift.py b/src/tike/operators/cupy/shift.py index 7fbb26ef..f837dabc 100644 --- a/src/tike/operators/cupy/shift.py +++ b/src/tike/operators/cupy/shift.py @@ -37,4 +37,6 @@ def fwd(self, a, shift, overwrite=False, cval=None): def adj(self, a, shift, overwrite=False, cval=None): if shift is None: return a - return self.fwd(a, -shift, overwrite=overwrite) + return self.fwd(a, -shift, overwrite=overwrite, cval=cval) + + inv = adj From f56724b7b418f2940c09370072a1baf4b6028d6c Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 11 Feb 2021 13:41:00 -0600 Subject: [PATCH 063/109] NEW: Smaller cross correlation weight --- src/tike/align/solvers/cross_correlation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tike/align/solvers/cross_correlation.py b/src/tike/align/solvers/cross_correlation.py index 87b029e7..b2bd28ed 100644 --- a/src/tike/align/solvers/cross_correlation.py +++ b/src/tike/align/solvers/cross_correlation.py @@ -36,7 +36,7 @@ def cross_correlation( upsample_factor=1, space="real", num_iter=None, - reg_weight=1e-6, + reg_weight=1e-9, ): """Efficient subpixel image translation alignment by cross-correlation. From 7c0ce980d368d571054addeb3172fdb4f791a10a Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 11 Feb 2021 13:41:58 -0600 Subject: [PATCH 064/109] NEW: Set subproblem iters separate from cg_iter --- src/tike/admm/lamino.py | 8 ++++++-- src/tike/admm/ptycho.py | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tike/admm/lamino.py b/src/tike/admm/lamino.py index 1af25892..0e6b7db6 100644 --- a/src/tike/admm/lamino.py +++ b/src/tike/admm/lamino.py @@ -21,6 +21,7 @@ def subproblem( ρ_l, Hu0=None, # parameters + num_iter=1, cg_iter=1, folder=None, save_result=False, @@ -46,6 +47,7 @@ def subproblem( # Gather all to one process λ_l, phi, theta = [comm.gather(x) for x in (λ_l, phi, theta)] + cost = None if comm.rank == 0: if save_result: # We cannot reorder phi, theta without ruining correspondence @@ -68,15 +70,16 @@ def subproblem( tilt=tilt, obj=u, algorithm='cgrad', - num_iter=1, + num_iter=num_iter, cg_iter=cg_iter, num_gpu=comm.size, ) u = lresult['obj'] + cost = lresult['cost'] # Separate again to multiple processes λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] - u = comm.broadcast(u) + # u = comm.broadcast(u) # volume too large to fit in MPI buffer Hu = np.exp(1j * tike.lamino.simulate( obj=u, @@ -121,4 +124,5 @@ def subproblem( λ_l, ρ_l, Hu0, + cost, ) diff --git a/src/tike/admm/ptycho.py b/src/tike/admm/ptycho.py index d40706bd..4f0c691d 100644 --- a/src/tike/admm/ptycho.py +++ b/src/tike/admm/ptycho.py @@ -18,9 +18,11 @@ def subproblem( # updated presult, # parameters + num_iter=1, cg_iter=1, folder=None, save_result=False, + rescale=False, ): """Solve the ptychography subsproblem. @@ -35,12 +37,13 @@ def subproblem( reg=λ_p / ρ_p - Aφ, rho=ρ_p, algorithm='cgrad', - num_iter=1, + num_iter=num_iter, cg_iter=cg_iter, recover_psi=True, recover_probe=True, recover_positions=False, model='gaussian', + rescale=rescale, **presult, ) From 551724ba683aae0234b93ec28068527d20480154 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 11 Feb 2021 13:43:36 -0600 Subject: [PATCH 065/109] BUG: Proper alignment in ptycho_lamino --- src/tike/admm/pl.py | 105 +++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/src/tike/admm/pl.py b/src/tike/admm/pl.py index e2af0734..b64b7772 100644 --- a/src/tike/admm/pl.py +++ b/src/tike/admm/pl.py @@ -6,6 +6,7 @@ import tike.admm.alignment import tike.admm.lamino import tike.admm.ptycho +import tike.align import tike.communicator from .admm import print_log_line @@ -23,7 +24,7 @@ def ptycho_lamino( angle, w, flow=False, - shift=False, + shift=None, niter=1, interval=8, folder=None, @@ -38,16 +39,10 @@ def ptycho_lamino( } u = np.zeros((w, w, w), dtype='complex64') - Hu = np.ones((len(theta), w, w), dtype='complex64') - phi = Hu - Aφ = np.ones(psi.shape, dtype='complex64') - + Hu = np.ones_like(psi) λ_p = np.zeros_like(psi) ρ_p = 0.5 - λ_l = np.zeros([len(theta), w, w], dtype='complex64') - ρ_l = 0.5 - comm = tike.communicator.MPICommunicator() with cp.cuda.Device(comm.rank if comm.size > 1 else None): @@ -56,53 +51,53 @@ def ptycho_lamino( save_result = k if k % interval == 0 else False presult = tike.admm.ptycho.subproblem( + comm, # constants data, λ_p=λ_p, ρ_p=ρ_p, - Aφ=Aφ, + Aφ=Hu, # updated presult=presult, # parameters - cg_iter=1, + num_iter=4, + cg_iter=cg_iter, folder=folder, save_result=save_result, + rescale=(k == 1), ) - ( - phi, - λ_p, - ρ_p, - flow, - shift, - Aφ, - ) = tike.admm.alignment.subproblem( - # constants - comm, + phi = tike.align.invert( presult['psi'], - angle, + angle=angle, + flow=None, + shift=shift, + unpadded_shape=(len(theta), w, w), + cval=1.0, + ) + Hu = tike.align.invert( Hu, - λ_l, - ρ_l, - # updated - phi, + angle=angle, + flow=None, + shift=shift, + unpadded_shape=(len(theta), w, w), + cval=1.0, + ) + λ_p = tike.align.invert( λ_p, - ρ_p, + angle=angle, flow=None, - shift=None, - Aφ0=Aφ, - # parameters - align_method='', - cg_iter=1, - folder=folder, - save_result=save_result, + shift=shift, + unpadded_shape=(len(theta), w, w), + cval=1.0, ) ( u, - λ_l, - ρ_l, + λ_p, + ρ_p, Hu, + lamino_cost, ) = tike.admm.lamino.subproblem( # constants comm, @@ -111,27 +106,40 @@ def ptycho_lamino( tilt, # updated u, - λ_l, - ρ_l, + λ_p, + ρ_p, Hu0=Hu, # parameters - cg_iter=1, + num_iter=4, + cg_iter=cg_iter, folder=folder, save_result=save_result, ) + Hu = tike.align.simulate( + Hu, + angle=angle, + flow=None, + shift=shift, + padded_shape=psi.shape, + cval=1.0, + ) + λ_p = tike.align.simulate( + λ_p, + angle=angle, + flow=None, + shift=shift, + padded_shape=psi.shape, + cval=1.0, + ) + # Record metrics for each subproblem - ψAφ = presult['psi'] - Aφ - φHu = phi - Hu + ψHu = presult['psi'] - Hu lagrangian = ( [presult['cost']], [ - 2 * np.real(λ_p.conj() * ψAφ) + - ρ_p * np.linalg.norm(ψAφ.ravel())**2 - ], - [ - 2 * np.real(λ_l.conj() * φHu) + - ρ_l * np.linalg.norm(φHu.ravel())**2 + 2 * np.real(λ_p.conj() * ψHu) + + ρ_p * np.linalg.norm(ψHu.ravel())**2 ], ) lagrangian = [comm.gather(x) for x in lagrangian] @@ -141,9 +149,8 @@ def ptycho_lamino( print_log_line( k=k, ρ_p=ρ_p, - ρ_l=ρ_l, Lagrangian=np.sum(lagrangian), dGψ=lagrangian[0], - ψAφ=lagrangian[1], - φHu=lagrangian[2], + ψHu=lagrangian[1], + lamino=float(lamino_cost), ) From 2557b2a359ad5e4dda723b0686e0f00bcbabb731 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 11 Feb 2021 16:49:04 -0600 Subject: [PATCH 066/109] REF: Use same subproblems for each formulation --- src/tike/admm/__init__.py | 1 + src/tike/admm/al.py | 296 ++++++++++++--------------------- src/tike/admm/alignment.py | 90 +++++----- src/tike/admm/pal.py | 333 +++++++++++-------------------------- src/tike/admm/ptycho.py | 8 +- 5 files changed, 263 insertions(+), 465 deletions(-) diff --git a/src/tike/admm/__init__.py b/src/tike/admm/__init__.py index 75423b6c..fa8577d8 100644 --- a/src/tike/admm/__init__.py +++ b/src/tike/admm/__init__.py @@ -1,3 +1,4 @@ from .admm import * from .al import * from .pal import * +from .pl import * diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index a5b8ce34..2227a313 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -1,201 +1,145 @@ import logging - import numpy as np import cupy as cp -import dxchange -import tike.align -from tike.communicator import MPICommunicator -import tike.lamino +import tike.admm.alignment +import tike.admm.lamino +import tike.admm.ptycho +import tike.communicator + +from .admm import print_log_line + +logger = logging.getLogger(__name__) -from .admm import * -def lamino_align( +def ptycho__align_lamino( + data, psi, + scan, + probe, theta, tilt, - u=None, + angle, + w, flow=None, + shift=None, niter=1, + interval=8, folder=None, - angle=0, - w=256 + 64 + 16 + 8, cg_iter=4, - shift=None, - interval=8, - winsize=None, - align_method='', + align_method=False, ): - """Solve the joint ptycho-lamino-alignment problem using ADMM.""" + """Solve the joint ptycho-lamino problem using ADMM.""" + presult = { + 'psi': psi, + 'scan': scan, + 'probe': probe, + } + u = np.zeros((w, w, w), dtype='complex64') Hu = np.ones((len(theta), w, w), dtype='complex64') phi = Hu - flow = np.zeros([*psi.shape, 2], dtype='float32') - winsize = min(*psi.shape[-2:]) if winsize is None else winsize + Aφ = np.ones(psi.shape, dtype='complex64') + λ_l = np.zeros([len(theta), w, w], dtype='complex64') - ρ_p = 1 ρ_l = 0.5 - comm = MPICommunicator() - with cp.cuda.Device(comm.rank if comm.size > 1 else None): - hi, lo = find_min_max(np.angle(psi)) + comm = tike.communicator.MPICommunicator() - for k in range(niter): - logging.info(f"Start ADMM iteration {k}.") + with cp.cuda.Device(comm.rank if comm.size > 1 else None): - rotated = tike.align.simulate( - psi, - angle=-angle, - flow=None, - padded_shape=None, - cval=1.0, + presult = tike.admm.ptycho.subproblem( + # constants + data, + λ_p=None, + ρ_p=None, + Aφ=None, + # updated + presult=presult, + # parameters + num_iter=4 * niter, + cg_iter=cg_iter, + folder=folder, + save_result=save_result, + rescale=True, ) - if (k + 1) == 8: - dxchange.write_tiff( - rotated.real, - f'{folder}/{comm.rank}-rotated-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - rotated.imag, - f'{folder}/{comm.rank}-rotated-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - logging.info("Recover aligned projections from unaligned.") - aresult = tike.align.reconstruct( - unaligned=psi, - original=phi, + for k in range(1, niter + 1): + logger.info(f"Start ADMM iteration {k}.") + save_result = k if k % interval == 0 else False + + ( + phi, + _, + _, + flow, + shift, + Aφ, + align_cost, + ) = tike.admm.alignment.subproblem( + # constants + comm, + presult['psi'], + angle, + Hu, + λ_l, + ρ_l, + # updated + phi, + λ_p=None, + ρ_p=None, flow=flow, - angle=angle, - num_iter=cg_iter, - algorithm='cgrad', - reg=Hu - λ_l / ρ_l, - rho_p=ρ_p, - rho_a=ρ_l, - cval=1.0, + shift=shift, + Aφ0=Aφ, + # parameters + align_method=align_method, + cg_iter=cg_iter, + num_iter=4, + folder=folder, + save_result=save_result, ) - phi = aresult['original'] - padded = tike.align.simulate( + ( + u, + λ_l, + ρ_l, + Hu, + lamino_cost, + ) = tike.admm.lamino.subproblem( + # constants + comm, phi, - angle=None, - flow=None, - padded_shape=psi.shape, - cval=1.0, + theta, + tilt, + # updated + u, + λ_l=λ_l, + ρ_l=ρ_l, + Hu0=Hu, + # parameters + num_iter=4, + cg_iter=cg_iter, + folder=folder, + save_result=save_result, ) - if comm.rank == 0 and (k + 1) % interval == 0: - dxchange.write_tiff( - rotated.real, - f'{folder}/rotated-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - rotated.imag, - f'{folder}/rotated-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - padded.real, - f'{folder}/padded-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - padded.imag, - f'{folder}/padded-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - if align_method.lower() == 'flow': - winsize = max(winsize - 1, 24) - logging.info("Estimate alignment using Farneback.") - fresult = tike.align.solvers.farneback( - op=None, - unaligned=np.angle(rotated), - original=np.angle(padded), - flow=flow, - pyr_scale=0.5, - levels=4, - winsize=winsize, - num_iter=32, - hi=hi, lo=lo, - ) - flow = fresult['flow'] - elif align_method.lower() == 'tvl1': - logging.info("Estimate alignment using TV-L1.") - flow = optical_flow_tvl1( - unaligned=rotated, - original=padded, - num_iter=8, - ) - else: - logging.info("Estimate rigid alignment with cross correlation.") - sresult = tike.align.reconstruct( - algorithm='cross_correlation', - unaligned=rotated, - original=padded, - upsample_factor=100, - ) - flow[:] = sresult['shift'][:, None, None, :] - - # Gather all to one thread - λ_l, phi, theta = [comm.gather(x) for x in (λ_l, phi, theta)] - - if comm.rank == 0: - logging.info('Solve the laminography problem.') - lresult = tike.lamino.reconstruct( - data=-1j * np.log(phi + λ_l / ρ_l), - theta=theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - cg_iter=cg_iter, - ) - u = lresult['obj'] - - # We cannot reorder phi, theta without ruining correspondence - # with data, psi, etc, but we can reorder the saved array - if (k + 1) % interval == 0: - order = np.argsort(theta) - dxchange.write_tiff( - phi[order].real, - f'{folder}/phi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - phi[order].imag, - f'{folder}/phi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - - # Separate again to multiple threads - λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] - u = comm.broadcast(u) - - logging.info('Update lambdas and rhos.') - - Hu = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) + # Record metrics for each subproblem + ψAφ = presult['psi'] - Aφ φHu = phi - Hu - λ_l += ρ_l * φHu - - if k > 0: - ρ_l = update_penalty(comm, phi, Hu, Hu0, ρ_l) - Hu0 = Hu - lagrangian = ( + [presult['cost']], + [ + 2 * np.real(λ_p.conj() * ψAφ) + + ρ_p * np.linalg.norm(ψAφ.ravel())**2 + ], [ 2 * np.real(λ_l.conj() * φHu) + ρ_l * np.linalg.norm(φHu.ravel())**2 ], + [align_cost], ) lagrangian = [comm.gather(x) for x in lagrangian] - acost = comm.gather([aresult['cost']]) if comm.rank == 0: lagrangian = [np.sum(x) for x in lagrangian] @@ -203,32 +147,10 @@ def lamino_align( k=k, ρ_p=ρ_p, ρ_l=ρ_l, - winsize=winsize, - alignment=np.sum(acost), - laminography=float(lresult['cost']), - φHu=lagrangian[0], - ) - if comm.rank == 0 and (k + 1) % interval == 0: - dxchange.write_tiff( - u.real, - f'{folder}/particle-real-{(k+1):03d}.tiff', - dtype='float32', + Lagrangian=np.sum(lagrangian[:3]), + dGψ=lagrangian[0], + ψAφ=lagrangian[1], + φHu=lagrangian[2], + align=lagrangian[3], + lamino=float(lamino_cost), ) - dxchange.write_tiff( - u.imag, - f'{folder}/particle-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.real, - f'{folder}/Hu-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.imag, - f'{folder}/Hu-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - np.save(f"{folder}/flow-{(k+1):03d}", flow) - - return u diff --git a/src/tike/admm/alignment.py b/src/tike/admm/alignment.py index 0ab29f3f..9abc2b34 100644 --- a/src/tike/admm/alignment.py +++ b/src/tike/admm/alignment.py @@ -24,6 +24,7 @@ def subproblem( # parameters align_method=False, cg_iter=1, + num_iter=1, folder=None, save_result=False, ): @@ -43,12 +44,12 @@ def subproblem( save_result = False if folder is None else save_result aresult = tike.align.reconstruct( - unaligned=psi + λ_p / ρ_p, + unaligned=psi if λ_p is None else psi + λ_p / ρ_p, original=phi, flow=flow, shift=shift, angle=angle, - num_iter=cg_iter, + num_iter=cg_iter * num_iter, algorithm='cgrad', reg=Hu - λ_l / ρ_l, rho_p=ρ_p, @@ -56,13 +57,16 @@ def subproblem( cval=1.0, ) phi = aresult['original'] + cost = aresult['cost'] if align_method: - hi, lo = find_min_max(np.angle(psi + λ_p / ρ_p)) + hi, lo = find_min_max(np.angle(psi if λ_p is None else psi + λ_p / ρ_p)) + # TODO: Try combining rotation and flow because they use the same + # interpolator rotated = tike.align.simulate( - psi + λ_p / ρ_p, + psi if λ_p is None else psi + λ_p / ρ_p, angle=-angle, flow=None, shift=None, @@ -90,42 +94,42 @@ def subproblem( dtype='float32', ) - if align_method.lower() == 'flow': - winsize = max(winsize - 1, 128) - logging.info("Estimate alignment using Farneback.") - fresult = tike.align.solvers.farneback( - op=None, - unaligned=np.angle(rotated), - original=np.angle(padded), - flow=flow, - pyr_scale=0.5, - levels=4, - winsize=winsize, - num_iter=32, - hi=hi, - lo=lo, - ) - flow = fresult['flow'] - elif align_method.lower() == 'tvl1': - logging.info("Estimate alignment using TV-L1.") - flow = optical_flow_tvl1( - unaligned=rotated, - original=padded, - num_iter=cg_iter, - ) - elif align_method.lower() == 'xcor': - logging.info("Estimate rigid alignment with cross correlation.") - sresult = tike.align.reconstruct( - algorithm='cross_correlation', - unaligned=rotated, - original=padded, - upsample_factor=100, - ) - # Limit shift change per iteration - if shift is None: - shift = np.clip(sresult['shift'], -16, 16) - else: - shift += np.clip(sresult['shift'] - shift, -16, 16) + if align_method.lower() == 'flow': + winsize = max(winsize - 1, 128) + logging.info("Estimate alignment using Farneback.") + fresult = tike.align.solvers.farneback( + op=None, + unaligned=np.angle(rotated), + original=np.angle(padded), + flow=flow, + pyr_scale=0.5, + levels=4, + winsize=winsize, + num_iter=32, + hi=hi, + lo=lo, + ) + flow = fresult['flow'] + elif align_method.lower() == 'tvl1': + logging.info("Estimate alignment using TV-L1.") + flow = optical_flow_tvl1( + unaligned=rotated, + original=padded, + num_iter=cg_iter, + ) + elif align_method.lower() == 'xcor': + logging.info("Estimate rigid alignment with cross correlation.") + sresult = tike.align.reconstruct( + algorithm='cross_correlation', + unaligned=rotated, + original=padded, + upsample_factor=100, + ) + # Limit shift change per iteration + if shift is None: + shift = np.clip(sresult['shift'], -16, 16) + else: + shift += np.clip(sresult['shift'] - shift, -16, 16) Aφ = tike.align.simulate( phi, @@ -139,9 +143,10 @@ def subproblem( logger.info("Update alignment lambdas and rhos") - λ_p += ρ_p * ψAφ + if λ_p is not None: + λ_p += ρ_p * ψAφ - if Aφ0 is not None: + if ρ_p is not None and Aφ0 is not None: ρ_p = update_penalty(comm, psi, Aφ, Aφ0, ρ_p) Aφ0 = Aφ @@ -153,4 +158,5 @@ def subproblem( flow, shift, Aφ0, + cost, ) diff --git a/src/tike/admm/pal.py b/src/tike/admm/pal.py index 9baede22..52f0f0b1 100644 --- a/src/tike/admm/pal.py +++ b/src/tike/admm/pal.py @@ -1,242 +1,147 @@ import logging - import numpy as np import cupy as cp -import dxchange -import tike.align -from tike.communicator import MPICommunicator -import tike.ptycho -import tike.lamino +import tike.admm.alignment +import tike.admm.lamino +import tike.admm.ptycho +import tike.communicator + +from .admm import print_log_line -from .admm import * +logger = logging.getLogger(__name__) -def ptycho_lamino_align( + +def ptycho_align_lamino( data, psi, scan, probe, theta, tilt, - u=None, + angle, + w, flow=None, + shift=None, niter=1, + interval=8, folder=None, - fixed_crop=False, - angle=0, - w=256 + 64 + 16 + 8, cg_iter=4, - shift=None, - interval=8, - winsize=None, - align_method='', + align_method=False, ): - """Solve the joint ptycho-lamino-alignment problem using ADMM.""" + """Solve the joint ptycho-lamino problem using ADMM.""" + presult = { + 'psi': psi, + 'scan': scan, + 'probe': probe, + } + u = np.zeros((w, w, w), dtype='complex64') Hu = np.ones((len(theta), w, w), dtype='complex64') phi = Hu - TDPφ = np.ones(psi.shape, dtype='complex64') - flow = np.zeros([*psi.shape, 2], dtype='float32') - winsize = min(*psi.shape[-2:]) if winsize is None else winsize - presult = { # ptychography result - 'psi': np.ones(psi.shape, dtype='complex64'), - 'scan': scan, - 'probe': probe, - } + Aφ = np.ones(psi.shape, dtype='complex64') + λ_p = np.zeros_like(psi) ρ_p = 0.5 + λ_l = np.zeros([len(theta), w, w], dtype='complex64') ρ_l = 0.5 - comm = MPICommunicator() - with cp.cuda.Device(comm.rank if comm.size > 1 else None): - for k in range(niter): - logging.info(f"Start ADMM iteration {k}.") + comm = tike.communicator.MPICommunicator() - logging.info("Solve the ptychography problem.") - presult = tike.ptycho.reconstruct( - data=data, - reg=λ_p / ρ_p - TDPφ, - rho=ρ_p, - algorithm='combined', - num_iter=1, + with cp.cuda.Device(comm.rank if comm.size > 1 else None): + for k in range(1, niter + 1): + logger.info(f"Start ADMM iteration {k}.") + save_result = k if k % interval == 0 else False + + presult = tike.admm.ptycho.subproblem( + # constants + data, + λ_p=λ_p, + ρ_p=ρ_p, + Aφ=Aφ, + # updated + presult=presult, + # parameters + num_iter=4, cg_iter=cg_iter, - recover_psi=True, - recover_probe=True, - recover_positions=False, - model='gaussian', - **presult, - ) - psi = presult['psi'] - - rotated = tike.align.simulate( - psi + λ_p / ρ_p, - angle=-angle, - flow=None, - padded_shape=None, - cval=1.0, + folder=folder, + save_result=save_result, + rescale=(k == 1), ) - if (k + 1) == 8: - dxchange.write_tiff( - rotated.real, - f'{folder}/{comm.rank}-rotated-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - rotated.imag, - f'{folder}/{comm.rank}-rotated-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - logging.info("Recover aligned projections from unaligned.") - aresult = tike.align.reconstruct( - unaligned=psi + λ_p / ρ_p, - original=phi, + ( + phi, + λ_p, + ρ_p, + flow, + shift, + Aφ, + align_cost, + ) = tike.admm.alignment.subproblem( + # constants + comm, + presult['psi'], + angle, + Hu, + λ_l, + ρ_l, + # updated + phi, + λ_p, + ρ_p, flow=flow, - angle=angle, - num_iter=cg_iter, - algorithm='cgrad', - reg=Hu - λ_l / ρ_l, - rho_p=ρ_p, - rho_a=ρ_l, - cval=1.0, + shift=shift, + Aφ0=Aφ, + # parameters + align_method=align_method, + cg_iter=cg_iter, + num_iter=4, + folder=folder, + save_result=save_result, ) - phi = aresult['original'] - padded = tike.align.simulate( + ( + u, + λ_l, + ρ_l, + Hu, + lamino_cost, + ) = tike.admm.lamino.subproblem( + # constants + comm, phi, - angle=None, - flow=None, - padded_shape=psi.shape, - cval=1.0, + theta, + tilt, + # updated + u, + λ_l=λ_l, + ρ_l=ρ_l, + Hu0=Hu, + # parameters + num_iter=4, + cg_iter=cg_iter, + folder=folder, + save_result=save_result, ) - if comm.rank == 0 and (k + 1) % interval == 0: - dxchange.write_tiff( - rotated.real, - f'{folder}/rotated-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - rotated.imag, - f'{folder}/rotated-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - padded.real, - f'{folder}/padded-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - padded.imag, - f'{folder}/padded-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - - if align_method.lower() == 'flow': - winsize = max(winsize - 1, 24) - logging.info("Estimate alignment using Farneback.") - fresult = tike.align.solvers.farneback( - op=None, - unaligned=rotated, - original=padded, - flow=flow, - pyr_scale=0.5, - levels=4, - winsize=winsize, - num_iter=32, - ) - flow = fresult['flow'] - elif align_method.lower() == 'tvl1': - logging.info("Estimate alignment using TV-L1.") - flow = optical_flow_tvl1( - unaligned=rotated, - original=padded, - num_iter=8, - ) - else: - logging.info("Estimate rigid alignment with cross correlation.") - sresult = tike.align.reconstruct( - algorithm='cross_correlation', - unaligned=rotated, - original=padded, - upsample_factor=100, - ) - flow[:] = sresult['shift'][:, None, None, :] - - # Gather all to one thread - λ_l, phi, theta = [comm.gather(x) for x in (λ_l, phi, theta)] - - if comm.rank == 0: - logging.info('Solve the laminography problem.') - lresult = tike.lamino.reconstruct( - data=-1j * np.log(phi + λ_l / ρ_l), - theta=theta, - tilt=tilt, - obj=u, - algorithm='cgrad', - num_iter=1, - cg_iter=cg_iter, - ) - u = lresult['obj'] - - # We cannot reorder phi, theta without ruining correspondence - # with data, psi, etc, but we can reorder the saved array - if (k + 1) % interval == 0: - order = np.argsort(theta) - dxchange.write_tiff( - phi[order].real, - f'{folder}/phi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - phi[order].imag, - f'{folder}/phi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - - # Separate again to multiple threads - λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] - u = comm.broadcast(u) - - logging.info('Update lambdas and rhos.') - TDPφ = tike.align.simulate( - phi, - angle=angle, - flow=flow, - padded_shape=psi.shape, - cval=1.0, - ) - ψTDPφ = psi - TDPφ - Hu = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) + # Record metrics for each subproblem + ψAφ = presult['psi'] - Aφ φHu = phi - Hu - λ_p += ρ_p * ψTDPφ - λ_l += ρ_l * φHu - - if k > 0: - ρ_p = update_penalty(comm, psi, TDPφ, TDPφ0, ρ_p) - ρ_l = update_penalty(comm, phi, Hu, Hu0, ρ_l) - Hu0 = Hu - TDPφ0 = TDPφ - lagrangian = ( [presult['cost']], [ - 2 * np.real(λ_p.conj() * ψTDPφ) + - ρ_p * np.linalg.norm(ψTDPφ.ravel())**2 + 2 * np.real(λ_p.conj() * ψAφ) + + ρ_p * np.linalg.norm(ψAφ.ravel())**2 ], [ 2 * np.real(λ_l.conj() * φHu) + ρ_l * np.linalg.norm(φHu.ravel())**2 ], + [align_cost], ) lagrangian = [comm.gather(x) for x in lagrangian] - acost = comm.gather([aresult['cost']]) if comm.rank == 0: lagrangian = [np.sum(x) for x in lagrangian] @@ -244,46 +149,10 @@ def ptycho_lamino_align( k=k, ρ_p=ρ_p, ρ_l=ρ_l, - # shift=np.linalg.norm(shift[:, None, None, :] - flow), - winsize=winsize, - alignment=np.sum(acost), - laminography=float(lresult['cost']), - Lagrangian=np.sum(lagrangian), + Lagrangian=np.sum(lagrangian[:3]), dGψ=lagrangian[0], - ψDφ=lagrangian[1], + ψAφ=lagrangian[1], φHu=lagrangian[2], + align=lagrangian[3], + lamino=float(lamino_cost), ) - if comm.rank == 0 and (k + 1) % interval == 0: - dxchange.write_tiff( - psi.real, - f'{folder}/psi-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - psi.imag, - f'{folder}/psi-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.real, - f'{folder}/particle-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - u.imag, - f'{folder}/particle-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.real, - f'{folder}/Hu-real-{(k+1):03d}.tiff', - dtype='float32', - ) - dxchange.write_tiff( - Hu.imag, - f'{folder}/Hu-imag-{(k+1):03d}.tiff', - dtype='float32', - ) - np.save(f"{folder}/flow-{(k+1):03d}", flow) - - return u diff --git a/src/tike/admm/ptycho.py b/src/tike/admm/ptycho.py index 4f0c691d..42655d9b 100644 --- a/src/tike/admm/ptycho.py +++ b/src/tike/admm/ptycho.py @@ -12,8 +12,8 @@ def subproblem( comm, # constants data, - λ_p, - ρ_p, + λ, + ρ, Aφ, # updated presult, @@ -34,8 +34,8 @@ def subproblem( presult = tike.ptycho.reconstruct( data=data, - reg=λ_p / ρ_p - Aφ, - rho=ρ_p, + reg=None if λ is None else λ / ρ - Aφ, + rho=ρ, algorithm='cgrad', num_iter=num_iter, cg_iter=cg_iter, From 6a4bbbcfcc6916c07d68be7b34a40f2149be93dd Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 12 Feb 2021 14:38:07 -0600 Subject: [PATCH 067/109] BUG: Mismatched indent --- src/tike/admm/al.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index 2227a313..abffce4b 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -49,21 +49,21 @@ def ptycho__align_lamino( with cp.cuda.Device(comm.rank if comm.size > 1 else None): - presult = tike.admm.ptycho.subproblem( - # constants - data, - λ_p=None, - ρ_p=None, - Aφ=None, - # updated - presult=presult, - # parameters - num_iter=4 * niter, - cg_iter=cg_iter, - folder=folder, - save_result=save_result, - rescale=True, - ) + presult = tike.admm.ptycho.subproblem( + # constants + data, + λ_p=None, + ρ_p=None, + Aφ=None, + # updated + presult=presult, + # parameters + num_iter=4 * niter, + cg_iter=cg_iter, + folder=folder, + save_result=save_result, + rescale=True, + ) for k in range(1, niter + 1): logger.info(f"Start ADMM iteration {k}.") From 7aad5e7569d99e324c89033dd2d5783608ea94bc Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 12 Feb 2021 14:39:55 -0600 Subject: [PATCH 068/109] BUG: Changed parameter name --- src/tike/admm/al.py | 4 ++-- src/tike/admm/pal.py | 4 ++-- src/tike/admm/pl.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index abffce4b..2a9c4306 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -52,8 +52,8 @@ def ptycho__align_lamino( presult = tike.admm.ptycho.subproblem( # constants data, - λ_p=None, - ρ_p=None, + λ=None, + ρ=None, Aφ=None, # updated presult=presult, diff --git a/src/tike/admm/pal.py b/src/tike/admm/pal.py index 52f0f0b1..adb47648 100644 --- a/src/tike/admm/pal.py +++ b/src/tike/admm/pal.py @@ -58,8 +58,8 @@ def ptycho_align_lamino( presult = tike.admm.ptycho.subproblem( # constants data, - λ_p=λ_p, - ρ_p=ρ_p, + λ=λ_p, + ρ=ρ_p, Aφ=Aφ, # updated presult=presult, diff --git a/src/tike/admm/pl.py b/src/tike/admm/pl.py index b64b7772..93310e8b 100644 --- a/src/tike/admm/pl.py +++ b/src/tike/admm/pl.py @@ -54,8 +54,8 @@ def ptycho_lamino( comm, # constants data, - λ_p=λ_p, - ρ_p=ρ_p, + λ=λ_p, + ρ=ρ_p, Aφ=Hu, # updated presult=presult, From 66e966c6e7265ef808b4293dc6c5a189bab37b2d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 12 Feb 2021 14:46:14 -0600 Subject: [PATCH 069/109] BUG: Missing imports --- src/tike/admm/alignment.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tike/admm/alignment.py b/src/tike/admm/alignment.py index 9abc2b34..43eed0ce 100644 --- a/src/tike/admm/alignment.py +++ b/src/tike/admm/alignment.py @@ -1,6 +1,9 @@ import logging -from .admm import update_penalty +import dxchange +import numpy as np + +from .admm import update_penalty, find_min_max, optical_flow_tvl1, center_of_mass import tike.align logger = logging.getLogger(__name__) From 6f10f329286270ac5ad014dad4fcf0896bfd87d7 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 12 Feb 2021 14:46:38 -0600 Subject: [PATCH 070/109] STY: Name all parameters --- src/tike/admm/pal.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/tike/admm/pal.py b/src/tike/admm/pal.py index adb47648..7af65999 100644 --- a/src/tike/admm/pal.py +++ b/src/tike/admm/pal.py @@ -57,7 +57,8 @@ def ptycho_align_lamino( presult = tike.admm.ptycho.subproblem( # constants - data, + comm=comm, + data=data, λ=λ_p, ρ=ρ_p, Aφ=Aφ, @@ -81,16 +82,16 @@ def ptycho_align_lamino( align_cost, ) = tike.admm.alignment.subproblem( # constants - comm, - presult['psi'], - angle, - Hu, - λ_l, - ρ_l, + comm=comm, + psi=presult['psi'], + angle=angle, + Hu=Hu, + λ_l=λ_l, + ρ_l=ρ_l, # updated - phi, - λ_p, - ρ_p, + phi=phi, + λ_p=λ_p, + ρ_p=ρ_p, flow=flow, shift=shift, Aφ0=Aφ, @@ -110,12 +111,12 @@ def ptycho_align_lamino( lamino_cost, ) = tike.admm.lamino.subproblem( # constants - comm, - phi, - theta, - tilt, + comm=comm, + phi=phi, + theta=theta, + tilt=tilt, # updated - u, + u=u, λ_l=λ_l, ρ_l=ρ_l, Hu0=Hu, From cbeccce6d8d6563c3f99a032f39809eca1550db9 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 12 Feb 2021 16:03:18 -0600 Subject: [PATCH 071/109] REF: Use MSE instead of L2-Norm in Lagrangian --- src/tike/admm/pal.py | 19 +++++++++++-------- src/tike/admm/ptycho.py | 9 ++++++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/tike/admm/pal.py b/src/tike/admm/pal.py index 7af65999..cb6dd825 100644 --- a/src/tike/admm/pal.py +++ b/src/tike/admm/pal.py @@ -55,7 +55,7 @@ def ptycho_align_lamino( logger.info(f"Start ADMM iteration {k}.") save_result = k if k % interval == 0 else False - presult = tike.admm.ptycho.subproblem( + presult, Gψ = tike.admm.ptycho.subproblem( # constants comm=comm, data=data, @@ -131,21 +131,23 @@ def ptycho_align_lamino( ψAφ = presult['psi'] - Aφ φHu = phi - Hu lagrangian = ( - [presult['cost']], + [np.mean(np.square(data - Gψ))], [ - 2 * np.real(λ_p.conj() * ψAφ) + - ρ_p * np.linalg.norm(ψAφ.ravel())**2 + 2 * np.mean(np.real(λ_p.conj() * ψAφ)) + + ρ_p * np.mean(np.square(ψAφ)) ], [ - 2 * np.real(λ_l.conj() * φHu) + - ρ_l * np.linalg.norm(φHu.ravel())**2 + 2 * np.mean(np.real(λ_l.conj() * φHu)) + + ρ_l * np.mean(np.square(φHu)) ], + [presult['cost']], [align_cost], ) + lagrangian = [comm.gather(x) for x in lagrangian] if comm.rank == 0: - lagrangian = [np.sum(x) for x in lagrangian] + lagrangian = [np.mean(x) for x in lagrangian] print_log_line( k=k, ρ_p=ρ_p, @@ -154,6 +156,7 @@ def ptycho_align_lamino( dGψ=lagrangian[0], ψAφ=lagrangian[1], φHu=lagrangian[2], - align=lagrangian[3], + ptycho=lagrangian[3], + align=lagrangian[4], lamino=float(lamino_cost), ) diff --git a/src/tike/admm/ptycho.py b/src/tike/admm/ptycho.py index 42655d9b..ea3144c6 100644 --- a/src/tike/admm/ptycho.py +++ b/src/tike/admm/ptycho.py @@ -61,4 +61,11 @@ def subproblem( dtype='float32', ) - return presult + Gψ = tike.ptycho.simulate( + detector_shape=data.shape[-1], + probe=presult['probe'], + scan=presult['scan'], + psi=presult['psi'], + ) + + return presult, Gψ From 91572496a58a2dff30a93559a31bbae7f327b77f Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 12 Feb 2021 16:04:13 -0600 Subject: [PATCH 072/109] DOC: Save angle instead of imag in lamino subproblem --- src/tike/admm/lamino.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tike/admm/lamino.py b/src/tike/admm/lamino.py index 0e6b7db6..7835dcf8 100644 --- a/src/tike/admm/lamino.py +++ b/src/tike/admm/lamino.py @@ -54,13 +54,13 @@ def subproblem( # with data, psi, etc, but we can reorder the saved array order = np.argsort(theta) dxchange.write_tiff( - phi[order].real, - f'{folder}/phi-real-{save_result:03d}.tiff', + np.angle(phi[order]), + f'{folder}/phi-angle-{save_result:03d}.tiff', dtype='float32', ) dxchange.write_tiff( - phi[order].imag, - f'{folder}/phi-imag-{save_result:03d}.tiff', + np.abs(phi[order]), + f'{folder}/phi-abs-{save_result:03d}.tiff', dtype='float32', ) @@ -109,13 +109,13 @@ def subproblem( dtype='float32', ) dxchange.write_tiff( - Hu.real, - f'{folder}/Hu-real-{save_result:03d}.tiff', + np.angle(Hu), + f'{folder}/Hu-angle-{save_result:03d}.tiff', dtype='float32', ) dxchange.write_tiff( - Hu.imag, - f'{folder}/Hu-imag-{save_result:03d}.tiff', + np.abs(Hu), + f'{folder}/Hu-abs-{save_result:03d}.tiff', dtype='float32', ) From 89c382b15f796e10e019456b8e31e9ae43b6c1e1 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Feb 2021 14:01:27 -0600 Subject: [PATCH 073/109] NEW: Access early termination for ptycho subproblem --- src/tike/admm/ptycho.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tike/admm/ptycho.py b/src/tike/admm/ptycho.py index ea3144c6..5abf6fd4 100644 --- a/src/tike/admm/ptycho.py +++ b/src/tike/admm/ptycho.py @@ -23,6 +23,7 @@ def subproblem( folder=None, save_result=False, rescale=False, + rtol=-1, ): """Solve the ptychography subsproblem. @@ -44,6 +45,7 @@ def subproblem( recover_positions=False, model='gaussian', rescale=rescale, + rtol=rtol, **presult, ) From a3b73406d3bed23d0550fbe10b058f0921b63bd2 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Feb 2021 14:03:22 -0600 Subject: [PATCH 074/109] REF: Remove skimage dependency for center mass --- src/tike/admm/admm.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/tike/admm/admm.py b/src/tike/admm/admm.py index 2465aae4..1766d3ae 100644 --- a/src/tike/admm/admm.py +++ b/src/tike/admm/admm.py @@ -120,13 +120,28 @@ def optical_flow_tvl1(unaligned, original, num_iter=16): return flow -def center_of_mass(x): - """Find the center of mass""" - import skimage.measure - center = np.empty((len(x), 2), dtype='float32') - for i in range(len(x)): - phase = np.angle(x[i]) - phase[phase < 0] = 0 - M = skimage.measure.moments(phase, order=1) - center[i] = M[1, 0] / M[0, 0], M[0, 1] / M[0, 0] - return center +def center_of_mass(m, axis=None): + """Return the center of mass of m along the given axis. + + Parameters + ---------- + m : array + Values to find the center of mass from + axis : tuple(int) + The axes to find center of mass along. + + Returns + ------- + center : (..., len(axis)) array[int] + The shape of center is the shape of m with the dimensions corresponding + to axis removed plus a new dimension appended whose length is the + of length of axis in the order of axis. + + """ + centers = [] + for a in range(m.ndim) if axis is None else axis: + shape = np.ones_like(m.shape) + shape[a] = m.shape[a] + x = np.arange(1, m.shape[a] + 1).reshape(*shape).astype(m.dtype) + centers.append((m * x).sum(axis=axis) / m.sum(axis=axis) - 1) + return np.stack(centers, axis=-1) From b2fbc470a173328667e3480b9b5fc802adbd2ed3 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Feb 2021 14:07:45 -0600 Subject: [PATCH 075/109] REF: Ensure correct lagrangians again --- src/tike/admm/al.py | 55 ++++++++++++++++++++------------------------ src/tike/admm/pal.py | 8 +++---- src/tike/admm/pl.py | 4 ++-- 3 files changed, 31 insertions(+), 36 deletions(-) diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index 2a9c4306..d98e42db 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -49,9 +49,10 @@ def ptycho__align_lamino( with cp.cuda.Device(comm.rank if comm.size > 1 else None): - presult = tike.admm.ptycho.subproblem( + presult, _ = tike.admm.ptycho.subproblem( # constants - data, + comm=comm, + data=data, λ=None, ρ=None, Aφ=None, @@ -61,7 +62,7 @@ def ptycho__align_lamino( num_iter=4 * niter, cg_iter=cg_iter, folder=folder, - save_result=save_result, + save_result=niter + 1, rescale=True, ) @@ -79,19 +80,19 @@ def ptycho__align_lamino( align_cost, ) = tike.admm.alignment.subproblem( # constants - comm, - presult['psi'], - angle, - Hu, - λ_l, - ρ_l, + comm=comm, + psi=presult['psi'], + angle=angle, + Hu=Hu, + λ_l=λ_l, + ρ_l=ρ_l, # updated - phi, + phi=phi, λ_p=None, - ρ_p=None, + ρ_p=1, flow=flow, shift=shift, - Aφ0=Aφ, + Aφ0=None, # parameters align_method=align_method, cg_iter=cg_iter, @@ -108,12 +109,12 @@ def ptycho__align_lamino( lamino_cost, ) = tike.admm.lamino.subproblem( # constants - comm, - phi, - theta, - tilt, + comm=comm, + phi=phi, + theta=theta, + tilt=tilt, # updated - u, + u=u, λ_l=λ_l, ρ_l=ρ_l, Hu0=Hu, @@ -128,14 +129,10 @@ def ptycho__align_lamino( ψAφ = presult['psi'] - Aφ φHu = phi - Hu lagrangian = ( - [presult['cost']], - [ - 2 * np.real(λ_p.conj() * ψAφ) + - ρ_p * np.linalg.norm(ψAφ.ravel())**2 - ], + [np.mean(np.real(ψAφ.conj() * ψAφ))], [ - 2 * np.real(λ_l.conj() * φHu) + - ρ_l * np.linalg.norm(φHu.ravel())**2 + 2 * np.mean(np.real(λ_l.conj() * φHu)) + + ρ_l * np.mean(np.real(φHu.conj() * φHu)) ], [align_cost], ) @@ -145,12 +142,10 @@ def ptycho__align_lamino( lagrangian = [np.sum(x) for x in lagrangian] print_log_line( k=k, - ρ_p=ρ_p, ρ_l=ρ_l, - Lagrangian=np.sum(lagrangian[:3]), - dGψ=lagrangian[0], - ψAφ=lagrangian[1], - φHu=lagrangian[2], - align=lagrangian[3], + Lagrangian=np.sum(lagrangian[:2]), + ψAφ=lagrangian[0], + φHu=lagrangian[1], + align=lagrangian[2], lamino=float(lamino_cost), ) diff --git a/src/tike/admm/pal.py b/src/tike/admm/pal.py index cb6dd825..fa2537ec 100644 --- a/src/tike/admm/pal.py +++ b/src/tike/admm/pal.py @@ -133,12 +133,12 @@ def ptycho_align_lamino( lagrangian = ( [np.mean(np.square(data - Gψ))], [ - 2 * np.mean(np.real(λ_p.conj() * ψAφ)) + - ρ_p * np.mean(np.square(ψAφ)) + 2 * np.mean(np.real(λ_p.conj() * ψAφ)) + + ρ_p * np.mean(np.real(ψAφ.conj() * ψAφ)) ], [ - 2 * np.mean(np.real(λ_l.conj() * φHu)) + - ρ_l * np.mean(np.square(φHu)) + 2 * np.mean(np.real(λ_l.conj() * φHu)) + + ρ_l * np.mean(np.real(φHu.conj() * φHu)) ], [presult['cost']], [align_cost], diff --git a/src/tike/admm/pl.py b/src/tike/admm/pl.py index 93310e8b..aee8b9ca 100644 --- a/src/tike/admm/pl.py +++ b/src/tike/admm/pl.py @@ -50,7 +50,7 @@ def ptycho_lamino( logger.info(f"Start ADMM iteration {k}.") save_result = k if k % interval == 0 else False - presult = tike.admm.ptycho.subproblem( + presult, Gψ = tike.admm.ptycho.subproblem( comm, # constants data, @@ -136,7 +136,7 @@ def ptycho_lamino( # Record metrics for each subproblem ψHu = presult['psi'] - Hu lagrangian = ( - [presult['cost']], + [np.mean(np.square(data - Gψ))], [ 2 * np.real(λ_p.conj() * ψHu) + ρ_p * np.linalg.norm(ψHu.ravel())**2 From 64eb859212a6567cd7a2ef1b463d2029e6c0f833 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Feb 2021 14:08:27 -0600 Subject: [PATCH 076/109] REF: Update penalty params more often --- src/tike/admm/admm.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/tike/admm/admm.py b/src/tike/admm/admm.py index 1766d3ae..e130d9ec 100644 --- a/src/tike/admm/admm.py +++ b/src/tike/admm/admm.py @@ -7,16 +7,26 @@ import tike.ptycho -def update_penalty(comm, psi, h, h0, rho, diff=10): - r = np.linalg.norm(psi - h)**2 - s = np.linalg.norm(rho * (h - h0))**2 - r, s = [comm.gather(x) for x in ([r], [s])] +def update_penalty(comm, g, h, h0, rho, diff=4): + """Increase rho when L2 error between g and h becomes too large. + + If rho is the penalty parameter associated with the constraint norm(y - x), + then rho is increased when + + norm(g - h) > diff * rho^2 * norm(h - h0) + + and decreased when + + norm(g - h) * diff < rho^2 * norm(h - h0) + + """ + r = np.linalg.norm(g - h)**2 + s = rho * rho * np.linalg.norm(h - h0)**2 + r, s = [np.sum(comm.gather(x)) for x in ([r], [s])] if comm.rank == 0: - r = np.sum(r) - s = np.sum(s) if (r > diff * s): rho *= 2 - elif (s > diff * r): + elif (r * diff < s): rho *= 0.5 rho = comm.broadcast(rho) logging.info(f"Update penalty parameter ρ = {rho}.") From 93e21469e8fd539a154229622e46de77c92031ef Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Feb 2021 14:12:04 -0600 Subject: [PATCH 077/109] BUG: Reenable u broadcase --- src/tike/admm/alignment.py | 5 ++--- src/tike/admm/lamino.py | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/tike/admm/alignment.py b/src/tike/admm/alignment.py index 43eed0ce..9840be65 100644 --- a/src/tike/admm/alignment.py +++ b/src/tike/admm/alignment.py @@ -142,14 +142,13 @@ def subproblem( padded_shape=psi.shape, cval=1.0, ) - ψAφ = psi - Aφ logger.info("Update alignment lambdas and rhos") if λ_p is not None: - λ_p += ρ_p * ψAφ + λ_p += ρ_p * (psi - Aφ) - if ρ_p is not None and Aφ0 is not None: + if Aφ0 is not None: ρ_p = update_penalty(comm, psi, Aφ, Aφ0, ρ_p) Aφ0 = Aφ diff --git a/src/tike/admm/lamino.py b/src/tike/admm/lamino.py index 7835dcf8..309f9765 100644 --- a/src/tike/admm/lamino.py +++ b/src/tike/admm/lamino.py @@ -75,22 +75,22 @@ def subproblem( num_gpu=comm.size, ) u = lresult['obj'] - cost = lresult['cost'] + cost = lresult['cost'][-1] # Separate again to multiple processes λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] - # u = comm.broadcast(u) # volume too large to fit in MPI buffer + # FIXME: volume becomes too large to fit in MPI buffer + u = comm.broadcast(u) Hu = np.exp(1j * tike.lamino.simulate( obj=u, tilt=tilt, theta=theta, )) - φHu = phi - Hu logger.info('Update laminography lambdas and rhos.') - λ_l += ρ_l * φHu + λ_l += ρ_l * (phi - Hu) if Hu0 is not None: ρ_l = update_penalty(comm, phi, Hu, Hu0, ρ_l) From 39d6571f007d211db2c1304c7747a2e8ba5249a5 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Feb 2021 16:13:56 -0600 Subject: [PATCH 078/109] NEW: Enable center of mass in alignment subproblem --- src/tike/admm/alignment.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/tike/admm/alignment.py b/src/tike/admm/alignment.py index 9840be65..641d4aec 100644 --- a/src/tike/admm/alignment.py +++ b/src/tike/admm/alignment.py @@ -127,12 +127,14 @@ def subproblem( unaligned=rotated, original=padded, upsample_factor=100, + reg_weight=0, ) - # Limit shift change per iteration - if shift is None: - shift = np.clip(sresult['shift'], -16, 16) - else: - shift += np.clip(sresult['shift'] - shift, -16, 16) + shift = sresult['shift'] + else: + logging.info("Estimate rigid alignment with center of mass.") + centers = center_of_mass(np.abs(np.angle(rotated)), axis=(-2, -1)) + # shift is defined from padded coords to rotated coords + shift = centers - np.array(rotated.shape[-2:]) / 2 Aφ = tike.align.simulate( phi, From 2fd446da2366a48773fe3692d5b0a4c89846c110 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Feb 2021 16:15:02 -0600 Subject: [PATCH 079/109] REF: Only minmax when needed --- src/tike/admm/al.py | 2 +- src/tike/admm/alignment.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index d98e42db..8ee60621 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -94,7 +94,7 @@ def ptycho__align_lamino( shift=shift, Aφ0=None, # parameters - align_method=align_method, + align_method=align_method if k > 8 else 'mass', cg_iter=cg_iter, num_iter=4, folder=folder, diff --git a/src/tike/admm/alignment.py b/src/tike/admm/alignment.py index 641d4aec..0e8a859f 100644 --- a/src/tike/admm/alignment.py +++ b/src/tike/admm/alignment.py @@ -64,16 +64,14 @@ def subproblem( if align_method: - hi, lo = find_min_max(np.angle(psi if λ_p is None else psi + λ_p / ρ_p)) - # TODO: Try combining rotation and flow because they use the same # interpolator - rotated = tike.align.simulate( + rotated = tike.align.invert( psi if λ_p is None else psi + λ_p / ρ_p, - angle=-angle, + angle=angle, flow=None, shift=None, - padded_shape=None, + unpadded_shape=None, cval=1.0, ) padded = tike.align.simulate( @@ -98,6 +96,7 @@ def subproblem( ) if align_method.lower() == 'flow': + hi, lo = find_min_max(np.angle(psi)) winsize = max(winsize - 1, 128) logging.info("Estimate alignment using Farneback.") fresult = tike.align.solvers.farneback( From d5b88b36788ef4ae95a4b44fb2a26f707206006d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Feb 2021 16:32:36 -0600 Subject: [PATCH 080/109] REF: Put admm subproblem into own module --- src/tike/admm/admm.py | 90 ------------------- src/tike/admm/al.py | 11 ++- src/tike/admm/pal.py | 10 +-- src/tike/admm/pl.py | 8 +- src/tike/admm/subproblem/__init__.py | 37 ++++++++ .../{alignment.py => subproblem/align.py} | 75 ++++++++++++++-- src/tike/admm/{ => subproblem}/lamino.py | 4 +- src/tike/admm/{ => subproblem}/ptycho.py | 2 +- 8 files changed, 122 insertions(+), 115 deletions(-) create mode 100644 src/tike/admm/subproblem/__init__.py rename src/tike/admm/{alignment.py => subproblem/align.py} (64%) rename src/tike/admm/{ => subproblem}/lamino.py (98%) rename src/tike/admm/{ => subproblem}/ptycho.py (98%) diff --git a/src/tike/admm/admm.py b/src/tike/admm/admm.py index e130d9ec..96d67b20 100644 --- a/src/tike/admm/admm.py +++ b/src/tike/admm/admm.py @@ -7,47 +7,6 @@ import tike.ptycho -def update_penalty(comm, g, h, h0, rho, diff=4): - """Increase rho when L2 error between g and h becomes too large. - - If rho is the penalty parameter associated with the constraint norm(y - x), - then rho is increased when - - norm(g - h) > diff * rho^2 * norm(h - h0) - - and decreased when - - norm(g - h) * diff < rho^2 * norm(h - h0) - - """ - r = np.linalg.norm(g - h)**2 - s = rho * rho * np.linalg.norm(h - h0)**2 - r, s = [np.sum(comm.gather(x)) for x in ([r], [s])] - if comm.rank == 0: - if (r > diff * s): - rho *= 2 - elif (r * diff < s): - rho *= 0.5 - rho = comm.broadcast(rho) - logging.info(f"Update penalty parameter ρ = {rho}.") - return rho - - -def find_min_max(data): - mmin = np.zeros(data.shape[0], dtype='float32') - mmax = np.zeros(data.shape[0], dtype='float32') - - for k in range(data.shape[0]): - h, e = np.histogram(data[k][:], 1000) - stend = np.where(h > np.max(h) * 0.005) - st = stend[0][0] - end = stend[0][-1] - mmin[k] = e[st] - mmax[k] = e[end + 1] - - return mmin, mmax - - def simulate( u, scan, @@ -106,52 +65,3 @@ def print_log_line(**kwargs): line.append(f'"{k}": {v}') # Combine all the strings and strip the last comma print("{", ", ".join(line), "}", flush=True) - - -def optical_flow_tvl1(unaligned, original, num_iter=16): - """Wrap scikit-image optical_flow_tvl1 for complex values""" - from skimage.registration import optical_flow_tvl1 - iflow = [ - optical_flow_tvl1( - original[i].imag, - unaligned[i].imag, - num_iter=num_iter, - ) for i in range(len(original)) - ] - rflow = [ - optical_flow_tvl1( - original[i].real, - unaligned[i].real, - num_iter=num_iter, - ) for i in range(len(original)) - ] - flow = np.array(rflow, dtype='float32') + np.array(iflow, dtype='float32') - flow = np.moveaxis(flow, 1, -1) / 2.0 - return flow - - -def center_of_mass(m, axis=None): - """Return the center of mass of m along the given axis. - - Parameters - ---------- - m : array - Values to find the center of mass from - axis : tuple(int) - The axes to find center of mass along. - - Returns - ------- - center : (..., len(axis)) array[int] - The shape of center is the shape of m with the dimensions corresponding - to axis removed plus a new dimension appended whose length is the - of length of axis in the order of axis. - - """ - centers = [] - for a in range(m.ndim) if axis is None else axis: - shape = np.ones_like(m.shape) - shape[a] = m.shape[a] - x = np.arange(1, m.shape[a] + 1).reshape(*shape).astype(m.dtype) - centers.append((m * x).sum(axis=axis) / m.sum(axis=axis) - 1) - return np.stack(centers, axis=-1) diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index 8ee60621..2070d047 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -3,9 +3,7 @@ import numpy as np import cupy as cp -import tike.admm.alignment -import tike.admm.lamino -import tike.admm.ptycho +import tike.admm.subproblem import tike.communicator from .admm import print_log_line @@ -49,7 +47,7 @@ def ptycho__align_lamino( with cp.cuda.Device(comm.rank if comm.size > 1 else None): - presult, _ = tike.admm.ptycho.subproblem( + presult, _ = tike.admm.subproblem.ptycho( # constants comm=comm, data=data, @@ -64,6 +62,7 @@ def ptycho__align_lamino( folder=folder, save_result=niter + 1, rescale=True, + rtol=1e-6, ) for k in range(1, niter + 1): @@ -78,7 +77,7 @@ def ptycho__align_lamino( shift, Aφ, align_cost, - ) = tike.admm.alignment.subproblem( + ) = tike.admm.subproblem.align( # constants comm=comm, psi=presult['psi'], @@ -107,7 +106,7 @@ def ptycho__align_lamino( ρ_l, Hu, lamino_cost, - ) = tike.admm.lamino.subproblem( + ) = tike.admm.subproblem.lamino( # constants comm=comm, phi=phi, diff --git a/src/tike/admm/pal.py b/src/tike/admm/pal.py index fa2537ec..16318195 100644 --- a/src/tike/admm/pal.py +++ b/src/tike/admm/pal.py @@ -3,9 +3,7 @@ import numpy as np import cupy as cp -import tike.admm.alignment -import tike.admm.lamino -import tike.admm.ptycho +import tike.admm.subproblem import tike.communicator from .admm import print_log_line @@ -55,7 +53,7 @@ def ptycho_align_lamino( logger.info(f"Start ADMM iteration {k}.") save_result = k if k % interval == 0 else False - presult, Gψ = tike.admm.ptycho.subproblem( + presult, Gψ = tike.admm.subproblem.ptycho( # constants comm=comm, data=data, @@ -80,7 +78,7 @@ def ptycho_align_lamino( shift, Aφ, align_cost, - ) = tike.admm.alignment.subproblem( + ) = tike.admm.subproblem.align( # constants comm=comm, psi=presult['psi'], @@ -109,7 +107,7 @@ def ptycho_align_lamino( ρ_l, Hu, lamino_cost, - ) = tike.admm.lamino.subproblem( + ) = tike.admm.subproblem.lamino( # constants comm=comm, phi=phi, diff --git a/src/tike/admm/pl.py b/src/tike/admm/pl.py index aee8b9ca..e3fbe810 100644 --- a/src/tike/admm/pl.py +++ b/src/tike/admm/pl.py @@ -3,9 +3,7 @@ import numpy as np import cupy as cp -import tike.admm.alignment -import tike.admm.lamino -import tike.admm.ptycho +import tike.admm.subproblem import tike.align import tike.communicator @@ -50,7 +48,7 @@ def ptycho_lamino( logger.info(f"Start ADMM iteration {k}.") save_result = k if k % interval == 0 else False - presult, Gψ = tike.admm.ptycho.subproblem( + presult, Gψ = tike.admm.subproblem.ptycho( comm, # constants data, @@ -98,7 +96,7 @@ def ptycho_lamino( ρ_p, Hu, lamino_cost, - ) = tike.admm.lamino.subproblem( + ) = tike.admm.subproblem.lamino( # constants comm, phi, diff --git a/src/tike/admm/subproblem/__init__.py b/src/tike/admm/subproblem/__init__.py new file mode 100644 index 00000000..f6c27877 --- /dev/null +++ b/src/tike/admm/subproblem/__init__.py @@ -0,0 +1,37 @@ +"""Implements subproblem formulations for ADMM. + +Each ADMM subproblem is implemented in a separate function such that the +problems are consistently implemented across different ADMM compositions. + +""" + + +def update_penalty(comm, g, h, h0, rho, diff=4): + """Increase rho when L2 error between g and h becomes too large. + + If rho is the penalty parameter associated with the constraint norm(y - x), + then rho is increased when + + norm(g - h) > diff * rho^2 * norm(h - h0) + + and decreased when + + norm(g - h) * diff < rho^2 * norm(h - h0) + + """ + r = np.linalg.norm(g - h)**2 + s = rho * rho * np.linalg.norm(h - h0)**2 + r, s = [np.sum(comm.gather(x)) for x in ([r], [s])] + if comm.rank == 0: + if (r > diff * s): + rho *= 2 + elif (r * diff < s): + rho *= 0.5 + rho = comm.broadcast(rho) + logging.info(f"Update penalty parameter ρ = {rho}.") + return rho + + +from .align import * +from .lamino import * +from .ptycho import * diff --git a/src/tike/admm/alignment.py b/src/tike/admm/subproblem/align.py similarity index 64% rename from src/tike/admm/alignment.py rename to src/tike/admm/subproblem/align.py index 0e8a859f..ed7fb76e 100644 --- a/src/tike/admm/alignment.py +++ b/src/tike/admm/subproblem/align.py @@ -3,13 +3,78 @@ import dxchange import numpy as np -from .admm import update_penalty, find_min_max, optical_flow_tvl1, center_of_mass import tike.align +from . import update_penalty logger = logging.getLogger(__name__) -def subproblem( +def _find_min_max(data): + mmin = np.zeros(data.shape[0], dtype='float32') + mmax = np.zeros(data.shape[0], dtype='float32') + + for k in range(data.shape[0]): + h, e = np.histogram(data[k][:], 1000) + stend = np.where(h > np.max(h) * 0.005) + st = stend[0][0] + end = stend[0][-1] + mmin[k] = e[st] + mmax[k] = e[end + 1] + + return mmin, mmax + + +def _optical_flow_tvl1(unaligned, original, num_iter=16): + """Wrap scikit-image optical_flow_tvl1 for complex values""" + from skimage.registration import optical_flow_tvl1 + pflow = [ + optical_flow_tvl1( + np.angle(original[i]), + np.angle(unaligned[i]), + num_iter=num_iter, + ) for i in range(len(original)) + ] + # rflow = [ + # optical_flow_tvl1( + # original[i].real, + # unaligned[i].real, + # num_iter=num_iter, + # ) for i in range(len(original)) + # ] + flow = np.array(pflow, dtype='float32') + #+ np.array(iflow, dtype='float32')) /2 + flow = np.moveaxis(flow, 1, -1) + return flow + + +def _center_of_mass(m, axis=None): + """Return the center of mass of m along the given axis. + + Parameters + ---------- + m : array + Values to find the center of mass from + axis : tuple(int) + The axes to find center of mass along. + + Returns + ------- + center : (..., len(axis)) array[int] + The shape of center is the shape of m with the dimensions corresponding + to axis removed plus a new dimension appended whose length is the + of length of axis in the order of axis. + + """ + centers = [] + for a in range(m.ndim) if axis is None else axis: + shape = np.ones_like(m.shape) + shape[a] = m.shape[a] + x = np.arange(1, m.shape[a] + 1).reshape(*shape).astype(m.dtype) + centers.append((m * x).sum(axis=axis) / m.sum(axis=axis) - 1) + return np.stack(centers, axis=-1) + + +def align( # constants comm, psi, @@ -96,7 +161,7 @@ def subproblem( ) if align_method.lower() == 'flow': - hi, lo = find_min_max(np.angle(psi)) + hi, lo = _find_min_max(np.angle(psi)) winsize = max(winsize - 1, 128) logging.info("Estimate alignment using Farneback.") fresult = tike.align.solvers.farneback( @@ -114,7 +179,7 @@ def subproblem( flow = fresult['flow'] elif align_method.lower() == 'tvl1': logging.info("Estimate alignment using TV-L1.") - flow = optical_flow_tvl1( + flow = _optical_flow_tvl1( unaligned=rotated, original=padded, num_iter=cg_iter, @@ -131,7 +196,7 @@ def subproblem( shift = sresult['shift'] else: logging.info("Estimate rigid alignment with center of mass.") - centers = center_of_mass(np.abs(np.angle(rotated)), axis=(-2, -1)) + centers = _center_of_mass(np.abs(np.angle(rotated)), axis=(-2, -1)) # shift is defined from padded coords to rotated coords shift = centers - np.array(rotated.shape[-2:]) / 2 diff --git a/src/tike/admm/lamino.py b/src/tike/admm/subproblem/lamino.py similarity index 98% rename from src/tike/admm/lamino.py rename to src/tike/admm/subproblem/lamino.py index 309f9765..8b595355 100644 --- a/src/tike/admm/lamino.py +++ b/src/tike/admm/subproblem/lamino.py @@ -4,12 +4,12 @@ import numpy as np import tike.lamino -from .admm import update_penalty +from . import update_penalty logger = logging.getLogger(__name__) -def subproblem( +def lamino( # constants comm, phi, diff --git a/src/tike/admm/ptycho.py b/src/tike/admm/subproblem/ptycho.py similarity index 98% rename from src/tike/admm/ptycho.py rename to src/tike/admm/subproblem/ptycho.py index 5abf6fd4..dde2b45b 100644 --- a/src/tike/admm/ptycho.py +++ b/src/tike/admm/subproblem/ptycho.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -def subproblem( +def ptycho( comm, # constants data, From 6444f19d4779bf00f5a19ff24b02ac11cdc6c4bd Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 23 Feb 2021 12:22:40 -0600 Subject: [PATCH 081/109] BUG: Don't convert scalars to arrays --- src/tike/ptycho/ptycho.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tike/ptycho/ptycho.py b/src/tike/ptycho/ptycho.py index 956b69e2..d241424f 100644 --- a/src/tike/ptycho/ptycho.py +++ b/src/tike/ptycho/ptycho.py @@ -311,7 +311,10 @@ def reconstruct( for k, v in result.items(): if isinstance(v, list): result[k] = v[0] - return {k: operator.asnumpy(v) for k, v in result.items()} + return { + k: v if np.ndim(v) < 1 else operator.asnumpy(v) + for k, v in result.items() + } else: raise ValueError(f"The '{algorithm}' algorithm is not an option.\n" f"\tAvailable algorithms are : {solvers.__all__}") From 190fecdc96ed4f3f8b39bc55b09769c205b7206e Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 23 Feb 2021 15:55:46 -0600 Subject: [PATCH 082/109] NEW: Remove duplicate interpolation in Alignment operator --- src/tike/operators/cupy/alignment.py | 58 +++++++++++++++++----------- src/tike/operators/cupy/rotate.py | 14 +++++++ tests/operators/test_rotate.py | 13 +++++-- 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/tike/operators/cupy/alignment.py b/src/tike/operators/cupy/alignment.py index f843c1a1..7542a445 100644 --- a/src/tike/operators/cupy/alignment.py +++ b/src/tike/operators/cupy/alignment.py @@ -51,23 +51,30 @@ def fwd( unpadded_shape=None, cval=0.0, ): - return self.rotate.fwd( - unrotated=self.flow.fwd( - f=self.shift.fwd( - a=self.pad.fwd( - unpadded=unpadded, - padded_shape=padded_shape, - cval=cval, - ), - shift=shift, - cval=cval, - ), - flow=flow, + unflowed = self.shift.fwd( + a=self.pad.fwd( + unpadded=unpadded, + padded_shape=padded_shape, cval=cval, ), - angle=angle, + shift=shift, cval=cval, ) + if flow is None: + return self.rotate.fwd( + unrotated=unflowed, + angle=angle, + cval=cval, + ) + else: + if angle is not None: + flow = flow + self.rotate._make_flow(unrotated=unflowed, + angle=angle) + return self.flow.fwd( + f=unflowed, + flow=flow, + cval=cval, + ) def adj( self, @@ -79,17 +86,24 @@ def adj( padded_shape=None, cval=0.0, ): + if flow is None: + unflowed = self.rotate.adj( + rotated=rotated, + angle=angle, + cval=cval, + ) + else: + if angle is not None: + flow = flow + self.rotate._make_flow(unrotated=rotated, + angle=angle) + unflowed = self.flow.adj( + g=rotated, + flow=flow, + cval=cval, + ) return self.pad.adj( padded=self.shift.adj( - a=self.flow.adj( - g=self.rotate.adj( - rotated=rotated, - angle=angle, - cval=cval, - ), - flow=flow, - cval=cval, - ), + a=unflowed, shift=shift, cval=cval, ), diff --git a/src/tike/operators/cupy/rotate.py b/src/tike/operators/cupy/rotate.py index e214657c..2d469f8c 100644 --- a/src/tike/operators/cupy/rotate.py +++ b/src/tike/operators/cupy/rotate.py @@ -20,6 +20,20 @@ class Rotate(Operator): original image. """ + def _make_flow(self, unrotated, angle): + """Return a flow that performs the rotation.""" + cos, sin = np.cos(angle), np.sin(angle) + shifti = (unrotated.shape[-2] - 1) / 2.0 + shiftj = (unrotated.shape[-1] - 1) / 2.0 + + i, j = self.xp.mgrid[0:unrotated.shape[-2], + 0:unrotated.shape[-1]].astype('float32') + + di = i - ((+cos * (i - shifti) + sin * (j - shiftj)) + shifti) + dj = j - ((-sin * (i - shifti) + cos * (j - shiftj)) + shiftj) + + return self.xp.stack([di, dj], axis=-1) + def _make_grid(self, unrotated, angle): """Return the points on the rotated grid.""" cos, sin = np.cos(angle), np.sin(angle) diff --git a/tests/operators/test_rotate.py b/tests/operators/test_rotate.py index f922f4fb..64fc5bb9 100644 --- a/tests/operators/test_rotate.py +++ b/tests/operators/test_rotate.py @@ -4,7 +4,7 @@ import unittest import numpy as np -from tike.operators import Rotate +from tike.operators import Rotate, Flow from .util import random_complex, OperatorTests @@ -33,19 +33,24 @@ def setUp(self, shape=(7, 25, 53)): } print(self.operator) - def debug_show(self): + def debug_show(self, angle=19 * np.pi / 6): import libimage import matplotlib.pyplot as plt x = self.xp.asarray(libimage.load('coins', 256), dtype='complex64') - y = self.operator.fwd(x[None], 4 * np.pi) + y = self.operator.fwd(x[None], angle) + flow = self.operator._make_flow(unrotated=x, angle=angle) + y1 = Flow().fwd(x, flow) - print(x.shape, y.shape) + print(x.shape, y.shape, y1.shape) plt.figure() plt.imshow(x.real.get()) plt.figure() plt.imshow(y[0].real.get()) + + plt.figure() + plt.imshow(y1.real.get()) plt.show() @unittest.skip('FIXME: This operator is not scaled.') From 2f3b1b285ee4399024cde095b63fd717a802586d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 25 Feb 2021 11:29:17 -0600 Subject: [PATCH 083/109] BUG: Start with shift as initial flow --- src/tike/admm/subproblem/align.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tike/admm/subproblem/align.py b/src/tike/admm/subproblem/align.py index ed7fb76e..f2b982ef 100644 --- a/src/tike/admm/subproblem/align.py +++ b/src/tike/admm/subproblem/align.py @@ -161,8 +161,12 @@ def align( ) if align_method.lower() == 'flow': - hi, lo = _find_min_max(np.angle(psi)) - winsize = max(winsize - 1, 128) + if shift is not None: + flow = np.zeros((*rotated.shape, 2), dtype='float32') + flow[..., :] = shift[..., None, None, :] + shift = None + hi, lo = _find_min_max(np.angle(rotated)) + winsize = max(winsize - 1, 32) logging.info("Estimate alignment using Farneback.") fresult = tike.align.solvers.farneback( op=None, From a3e998013696a24340b9da34035eb0c1adae974d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 25 Feb 2021 11:29:41 -0600 Subject: [PATCH 084/109] DOC: Fix typo in penalty docstring --- src/tike/admm/subproblem/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tike/admm/subproblem/__init__.py b/src/tike/admm/subproblem/__init__.py index f6c27877..6ef448b9 100644 --- a/src/tike/admm/subproblem/__init__.py +++ b/src/tike/admm/subproblem/__init__.py @@ -9,7 +9,7 @@ def update_penalty(comm, g, h, h0, rho, diff=4): """Increase rho when L2 error between g and h becomes too large. - If rho is the penalty parameter associated with the constraint norm(y - x), + If rho is the penalty parameter associated with the constraint norm(g - h), then rho is increased when norm(g - h) > diff * rho^2 * norm(h - h0) From 7f46b389ffd2745ab7a8b4566668dc1b1fac0b9d Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 25 Feb 2021 12:41:58 -0600 Subject: [PATCH 085/109] API: Change regularization scheme for cross_correlation --- src/tike/align/solvers/cross_correlation.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/tike/align/solvers/cross_correlation.py b/src/tike/align/solvers/cross_correlation.py index b2bd28ed..e757736b 100644 --- a/src/tike/align/solvers/cross_correlation.py +++ b/src/tike/align/solvers/cross_correlation.py @@ -36,7 +36,7 @@ def cross_correlation( upsample_factor=1, space="real", num_iter=None, - reg_weight=1e-9, + reg_weight=0, ): """Efficient subpixel image translation alignment by cross-correlation. @@ -46,6 +46,13 @@ def cross_correlation( then refines the shift estimation by upsampling the DFT only in a small neighborhood of that estimate by means of a matrix-multiply DFT. + Parameters + ---------- + reg_weight: float [0, 1] + Determines how strongly the cross-correlation overlap matters. If C(x) is + the cross correlation function and A(x) is the overlap function, then + we choose the best alignment where (1 - reg)C + (reg)CA is a maximum. + References ---------- Stéfan van der Walt, Johannes L. Schönberger, Juan Nunez-Iglesias, @@ -81,11 +88,11 @@ def cross_correlation( # the cross_correlation is the same for multiple shifts. if reg_weight > 0: w = _area_overlap(op, cross_correlation) - w = op.xp.fft.fftshift(w) * reg_weight + w = reg_weight * op.xp.fft.fftshift(w) + (1 - reg_weight) else: - w = 0 + w = 1 - A = np.abs(cross_correlation) + w + A = np.abs(cross_correlation) * w maxima = A.reshape(A.shape[0], -1).argmax(1) maxima = np.column_stack(np.unravel_index(maxima, A[0, :, :].shape)) shifts = op.xp.array(maxima, dtype='float32') From 84229a498f86dc39e76cbdea0e9b42311c8ad512 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 25 Feb 2021 12:42:38 -0600 Subject: [PATCH 086/109] BUG: Properly log strings in log_line --- src/tike/admm/admm.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tike/admm/admm.py b/src/tike/admm/admm.py index 96d67b20..1091fca8 100644 --- a/src/tike/admm/admm.py +++ b/src/tike/admm/admm.py @@ -61,6 +61,8 @@ def print_log_line(**kwargs): line.append(f'"{k}": {v:6.3e}') elif isinstance(v, (int, np.integer)): line.append(f'"{k}": {v:3d}') + elif isinstance(v, str): + line.append(f'"{k}": "{v}"') else: line.append(f'"{k}": {v}') # Combine all the strings and strip the last comma From 9b6660a6bdf13b05a28fa44933487998de041470 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 25 Feb 2021 12:55:00 -0600 Subject: [PATCH 087/109] NEW: Track winsize parameter --- src/tike/admm/al.py | 6 +++++- src/tike/admm/pal.py | 6 +++++- src/tike/admm/subproblem/align.py | 6 ++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index 2070d047..2deabe0a 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -77,6 +77,7 @@ def ptycho__align_lamino( shift, Aφ, align_cost, + winsize, ) = tike.admm.subproblem.align( # constants comm=comm, @@ -93,11 +94,12 @@ def ptycho__align_lamino( shift=shift, Aφ0=None, # parameters - align_method=align_method if k > 8 else 'mass', + align_method=align_method, cg_iter=cg_iter, num_iter=4, folder=folder, save_result=save_result, + winsize=winsize if k > 1 else 129, ) ( @@ -142,6 +144,8 @@ def ptycho__align_lamino( print_log_line( k=k, ρ_l=ρ_l, + winsize=winsize, + align_method=align_method, Lagrangian=np.sum(lagrangian[:2]), ψAφ=lagrangian[0], φHu=lagrangian[1], diff --git a/src/tike/admm/pal.py b/src/tike/admm/pal.py index 16318195..fee38495 100644 --- a/src/tike/admm/pal.py +++ b/src/tike/admm/pal.py @@ -78,6 +78,7 @@ def ptycho_align_lamino( shift, Aφ, align_cost, + winsize, ) = tike.admm.subproblem.align( # constants comm=comm, @@ -99,6 +100,7 @@ def ptycho_align_lamino( num_iter=4, folder=folder, save_result=save_result, + winsize=winsize if k > 1 else 128, ) ( @@ -140,7 +142,7 @@ def ptycho_align_lamino( ], [presult['cost']], [align_cost], - ) + ) # yapf: disable lagrangian = [comm.gather(x) for x in lagrangian] @@ -150,6 +152,8 @@ def ptycho_align_lamino( k=k, ρ_p=ρ_p, ρ_l=ρ_l, + winsize=winsize, + align_method=align_method, Lagrangian=np.sum(lagrangian[:3]), dGψ=lagrangian[0], ψAφ=lagrangian[1], diff --git a/src/tike/admm/subproblem/align.py b/src/tike/admm/subproblem/align.py index f2b982ef..199db712 100644 --- a/src/tike/admm/subproblem/align.py +++ b/src/tike/admm/subproblem/align.py @@ -95,6 +95,7 @@ def align( num_iter=1, folder=None, save_result=False, + winsize=0, ): """ Parameters @@ -166,7 +167,7 @@ def align( flow[..., :] = shift[..., None, None, :] shift = None hi, lo = _find_min_max(np.angle(rotated)) - winsize = max(winsize - 1, 32) + winsize = max(winsize - 2, 31) logging.info("Estimate alignment using Farneback.") fresult = tike.align.solvers.farneback( op=None, @@ -195,7 +196,7 @@ def align( unaligned=rotated, original=padded, upsample_factor=100, - reg_weight=0, + reg_weight=0.0, ) shift = sresult['shift'] else: @@ -231,4 +232,5 @@ def align( shift, Aφ0, cost, + winsize, ) From b4a7256e9e2d0f6c1010e37744502c95868caa4f Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 9 Mar 2021 14:54:12 -0600 Subject: [PATCH 088/109] BUG: Avoid MPI buffer overflow --- src/tike/admm/subproblem/lamino.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/tike/admm/subproblem/lamino.py b/src/tike/admm/subproblem/lamino.py index 8b595355..6deaebc7 100644 --- a/src/tike/admm/subproblem/lamino.py +++ b/src/tike/admm/subproblem/lamino.py @@ -47,7 +47,7 @@ def lamino( # Gather all to one process λ_l, phi, theta = [comm.gather(x) for x in (λ_l, phi, theta)] - cost = None + cost, Hu = None, None if comm.rank == 0: if save_result: # We cannot reorder phi, theta without ruining correspondence @@ -72,21 +72,23 @@ def lamino( algorithm='cgrad', num_iter=num_iter, cg_iter=cg_iter, - num_gpu=comm.size, + # FIXME: Communications overhead makes 1 GPU faster than 8. + num_gpu=1, #comm.size, ) u = lresult['obj'] cost = lresult['cost'][-1] + # FIXME: volume becomes too large to fit in MPI buffer. + # Used to broadcast u, now broadcast only Hu + # u = comm.broadcast(u) + Hu = np.exp(1j * tike.lamino.simulate( + obj=u, + tilt=tilt, + theta=theta, + )) + # Separate again to multiple processes - λ_l, phi, theta = [comm.scatter(x) for x in (λ_l, phi, theta)] - # FIXME: volume becomes too large to fit in MPI buffer - u = comm.broadcast(u) - - Hu = np.exp(1j * tike.lamino.simulate( - obj=u, - tilt=tilt, - theta=theta, - )) + λ_l, phi, theta, Hu = [comm.scatter(x) for x in (λ_l, phi, theta, Hu)] logger.info('Update laminography lambdas and rhos.') From 49b0ebf105874a1e1b7ab1eeff919175dbaf4db0 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 9 Mar 2021 14:56:37 -0600 Subject: [PATCH 089/109] API: Use overwrite intermediate saves --- src/tike/admm/subproblem/align.py | 2 ++ src/tike/admm/subproblem/lamino.py | 6 ++++++ src/tike/admm/subproblem/ptycho.py | 8 +++++--- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tike/admm/subproblem/align.py b/src/tike/admm/subproblem/align.py index 199db712..85e6854c 100644 --- a/src/tike/admm/subproblem/align.py +++ b/src/tike/admm/subproblem/align.py @@ -154,11 +154,13 @@ def align( np.angle(rotated), f'{folder}/rotated-angle-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) dxchange.write_tiff( np.angle(padded), f'{folder}/padded-angle-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) if align_method.lower() == 'flow': diff --git a/src/tike/admm/subproblem/lamino.py b/src/tike/admm/subproblem/lamino.py index 6deaebc7..94fac283 100644 --- a/src/tike/admm/subproblem/lamino.py +++ b/src/tike/admm/subproblem/lamino.py @@ -57,11 +57,13 @@ def lamino( np.angle(phi[order]), f'{folder}/phi-angle-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) dxchange.write_tiff( np.abs(phi[order]), f'{folder}/phi-abs-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) lresult = tike.lamino.reconstruct( @@ -104,21 +106,25 @@ def lamino( u.real, f'{folder}/particle-real-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) dxchange.write_tiff( u.imag, f'{folder}/particle-imag-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) dxchange.write_tiff( np.angle(Hu), f'{folder}/Hu-angle-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) dxchange.write_tiff( np.abs(Hu), f'{folder}/Hu-abs-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) return ( diff --git a/src/tike/admm/subproblem/ptycho.py b/src/tike/admm/subproblem/ptycho.py index dde2b45b..0c0cb0b8 100644 --- a/src/tike/admm/subproblem/ptycho.py +++ b/src/tike/admm/subproblem/ptycho.py @@ -51,16 +51,18 @@ def ptycho( logger.info("No update for ptychography lambdas and rhos") - if comm.rank == 0 and save_result: + if save_result: dxchange.write_tiff( np.abs(presult['psi']), - f'{folder}/psi-abs-{save_result:03d}.tiff', + f'{folder}/{comm.rank}-psi-abs-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) dxchange.write_tiff( np.angle(presult['psi']), - f'{folder}/psi-angle-{save_result:03d}.tiff', + f'{folder}/{comm.rank}-psi-angle-{save_result:03d}.tiff', dtype='float32', + overwrite=True, ) Gψ = tike.ptycho.simulate( From 1c925d62d61988dc89eefa60c8ea2dc63725c421 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 12 Mar 2021 11:36:11 -0600 Subject: [PATCH 090/109] REF: Move alignment operator to left --- src/tike/admm/subproblem/align.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tike/admm/subproblem/align.py b/src/tike/admm/subproblem/align.py index 85e6854c..1b05afb4 100644 --- a/src/tike/admm/subproblem/align.py +++ b/src/tike/admm/subproblem/align.py @@ -195,12 +195,12 @@ def align( logging.info("Estimate rigid alignment with cross correlation.") sresult = tike.align.reconstruct( algorithm='cross_correlation', - unaligned=rotated, - original=padded, + unaligned=padded, + original=rotated, upsample_factor=100, reg_weight=0.0, ) - shift = sresult['shift'] + shift = -sresult['shift'] else: logging.info("Estimate rigid alignment with center of mass.") centers = _center_of_mass(np.abs(np.angle(rotated)), axis=(-2, -1)) From 177940cc582a10c57fe348de991e7eda84ff0ff6 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Mon, 15 Mar 2021 12:50:06 -0500 Subject: [PATCH 091/109] REF: Solve inverse flow problem --- src/tike/admm/subproblem/align.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tike/admm/subproblem/align.py b/src/tike/admm/subproblem/align.py index 1b05afb4..1c295a07 100644 --- a/src/tike/admm/subproblem/align.py +++ b/src/tike/admm/subproblem/align.py @@ -173,9 +173,9 @@ def align( logging.info("Estimate alignment using Farneback.") fresult = tike.align.solvers.farneback( op=None, - unaligned=np.angle(rotated), - original=np.angle(padded), - flow=flow, + unaligned=np.angle(padded), + original=np.angle(rotated), + flow=flow if flow is None else -flow, pyr_scale=0.5, levels=4, winsize=winsize, @@ -183,12 +183,12 @@ def align( hi=hi, lo=lo, ) - flow = fresult['flow'] + flow = -fresult['flow'] elif align_method.lower() == 'tvl1': logging.info("Estimate alignment using TV-L1.") - flow = _optical_flow_tvl1( - unaligned=rotated, - original=padded, + flow = -_optical_flow_tvl1( + unaligned=padded, + original=rotated, num_iter=cg_iter, ) elif align_method.lower() == 'xcor': From e01cd8d3c31b443cda19650234c1fd164a0b134b Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Mar 2021 17:31:06 -0500 Subject: [PATCH 092/109] NEW: Gradient operator for reg subproblem --- src/broken/reg.py | 33 ---------------------- src/tike/operators/cupy/__init__.py | 2 ++ src/tike/operators/cupy/gradient.py | 43 +++++++++++++++++++++++++++++ tests/operators/test_gradient.py | 41 +++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 33 deletions(-) delete mode 100644 src/broken/reg.py create mode 100644 src/tike/operators/cupy/gradient.py create mode 100644 tests/operators/test_gradient.py diff --git a/src/broken/reg.py b/src/broken/reg.py deleted file mode 100644 index e8ed918f..00000000 --- a/src/broken/reg.py +++ /dev/null @@ -1,33 +0,0 @@ -import numpy as np - -def run(xp, u, mu, tau, alpha): - """Provide some kind of regularization.""" - z = fwd(xp, u) + mu / tau - # Soft-thresholding - # za = xp.sqrt(xp.sum(xp.abs(z), axis=0)) - za = xp.sqrt(xp.real(xp.sum(z*xp.conj(z), 0))) - zeros = (za <= alpha / tau) - z[:, zeros] = 0 - z[:, ~zeros] -= z[:, ~zeros] * alpha / (tau * za[~zeros]) - return z - -def fwd(xp, u): - """Forward operator for regularization (J).""" - res = xp.zeros((3, *u.shape), dtype=u.dtype, order='C') - res[0, :, :, :-1] = u[:, :, 1:] - u[:, :, :-1] - res[1, :, :-1, :] = u[:, 1:, :] - u[:, :-1, :] - res[2, :-1, :, :] = u[1:, :, :] - u[:-1, :, :] - res *= 2 / np.sqrt(3) # normalization - return res - -def adj(xp, gr): - """Adjoint operator for regularization (J^*).""" - res = xp.zeros(gr.shape[1:], gr.dtype, order='C') - res[:, :, 1:] = gr[0, :, :, 1:] - gr[0, :, :, :-1] - res[:, :, 0] = gr[0, :, :, 0] - res[:, 1:, :] += gr[1, :, 1:, :] - gr[1, :, :-1, :] - res[:, 0, :] += gr[1, :, 0, :] - res[1:, :, :] += gr[2, 1:, :, :] - gr[2, :-1, :, :] - res[0, :, :] += gr[2, 0, :, :] - res *= -2 / np.sqrt(3) # normalization - return res diff --git a/src/tike/operators/cupy/__init__.py b/src/tike/operators/cupy/__init__.py index 092c72a9..5198f95a 100644 --- a/src/tike/operators/cupy/__init__.py +++ b/src/tike/operators/cupy/__init__.py @@ -8,6 +8,7 @@ from .alignment import * from .convolution import * from .flow import * +from .gradient import * from .lamino import * from .operator import * from .pad import * @@ -20,6 +21,7 @@ __all__ = ( 'Alignment', 'Convolution', + 'Gradient', 'Flow', 'Lamino', 'Operator', diff --git a/src/tike/operators/cupy/gradient.py b/src/tike/operators/cupy/gradient.py new file mode 100644 index 00000000..09164cba --- /dev/null +++ b/src/tike/operators/cupy/gradient.py @@ -0,0 +1,43 @@ +__author__ = "Viktor Nikitin, Daniel Ching" +__copyright__ = "Copyright (c) 2021, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + +from .operator import Operator + +class Gradient(Operator): + """Returns the Gradient approximation of a 3D array.""" + +# def run(xp, u, mu, tau, alpha): +# """Provide some kind of regularization.""" +# z = fwd(xp, u) + mu / tau +# # Soft-thresholding +# # za = xp.sqrt(xp.sum(xp.abs(z), axis=0)) +# za = xp.sqrt(xp.real(xp.sum(z*xp.conj(z), 0))) +# zeros = (za <= alpha / tau) +# z[:, zeros] = 0 +# z[:, ~zeros] -= z[:, ~zeros] * alpha / (tau * za[~zeros]) +# return z + + def fwd(self, u): + """Forward operator for regularization.""" + res = self.xp.empty((3, *u.shape), dtype=u.dtype) + res[0, :, :, :-1] = u[:, :, 1:] - u[:, :, :-1] + res[0, :, :, -1] = u[:, :, 0] - u[:, :, -1] + res[1, :, :-1, :] = u[:, 1:, :] - u[:, :-1, :] + res[1, :, -1, :] = u[:, 0, :] - u[:, -1, :] + res[2, :-1, :, :] = u[1:, :, :] - u[:-1, :, :] + res[2, -1, :, :] = u[ 0, :, :] - u[ -1, :, :] + res *= 1 / self.xp.sqrt(3) # normalization + return res + + def adj(self, g): + """Adjoint operator for regularization.""" + res = self.xp.empty(g.shape[1:], g.dtype) + res[:, :, 1:] = g[0, :, :, 1:] - g[0, :, :, :-1] + res[:, :, 0] = g[0, :, :, 0] - g[0, :, :, -1] + res[:, 1:, :] += g[1, :, 1:, :] - g[1, :, :-1, :] + res[:, 0, :] += g[1, :, 0, :] - g[1, :, -1, :] + res[1:, :, :] += g[2, 1:, :, :] - g[2, :-1, :, :] + res[0, :, :] += g[2, 0, :, :] - g[2, -1, :, :] + res *= -1 / self.xp.sqrt(3) # normalization + return res diff --git a/tests/operators/test_gradient.py b/tests/operators/test_gradient.py new file mode 100644 index 00000000..b090a760 --- /dev/null +++ b/tests/operators/test_gradient.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import unittest + +import numpy as np +from tike.operators import Gradient +import tike.random + +from .util import random_complex, OperatorTests + +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2021, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + + +class TestGradient(unittest.TestCase, OperatorTests): + """Test the Gradient operator.""" + + def setUp(self, shape=(8, 19, 5)): + """Load a dataset for reconstruction.""" + + self.operator = Gradient() + self.operator.__enter__() + self.xp = self.operator.xp + + np.random.seed(0) + self.m = tike.random.cupy_complex(*shape) + self.m_name = 'u' + self.d = tike.random.cupy_complex(3, *shape) + self.d_name = 'g' + self.kwargs = { } + print(self.operator) + + @unittest.skip('FIXME: This operator is not scaled.') + def test_scaled(self): + pass + + +if __name__ == '__main__': + unittest.main() From 95d02d42ccf91e1e795fe396396ff635d3b01727 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 16 Mar 2021 17:31:06 -0500 Subject: [PATCH 093/109] NEW: Gradient operator for reg subproblem --- src/broken/reg.py | 33 ---------------------- src/tike/operators/cupy/__init__.py | 2 ++ src/tike/operators/cupy/gradient.py | 43 +++++++++++++++++++++++++++++ tests/operators/test_gradient.py | 41 +++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 33 deletions(-) delete mode 100644 src/broken/reg.py create mode 100644 src/tike/operators/cupy/gradient.py create mode 100644 tests/operators/test_gradient.py diff --git a/src/broken/reg.py b/src/broken/reg.py deleted file mode 100644 index e8ed918f..00000000 --- a/src/broken/reg.py +++ /dev/null @@ -1,33 +0,0 @@ -import numpy as np - -def run(xp, u, mu, tau, alpha): - """Provide some kind of regularization.""" - z = fwd(xp, u) + mu / tau - # Soft-thresholding - # za = xp.sqrt(xp.sum(xp.abs(z), axis=0)) - za = xp.sqrt(xp.real(xp.sum(z*xp.conj(z), 0))) - zeros = (za <= alpha / tau) - z[:, zeros] = 0 - z[:, ~zeros] -= z[:, ~zeros] * alpha / (tau * za[~zeros]) - return z - -def fwd(xp, u): - """Forward operator for regularization (J).""" - res = xp.zeros((3, *u.shape), dtype=u.dtype, order='C') - res[0, :, :, :-1] = u[:, :, 1:] - u[:, :, :-1] - res[1, :, :-1, :] = u[:, 1:, :] - u[:, :-1, :] - res[2, :-1, :, :] = u[1:, :, :] - u[:-1, :, :] - res *= 2 / np.sqrt(3) # normalization - return res - -def adj(xp, gr): - """Adjoint operator for regularization (J^*).""" - res = xp.zeros(gr.shape[1:], gr.dtype, order='C') - res[:, :, 1:] = gr[0, :, :, 1:] - gr[0, :, :, :-1] - res[:, :, 0] = gr[0, :, :, 0] - res[:, 1:, :] += gr[1, :, 1:, :] - gr[1, :, :-1, :] - res[:, 0, :] += gr[1, :, 0, :] - res[1:, :, :] += gr[2, 1:, :, :] - gr[2, :-1, :, :] - res[0, :, :] += gr[2, 0, :, :] - res *= -2 / np.sqrt(3) # normalization - return res diff --git a/src/tike/operators/cupy/__init__.py b/src/tike/operators/cupy/__init__.py index 092c72a9..5198f95a 100644 --- a/src/tike/operators/cupy/__init__.py +++ b/src/tike/operators/cupy/__init__.py @@ -8,6 +8,7 @@ from .alignment import * from .convolution import * from .flow import * +from .gradient import * from .lamino import * from .operator import * from .pad import * @@ -20,6 +21,7 @@ __all__ = ( 'Alignment', 'Convolution', + 'Gradient', 'Flow', 'Lamino', 'Operator', diff --git a/src/tike/operators/cupy/gradient.py b/src/tike/operators/cupy/gradient.py new file mode 100644 index 00000000..09164cba --- /dev/null +++ b/src/tike/operators/cupy/gradient.py @@ -0,0 +1,43 @@ +__author__ = "Viktor Nikitin, Daniel Ching" +__copyright__ = "Copyright (c) 2021, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + +from .operator import Operator + +class Gradient(Operator): + """Returns the Gradient approximation of a 3D array.""" + +# def run(xp, u, mu, tau, alpha): +# """Provide some kind of regularization.""" +# z = fwd(xp, u) + mu / tau +# # Soft-thresholding +# # za = xp.sqrt(xp.sum(xp.abs(z), axis=0)) +# za = xp.sqrt(xp.real(xp.sum(z*xp.conj(z), 0))) +# zeros = (za <= alpha / tau) +# z[:, zeros] = 0 +# z[:, ~zeros] -= z[:, ~zeros] * alpha / (tau * za[~zeros]) +# return z + + def fwd(self, u): + """Forward operator for regularization.""" + res = self.xp.empty((3, *u.shape), dtype=u.dtype) + res[0, :, :, :-1] = u[:, :, 1:] - u[:, :, :-1] + res[0, :, :, -1] = u[:, :, 0] - u[:, :, -1] + res[1, :, :-1, :] = u[:, 1:, :] - u[:, :-1, :] + res[1, :, -1, :] = u[:, 0, :] - u[:, -1, :] + res[2, :-1, :, :] = u[1:, :, :] - u[:-1, :, :] + res[2, -1, :, :] = u[ 0, :, :] - u[ -1, :, :] + res *= 1 / self.xp.sqrt(3) # normalization + return res + + def adj(self, g): + """Adjoint operator for regularization.""" + res = self.xp.empty(g.shape[1:], g.dtype) + res[:, :, 1:] = g[0, :, :, 1:] - g[0, :, :, :-1] + res[:, :, 0] = g[0, :, :, 0] - g[0, :, :, -1] + res[:, 1:, :] += g[1, :, 1:, :] - g[1, :, :-1, :] + res[:, 0, :] += g[1, :, 0, :] - g[1, :, -1, :] + res[1:, :, :] += g[2, 1:, :, :] - g[2, :-1, :, :] + res[0, :, :] += g[2, 0, :, :] - g[2, -1, :, :] + res *= -1 / self.xp.sqrt(3) # normalization + return res diff --git a/tests/operators/test_gradient.py b/tests/operators/test_gradient.py new file mode 100644 index 00000000..b090a760 --- /dev/null +++ b/tests/operators/test_gradient.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import unittest + +import numpy as np +from tike.operators import Gradient +import tike.random + +from .util import random_complex, OperatorTests + +__author__ = "Daniel Ching" +__copyright__ = "Copyright (c) 2021, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + + +class TestGradient(unittest.TestCase, OperatorTests): + """Test the Gradient operator.""" + + def setUp(self, shape=(8, 19, 5)): + """Load a dataset for reconstruction.""" + + self.operator = Gradient() + self.operator.__enter__() + self.xp = self.operator.xp + + np.random.seed(0) + self.m = tike.random.cupy_complex(*shape) + self.m_name = 'u' + self.d = tike.random.cupy_complex(3, *shape) + self.d_name = 'g' + self.kwargs = { } + print(self.operator) + + @unittest.skip('FIXME: This operator is not scaled.') + def test_scaled(self): + pass + + +if __name__ == '__main__': + unittest.main() From 77790680cfea61d396c00afc4be84237014fe8ce Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 19 Mar 2021 12:06:43 -0500 Subject: [PATCH 094/109] REF: Move laminography cost/grad out of operators --- src/tike/lamino/solvers/cgrad.py | 23 +++++++++++++++++++++-- src/tike/operators/cupy/lamino.py | 17 ----------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/tike/lamino/solvers/cgrad.py b/src/tike/lamino/solvers/cgrad.py index d5c9648f..26848668 100644 --- a/src/tike/lamino/solvers/cgrad.py +++ b/src/tike/lamino/solvers/cgrad.py @@ -24,6 +24,25 @@ def _estimate_step_length(obj, theta, op): return 2 * scaler if op.xp.isfinite(scaler) else 1.0 +def _cost(data, theta, obj, op): + """Cost function for the least-squres laminography problem.""" + return tike.linalg.norm(op.fwd( + u=obj, + theta=theta, + ) - data)**2 + + +def _grad(data, theta, obj, op): + """Gradient for the least-squares laminography problem.""" + return op.adj( + data=op.fwd( + u=obj, + theta=theta, + ) - data, + theta=theta, + ) + + def cgrad( op, comm, @@ -59,14 +78,14 @@ def update_obj(op, comm, data, theta, obj, num_iter=1, step_length=1): """Solver the object recovery problem.""" def cost_function(obj): - cost_out = comm.pool.map(op.cost, data, theta, obj) + cost_out = comm.pool.map(_cost, data, theta, obj, op=op) if comm.use_mpi: return comm.Allreduce_reduce(cost_out, 'cpu') else: return comm.reduce(cost_out, 'cpu') def grad(obj): - grad_list = comm.pool.map(op.grad, data, theta, obj) + grad_list = comm.pool.map(_grad, data, theta, obj, op=op) if comm.use_mpi: return comm.Allreduce_reduce(grad_list, 'gpu') else: diff --git a/src/tike/operators/cupy/lamino.py b/src/tike/operators/cupy/lamino.py index 5524bd1d..43f59729 100644 --- a/src/tike/operators/cupy/lamino.py +++ b/src/tike/operators/cupy/lamino.py @@ -154,23 +154,6 @@ def gather(self, Fe, x, n, m, mu): )) return F - def cost(self, data, theta, obj): - "Cost function for the least-squres laminography problem" - return self.xp.linalg.norm((self.fwd( - u=obj, - theta=theta, - ) - data).ravel())**2 - - def grad(self, data, theta, obj): - "Gradient for the least-squares laminography problem" - return self.adj( - data=self.fwd( - u=obj, - theta=theta, - ) - data, - theta=theta, - ) / (data.shape[-3] * self.n**3) - def _make_grids(self, theta): """Return (ntheta*n*n, 3) unequally-spaced frequencies for the USFFT.""" [kv, ku] = self.xp.mgrid[-self.n // 2:self.n // 2, From fe4d35c313c945a75799f7271b407e5893de6337 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 19 Mar 2021 13:28:09 -0500 Subject: [PATCH 095/109] NEW: Add functions for total variation regularization --- src/tike/linalg.py | 5 +++ src/tike/operators/cupy/gradient.py | 12 +------ src/tike/regularization.py | 54 +++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 src/tike/regularization.py diff --git a/src/tike/linalg.py b/src/tike/linalg.py index 0948ffdf..6a9036a9 100644 --- a/src/tike/linalg.py +++ b/src/tike/linalg.py @@ -7,6 +7,11 @@ import numpy as np +def norm1(x, axis=None, keepdims=None): + """Return the vector 1-norm of x along given axis.""" + return np.sum(np.abs(x), axis=axis, keepdims=keepdims) + + def norm(x, axis=None, keepdims=None): """Return the vector 2-norm of x along given axis.""" return np.sqrt(np.sum((x * x.conj()).real, axis=axis, keepdims=keepdims)) diff --git a/src/tike/operators/cupy/gradient.py b/src/tike/operators/cupy/gradient.py index 09164cba..eeac24f6 100644 --- a/src/tike/operators/cupy/gradient.py +++ b/src/tike/operators/cupy/gradient.py @@ -4,20 +4,10 @@ from .operator import Operator + class Gradient(Operator): """Returns the Gradient approximation of a 3D array.""" -# def run(xp, u, mu, tau, alpha): -# """Provide some kind of regularization.""" -# z = fwd(xp, u) + mu / tau -# # Soft-thresholding -# # za = xp.sqrt(xp.sum(xp.abs(z), axis=0)) -# za = xp.sqrt(xp.real(xp.sum(z*xp.conj(z), 0))) -# zeros = (za <= alpha / tau) -# z[:, zeros] = 0 -# z[:, ~zeros] -= z[:, ~zeros] * alpha / (tau * za[~zeros]) -# return z - def fwd(self, u): """Forward operator for regularization.""" res = self.xp.empty((3, *u.shape), dtype=u.dtype) diff --git a/src/tike/regularization.py b/src/tike/regularization.py new file mode 100644 index 00000000..909bf85f --- /dev/null +++ b/src/tike/regularization.py @@ -0,0 +1,54 @@ +__author__ = "Viktor Nikitin, Daniel Ching" +__copyright__ = "Copyright (c) 2021, UChicago Argonne, LLC." +__docformat__ = 'restructuredtext en' + +import tike.linalg + + +def cost(op, x, dual, penalty, alpha): + """Minimization functional for regularization problem. + + Parameters + ---------- + op : operators.Gradient + The gradient operator. + x : (L, M, N) array-like + The object being regularized + dual : float + ADMM dual variable. + penalty : float + ADMM penalty parameter. + alpha : float + Some tuning parameter. + """ + grad = op.fwd(x) + cost = alpha * tike.linalg.norm1(grad) + cost += penalty * tike.linalg.norm(grad - reg + dual / penalty)**2 + return cost + + +def soft_threshold(op, x, dual, penalty, alpha): + """Soft thresholding operator for solving something. + + Parameters + ---------- + op : operators.Gradient + The gradient operator. + x : (L, M, N) array-like + The object being regularized + dual : float + ADMM dual variable. + penalty : float + ADMM penalty parameter. + alpha : float + Some tuning parameter. + + Returns + ------- + x1 : (L, M, N) array-like + The updated x. + + """ + z = op.fwd(x) + dual / penalty + za = op.xp.abs(z) + return z / za * op.xp.maximum(0, za - alpha / penalty) From 9567f199db205b72af497d024545d4f076efd5cd Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Mon, 22 Mar 2021 11:37:44 -0500 Subject: [PATCH 096/109] NEW: Add regularization to lamino cost function --- src/tike/lamino/solvers/cgrad.py | 71 ++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/src/tike/lamino/solvers/cgrad.py b/src/tike/lamino/solvers/cgrad.py index 26848668..3a19747a 100644 --- a/src/tike/lamino/solvers/cgrad.py +++ b/src/tike/lamino/solvers/cgrad.py @@ -24,23 +24,68 @@ def _estimate_step_length(obj, theta, op): return 2 * scaler if op.xp.isfinite(scaler) else 1.0 -def _cost(data, theta, obj, op): - """Cost function for the least-squres laminography problem.""" - return tike.linalg.norm(op.fwd( +def _cost_tv( + data, + theta, + obj, + reg=0, + K=1, + penalty0=1, + penalty1=0, + op=None, + op1=None, +): + """Cost function for the regularized laminography problem. + + The cost function F(u) is a two term function as follows: + + F(u) = penalty0 * norm(K * R(u) − data)**2 + + penalty1 * norm( J(u) − reg)**2 + + where + + K = 1j / v * 2π * (ψ − dual0 / penalty0) + data = (ψ − dual0 / penalty0) * log(ψ − dual0/penalty0) + reg = ω − dual1 / penalty1 + + where ψ is the projection through the object and ω is J(obj) and v is the + wavenumber. + + """ + cost = penalty0 * tike.linalg.norm(K * op.fwd( u=obj, theta=theta, ) - data)**2 + if penalty1 > 0: + cost += penalty1 * tike.linalg.norm(op1.fwd(obj) - reg)**2 + return cost + + +def _grad_tv( + data, + theta, + obj, + reg=0, + K=1, + penalty0=1, + penalty1=0, + op=None, + op1=None, +): + """Gradient for the regularized laminography problem. + + ∇F(u) = penalty0 * R_adj(conj(K) * (K * R(u) − data)) + + penalty1 * J_adj( J(u) − reg) - -def _grad(data, theta, obj, op): - """Gradient for the least-squares laminography problem.""" - return op.adj( - data=op.fwd( - u=obj, - theta=theta, - ) - data, + """ + Kconj = op.xp.conj(K) if K != 1 else K + grad = penalty0 * op.adj( + data=Kconj * (K * op.fwd(u=obj, theta=theta) - data), theta=theta, ) + if penalty1 > 0: + grad += penalty1 * op1.adj(op1.fwd(obj) - reg) + return grad def cgrad( @@ -78,14 +123,14 @@ def update_obj(op, comm, data, theta, obj, num_iter=1, step_length=1): """Solver the object recovery problem.""" def cost_function(obj): - cost_out = comm.pool.map(_cost, data, theta, obj, op=op) + cost_out = comm.pool.map(_cost_tv, data, theta, obj, op=op) if comm.use_mpi: return comm.Allreduce_reduce(cost_out, 'cpu') else: return comm.reduce(cost_out, 'cpu') def grad(obj): - grad_list = comm.pool.map(_grad, data, theta, obj, op=op) + grad_list = comm.pool.map(_grad_tv, data, theta, obj, op=op) if comm.use_mpi: return comm.Allreduce_reduce(grad_list, 'gpu') else: From b1d725c471f60b7af97ae31046d4ea6a1fd3f25a Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 23 Mar 2021 15:26:16 -0500 Subject: [PATCH 097/109] NEW: Change tomography linear approximation --- src/tike/admm/subproblem/lamino.py | 8 ++++++-- src/tike/lamino/solvers/cgrad.py | 16 +++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/tike/admm/subproblem/lamino.py b/src/tike/admm/subproblem/lamino.py index 94fac283..e44a1177 100644 --- a/src/tike/admm/subproblem/lamino.py +++ b/src/tike/admm/subproblem/lamino.py @@ -66,8 +66,12 @@ def lamino( overwrite=True, ) + K = 2j * np.pi * (phi + λ_l / ρ_l) + data = (phi + λ_l / ρ_l) * np.log(phi + λ_l / ρ_l) + lresult = tike.lamino.reconstruct( - data=-1j * np.log(phi + λ_l / ρ_l), + data=data, + K=K, theta=theta, tilt=tilt, obj=u, @@ -75,7 +79,7 @@ def lamino( num_iter=num_iter, cg_iter=cg_iter, # FIXME: Communications overhead makes 1 GPU faster than 8. - num_gpu=1, #comm.size, + num_gpu=1, # comm.size, ) u = lresult['obj'] cost = lresult['cost'][-1] diff --git a/src/tike/lamino/solvers/cgrad.py b/src/tike/lamino/solvers/cgrad.py index 3a19747a..d64858cf 100644 --- a/src/tike/lamino/solvers/cgrad.py +++ b/src/tike/lamino/solvers/cgrad.py @@ -6,7 +6,7 @@ logger = logging.getLogger(__name__) -def _estimate_step_length(obj, theta, op): +def _estimate_step_length(obj, theta, K, op): """Use norm of forward adjoint operations to estimate step length. Scaling the adjoint operation by |F*Fm| / |m| puts the step length in the @@ -14,8 +14,9 @@ def _estimate_step_length(obj, theta, op): """ logger.info('Estimate step length from forward adjoint operations.') + Kconj = op.xp.conj(K) if K != 1 else K outnback = op.adj( - data=op.fwd(u=obj, theta=theta), + data=Kconj * (K * op.fwd(u=obj, theta=theta)), theta=theta, overwrite=False, ) @@ -94,15 +95,19 @@ def cgrad( data, theta, obj, cg_iter=4, step_length=1, + K=None, **kwargs ): # yapf: disable """Solve the Laminogarphy problem using the conjugate gradients method.""" + K = [1] * comm.pool.num_workers if K is None else K + step_length = comm.pool.reduce_cpu( comm.pool.map( _estimate_step_length, obj, theta, + K, op=op, )) / comm.pool.num_workers if step_length == 1 else step_length @@ -112,6 +117,7 @@ def cgrad( data, theta, obj, + K, num_iter=cg_iter, step_length=step_length, ) @@ -119,18 +125,18 @@ def cgrad( return {'obj': obj, 'cost': cost, 'step_length': step_length} -def update_obj(op, comm, data, theta, obj, num_iter=1, step_length=1): +def update_obj(op, comm, data, theta, obj, K, num_iter=1, step_length=1): """Solver the object recovery problem.""" def cost_function(obj): - cost_out = comm.pool.map(_cost_tv, data, theta, obj, op=op) + cost_out = comm.pool.map(_cost_tv, data, theta, obj, K, op=op) if comm.use_mpi: return comm.Allreduce_reduce(cost_out, 'cpu') else: return comm.reduce(cost_out, 'cpu') def grad(obj): - grad_list = comm.pool.map(_grad_tv, data, theta, obj, op=op) + grad_list = comm.pool.map(_grad_tv, data, theta, obj, K, op=op) if comm.use_mpi: return comm.Allreduce_reduce(grad_list, 'gpu') else: From e9912253ea2067cdb29239c12fea7744e872aafa Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 23 Mar 2021 15:40:10 -0500 Subject: [PATCH 098/109] BUG: New handling of K --- src/tike/lamino/solvers/cgrad.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/tike/lamino/solvers/cgrad.py b/src/tike/lamino/solvers/cgrad.py index d64858cf..90b1563a 100644 --- a/src/tike/lamino/solvers/cgrad.py +++ b/src/tike/lamino/solvers/cgrad.py @@ -14,9 +14,12 @@ def _estimate_step_length(obj, theta, K, op): """ logger.info('Estimate step length from forward adjoint operations.') - Kconj = op.xp.conj(K) if K != 1 else K + if K is None: + KconjK = 1 + else: + KconjK = op.xp.conj(K) * K outnback = op.adj( - data=Kconj * (K * op.fwd(u=obj, theta=theta)), + data=KconjK * op.fwd(u=obj, theta=theta), theta=theta, overwrite=False, ) @@ -30,7 +33,7 @@ def _cost_tv( theta, obj, reg=0, - K=1, + K=None, penalty0=1, penalty1=0, op=None, @@ -53,6 +56,7 @@ def _cost_tv( wavenumber. """ + K = 1 if K is None else K cost = penalty0 * tike.linalg.norm(K * op.fwd( u=obj, theta=theta, @@ -67,7 +71,7 @@ def _grad_tv( theta, obj, reg=0, - K=1, + K=None, penalty0=1, penalty1=0, op=None, @@ -79,11 +83,11 @@ def _grad_tv( + penalty1 * J_adj( J(u) − reg) """ - Kconj = op.xp.conj(K) if K != 1 else K - grad = penalty0 * op.adj( - data=Kconj * (K * op.fwd(u=obj, theta=theta) - data), - theta=theta, - ) + if K is None: + d = op.fwd(u=obj, theta=theta) - data + else: + d = op.xp.conj(K) * (K * op.fwd(u=obj, theta=theta) - data) + grad = penalty0 * op.adj(data=d, theta=theta) if penalty1 > 0: grad += penalty1 * op1.adj(op1.fwd(obj) - reg) return grad @@ -100,7 +104,7 @@ def cgrad( ): # yapf: disable """Solve the Laminogarphy problem using the conjugate gradients method.""" - K = [1] * comm.pool.num_workers if K is None else K + K = [None] * comm.pool.num_workers if K is None else K step_length = comm.pool.reduce_cpu( comm.pool.map( From 7a3178bc3d7c78825307e7126bc7d7370bf3cc72 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 24 Mar 2021 15:06:10 -0500 Subject: [PATCH 099/109] STUB: Regularization subproblem --- src/tike/admm/subproblem/reg.py | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/tike/admm/subproblem/reg.py diff --git a/src/tike/admm/subproblem/reg.py b/src/tike/admm/subproblem/reg.py new file mode 100644 index 00000000..ab648f4e --- /dev/null +++ b/src/tike/admm/subproblem/reg.py @@ -0,0 +1,50 @@ +import logging + +import dxchange +import numpy as np + +import tike.regularization +from . import update_penalty + +logger = logging.getLogger(__name__) + + +def reg( + # constants + comm, + u, + # updated + omega, + dual, + penalty, + Ju0=None, + # parameters + folder=None, + save_result=False, +): + """Update omega, the regularized object.""" + op = tike.operators.Gradient() + + omega = tike.regularization.soft_threshold( + op, + x=u + dual=dual, + penalty=penalty, + alpha=alpha, + ) + + logger.info('Update regularization lambdas and rhos.') + + dual += penalty * (omega - Ju) + + if Ju0 is not None: + penalty = update_penalty(comm, omega, Ju, Ju0, penalty) + + Ju0 = Ju + + return ( + omega, + dual, + penalty, + Ju0, + ) From 064e4c261fae6201b762e597094dab2ab939f281 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 26 Mar 2021 17:33:23 -0500 Subject: [PATCH 100/109] REF: Avoid astype() copies --- src/tike/align/solvers/cross_correlation.py | 2 +- src/tike/lamino/lamino.py | 6 ++--- src/tike/operators/cupy/lamino.py | 27 +++++++++++++-------- src/tike/ptycho/ptycho.py | 6 ++--- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/tike/align/solvers/cross_correlation.py b/src/tike/align/solvers/cross_correlation.py index e757736b..c2cbd9d0 100644 --- a/src/tike/align/solvers/cross_correlation.py +++ b/src/tike/align/solvers/cross_correlation.py @@ -128,7 +128,7 @@ def cross_correlation( maxima = np.column_stack(np.unravel_index(maxima, A[0, :, :].shape)) maxima = maxima - dftshift shifts = shifts + maxima / upsample_factor - return {'shift': shifts.astype('float32'), 'cost': -1} + return {'shift': shifts.astype('float32', copy=False), 'cost': -1} def _upsampled_dft(op, data, ups, upsample_factor, axis_offsets): diff --git a/src/tike/lamino/lamino.py b/src/tike/lamino/lamino.py index 3d645908..c4416556 100644 --- a/src/tike/lamino/lamino.py +++ b/src/tike/lamino/lamino.py @@ -117,14 +117,14 @@ def reconstruct( **kwargs, ) as operator, Comm(num_gpu, mpi=None) as comm: # send any array-likes to device - data = np.array_split(data.astype('complex64'), + data = np.array_split(data.astype('complex64', copy=False), comm.pool.num_workers) data = comm.pool.scatter(data) - theta = np.array_split(theta.astype('float32'), + theta = np.array_split(theta.astype('float32', copy=False), comm.pool.num_workers) theta = comm.pool.scatter(theta) result = { - 'obj': comm.pool.bcast(obj.astype('complex64')), + 'obj': comm.pool.bcast(obj.astype('complex64', copy=False)), } for key, value in kwargs.items(): if np.ndim(value) > 0: diff --git a/src/tike/operators/cupy/lamino.py b/src/tike/operators/cupy/lamino.py index 43f59729..ae16b3ca 100644 --- a/src/tike/operators/cupy/lamino.py +++ b/src/tike/operators/cupy/lamino.py @@ -124,16 +124,20 @@ def scatter(self, f, x, n, m, mu): G = cp.zeros([2 * n] * 3, dtype="complex64") const = cp.array([cp.sqrt(cp.pi / mu)**3, -cp.pi**2 / mu], dtype='float32') + assert G.dtype == cp.complex64 + assert f.dtype == cp.complex64 + assert x.dtype == cp.float32 + assert const.dtype == cp.float32 block = (min(self.scatter_kernel.max_threads_per_block, (2 * m)**3),) grid = (1, 0, min(f.shape[0], 65535)) self.scatter_kernel(grid, block, ( G, - f.astype('complex64'), + f, f.shape[0], - x.astype('float32'), + x, n, m, - const.astype('float32'), + const, )) return G @@ -141,25 +145,28 @@ def gather(self, Fe, x, n, m, mu): F = cp.zeros(x.shape[0], dtype="complex64") const = cp.array([cp.sqrt(cp.pi / mu)**3, -cp.pi**2 / mu], dtype='float32') + assert F.dtype == cp.complex64 + assert Fe.dtype == cp.complex64 + assert x.dtype == cp.float32 + assert const.dtype == cp.float32 block = (min(self.scatter_kernel.max_threads_per_block, (2 * m)**3),) grid = (1, 0, min(x.shape[0], 65535)) self.gather_kernel(grid, block, ( F, - Fe.astype('complex64'), + Fe, x.shape[0], - x.astype('float32'), + x, n, m, - const.astype('float32'), + const, )) return F def _make_grids(self, theta): """Return (ntheta*n*n, 3) unequally-spaced frequencies for the USFFT.""" - [kv, ku] = self.xp.mgrid[-self.n // 2:self.n // 2, - -self.n // 2:self.n // 2] / self.n - ku = ku.ravel().astype('float32') - kv = kv.ravel().astype('float32') + u = self.xp.arange(-self.n // 2, self.n // 2, dtype='float32') / self.n + ku = self.xp.broadcast_to(u, (self.n, self.n)).ravel() + kv = self.xp.broadcast_to(u[:, None], (self.n, self.n)).ravel() xi = self.xp.zeros([theta.shape[-1], self.n * self.n, 3], dtype='float32') ctilt, stilt = self.xp.cos(self.tilt), self.xp.sin(self.tilt) diff --git a/src/tike/ptycho/ptycho.py b/src/tike/ptycho/ptycho.py index eec6588a..5575e45e 100644 --- a/src/tike/ptycho/ptycho.py +++ b/src/tike/ptycho/ptycho.py @@ -233,11 +233,11 @@ def reconstruct( ) result = { 'psi': - comm.pool.bcast(psi.astype('complex64')), + comm.pool.bcast(psi.astype('complex64', copy=False)), 'probe': - comm.pool.bcast(probe.astype('complex64')), + comm.pool.bcast(probe.astype('complex64', copy=False)), 'eigen_probe': - comm.pool.bcast(eigen_probe.astype('complex64')) + comm.pool.bcast(eigen_probe.astype('complex64', copy=False)) if eigen_probe is not None else None, 'scan': scan, From bf1c8f93eae9c12345e33c719f2cf5eb8d99f181 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Fri, 26 Mar 2021 19:10:01 -0500 Subject: [PATCH 101/109] REF: Reduce Lamino mem footprint --- src/tike/operators/cupy/lamino.py | 16 +++++++++------- src/tike/operators/cupy/usfft.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/tike/operators/cupy/lamino.py b/src/tike/operators/cupy/lamino.py index ae16b3ca..1a706a9d 100644 --- a/src/tike/operators/cupy/lamino.py +++ b/src/tike/operators/cupy/lamino.py @@ -4,6 +4,7 @@ from importlib_resources import files import cupy as cp +import numpy as np from .cache import CachedFFT from .usfft import eq2us, us2eq, checkerboard @@ -44,8 +45,8 @@ def __init__(self, n, tilt, eps=1e-3, **kwargs): # noqa: D102 yapf: disable """Please see help(Lamino) for more info.""" self.n = n - self.tilt = tilt - self.eps = eps + self.tilt = np.float32(tilt) + self.eps = np.float32(eps) def __enter__(self): """Return self at start of a with-block.""" @@ -170,12 +171,13 @@ def _make_grids(self, theta): xi = self.xp.zeros([theta.shape[-1], self.n * self.n, 3], dtype='float32') ctilt, stilt = self.xp.cos(self.tilt), self.xp.sin(self.tilt) + ctheta, stheta = self.xp.cos(theta), self.xp.sin(theta) + for itheta in range(theta.shape[-1]): - ctheta = self.xp.cos(theta[itheta]) - stheta = self.xp.sin(theta[itheta]) - xi[itheta, :, 2] = ku * ctheta + kv * stheta * ctilt - xi[itheta, :, 1] = -ku * stheta + kv * ctheta * ctilt - xi[itheta, :, 0] = kv * stilt + xi[itheta, :, 2] = +ku * ctheta[itheta] + kv * stheta[itheta] * ctilt + xi[itheta, :, 1] = -ku * stheta[itheta] + kv * ctheta[itheta] * ctilt + xi[:, :, 0] = kv * stilt + # make sure coordinates are in (-0.5,0.5), probably unnecessary xi[xi >= 0.5] = 0.5 - 1e-5 xi[xi < -0.5] = -0.5 + 1e-5 diff --git a/src/tike/operators/cupy/usfft.py b/src/tike/operators/cupy/usfft.py index 92a62021..6ff13068 100644 --- a/src/tike/operators/cupy/usfft.py +++ b/src/tike/operators/cupy/usfft.py @@ -10,8 +10,13 @@ def _get_kernel(xp, pad, mu): """Return the interpolation kernel for the USFFT.""" - xeq = xp.mgrid[-pad:pad, -pad:pad, -pad:pad] - return xp.exp(-mu * xp.sum(xeq**2, axis=0)).astype('float32') + u = -mu * xp.arange(-pad, pad, dtype='float32')**2 + kernel_shape = (len(u), len(u), len(u)) + norm = xp.zeros(kernel_shape, dtype='float32') + norm += u + norm += u[:, None] + norm += u[:, None, None] + return xp.exp(norm) def vector_gather(xp, Fe, x, n, m, mu): @@ -95,10 +100,12 @@ def eq2us(f, x, n, eps, xp, gather=vector_gather, fftn=None): # smearing kernel (kernel) kernel = _get_kernel(xp, pad, mu) + kernel *= (2 * n)**ndim # FFT and compesantion for smearing fe = xp.zeros([2 * n] * ndim, dtype="complex64") - fe[pad:end, pad:end, pad:end] = f / ((2 * n)**ndim * kernel) + fe[pad:end, pad:end, pad:end] = f + fe[pad:end, pad:end, pad:end] / kernel Fe = checkerboard(xp, fftn(checkerboard(xp, fe)), inverse=True) F = gather(xp, Fe, x, n, m, mu) @@ -199,12 +206,13 @@ def us2eq(f, x, n, eps, xp, scatter=vector_scatter, fftn=None): # smearing kernel (ker) kernel = _get_kernel(xp, pad, mu) + kernel *= (2 * n)**3 G = scatter(xp, f, x, n, m, mu) # FFT and compesantion for smearing F = checkerboard(xp, fftn(checkerboard(xp, G)), inverse=True) - F = F[pad:end, pad:end, pad:end] / ((2 * n)**3 * kernel) + F = F[pad:end, pad:end, pad:end] / kernel return F From 7e17193c9aef0fb76d2f1424cea5c83c32470c2a Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 30 Mar 2021 11:38:54 -0500 Subject: [PATCH 102/109] Undo new tomography model --- src/tike/admm/subproblem/lamino.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/tike/admm/subproblem/lamino.py b/src/tike/admm/subproblem/lamino.py index e44a1177..df09cea6 100644 --- a/src/tike/admm/subproblem/lamino.py +++ b/src/tike/admm/subproblem/lamino.py @@ -66,12 +66,8 @@ def lamino( overwrite=True, ) - K = 2j * np.pi * (phi + λ_l / ρ_l) - data = (phi + λ_l / ρ_l) * np.log(phi + λ_l / ρ_l) - lresult = tike.lamino.reconstruct( - data=data, - K=K, + data=-1j * np.log(phi + λ_l / ρ_l), theta=theta, tilt=tilt, obj=u, From cf1bfc512211f0ebada61ef9c61314001fe5a0ff Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 30 Mar 2021 13:36:23 -0500 Subject: [PATCH 103/109] BUG: Fix lamino step length search --- src/tike/lamino/solvers/cgrad.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/tike/lamino/solvers/cgrad.py b/src/tike/lamino/solvers/cgrad.py index 90b1563a..c08deebf 100644 --- a/src/tike/lamino/solvers/cgrad.py +++ b/src/tike/lamino/solvers/cgrad.py @@ -13,19 +13,26 @@ def _estimate_step_length(obj, theta, K, op): proper order of magnitude. """ - logger.info('Estimate step length from forward adjoint operations.') + logger.info('Estimate lamino step length from forward adjoint operations.') if K is None: - KconjK = 1 + outnback = op.adj( + data=op.fwd(u=obj, theta=theta), + theta=theta, + overwrite=False, + ) else: - KconjK = op.xp.conj(K) * K - outnback = op.adj( - data=KconjK * op.fwd(u=obj, theta=theta), - theta=theta, - overwrite=False, - ) - scaler = tike.linalg.norm(outnback) / tike.linalg.norm(obj) + outnback = op.adj( + data=op.xp.conj(K) * K * op.fwd(u=obj, theta=theta), + theta=theta, + overwrite=False, + ) + scaler = tike.linalg.norm(obj) / tike.linalg.norm(outnback) # Multiply by 2 to because we prefer over-estimating the step - return 2 * scaler if op.xp.isfinite(scaler) else 1.0 + if op.xp.isfinite(scaler): + return 2 * scaler + else: + logger.warning('Lamino step length estimate is non-finite.') + return 1.0 def _cost_tv( From b31fda1a7d65ea1ba7a0386db24868ae32081ae2 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 30 Mar 2021 15:14:43 -0500 Subject: [PATCH 104/109] BUG: Add missing equals sign --- src/tike/operators/cupy/usfft.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/tike/operators/cupy/usfft.py b/src/tike/operators/cupy/usfft.py index 6ff13068..581c7fe2 100644 --- a/src/tike/operators/cupy/usfft.py +++ b/src/tike/operators/cupy/usfft.py @@ -10,13 +10,8 @@ def _get_kernel(xp, pad, mu): """Return the interpolation kernel for the USFFT.""" - u = -mu * xp.arange(-pad, pad, dtype='float32')**2 - kernel_shape = (len(u), len(u), len(u)) - norm = xp.zeros(kernel_shape, dtype='float32') - norm += u - norm += u[:, None] - norm += u[:, None, None] - return xp.exp(norm) + xeq = xp.mgrid[-pad:pad, -pad:pad, -pad:pad] + return xp.exp(-mu * xp.sum(xeq**2, axis=0)).astype('float32') def vector_gather(xp, Fe, x, n, m, mu): @@ -105,7 +100,7 @@ def eq2us(f, x, n, eps, xp, gather=vector_gather, fftn=None): # FFT and compesantion for smearing fe = xp.zeros([2 * n] * ndim, dtype="complex64") fe[pad:end, pad:end, pad:end] = f - fe[pad:end, pad:end, pad:end] / kernel + fe[pad:end, pad:end, pad:end] /= kernel Fe = checkerboard(xp, fftn(checkerboard(xp, fe)), inverse=True) F = gather(xp, Fe, x, n, m, mu) From 42471027fba354f284f84412fd401a5c3dcd17f7 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Wed, 21 Jul 2021 16:19:02 -0500 Subject: [PATCH 105/109] DOC: Add more information to Lamino docstrings --- src/tike/lamino/lamino.py | 12 +++++++++++- src/tike/operators/cupy/lamino.py | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tike/lamino/lamino.py b/src/tike/lamino/lamino.py index 3d645908..55221d84 100644 --- a/src/tike/lamino/lamino.py +++ b/src/tike/lamino/lamino.py @@ -104,7 +104,17 @@ def reconstruct( rtol : float Terminate early if the relative decrease of the cost function is less than this amount. - + tilt : float32 [radians] + The tilt angle; the angle between the rotation axis of the object and + the light source. π / 2 for conventional tomography. 0 for a beam path + along the rotation axis. + obj : (nz, n, n) complex64 + The complex refractive index of the object. nz is the axis + corresponding to the rotation axis. + data : (ntheta, n, n) complex64 + The complex projection data of the object. + theta : array-like float32 [radians] + The projection angles; rotation around the vertical axis of the object. """ n = data.shape[2] obj = np.zeros([n, n, n], dtype='complex64') if obj is None else obj diff --git a/src/tike/operators/cupy/lamino.py b/src/tike/operators/cupy/lamino.py index 31d23318..c2878517 100644 --- a/src/tike/operators/cupy/lamino.py +++ b/src/tike/operators/cupy/lamino.py @@ -26,7 +26,7 @@ class Lamino(CachedFFT, Operator): ---------- n : int The pixel width of the cubic reconstructed grid. - tilt : float32 + tilt : float32 [radians] The tilt angle; the angle between the rotation axis of the object and the light source. π / 2 for conventional tomography. 0 for a beam path along the rotation axis. @@ -38,7 +38,7 @@ class Lamino(CachedFFT, Operator): corresponding to the rotation axis. data : (ntheta, n, n) complex64 The complex projection data of the object. - theta : array-like float32 + theta : array-like float32 [radians] The projection angles; rotation around the vertical axis of the object. """ From f077d96c7a0ade162aee9ba8ef07eb3e851f59cc Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 22 Jul 2021 20:05:05 -0500 Subject: [PATCH 106/109] DOC: Use object instead of particle label --- src/tike/admm/subproblem/lamino.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tike/admm/subproblem/lamino.py b/src/tike/admm/subproblem/lamino.py index df09cea6..16429044 100644 --- a/src/tike/admm/subproblem/lamino.py +++ b/src/tike/admm/subproblem/lamino.py @@ -104,13 +104,13 @@ def lamino( if comm.rank == 0 and save_result: dxchange.write_tiff( u.real, - f'{folder}/particle-real-{save_result:03d}.tiff', + f'{folder}/object-real-{save_result:03d}.tiff', dtype='float32', overwrite=True, ) dxchange.write_tiff( u.imag, - f'{folder}/particle-imag-{save_result:03d}.tiff', + f'{folder}/object-imag-{save_result:03d}.tiff', dtype='float32', overwrite=True, ) From ec201f938648f09505a079c2c94eb021be6b9cea Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Thu, 22 Jul 2021 20:05:30 -0500 Subject: [PATCH 107/109] REF: Allow skipping ptycho subproblem in alignment lamino admm --- src/tike/admm/al.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/tike/admm/al.py b/src/tike/admm/al.py index 2deabe0a..053f546a 100644 --- a/src/tike/admm/al.py +++ b/src/tike/admm/al.py @@ -27,6 +27,7 @@ def ptycho__align_lamino( folder=None, cg_iter=4, align_method=False, + skip_ptycho=False, ): """Solve the joint ptycho-lamino problem using ADMM.""" presult = { @@ -47,23 +48,24 @@ def ptycho__align_lamino( with cp.cuda.Device(comm.rank if comm.size > 1 else None): - presult, _ = tike.admm.subproblem.ptycho( - # constants - comm=comm, - data=data, - λ=None, - ρ=None, - Aφ=None, - # updated - presult=presult, - # parameters - num_iter=4 * niter, - cg_iter=cg_iter, - folder=folder, - save_result=niter + 1, - rescale=True, - rtol=1e-6, - ) + if not skip_ptycho: + presult, _ = tike.admm.subproblem.ptycho( + # constants + comm=comm, + data=data, + λ=None, + ρ=None, + Aφ=None, + # updated + presult=presult, + # parameters + num_iter=4 * niter, + cg_iter=cg_iter, + folder=folder, + save_result=niter + 1, + rescale=True, + rtol=1e-6, + ) for k in range(1, niter + 1): logger.info(f"Start ADMM iteration {k}.") From 9acb11b6a30099304d997c28ce80f805a89e3319 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 27 Jul 2021 11:32:34 -0500 Subject: [PATCH 108/109] BUG: Add error if padded shape is smaller than unpadded --- src/tike/operators/cupy/pad.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tike/operators/cupy/pad.py b/src/tike/operators/cupy/pad.py index defcaa7e..250e226a 100644 --- a/src/tike/operators/cupy/pad.py +++ b/src/tike/operators/cupy/pad.py @@ -31,6 +31,9 @@ def fwd(self, unpadded, corner=None, padded_shape=None, cval=0.0, **kwargs): """ if padded_shape is None: padded_shape = unpadded.shape + elif (padded_shape[-1] < unpadded.shape[-1] + or padded_shape[-2] < unpadded.shape[-2]): + raise ValueError("Padded shape must be larger than unpadded.") if corner is None: corner = self.xp.tile( (((padded_shape[-2] - unpadded.shape[-2]) // 2, @@ -64,6 +67,9 @@ def adj(self, padded, corner=None, unpadded_shape=None, **kwargs): """ if unpadded_shape is None: unpadded_shape = padded.shape + elif (padded.shape[-1] < unpadded_shape[-1] + or padded.shape[-2] < unpadded_shape[-2]): + raise ValueError("Padded shape must be larger than unpadded.") if corner is None: corner = self.xp.tile( (((padded.shape[-2] - unpadded_shape[-2]) // 2, From 69ca52fb749ceb7b41a6eb2948562f926a86651b Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 27 Jul 2021 11:33:50 -0500 Subject: [PATCH 109/109] DEV: Require 2x upsampling for lamino problem to mitigate noise --- src/tike/admm/subproblem/lamino.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tike/admm/subproblem/lamino.py b/src/tike/admm/subproblem/lamino.py index 16429044..b20762d5 100644 --- a/src/tike/admm/subproblem/lamino.py +++ b/src/tike/admm/subproblem/lamino.py @@ -76,6 +76,7 @@ def lamino( cg_iter=cg_iter, # FIXME: Communications overhead makes 1 GPU faster than 8. num_gpu=1, # comm.size, + upsample=2, ) u = lresult['obj'] cost = lresult['cost'][-1]