Repository: scikit-image/scikit-image

Issue:
Feature request: optionally resolve real-space shift from phase cross-correlation registration
### Description:

It turns out 😅 that phase cross-correlation registration shifts are only correct *modulo* the image size along each axis. If your image size is (2000, 2000), and you get a shift of (-150, 200), then the *actual* shift could be any of:

- (-150, 200)
- (-150, -1800)
- (1850, 200)
- (1850, -1800)

In general, all $2^n$ possible combinations are equally likely, where $n$ is the number of dimensions. The only way to disambiguate appears to be to compute the cross-correlation in real space *post-hoc* for the overlapping area in all of these combinations. Note that this is only O(npixels) because the possible overlap areas are mutually exclusive and form a tiling of the reference image, so it's not as expensive as it initially sounds.

See [this image.sc thread for details](https://forum.image.sc/t/recovering-shift-translation-from-phase-cross-correlation-registration/73216).


Discussion:
Nice find! A great reminder of the properties of the FFT :)

Is intensity correlation the best way to disambiguate, or should we also allow for other options (i.e., the cost functions supported by the registration module)?
Well, maybe, but (correct me if I'm wrong), phase cross correlation *implies* image cross correlation as the metric, so it would be weird to switch metrics halfway?
I think you're right.

Resolving patch:
diff --git a/skimage/registration/_phase_cross_correlation.py b/skimage/registration/_phase_cross_correlation.py
--- a/skimage/registration/_phase_cross_correlation.py
+++ b/skimage/registration/_phase_cross_correlation.py
@@ -3,10 +3,12 @@
 http://www.mathworks.com/matlabcentral/fileexchange/18401-efficient-subpixel-image-registration-by-cross-correlation
 """
 
+import itertools
 import warnings
 
 import numpy as np
 from scipy.fft import fftn, ifftn, fftfreq
+from scipy import ndimage as ndi
 
 from ._masked_phase_cross_correlation import _masked_phase_cross_correlation
 
@@ -111,8 +113,75 @@ def _compute_error(cross_correlation_max, src_amp, target_amp):
     return np.sqrt(np.abs(error))
 
 
+def _disambiguate_shift(reference_image, moving_image, shift):
+    """Determine the correct real-space shift based on periodic shift.
+
+    When determining a translation shift from phase cross-correlation in
+    Fourier space, the shift is only correct to within a period of the image
+    size along each axis, resulting in $2^n$ possible shifts, where $n$ is the
+    number of dimensions of the image. This function checks the
+    cross-correlation in real space for each of those shifts, and returns the
+    one with the highest cross-correlation.
+
+    The strategy we use is to perform the shift on the moving image *using the
+    'grid-wrap' mode* in `scipy.ndimage`. The moving image's original borders
+    then define $2^n$ quadrants, which we cross-correlate with the reference
+    image in turn using slicing. The entire operation is thus $O(2^n + m)$,
+    where $m$ is the number of pixels in the image (and typically dominates).
+
+    Parameters
+    ----------
+    reference_image : numpy array
+        The reference (non-moving) image.
+    moving_image : numpy array
+        The moving image: applying the shift to this image overlays it on the
+        reference image. Must be the same shape as the reference image.
+    shift : tuple of float
+        The shift to apply to each axis of the moving image, *modulo* image
+        size. The length of ``shift`` must be equal to ``moving_image.ndim``.
+
+    Returns
+    -------
+    real_shift : tuple of float
+        The shift disambiguated in real space.
+    """
+    shape = reference_image.shape
+    positive_shift = [shift_i % s for shift_i, s in zip(shift, shape)]
+    negative_shift = [shift_i - s
+                      for shift_i, s in zip(positive_shift, shape)]
+    subpixel = np.any(np.array(shift) % 1 != 0)
+    interp_order = 3 if subpixel else 0
+    shifted = ndi.shift(
+            moving_image, shift, mode='grid-wrap', order=interp_order
+            )
+    indices = np.round(positive_shift).astype(int)
+    splits_per_dim = [(slice(0, i), slice(i, None)) for i in indices]
+    max_corr = -1.0
+    max_slice = None
+    for test_slice in itertools.product(*splits_per_dim):
+        reference_tile = np.reshape(reference_image[test_slice], -1)
+        moving_tile = np.reshape(shifted[test_slice], -1)
+        corr = -1.0
+        if reference_tile.size > 2:
+            corr = np.corrcoef(reference_tile, moving_tile)[0, 1]
+        if corr > max_corr:
+            max_corr = corr
+            max_slice = test_slice
+    real_shift_acc = []
+    for sl, pos_shift, neg_shift in zip(
+            max_slice, positive_shift, negative_shift
+            ):
+        real_shift_acc.append(pos_shift if sl.stop is None else neg_shift)
+    if not subpixel:
+        real_shift = tuple(map(int, real_shift_acc))
+    else:
+        real_shift = tuple(real_shift_acc)
+    return real_shift
+
+
 def phase_cross_correlation(reference_image, moving_image, *,
                             upsample_factor=1, space="real",
+                            disambiguate=False,
                             return_error=True, reference_mask=None,
                             moving_mask=None, overlap_ratio=0.3,
                             normalization="phase"):
@@ -143,10 +212,16 @@ def phase_cross_correlation(reference_image, moving_image, *,
         data will bypass FFT of input data. Case insensitive. Not
         used if any of ``reference_mask`` or ``moving_mask`` is not
         None.
+    disambiguate : bool
+        The shift returned by this function is only accurate *modulo* the
+        image shape, due to the periodic nature of the Fourier transform. If
+        this parameter is set to ``True``, the *real* space cross-correlation
+        is computed for each possible shift, and the shift with the highest
+        cross-correlation within the overlapping area is returned.
     return_error : bool, {"always"}, optional
         Returns error and phase difference if "always" is given. If False, or
-        either ``reference_mask`` or ``moving_mask`` are given, only shifts are
-        returned.
+        either ``reference_mask`` or ``moving_mask`` are given, only the shift
+        is returned.
     reference_mask : ndarray
         Boolean mask for ``reference_image``. The mask should evaluate
         to ``True`` (or 1) on valid pixels. ``reference_mask`` should
@@ -170,10 +245,10 @@ def phase_cross_correlation(reference_image, moving_image, *,
 
     Returns
     -------
-    shifts : ndarray
+    shift : ndarray
         Shift vector (in pixels) required to register ``moving_image``
         with ``reference_image``. Axis ordering is consistent with
-        numpy (e.g. Z, Y, X)
+        the axis order of the input array.
     error : float
         Translation invariant normalized RMS error between
         ``reference_image`` and ``moving_image``. For masked cross-correlation
@@ -232,16 +307,16 @@ def warn_return_error():
         )
 
     if (reference_mask is not None) or (moving_mask is not None):
-        shifts = _masked_phase_cross_correlation(
+        shift = _masked_phase_cross_correlation(
             reference_image, moving_image,
             reference_mask, moving_mask,
             overlap_ratio
         )
         if return_error == "always":
-            return shifts, np.nan, np.nan
+            return shift, np.nan, np.nan
         else:
             warn_return_error()
-            return shifts
+            return shift
 
     # images must be the same shape
     if reference_image.shape != moving_image.shape:
@@ -271,12 +346,12 @@ def warn_return_error():
     # Locate maximum
     maxima = np.unravel_index(np.argmax(np.abs(cross_correlation)),
                               cross_correlation.shape)
-    midpoints = np.array([np.fix(axis_size / 2) for axis_size in shape])
+    midpoint = np.array([np.fix(axis_size / 2) for axis_size in shape])
 
     float_dtype = image_product.real.dtype
 
-    shifts = np.stack(maxima).astype(float_dtype, copy=False)
-    shifts[shifts > midpoints] -= np.array(shape)[shifts > midpoints]
+    shift = np.stack(maxima).astype(float_dtype, copy=False)
+    shift[shift > midpoint] -= np.array(shape)[shift > midpoint]
 
     if upsample_factor == 1:
         if return_error:
@@ -289,12 +364,12 @@ def warn_return_error():
     else:
         # Initial shift estimate in upsampled grid
         upsample_factor = np.array(upsample_factor, dtype=float_dtype)
-        shifts = np.round(shifts * upsample_factor) / upsample_factor
+        shift = np.round(shift * upsample_factor) / upsample_factor
         upsampled_region_size = np.ceil(upsample_factor * 1.5)
         # Center of output array at dftshift + 1
         dftshift = np.fix(upsampled_region_size / 2.0)
         # Matrix multiply DFT around the current shift estimate
-        sample_region_offset = dftshift - shifts*upsample_factor
+        sample_region_offset = dftshift - shift*upsample_factor
         cross_correlation = _upsampled_dft(image_product.conj(),
                                            upsampled_region_size,
                                            upsample_factor,
@@ -307,7 +382,7 @@ def warn_return_error():
         maxima = np.stack(maxima).astype(float_dtype, copy=False)
         maxima -= dftshift
 
-        shifts += maxima / upsample_factor
+        shift += maxima / upsample_factor
 
         if return_error:
             src_amp = np.sum(np.real(src_freq * src_freq.conj()))
@@ -317,7 +392,13 @@ def warn_return_error():
     # effect. We set to zero.
     for dim in range(src_freq.ndim):
         if shape[dim] == 1:
-            shifts[dim] = 0
+            shift[dim] = 0
+
+    if disambiguate:
+        if space.lower() != 'real':
+            reference_image = ifftn(reference_image)
+            moving_image = ifftn(moving_image)
+        shift = _disambiguate_shift(reference_image, moving_image, shift)
 
     if return_error:
         # Redirect user to masked_phase_cross_correlation if NaNs are observed
@@ -330,8 +411,8 @@ def warn_return_error():
                 "reference_mask=~np.isnan(reference_image), "
                 "moving_mask=~np.isnan(moving_image))")
 
-        return shifts, _compute_error(CCmax, src_amp, target_amp),\
+        return shift, _compute_error(CCmax, src_amp, target_amp),\
             _compute_phasediff(CCmax)
     else:
         warn_return_error()
-        return shifts
+        return shift


Test patch:
diff --git a/skimage/registration/tests/test_phase_cross_correlation.py b/skimage/registration/tests/test_phase_cross_correlation.py
--- a/skimage/registration/tests/test_phase_cross_correlation.py
+++ b/skimage/registration/tests/test_phase_cross_correlation.py
@@ -1,3 +1,4 @@
+import itertools
 import warnings
 import re
 
@@ -10,7 +11,7 @@
 from skimage import img_as_float
 from skimage._shared._warnings import expected_warnings
 from skimage._shared.utils import _supported_float_type
-from skimage.data import camera, binary_blobs
+from skimage.data import camera, binary_blobs, eagle
 from skimage.registration._phase_cross_correlation import (
     phase_cross_correlation, _upsampled_dft
 )
@@ -227,3 +228,41 @@ def test_mismatch_offsets_size():
     with pytest.raises(ValueError):
         _upsampled_dft(np.ones((4, 4)), 3,
                        axis_offsets=[3, 2, 1, 4])
+
+
+@pytest.mark.parametrize(
+        ('shift0', 'shift1'),
+        itertools.product((100, -100, 350, -350), (100, -100, 350, -350)),
+        )
+def test_disambiguate_2d(shift0, shift1):
+    image = eagle()[500:, 900:]  # use a highly textured part of image
+    shift = (shift0, shift1)
+    origin0 = []
+    for s in shift:
+        if s > 0:
+            origin0.append(0)
+        else:
+            origin0.append(-s)
+    origin1 = np.array(origin0) + shift
+    slice0 = tuple(slice(o, o+450) for o in origin0)
+    slice1 = tuple(slice(o, o+450) for o in origin1)
+    reference = image[slice0]
+    moving = image[slice1]
+    computed_shift, _, _ = phase_cross_correlation(
+            reference, moving, disambiguate=True, return_error='always'
+            )
+    np.testing.assert_equal(shift, computed_shift)
+
+
+def test_disambiguate_zero_shift():
+    """When the shift is 0, disambiguation becomes degenerate.
+
+    Some quadrants become size 0, which prevents computation of
+    cross-correlation. This test ensures that nothing bad happens in that
+    scenario.
+    """
+    image = camera()
+    computed_shift, _, _ = phase_cross_correlation(
+            image, image, disambiguate=True, return_error='always'
+            )
+    assert computed_shift == (0, 0)